Merge branch 'operacle:develop' into develop
@@ -1,6 +1,6 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: #[tolaleng]
|
||||
github: [tolaleng]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: #checkcle
|
||||
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
|
||||
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
|
||||
User: admin@example.com | Passwd: Admin123456
|
||||
|
||||
## 🌟 Core Features
|
||||
|
||||
@@ -110,11 +111,12 @@ services:
|
||||
4. Follow the Quick Start Guide at https://docs.checkcle.io
|
||||
|
||||
###
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
## 📝 Development Roadmap
|
||||
|
||||
@@ -128,10 +130,10 @@ services:
|
||||
- ✅ SSL & Domain Monitoring
|
||||
- ✅ Schedule Maintenance
|
||||
- ✅ Incident Management
|
||||
- [ ] Infrastructure Server Monitoring
|
||||
- ✅ Infrastructure Server Monitoring
|
||||
- ✅ Operational Status / Public Status Pages
|
||||
- ✅ 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
|
||||
- ✅ User Permission Roles
|
||||
- [ ] Notifications (Email/Slack/Discord/Signal)
|
||||
@@ -161,7 +163,7 @@ Here are some ways you can help improve CheckCle:
|
||||
- Website: [checkcle.io](https://checkcle.io)
|
||||
- Documentation: [docs.checkcle.io](https://docs.checkcle.io)
|
||||
- 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)
|
||||
- X: [@tlengoss](https://x.com/tlengoss)
|
||||
|
||||
@@ -171,12 +173,7 @@ CheckCle is released under the MIT License.
|
||||
|
||||
---
|
||||
## 👥 Contributors
|
||||
Thank you for contributing and continuously making CheckCle better, you're awesome 🫶
|
||||
|
||||
[](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
|
||||
// In a production app, this would be a server-side endpoint
|
||||
|
||||
console.log("API Realtime endpoint loaded");
|
||||
//console.log("API Realtime endpoint loaded");
|
||||
|
||||
// Simple implementation that simulates sending notifications
|
||||
export default async function handler(req) {
|
||||
|
||||
@@ -13,7 +13,13 @@ export const Sidebar = ({ collapsed }: SidebarProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
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} />
|
||||
<MainNavigation collapsed={collapsed} />
|
||||
<SettingsPanel collapsed={collapsed} />
|
||||
|
||||
@@ -34,7 +34,6 @@ export const MenuItem: React.FC<MenuItemProps> = ({
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasNavigation && path) {
|
||||
// Use navigate instead of window.location to prevent full page reload
|
||||
navigate(path, { replace: false });
|
||||
}
|
||||
};
|
||||
@@ -44,11 +43,22 @@ export const MenuItem: React.FC<MenuItemProps> = ({
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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="h-8 w-8 bg-gray-600 rounded flex items-center justify-center mr-2">
|
||||
<img
|
||||
src="/favicon.ico"
|
||||
src="/favicon_sidebar.ico"
|
||||
alt="CheckCle"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
|
||||
@@ -62,7 +62,7 @@ export const DockerContainersTable = ({ containers, isLoading, onRefresh }: Dock
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-full inline-block align-middle">
|
||||
<div className="overflow-hidden border border-border rounded-lg shadow-sm bg-card">
|
||||
<div className="overflow-hidden border border-border rounded-lg shadow-sm">
|
||||
<Table>
|
||||
<DockerTableHeader />
|
||||
<TableBody>
|
||||
|
||||
@@ -3,44 +3,51 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Container, Play, Square, AlertTriangle } from "lucide-react";
|
||||
import { DockerStats } from "@/types/docker.types";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface DockerStatsCardsProps {
|
||||
stats: DockerStats;
|
||||
}
|
||||
|
||||
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Total Containers",
|
||||
value: stats.total,
|
||||
icon: Container,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50",
|
||||
borderColor: "border-blue-200",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(59, 130, 246, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
|
||||
},
|
||||
{
|
||||
title: "Running",
|
||||
value: stats.running,
|
||||
icon: Play,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(16, 185, 129, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
|
||||
},
|
||||
{
|
||||
title: "Stopped",
|
||||
value: stats.stopped,
|
||||
icon: Square,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-50",
|
||||
borderColor: "border-gray-200",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(107, 114, 128, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
|
||||
},
|
||||
{
|
||||
title: "Warning",
|
||||
value: stats.warning,
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(245, 158, 11, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #f59e0b 100%)"
|
||||
},
|
||||
];
|
||||
|
||||
@@ -49,23 +56,39 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
||||
{cards.map((card) => {
|
||||
const IconComponent = card.icon;
|
||||
return (
|
||||
<Card key={card.title} className={`${card.borderColor} bg-card hover:shadow-md transition-shadow`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
<Card
|
||||
key={card.title}
|
||||
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
|
||||
style={{ background: card.gradient }}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white/70">
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<div className={`${card.bgColor} ${card.color} p-2 rounded-md`}>
|
||||
<IconComponent className="h-4 w-4" />
|
||||
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
|
||||
<IconComponent className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="relative z-10">
|
||||
<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}
|
||||
</div>
|
||||
<Badge
|
||||
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
|
||||
</Badge>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -6,7 +7,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, X, Server, Shield, AlertTriangle } from 'lucide-react';
|
||||
import { Plus, X, Server } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { serviceService } from '@/services/serviceService';
|
||||
@@ -17,12 +18,6 @@ interface ComponentsSelectorProps {
|
||||
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) => {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newComponent, setNewComponent] = useState({
|
||||
@@ -169,7 +164,7 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an uptime service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-50 bg-white border shadow-lg">
|
||||
<SelectContent>
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -206,37 +201,6 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -67,13 +67,13 @@ export const CreateOperationalPageDialog = () => {
|
||||
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);
|
||||
console.log('Created page:', createdPage);
|
||||
// console.log('Created page:', createdPage);
|
||||
|
||||
// Create components after page is created
|
||||
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) {
|
||||
const componentPayload = {
|
||||
operational_status_id: createdPage.id,
|
||||
@@ -84,7 +84,7 @@ export const CreateOperationalPageDialog = () => {
|
||||
display_order: component.display_order || 1,
|
||||
};
|
||||
|
||||
console.log('Creating component with payload:', componentPayload);
|
||||
// console.log('Creating component with payload:', componentPayload);
|
||||
await createComponentMutation.mutateAsync(componentPayload);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export const CreateOperationalPageDialog = () => {
|
||||
form.reset();
|
||||
setSelectedComponents([]);
|
||||
} 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>
|
||||
<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'}>
|
||||
{page.is_public === 'true' ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Updated:</span>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PublicStatusPageFooter } from './PublicStatusPageFooter';
|
||||
|
||||
export const PublicStatusPage = () => {
|
||||
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 [lastUpdated, setLastUpdated] = useState(new Date());
|
||||
@@ -51,7 +51,7 @@ export const PublicStatusPage = () => {
|
||||
};
|
||||
}, [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) {
|
||||
return (
|
||||
|
||||
@@ -19,61 +19,61 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
|
||||
useEffect(() => {
|
||||
const fetchPublicPage = async () => {
|
||||
if (!slug) {
|
||||
console.log('No slug provided');
|
||||
// console.log('No slug provided');
|
||||
setError('No status page slug provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Fetching public status page for slug:', slug);
|
||||
// console.log('Fetching public status page for slug:', slug);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch operational page
|
||||
console.log('Fetching operational pages...');
|
||||
// console.log('Fetching operational pages...');
|
||||
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');
|
||||
console.log('Found page:', foundPage);
|
||||
// console.log('Found page:', 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');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPage(foundPage);
|
||||
console.log('Page set successfully');
|
||||
// console.log('Page set successfully');
|
||||
|
||||
// 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);
|
||||
console.log('Components found:', pageComponents);
|
||||
// console.log('Components found:', pageComponents);
|
||||
setComponents(pageComponents);
|
||||
|
||||
// Fetch all services
|
||||
console.log('Fetching all services...');
|
||||
// console.log('Fetching all services...');
|
||||
const allServices = await serviceService.getServices();
|
||||
console.log('Services found:', allServices);
|
||||
// console.log('Services found:', allServices);
|
||||
setServices(allServices);
|
||||
|
||||
// Fetch uptime data for each component that has a service
|
||||
console.log('Fetching uptime data...');
|
||||
// console.log('Fetching uptime data...');
|
||||
const uptimePromises = pageComponents
|
||||
.filter(component => component.service_id)
|
||||
.map(async (component) => {
|
||||
try {
|
||||
console.log('Fetching uptime for service:', component.service_id);
|
||||
// console.log('Fetching uptime for service:', component.service_id);
|
||||
const endDate = new Date();
|
||||
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
|
||||
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
|
||||
console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
||||
// console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
||||
return { serviceId: component.service_id, history };
|
||||
} catch (error) {
|
||||
console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
||||
// console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
||||
return { serviceId: component.service_id, history: [] };
|
||||
}
|
||||
});
|
||||
@@ -84,12 +84,12 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
|
||||
uptimeMap[result.serviceId] = result.history;
|
||||
});
|
||||
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) {
|
||||
console.error('Error fetching public page:', err);
|
||||
// console.error('Error fetching public page:', err);
|
||||
setError(`Failed to load status page: ${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -18,6 +18,7 @@ interface OverviewCardProps {
|
||||
valueClassName?: string;
|
||||
isLoading?: boolean;
|
||||
color?: string;
|
||||
gradient?: string;
|
||||
}
|
||||
|
||||
export const OverviewCard = ({
|
||||
@@ -30,11 +31,15 @@ export const OverviewCard = ({
|
||||
valueClassName,
|
||||
isLoading = false,
|
||||
color = "blue",
|
||||
gradient,
|
||||
}: OverviewCardProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Map color prop to gradient colors
|
||||
const getGradientBackground = () => {
|
||||
if (gradient) {
|
||||
return gradient;
|
||||
}
|
||||
const colors = {
|
||||
blue: theme === 'dark'
|
||||
? "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 { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { OverviewCard } from '../common/OverviewCard';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
interface OverviewStatsProps {
|
||||
unresolved: number;
|
||||
@@ -24,6 +25,7 @@ export const OverviewCards: React.FC<OverviewCardsProps> = ({
|
||||
initialized
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<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()}
|
||||
icon={<AlertCircle className="h-5 w-5 text-white" />}
|
||||
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
|
||||
title={t('criticalIssues')}
|
||||
value={overviewStats.critical.toString()}
|
||||
icon={<AlertTriangle className="h-5 w-5 text-white" />}
|
||||
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
|
||||
title={t('highPriority')}
|
||||
value={overviewStats.highPriority.toString()}
|
||||
icon={<Flag className="h-5 w-5 text-white" />}
|
||||
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
|
||||
title={t('resolvedIncidents')}
|
||||
value={overviewStats.resolved.toString()}
|
||||
icon={<CheckCircle className="h-5 w-5 text-white" />}
|
||||
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
|
||||
title={t('avgResolutionTime')}
|
||||
value={overviewStats.avgResolutionTime}
|
||||
icon={<Clock className="h-5 w-5 text-white" />}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
} = 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
|
||||
const latestData = useMemo(() => {
|
||||
@@ -48,10 +48,10 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
<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="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -60,7 +60,7 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
|
||||
</div>
|
||||
<div className="w-16 h-4 bg-muted rounded"></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>
|
||||
</CardContent>
|
||||
</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 (
|
||||
<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">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
<h2 className="text-lg font-medium">Historical Performance</h2>
|
||||
@@ -143,12 +143,20 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
|
||||
<TimeRangeSelector value={timeRange} onChange={setTimeRange} />
|
||||
</div>
|
||||
|
||||
{/* Use CSS Grid for better performance than flexbox */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CPUChart data={chartData} latestData={latestData} />
|
||||
<MemoryChart data={chartData} latestData={latestData} />
|
||||
<DiskChart data={chartData} latestData={latestData} />
|
||||
<NetworkChart data={chartData} latestData={latestData} />
|
||||
{/* Improved responsive grid layout */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6 auto-rows-fr">
|
||||
<div className="w-full min-w-0">
|
||||
<CPUChart data={chartData} latestData={latestData} />
|
||||
</div>
|
||||
<div className="w-full min-w-0">
|
||||
<MemoryChart data={chartData} latestData={latestData} />
|
||||
</div>
|
||||
<div className="w-full min-w-0">
|
||||
<DiskChart data={chartData} latestData={latestData} />
|
||||
</div>
|
||||
<div className="w-full min-w-0">
|
||||
<NetworkChart data={chartData} latestData={latestData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,10 @@ export const ServerMetricsCharts = ({ serverId }: ServerMetricsChartsProps) => {
|
||||
queryKey: ['server-metrics', serverId, timeRange],
|
||||
queryFn: () => serverService.getServerMetrics(serverId, timeRange),
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { RefreshCw, Search, Eye, Activity, MoreHorizontal, Pause, Play, Edit, Trash2 } from "lucide-react";
|
||||
import { Server } from "@/types/server.types";
|
||||
import { ServerStatusBadge } from "./ServerStatusBadge";
|
||||
import { OSTypeIcon } from "./OSTypeIcon";
|
||||
import { EditServerDialog } from "./EditServerDialog";
|
||||
import { serverService } from "@/services/serverService";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface ServerTableProps {
|
||||
servers: Server[];
|
||||
@@ -21,9 +24,15 @@ interface ServerTableProps {
|
||||
}
|
||||
|
||||
export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => {
|
||||
const { theme } = useTheme();
|
||||
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 { toast } = useToast();
|
||||
|
||||
const filteredServers = servers.filter(server =>
|
||||
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
@@ -39,38 +48,165 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
||||
navigate(`/container-monitoring/${serverId}`);
|
||||
};
|
||||
|
||||
const handlePauseResume = (serverId: string) => {
|
||||
const isPaused = pausedServers.has(serverId);
|
||||
if (isPaused) {
|
||||
setPausedServers(prev => {
|
||||
const handlePauseResume = async (server: Server) => {
|
||||
const serverId = server.id;
|
||||
const isPaused = server.status === "paused";
|
||||
|
||||
if (pausingServers.has(serverId)) {
|
||||
return; // Already processing this server
|
||||
}
|
||||
|
||||
try {
|
||||
setPausingServers(prev => new Set(prev).add(serverId));
|
||||
|
||||
// Only update the status field, preserving all other server configuration
|
||||
const updateData = {
|
||||
status: isPaused ? "up" : "paused",
|
||||
last_checked: new Date().toISOString()
|
||||
};
|
||||
|
||||
await pb.collection('servers').update(serverId, updateData);
|
||||
|
||||
toast({
|
||||
title: isPaused ? "Server resumed" : "Server paused",
|
||||
description: `Monitoring ${isPaused ? 'resumed' : 'paused'} for ${server.name}`,
|
||||
});
|
||||
|
||||
// console.log(`${isPaused ? 'Resume' : 'Pause'} server monitoring: ${serverId}`);
|
||||
|
||||
// Refresh the server list to show updated status
|
||||
onRefresh();
|
||||
|
||||
} catch (error) {
|
||||
// console.error('Error updating server status:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: `Failed to ${isPaused ? 'resume' : 'pause'} server monitoring. Please try again.`,
|
||||
});
|
||||
} finally {
|
||||
setPausingServers(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(serverId);
|
||||
return newSet;
|
||||
});
|
||||
// console.log('Resume server monitoring:', serverId);
|
||||
} else {
|
||||
setPausedServers(prev => new Set(prev).add(serverId));
|
||||
// console.log('Pause server monitoring:', serverId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (serverId: string) => {
|
||||
// TODO: Implement edit functionality
|
||||
// console.log('Edit server:', serverId);
|
||||
const handleEdit = (server: Server) => {
|
||||
setSelectedServer(server);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (serverId: string) => {
|
||||
// TODO: Implement delete functionality
|
||||
// console.log('Delete server:', serverId);
|
||||
const handleDelete = (server: Server) => {
|
||||
setSelectedServer(server);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedServer || isDeleting) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
// Delete the server from the database
|
||||
await pb.collection('servers').delete(selectedServer.id);
|
||||
|
||||
toast({
|
||||
title: "Server deleted",
|
||||
description: `${selectedServer.name} has been deleted successfully.`,
|
||||
});
|
||||
|
||||
// Refresh the server list
|
||||
onRefresh();
|
||||
|
||||
// Close the dialog
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedServer(null);
|
||||
|
||||
} catch (error) {
|
||||
// console.error('Error deleting server:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to delete server. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const CustomProgressBar = ({
|
||||
value,
|
||||
label,
|
||||
subtitle,
|
||||
type
|
||||
}: {
|
||||
value: number;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
type: 'cpu' | 'memory' | 'disk'
|
||||
}) => {
|
||||
const getGradientColors = (type: string, value: number) => {
|
||||
if (type === 'cpu') {
|
||||
if (value > 90) return 'from-red-500 to-red-600';
|
||||
if (value > 75) return 'from-orange-500 to-orange-600';
|
||||
if (value > 60) return 'from-yellow-500 to-yellow-600';
|
||||
return 'from-green-500 to-green-600';
|
||||
}
|
||||
if (type === 'memory') {
|
||||
if (value > 90) return 'from-red-500 to-red-600';
|
||||
if (value > 75) return 'from-yellow-500 to-yellow-600';
|
||||
return 'from-blue-500 to-blue-600';
|
||||
}
|
||||
if (type === 'disk') {
|
||||
if (value > 95) return 'from-red-500 to-red-600';
|
||||
if (value > 85) return 'from-yellow-500 to-yellow-600';
|
||||
return 'from-orange-500 to-orange-600';
|
||||
}
|
||||
return 'from-gray-500 to-gray-600';
|
||||
};
|
||||
|
||||
const getTextColor = (value: number) => {
|
||||
if (value > 90) return 'text-red-600 dark:text-red-400';
|
||||
if (value > 75) return 'text-orange-600 dark:text-orange-400';
|
||||
if (value > 60) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 min-w-[120px]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={`text-sm font-semibold ${getTextColor(value)}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="w-full h-3 bg-muted/30 rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${getGradientColors(type, value)} rounded-full transition-all duration-700 ease-out relative overflow-hidden`}
|
||||
style={{ width: `${Math.min(value, 100)}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse opacity-60" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="flex-1 flex flex-col">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle>Servers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">Loading servers...</span>
|
||||
@@ -81,189 +217,218 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<CardTitle className="text-xl font-semibold">Servers</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 sm:w-64">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search servers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
<>
|
||||
<Card className="bg-transparent border-0 shadow-none">
|
||||
<CardHeader className="pb-4 px-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<CardTitle className="text-xl font-semibold">Servers</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 sm:w-64">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search servers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={onRefresh} variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={onRefresh} variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredServers.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No servers found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>OS</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>CPU</TableHead>
|
||||
<TableHead>Memory</TableHead>
|
||||
<TableHead>Disk</TableHead>
|
||||
<TableHead>Uptime</TableHead>
|
||||
<TableHead>Last Checked</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredServers.map((server) => {
|
||||
const cpuUsage = server.cpu_usage || 0;
|
||||
const memoryUsage = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 0;
|
||||
const diskUsage = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0;
|
||||
const isPaused = pausedServers.has(server.id);
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{filteredServers.length === 0 ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<p className="text-muted-foreground">No servers found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
|
||||
<Table>
|
||||
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Name</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Status</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>OS</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>IP Address</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>CPU</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Memory</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Disk</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Uptime</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Last Checked</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right`}>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredServers.map((server) => {
|
||||
const cpuUsage = server.cpu_usage || 0;
|
||||
const memoryUsage = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 0;
|
||||
const diskUsage = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0;
|
||||
const isPaused = server.status === "paused";
|
||||
const isProcessing = pausingServers.has(server.id);
|
||||
|
||||
return (
|
||||
<TableRow key={server.id} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{server.name}</div>
|
||||
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ServerStatusBadge status={server.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<OSTypeIcon osType={server.os_type} />
|
||||
<span className="text-sm">{server.os_type}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-1 py-0.5 rounded">{server.ip_address}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 min-w-[120px]">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{cpuUsage.toFixed(1)}%</span>
|
||||
<span className="text-muted-foreground">{server.cpu_cores} cores</span>
|
||||
return (
|
||||
<TableRow
|
||||
key={server.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleViewDetails(server.id)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="truncate" title={server.name}>
|
||||
{server.name}
|
||||
</div>
|
||||
<Progress
|
||||
value={cpuUsage}
|
||||
className="h-2"
|
||||
indicatorClassName={
|
||||
cpuUsage > 90 ? "bg-red-00" :
|
||||
cpuUsage > 75 ? "bg-orange-500" :
|
||||
cpuUsage > 60 ? "bg-yellow-500" : "bg-green-500"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 min-w-[120px]">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{memoryUsage.toFixed(1)}%</span>
|
||||
<span>{serverService.formatBytes(server.ram_total)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ServerStatusBadge status={server.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<OSTypeIcon osType={server.os_type} />
|
||||
<span className="text-sm truncate" title={server.os_type}>
|
||||
{server.os_type}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={memoryUsage}
|
||||
className="h-2"
|
||||
indicatorClassName={
|
||||
memoryUsage > 90 ? "bg-red-500" :
|
||||
memoryUsage > 75 ? "bg-yellow-500" : "bg-blue-500"
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-1 py-0.5 rounded text-xs">
|
||||
{server.ip_address}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CustomProgressBar
|
||||
value={cpuUsage}
|
||||
label={`${cpuUsage.toFixed(1)}%`}
|
||||
subtitle={`${server.cpu_cores} cores`}
|
||||
type="cpu"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 min-w-[120px]">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{diskUsage.toFixed(1)}%</span>
|
||||
<span>{serverService.formatBytes(server.disk_total)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CustomProgressBar
|
||||
value={memoryUsage}
|
||||
label={`${memoryUsage.toFixed(1)}%`}
|
||||
subtitle={serverService.formatBytes(server.ram_total)}
|
||||
type="memory"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CustomProgressBar
|
||||
value={diskUsage}
|
||||
label={`${diskUsage.toFixed(1)}%`}
|
||||
subtitle={serverService.formatBytes(server.disk_total)}
|
||||
type="disk"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm truncate" title={server.uptime}>
|
||||
{server.uptime}
|
||||
</div>
|
||||
<Progress
|
||||
value={diskUsage}
|
||||
className="h-2"
|
||||
indicatorClassName={
|
||||
diskUsage > 95 ? "bg-red-500" :
|
||||
diskUsage > 85 ? "bg-yellow-500" : "bg-orange-500"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">{server.uptime}</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(server.last_checked).toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuItem onClick={() => handleViewDetails(server.id)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Server Detail
|
||||
</DropdownMenuItem>
|
||||
{server.docker === 'true' && (
|
||||
<DropdownMenuItem onClick={() => handleViewContainers(server.id)}>
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
Container Monitoring
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-muted-foreground text-xs">
|
||||
{new Date(server.last_checked).toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" disabled={isProcessing}>
|
||||
<span className="sr-only">Open menu</span>
|
||||
{isProcessing ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(server.id); }}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Server Detail
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handlePauseResume(server.id)}>
|
||||
{isPaused ? (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume Monitoring
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause Monitoring
|
||||
</>
|
||||
{server.docker === 'true' && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewContainers(server.id); }}>
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
Container Monitoring
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleEdit(server.id)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Server
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(server.id)}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Server
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => { e.stopPropagation(); handlePauseResume(server); }}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isPaused ? (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume Monitoring
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause Monitoring
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleEdit(server); }}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Server
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(server); }}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Server
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Server Dialog */}
|
||||
<EditServerDialog
|
||||
server={selectedServer}
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
onServerUpdated={onRefresh}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete this server?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete{' '}
|
||||
<span className="font-semibold text-foreground">
|
||||
{selectedServer?.name}
|
||||
</span>{' '}
|
||||
and all of its monitoring data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { useTheme } from "@/contexts/ThemeContext";
|
||||
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
||||
@@ -18,65 +18,62 @@ export const CPUChart = ({ data, latestData }: CPUChartProps) => {
|
||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
|
||||
<CardHeader className="pb-2 flex-shrink-0">
|
||||
<CardTitle className="flex items-center justify-between text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-lg bg-blue-500/15">
|
||||
<Cpu className="h-5 w-5 text-blue-500" />
|
||||
<Cpu className="h-4 w-4 lg:h-5 lg:w-5 text-blue-500" />
|
||||
</div>
|
||||
CPU Usage
|
||||
<span className="text-sm lg:text-base">CPU Usage</span>
|
||||
</div>
|
||||
{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-xs text-muted-foreground">{latestData.cpuCores} cores</div>
|
||||
</div>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<ChartContainer config={{
|
||||
cpuUsage: {
|
||||
label: "CPU Usage (%)",
|
||||
color: theme === 'dark' ? "#3b82f6" : "#2563eb",
|
||||
}
|
||||
}} className="h-80">
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme === 'dark' ? "#3b82f6" : "#2563eb"} stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor={theme === 'dark' ? "#1e40af" : "#1d4ed8"} stopOpacity={0.1}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
label={{ value: 'CPU %', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<DetailedTooltipContent />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpuUsage"
|
||||
stroke={theme === 'dark' ? "#60a5fa" : "#2563eb"}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
fill="url(#cpuGradient)"
|
||||
fillOpacity={1}
|
||||
name="CPU Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<CardContent className="pt-2 flex-1 min-h-0">
|
||||
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme === 'dark' ? "#3b82f6" : "#2563eb"} stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor={theme === 'dark' ? "#1e40af" : "#1d4ed8"} stopOpacity={0.1}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
label={{ value: 'CPU %', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<DetailedTooltipContent />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpuUsage"
|
||||
stroke={theme === 'dark' ? "#60a5fa" : "#2563eb"}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
fill="url(#cpuGradient)"
|
||||
fillOpacity={1}
|
||||
name="CPU Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { useTheme } from "@/contexts/ThemeContext";
|
||||
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
||||
@@ -18,65 +18,62 @@ export const DiskChart = ({ data, latestData }: DiskChartProps) => {
|
||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
|
||||
<CardHeader className="pb-2 flex-shrink-0">
|
||||
<CardTitle className="flex items-center justify-between text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-lg bg-amber-500/15">
|
||||
<HardDrive className="h-5 w-5 text-amber-500" />
|
||||
<HardDrive className="h-4 w-4 lg:h-5 lg:w-5 text-amber-500" />
|
||||
</div>
|
||||
Disk Usage
|
||||
<span className="text-sm lg:text-base">Disk Usage</span>
|
||||
</div>
|
||||
{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-xs text-muted-foreground">{latestData.diskUsed} / {latestData.diskTotal}</div>
|
||||
</div>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<ChartContainer config={{
|
||||
diskUsagePercent: {
|
||||
label: "Disk Usage (%)",
|
||||
color: theme === 'dark' ? "#f59e0b" : "#d97706",
|
||||
}
|
||||
}} className="h-80">
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="diskGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme === 'dark' ? "#f59e0b" : "#d97706"} stopOpacity={0.6}/>
|
||||
<stop offset="95%" stopColor={theme === 'dark' ? "#d97706" : "#b45309"} stopOpacity={0.1}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
label={{ value: 'Disk %', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<DetailedTooltipContent />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="step"
|
||||
dataKey="diskUsagePercent"
|
||||
stroke={theme === 'dark' ? "#fbbf24" : "#d97706"}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
fill="url(#diskGradient)"
|
||||
fillOpacity={1}
|
||||
name="Disk Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<CardContent className="pt-2 flex-1 min-h-0">
|
||||
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="diskGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme === 'dark' ? "#f59e0b" : "#d97706"} stopOpacity={0.6}/>
|
||||
<stop offset="95%" stopColor={theme === 'dark' ? "#d97706" : "#b45309"} stopOpacity={0.1}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
label={{ value: 'Disk %', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<DetailedTooltipContent />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="step"
|
||||
dataKey="diskUsagePercent"
|
||||
stroke={theme === 'dark' ? "#fbbf24" : "#d97706"}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
fill="url(#diskGradient)"
|
||||
fillOpacity={1}
|
||||
name="Disk Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { useTheme } from "@/contexts/ThemeContext";
|
||||
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
|
||||
@@ -18,65 +18,62 @@ export const MemoryChart = ({ data, latestData }: MemoryChartProps) => {
|
||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
|
||||
<CardHeader className="pb-2 flex-shrink-0">
|
||||
<CardTitle className="flex items-center justify-between text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-lg bg-green-500/15">
|
||||
<MemoryStick className="h-5 w-5 text-green-500" />
|
||||
<MemoryStick className="h-4 w-4 lg:h-5 lg:w-5 text-green-500" />
|
||||
</div>
|
||||
Memory Usage
|
||||
<span className="text-sm lg:text-base">Memory Usage</span>
|
||||
</div>
|
||||
{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-xs text-muted-foreground">{latestData.ramUsed} / {latestData.ramTotal}</div>
|
||||
</div>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<ChartContainer config={{
|
||||
ramUsagePercent: {
|
||||
label: "Memory Usage (%)",
|
||||
color: theme === 'dark' ? "#10b981" : "#059669",
|
||||
}
|
||||
}} className="h-80">
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme === 'dark' ? "#10b981" : "#059669"} stopOpacity={0.5}/>
|
||||
<stop offset="95%" stopColor={theme === 'dark' ? "#047857" : "#065f46"} stopOpacity={0.1}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
label={{ value: 'Memory %', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<DetailedTooltipContent />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="basis"
|
||||
dataKey="ramUsagePercent"
|
||||
stroke={theme === 'dark' ? "#34d399" : "#059669"}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
fill="url(#memoryGradient)"
|
||||
fillOpacity={1}
|
||||
name="Memory Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<CardContent className="pt-2 flex-1 min-h-0">
|
||||
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme === 'dark' ? "#10b981" : "#059669"} stopOpacity={0.5}/>
|
||||
<stop offset="95%" stopColor={theme === 'dark' ? "#047857" : "#065f46"} stopOpacity={0.1}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
label={{ value: 'Memory %', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<DetailedTooltipContent />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="basis"
|
||||
dataKey="ramUsagePercent"
|
||||
stroke={theme === 'dark' ? "#34d399" : "#059669"}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
fill="url(#memoryGradient)"
|
||||
fillOpacity={1}
|
||||
name="Memory Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { useTheme } from "@/contexts/ThemeContext";
|
||||
import { NetworkTooltipContent } from "./tooltips/NetworkTooltipContent";
|
||||
@@ -18,84 +18,77 @@ export const NetworkChart = ({ data, latestData }: NetworkChartProps) => {
|
||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
|
||||
<CardHeader className="pb-2 flex-shrink-0">
|
||||
<CardTitle className="flex items-center justify-between text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-lg bg-purple-500/15">
|
||||
<Wifi className="h-5 w-5 text-purple-500" />
|
||||
<Wifi className="h-4 w-4 lg:h-5 lg:w-5 text-purple-500" />
|
||||
</div>
|
||||
Network Traffic
|
||||
<span className="text-sm lg:text-base">Network Traffic</span>
|
||||
</div>
|
||||
{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-red-500 font-semibold">{latestData.networkTxSpeed} KB/s ↑</div>
|
||||
</div>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<ChartContainer config={{
|
||||
networkRxSpeed: {
|
||||
label: "RX Speed",
|
||||
color: theme === 'dark' ? "#8b5cf6" : "#7c3aed",
|
||||
},
|
||||
networkTxSpeed: {
|
||||
label: "TX Speed",
|
||||
color: theme === 'dark' ? "#ef4444" : "#dc2626",
|
||||
}
|
||||
}} className="h-80">
|
||||
<LineChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="networkGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme === 'dark' ? "#8b5cf6" : "#7c3aed"} stopOpacity={0.2}/>
|
||||
<stop offset="95%" stopColor={theme === 'dark' ? "#7c3aed" : "#6d28d9"} stopOpacity={0.05}/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
label={{ value: 'Network Speed (KB/s)', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<NetworkTooltipContent />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkRxSpeed"
|
||||
stroke={theme === 'dark' ? "#a78bfa" : "#7c3aed"}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
name="RX Speed"
|
||||
filter="url(#glow)"
|
||||
strokeDasharray="0"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkTxSpeed"
|
||||
stroke={theme === 'dark' ? "#f87171" : "#dc2626"}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
name="TX Speed"
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<CardContent className="pt-2 flex-1 min-h-0">
|
||||
<div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="networkGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={theme === 'dark' ? "#8b5cf6" : "#7c3aed"} stopOpacity={0.2}/>
|
||||
<stop offset="95%" stopColor={theme === 'dark' ? "#7c3aed" : "#6d28d9"} stopOpacity={0.05}/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
label={{ value: 'Network Speed (KB/s)', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<NetworkTooltipContent />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkRxSpeed"
|
||||
stroke={theme === 'dark' ? "#a78bfa" : "#7c3aed"}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="RX Speed"
|
||||
filter="url(#glow)"
|
||||
strokeDasharray="0"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkTxSpeed"
|
||||
stroke={theme === 'dark' ? "#f87171" : "#dc2626"}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="TX Speed"
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
export const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -49,34 +48,82 @@ export const timeRangeOptions = [
|
||||
{ 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[] => {
|
||||
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();
|
||||
|
||||
// For 60m, let's be very specific about what we're looking for
|
||||
if (timeRange === '60m') {
|
||||
// console.log('⏰ 60m filter: Current time:', now.toISOString());
|
||||
|
||||
// Try exact 60 minutes first
|
||||
const cutoffTime60m = new Date(now.getTime() - (60 * 60 * 1000));
|
||||
// console.log('⏰ 60m filter: Cutoff time:', cutoffTime60m.toISOString());
|
||||
|
||||
const filtered60m = metrics.filter(metric => {
|
||||
const metricTime = new Date(metric.created || metric.timestamp);
|
||||
const isWithinRange = metricTime >= cutoffTime60m && metricTime <= now;
|
||||
|
||||
if (!isWithinRange) {
|
||||
const ageMinutes = Math.round((now.getTime() - metricTime.getTime()) / (1000 * 60));
|
||||
// console.log('⏰ Excluding record:', {
|
||||
// created: metric.created || metric.timestamp,
|
||||
// ageMinutes: ageMinutes,
|
||||
// reason: ageMinutes > 60 ? 'too old' : 'future date'
|
||||
// });
|
||||
}
|
||||
|
||||
return isWithinRange;
|
||||
});
|
||||
|
||||
// console.log('✅ 60m strict filter result:', filtered60m.length, 'records');
|
||||
|
||||
if (filtered60m.length > 0) {
|
||||
return filtered60m.sort((a, b) =>
|
||||
new Date(a.created || a.timestamp).getTime() - new Date(b.created || b.timestamp).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// If no data in exactly 60m, show what we have and return it anyway for debugging
|
||||
// console.log('⚠️ No data in 60m range, showing all available data ages:');
|
||||
metrics.forEach((metric, index) => {
|
||||
const metricTime = new Date(metric.created || metric.timestamp);
|
||||
const ageMinutes = Math.round((now.getTime() - metricTime.getTime()) / (1000 * 60));
|
||||
// console.log(`Record ${index}: ${metric.created || metric.timestamp} (${ageMinutes} minutes ago)`);
|
||||
});
|
||||
|
||||
// Return the most recent data regardless of age
|
||||
const sorted = metrics.sort((a, b) =>
|
||||
new Date(b.created || b.timestamp).getTime() - new Date(a.created || a.timestamp).getTime()
|
||||
);
|
||||
// console.log('🔄 Returning most recent available data for 60m view');
|
||||
return sorted.slice(0, 20); // Show last 20 records
|
||||
}
|
||||
|
||||
// For other time ranges, use normal filtering
|
||||
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
|
||||
if (!selectedRange) return metrics;
|
||||
|
||||
// Add small buffer to avoid edge cases
|
||||
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 cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
|
||||
|
||||
const filtered = metrics.filter(metric => {
|
||||
const metricTime = new Date(metric.created || metric.timestamp);
|
||||
return metricTime >= cutoffTime && metricTime <= now;
|
||||
});
|
||||
|
||||
// console.log('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) =>
|
||||
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 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) {
|
||||
timestampCache.clear();
|
||||
}
|
||||
@@ -127,37 +173,49 @@ const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => {
|
||||
return formatted;
|
||||
};
|
||||
|
||||
// Optimized chart data formatting with better performance
|
||||
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);
|
||||
// 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
|
||||
let maxDataPoints: number;
|
||||
switch (timeRange) {
|
||||
case '60m': maxDataPoints = 60; break; // 1 point per minute max
|
||||
case '1d': maxDataPoints = 144; break; // 1 point per 10 minutes max
|
||||
case '7d': maxDataPoints = 168; break; // 1 point per hour max
|
||||
case '1m': maxDataPoints = 120; break; // 1 point per 6 hours max
|
||||
case '3m': maxDataPoints = 90; break; // 1 point per day max
|
||||
case '60m': maxDataPoints = 60; break;
|
||||
case '1d': maxDataPoints = 144; break;
|
||||
case '7d': maxDataPoints = 168; break;
|
||||
case '1m': maxDataPoints = 120; break;
|
||||
case '3m': maxDataPoints = 90; break;
|
||||
default: maxDataPoints = 100;
|
||||
}
|
||||
|
||||
// Smart sampling - only sample if we have significantly more data
|
||||
const sampledMetrics = filteredMetrics.length > maxDataPoints * 1.2
|
||||
? filteredMetrics.filter((_, index) => index % Math.ceil(filteredMetrics.length / maxDataPoints) === 0)
|
||||
: filteredMetrics;
|
||||
|
||||
// console.log('formatChartData: After sampling:', sampledMetrics.length, 'metrics for display');
|
||||
// console.log('📊 formatChartData: After sampling:', sampledMetrics.length, 'metrics for display');
|
||||
|
||||
// Batch process the data transformation for better performance
|
||||
return sampledMetrics.map((metric) => {
|
||||
const formattedData = sampledMetrics.map((metric) => {
|
||||
const cpuUsage = typeof metric.cpu_usage === 'string' ?
|
||||
parseFloat(metric.cpu_usage.replace('%', '')) :
|
||||
parseFloat(metric.cpu_usage) || 0;
|
||||
@@ -210,4 +268,7 @@ export const formatChartData = (metrics: any[], timeRange: TimeRange) => {
|
||||
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 { serverService } from "@/services/serverService";
|
||||
import { formatChartData } from "../dataUtils";
|
||||
@@ -7,7 +7,7 @@ import { formatChartData } from "../dataUtils";
|
||||
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||
|
||||
export const useServerHistoryData = (serverId: string) => {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("60m");
|
||||
|
||||
const {
|
||||
data: metrics = [],
|
||||
@@ -23,15 +23,19 @@ export const useServerHistoryData = (serverId: string) => {
|
||||
return result || [];
|
||||
},
|
||||
enabled: !!serverId,
|
||||
refetchInterval: timeRange === '60m' ? 30000 : timeRange === '1d' ? 60000 : 120000, // Reduced frequency
|
||||
staleTime: timeRange === '60m' ? 15000 : timeRange === '1d' ? 30000 : 60000, // Increased stale time
|
||||
retry: 2, // Reduced retries
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 3000), // Faster retry
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes cache
|
||||
refetchInterval: timeRange === '60m' ? 60000 : timeRange === '1d' ? 120000 : 300000, // Significantly increased intervals
|
||||
staleTime: timeRange === '60m' ? 30000 : timeRange === '1d' ? 60000 : 120000, // Increased stale time
|
||||
retry: 1, // Reduced retries to prevent excessive requests
|
||||
retryDelay: 2000, // Slower retry
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes cache
|
||||
refetchOnWindowFocus: false, // Prevent refetch on window focus
|
||||
refetchOnMount: false, // Prevent refetch on mount if data exists
|
||||
});
|
||||
|
||||
// Memoize chart data formatting to prevent unnecessary recalculations
|
||||
const chartData = formatChartData(metrics, timeRange);
|
||||
const chartData = useMemo(() => {
|
||||
return formatChartData(metrics, timeRange);
|
||||
}, [metrics, timeRange]);
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { ServiceForm } from "./ServiceForm";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface AddServiceDialogProps {
|
||||
open: boolean;
|
||||
@@ -15,7 +16,11 @@ interface 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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AreaChart as AreaChartIcon, BarChart3, TrendingUp } from "lucide-react";
|
||||
|
||||
interface ResponseTimeChartProps {
|
||||
uptimeData: UptimeData[];
|
||||
}
|
||||
|
||||
type ChartType = 'area' | 'line' | 'bar';
|
||||
|
||||
export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
const { theme } = useTheme();
|
||||
const [chartType, setChartType] = useState<ChartType>('area');
|
||||
|
||||
// Modern color palette for different chart lines with solid colors at 90-100% opacity
|
||||
const modernColors = [
|
||||
{ stroke: '#f59e0b', fill: 'rgba(111, 86, 63, 0.95)' }, // Yellow (changed from blue)
|
||||
{ stroke: '#10b981', fill: 'rgba(0, 84, 56, 0.95)' }, // Emerald
|
||||
{ stroke: '#3b82f6', fill: 'rgba(59, 130, 246, 0.95)' }, // Blue (moved to second position)
|
||||
{ stroke: '#ef4444', fill: 'rgba(239, 68, 68, 0.95)' }, // Red
|
||||
{ stroke: '#8b5cf6', fill: 'rgba(139, 92, 246, 0.95)' }, // Violet
|
||||
{ stroke: '#06b6d4', fill: 'rgba(6, 182, 212, 0.95)' }, // Cyan
|
||||
{ stroke: '#f97316', fill: 'rgba(231, 148, 89, 0.95)' }, // Orange
|
||||
{ stroke: '#84cc16', fill: 'rgba(132, 204, 22, 0.95)' }, // Lime
|
||||
// Fixed color palette - consistent colors that don't change
|
||||
const fixedColors = [
|
||||
{ stroke: '#3b82f6', fill: 'rgba(59, 130, 246, 0.2)', name: 'Ocean Blue' },
|
||||
{ stroke: '#10b981', fill: 'rgba(16, 185, 129, 0.2)', name: 'Emerald Green' },
|
||||
{ stroke: '#f59e0b', fill: 'rgba(245, 158, 11, 0.2)', name: 'Golden Amber' },
|
||||
{ stroke: '#ef4444', fill: 'rgba(239, 68, 68, 0.2)', name: 'Ruby Red' },
|
||||
{ stroke: '#8b5cf6', fill: 'rgba(139, 92, 246, 0.2)', name: 'Royal Purple' },
|
||||
{ stroke: '#06b6d4', fill: 'rgba(6, 182, 212, 0.2)', name: 'Sky Cyan' },
|
||||
{ stroke: '#f97316', fill: 'rgba(249, 115, 22, 0.2)', name: 'Sunset Orange' },
|
||||
{ stroke: '#84cc16', fill: 'rgba(132, 204, 22, 0.2)', name: 'Fresh Lime' },
|
||||
{ stroke: '#ec4899', fill: 'rgba(236, 72, 153, 0.2)', name: 'Vibrant Pink' },
|
||||
{ stroke: '#14b8a6', fill: 'rgba(20, 184, 166, 0.2)', name: 'Ocean Teal' },
|
||||
];
|
||||
|
||||
// Check if we have data from multiple sources
|
||||
@@ -160,7 +167,7 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
return [Math.max(0, minValue - padding), maxValue + padding];
|
||||
}, [chartData, hasMultipleSources]);
|
||||
|
||||
// Get unique sources for legend with proper labeling and colors
|
||||
// Get unique sources for legend with consistent fixed colors
|
||||
const sources = useMemo(() => {
|
||||
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 [regionName, agentId] = source.split('|');
|
||||
|
||||
// Create proper label with region and agent info
|
||||
let label = regionName;
|
||||
if (data?.agent_id && data.agent_id !== '1') {
|
||||
label = `${regionName} (${data.agent_id})`;
|
||||
label = `${regionName} (Agent ${data.agent_id})`;
|
||||
} 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 {
|
||||
key: `regional_${source.replace('|', '_')}`,
|
||||
label: label,
|
||||
stroke: modernColors[colorIndex].stroke,
|
||||
fill: modernColors[colorIndex].fill
|
||||
stroke: fixedColors[colorIndex].stroke,
|
||||
fill: fixedColors[colorIndex].fill,
|
||||
colorName: fixedColors[colorIndex].name
|
||||
};
|
||||
});
|
||||
|
||||
@@ -205,9 +216,10 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
|
||||
const defaultSources = (hasPureDefault && !hasRegionalDefault) ? [{
|
||||
key: 'default',
|
||||
label: 'Default',
|
||||
stroke: modernColors[regionalSources.length % modernColors.length].stroke,
|
||||
fill: modernColors[regionalSources.length % modernColors.length].fill
|
||||
label: 'Default System',
|
||||
stroke: fixedColors[regionalSources.length % fixedColors.length].stroke,
|
||||
fill: fixedColors[regionalSources.length % fixedColors.length].fill,
|
||||
colorName: fixedColors[regionalSources.length % fixedColors.length].name
|
||||
}] : [];
|
||||
|
||||
return [...defaultSources, ...regionalSources];
|
||||
@@ -219,13 +231,13 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
const data = payload[0].payload;
|
||||
|
||||
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`}>
|
||||
<p className="text-sm font-medium">{String(label)}</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">{String(data.date)}</p>
|
||||
<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-semibold mb-1">{String(label)}</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">{String(data.date)}</p>
|
||||
|
||||
{hasMultipleSources ? (
|
||||
// Multi-source tooltip
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{sources.map(source => {
|
||||
const valueKey = source.key === 'default' ? 'defaultValue' : `${source.key}_value`;
|
||||
const statusKey = source.key === 'default' ? 'defaultStatus' : `${source.key}_status`;
|
||||
@@ -234,35 +246,45 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
|
||||
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";
|
||||
|
||||
if (status === "up") {
|
||||
statusColor = "bg-emerald-800";
|
||||
statusBadgeClass += " bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
|
||||
statusText = "Up";
|
||||
} 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";
|
||||
} 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";
|
||||
} 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";
|
||||
} else {
|
||||
statusBadgeClass += " bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={source.key} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div key={source.key} className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div
|
||||
className="w-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 }}
|
||||
></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">
|
||||
{status === "paused" ? "Paused" :
|
||||
value !== null && value !== undefined ? `${value} ms` : "No data"}
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={statusBadgeClass}>
|
||||
{statusText}
|
||||
</span>
|
||||
<div className="text-sm font-mono">
|
||||
{status === "paused" ? "Paused" :
|
||||
value !== null && value !== undefined ? `${value} ms` : "No data"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -271,19 +293,19 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
) : (
|
||||
// Single source tooltip - showing status with color
|
||||
<>
|
||||
<div className={`flex items-center gap-2 mt-1 ${data.status === "paused" ? "opacity-70" : ""}`}>
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
data.status === "up" ? "bg-emerald-800" :
|
||||
data.status === "down" ? "bg-red-800" :
|
||||
data.status === "warning" ? "bg-yellow-800" : "bg-gray-800"
|
||||
<div className={`flex items-center gap-3 mt-2 ${data.status === "paused" ? "opacity-70" : ""}`}>
|
||||
<div className={`w-4 h-4 rounded-full ${
|
||||
data.status === "up" ? "bg-green-500" :
|
||||
data.status === "down" ? "bg-red-500" :
|
||||
data.status === "warning" ? "bg-yellow-500" : "bg-gray-500"
|
||||
}`}></div>
|
||||
<span>{
|
||||
<span className="font-medium">{
|
||||
data.status === "up" ? "Up" :
|
||||
data.status === "down" ? "Down" :
|
||||
data.status === "warning" ? "Warning" : "Paused"
|
||||
}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm">
|
||||
<p className="mt-2 font-mono text-lg">
|
||||
{data.status === "paused" ? "Monitoring paused" :
|
||||
data.value !== null ? `${data.value} ms` : "No data"}
|
||||
</p>
|
||||
@@ -295,6 +317,182 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
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
|
||||
const hasData = uptimeData.length > 0;
|
||||
|
||||
@@ -319,21 +517,55 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
|
||||
<span>Response Time History</span>
|
||||
{hasData && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{dateRangeDisplay}
|
||||
</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 && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{dateRangeDisplay}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
{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 => (
|
||||
<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
|
||||
className="w-3 h-3 rounded-full"
|
||||
className="w-3 h-3 rounded-full border border-white shadow-sm"
|
||||
style={{ backgroundColor: source.stroke }}
|
||||
></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>
|
||||
@@ -347,118 +579,7 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
|
||||
) : (
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{hasMultipleSources ? (
|
||||
<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>
|
||||
)}
|
||||
{renderChart()}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { Service, UptimeData } from "@/types/service.types";
|
||||
|
||||
@@ -18,71 +18,117 @@ export const useRealTimeUpdates = ({
|
||||
setService,
|
||||
setUptimeData
|
||||
}: 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(() => {
|
||||
if (!serviceId) return;
|
||||
|
||||
// console.log(`Setting up real-time updates for service: ${serviceId}`);
|
||||
// console.log(`Setting up real-time updates for service: ${serviceId}`);
|
||||
|
||||
try {
|
||||
// Subscribe to the service record for real-time updates
|
||||
const subscription = pb.collection('services').subscribe(serviceId, function(e) {
|
||||
// console.log("Service updated:", e.record);
|
||||
|
||||
// Update our local state with the new data
|
||||
if (e.record) {
|
||||
setService(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
status: e.record.status || prev.status,
|
||||
responseTime: e.record.response_time || e.record.responseTime || prev.responseTime,
|
||||
uptime: e.record.uptime || prev.uptime,
|
||||
lastChecked: e.record.last_checked || e.record.lastChecked || prev.lastChecked,
|
||||
};
|
||||
});
|
||||
const setupSubscriptions = async () => {
|
||||
try {
|
||||
// Clean up existing subscriptions first
|
||||
if (subscriptionsRef.current.service) {
|
||||
subscriptionsRef.current.service();
|
||||
}
|
||||
if (subscriptionsRef.current.uptime) {
|
||||
subscriptionsRef.current.uptime();
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to uptime data updates
|
||||
const uptimeSubscription = pb.collection('uptime_data').subscribe('*', function(e) {
|
||||
if (e.record && e.record.service_id === serviceId) {
|
||||
// console.log("New uptime data:", e.record);
|
||||
// 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
|
||||
if (e.record) {
|
||||
setService(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
status: e.record.status || prev.status,
|
||||
responseTime: e.record.response_time || e.record.responseTime || prev.responseTime,
|
||||
uptime: e.record.uptime || prev.uptime,
|
||||
lastChecked: e.record.last_checked || e.record.lastChecked || prev.lastChecked,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
subscriptionsRef.current.service = serviceUnsubscribe;
|
||||
|
||||
// Subscribe to uptime data updates with throttling
|
||||
const uptimeUnsubscribe = await pb.collection('uptime_data').subscribe('*', function(e) {
|
||||
if (!e.record || e.record.service_id !== serviceId) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastUpdateRef.current < updateThrottleMs) {
|
||||
// console.log("Uptime data update throttled");
|
||||
return;
|
||||
}
|
||||
lastUpdateRef.current = now;
|
||||
|
||||
// console.log("New uptime data (throttled):", e.record);
|
||||
|
||||
// Add the new uptime data to our list if it's within the selected date range
|
||||
const timestamp = new Date(e.record.timestamp);
|
||||
if (timestamp >= startDate && timestamp <= endDate) {
|
||||
setUptimeData(prev => {
|
||||
// Limit the array size to prevent memory issues
|
||||
const maxRecords = 100;
|
||||
const newData: UptimeData = {
|
||||
id: e.record.id,
|
||||
service_id: e.record.service_id, // Include service_id
|
||||
serviceId: e.record.service_id, // Keep for backward compatibility
|
||||
service_id: e.record.service_id,
|
||||
serviceId: e.record.service_id,
|
||||
timestamp: e.record.timestamp,
|
||||
status: e.record.status,
|
||||
responseTime: e.record.response_time || 0,
|
||||
date: e.record.timestamp, // Adding required date property
|
||||
uptime: e.record.uptime || 0 // Adding required uptime property
|
||||
date: e.record.timestamp,
|
||||
uptime: e.record.uptime || 0
|
||||
};
|
||||
|
||||
// Add at the beginning of the array to maintain newest first sorting
|
||||
return [newData, ...prev];
|
||||
// Add at the beginning and limit array size
|
||||
const updatedData = [newData, ...prev];
|
||||
return updatedData.slice(0, maxRecords);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up the subscriptions
|
||||
return () => {
|
||||
// console.log(`Cleaning up subscriptions for service: ${serviceId}`);
|
||||
try {
|
||||
pb.collection('services').unsubscribe(serviceId);
|
||||
pb.collection('uptime_data').unsubscribe('*');
|
||||
} catch (error) {
|
||||
// console.error("Error cleaning up subscriptions:", error);
|
||||
subscriptionsRef.current.uptime = uptimeUnsubscribe;
|
||||
|
||||
} catch (error) {
|
||||
// console.error("Error setting up real-time updates:", error);
|
||||
}
|
||||
};
|
||||
|
||||
setupSubscriptions();
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
// console.log(`Cleaning up subscriptions for service: ${serviceId}`);
|
||||
try {
|
||||
if (subscriptionsRef.current.service) {
|
||||
subscriptionsRef.current.service();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// console.error("Error setting up real-time updates:", error);
|
||||
}
|
||||
if (subscriptionsRef.current.uptime) {
|
||||
subscriptionsRef.current.uptime();
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("Error cleaning up subscriptions:", error);
|
||||
}
|
||||
};
|
||||
}, [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 { Service, UptimeData } from "@/types/service.types";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -16,14 +17,17 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Get regional agents for "all" monitoring
|
||||
// Get regional agents for "all" monitoring with optimized caching
|
||||
const { data: regionalAgents = [] } = useQuery({
|
||||
queryKey: ['regional-services'],
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -38,7 +42,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
description: `Service status changed to ${newStatus}`,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error("Failed to update service status:", error);
|
||||
// console.error("Failed to update service status:", error);
|
||||
setService(prevService => prevService);
|
||||
|
||||
toast({
|
||||
@@ -47,9 +51,9 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
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 {
|
||||
if (!service) {
|
||||
// console.log('No service data available for uptime fetch');
|
||||
@@ -80,7 +84,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
|
||||
// Fetch default monitoring data
|
||||
const defaultData = await uptimeService.getUptimeHistory(serviceId, limit, start, end, service.type);
|
||||
// console.log(`Retrieved ${defaultData.length} default monitoring records`);
|
||||
// console.log(`Retrieved ${defaultData.length} default monitoring records`);
|
||||
|
||||
// Mark default data with source identifier
|
||||
const markedDefaultData = defaultData.map(record => ({
|
||||
@@ -100,7 +104,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
const regionalData = await uptimeService.getUptimeHistoryByRegionalAgent(
|
||||
serviceId, limit, start, end, service.type, agent.region_name, agent.agent_id
|
||||
);
|
||||
// console.log(`Retrieved ${regionalData.length} records from ${agent.region_name}`);
|
||||
// console.log(`Retrieved ${regionalData.length} records from ${agent.region_name}`);
|
||||
|
||||
// Mark regional data with source identifier
|
||||
const markedRegionalData = regionalData.map(record => ({
|
||||
@@ -112,18 +116,18 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
|
||||
history = [...history, ...markedRegionalData];
|
||||
} catch (error) {
|
||||
// console.error(`Error fetching data from ${agent.region_name}:`, error);
|
||||
// console.error(`Error fetching data from ${agent.region_name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(`Total combined records: ${history.length}`);
|
||||
// console.log(`Total combined records: ${history.length}`);
|
||||
|
||||
} else {
|
||||
// Fetch regional agent specific data
|
||||
const [regionName, agentId] = currentAgent.split("|");
|
||||
// console.log(`Fetching regional agent data for region: ${regionName}, agent: ${agentId} from ${service.type} collection`);
|
||||
console.log(`Fetching regional agent data for region: ${regionName}, agent: ${agentId} from ${service.type} collection`);
|
||||
history = await uptimeService.getUptimeHistoryByRegionalAgent(serviceId, limit, start, end, service.type, regionName, agentId);
|
||||
// console.log(`Retrieved ${history.length} regional monitoring records`);
|
||||
// console.log(`Retrieved ${history.length} regional monitoring records`);
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
@@ -131,7 +135,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
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);
|
||||
return filteredHistory;
|
||||
} catch (error) {
|
||||
@@ -143,9 +147,9 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}, [service, selectedRegionalAgent, regionalAgents, toast]);
|
||||
|
||||
const handleRegionalAgentChange = (agent: string) => {
|
||||
const handleRegionalAgentChange = useCallback((agent: string) => {
|
||||
// console.log(`Regional agent changed from ${selectedRegionalAgent} to: ${agent}`);
|
||||
|
||||
// Clear data immediately when switching
|
||||
@@ -154,78 +158,83 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
|
||||
// Refetch data with new agent selection
|
||||
if (serviceId && !isLoading && service) {
|
||||
// console.log(`Refetching data for new agent: ${agent}`);
|
||||
// console.log(`Refetching data for new agent: ${agent}`);
|
||||
fetchUptimeData(serviceId, startDate, endDate, '24h', agent);
|
||||
}
|
||||
};
|
||||
}, [selectedRegionalAgent, serviceId, isLoading, service, fetchUptimeData, startDate, endDate]);
|
||||
|
||||
// Memoize the service data fetching to prevent unnecessary re-runs
|
||||
const fetchServiceData = useCallback(async () => {
|
||||
try {
|
||||
if (!serviceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Request timed out")), 10000);
|
||||
});
|
||||
|
||||
const fetchPromise = pb.collection('services').getOne(serviceId);
|
||||
const serviceData = await Promise.race([fetchPromise, timeoutPromise]) as any;
|
||||
|
||||
const formattedService: Service = {
|
||||
id: serviceData.id,
|
||||
name: serviceData.name,
|
||||
url: serviceData.url || "",
|
||||
host: serviceData.host || "",
|
||||
port: serviceData.port || undefined,
|
||||
domain: serviceData.domain || "",
|
||||
type: serviceData.service_type || serviceData.type || "HTTP",
|
||||
status: serviceData.status || "paused",
|
||||
responseTime: serviceData.response_time || serviceData.responseTime || 0,
|
||||
uptime: serviceData.uptime || 0,
|
||||
lastChecked: serviceData.last_checked || serviceData.lastChecked || new Date().toLocaleString(),
|
||||
interval: serviceData.heartbeat_interval || serviceData.interval || 60,
|
||||
retries: serviceData.max_retries || serviceData.retries || 3,
|
||||
notificationChannel: serviceData.notification_id,
|
||||
alertTemplate: serviceData.template_id,
|
||||
alerts: serviceData.alerts || "unmuted"
|
||||
};
|
||||
|
||||
// console.log(`Loaded service: ${formattedService.name} (${formattedService.type})`);
|
||||
setService(formattedService);
|
||||
|
||||
// Small delay to ensure state is updated before fetching uptime data
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (error) {
|
||||
// console.error("Error fetching service:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to load service data. Please try again.",
|
||||
});
|
||||
navigate("/dashboard");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [serviceId, navigate, toast]);
|
||||
|
||||
// Initial data loading
|
||||
useEffect(() => {
|
||||
const fetchServiceData = async () => {
|
||||
try {
|
||||
if (!serviceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Request timed out")), 10000);
|
||||
});
|
||||
|
||||
const fetchPromise = pb.collection('services').getOne(serviceId);
|
||||
const serviceData = await Promise.race([fetchPromise, timeoutPromise]) as any;
|
||||
|
||||
const formattedService: Service = {
|
||||
id: serviceData.id,
|
||||
name: serviceData.name,
|
||||
url: serviceData.url || "",
|
||||
host: serviceData.host || "",
|
||||
port: serviceData.port || undefined,
|
||||
domain: serviceData.domain || "",
|
||||
type: serviceData.service_type || serviceData.type || "HTTP",
|
||||
status: serviceData.status || "paused",
|
||||
responseTime: serviceData.response_time || serviceData.responseTime || 0,
|
||||
uptime: serviceData.uptime || 0,
|
||||
lastChecked: serviceData.last_checked || serviceData.lastChecked || new Date().toLocaleString(),
|
||||
interval: serviceData.heartbeat_interval || serviceData.interval || 60,
|
||||
retries: serviceData.max_retries || serviceData.retries || 3,
|
||||
notificationChannel: serviceData.notification_id,
|
||||
alertTemplate: serviceData.template_id,
|
||||
alerts: serviceData.alerts || "unmuted"
|
||||
};
|
||||
|
||||
// console.log(`Loaded service: ${formattedService.name} (${formattedService.type})`);
|
||||
setService(formattedService);
|
||||
|
||||
// Small delay to ensure state is updated before fetching uptime data
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (error) {
|
||||
// console.error("Error fetching service:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to load service data. Please try again.",
|
||||
});
|
||||
navigate("/dashboard");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchServiceData();
|
||||
}, [serviceId, navigate, toast]);
|
||||
}, [fetchServiceData]);
|
||||
|
||||
// Update data when date range changes or when service is loaded
|
||||
// Update data when date range changes or when service is loaded - with debouncing
|
||||
useEffect(() => {
|
||||
if (serviceId && !isLoading && service) {
|
||||
// console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||
fetchUptimeData(serviceId, startDate, endDate, '24h', selectedRegionalAgent);
|
||||
}
|
||||
}, [startDate, endDate, serviceId, isLoading, service, selectedRegionalAgent, regionalAgents]);
|
||||
const timeoutId = setTimeout(() => {
|
||||
// console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||
fetchUptimeData(serviceId, startDate, endDate, '24h', selectedRegionalAgent);
|
||||
}, 500); // Debounce API calls by 500ms
|
||||
|
||||
return {
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [startDate, endDate, serviceId, isLoading, service, selectedRegionalAgent, regionalAgents, fetchUptimeData]);
|
||||
|
||||
return useMemo(() => ({
|
||||
service,
|
||||
setService,
|
||||
uptimeData,
|
||||
@@ -235,5 +244,5 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
|
||||
fetchUptimeData,
|
||||
selectedRegionalAgent,
|
||||
handleRegionalAgentChange
|
||||
};
|
||||
}), [service, uptimeData, isLoading, handleStatusChange, fetchUptimeData, selectedRegionalAgent, handleRegionalAgentChange]);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { Service, UptimeData } from "@/types/service.types";
|
||||
import { ServiceHeader } from "@/components/services/ServiceHeader";
|
||||
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
|
||||
@@ -37,6 +36,7 @@ export const ServiceDetailContent = ({
|
||||
onStatusChange={onStatusChange}
|
||||
selectedRegionalAgent={selectedRegionalAgent}
|
||||
onRegionalAgentChange={onRegionalAgentChange}
|
||||
uptimeData={uptimeData}
|
||||
/>
|
||||
<ServiceStatsCards service={service} uptimeData={uptimeData} />
|
||||
|
||||
|
||||
@@ -1,93 +1,116 @@
|
||||
|
||||
import { ArrowLeft, Globe } from "lucide-react";
|
||||
import { ArrowLeft, Globe, BarChart3 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/services/StatusBadge";
|
||||
import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ServiceHeaderProps {
|
||||
service: Service;
|
||||
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
||||
selectedRegionalAgent?: string;
|
||||
onRegionalAgentChange?: (agent: string) => void;
|
||||
uptimeData?: UptimeData[];
|
||||
}
|
||||
|
||||
export function ServiceHeader({
|
||||
service,
|
||||
onStatusChange,
|
||||
selectedRegionalAgent,
|
||||
onRegionalAgentChange
|
||||
onRegionalAgentChange,
|
||||
uptimeData = []
|
||||
}: ServiceHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-4 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold">{service.name}</h1>
|
||||
|
||||
{/* Pulsating Circle Animation */}
|
||||
<div className="relative ml-2 flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-3 w-3 relative",
|
||||
service.status === "up" ? "bg-green-500" :
|
||||
service.status === "down" ? "bg-red-500" :
|
||||
service.status === "warning" ? "bg-yellow-500" :
|
||||
"bg-blue-500",
|
||||
"rounded-full"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"animate-ping absolute h-3 w-3",
|
||||
service.status === "up" ? "bg-green-400" :
|
||||
service.status === "down" ? "bg-red-400" :
|
||||
service.status === "warning" ? "bg-yellow-400" :
|
||||
"bg-blue-400",
|
||||
"rounded-full opacity-75"
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-4 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold">{service.name}</h1>
|
||||
|
||||
{/* Pulsating Circle Animation */}
|
||||
<div className="relative ml-2 flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-3 w-3 relative",
|
||||
service.status === "up" ? "bg-green-500" :
|
||||
service.status === "down" ? "bg-red-500" :
|
||||
service.status === "warning" ? "bg-yellow-500" :
|
||||
"bg-blue-500",
|
||||
"rounded-full"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"animate-ping absolute h-3 w-3",
|
||||
service.status === "up" ? "bg-green-400" :
|
||||
service.status === "down" ? "bg-red-400" :
|
||||
service.status === "warning" ? "bg-yellow-400" :
|
||||
"bg-blue-400",
|
||||
"rounded-full opacity-75"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{service.url && (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
|
||||
>
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
{service.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{service.url && (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
|
||||
<div className="flex items-center space-x-4">
|
||||
<StatusBadge status={service.status} size="lg" />
|
||||
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowHeatmap(true)}
|
||||
className="bg-blue-900/20 hover:bg-blue-900/30"
|
||||
>
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
{service.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<StatusBadge status={service.status} size="lg" />
|
||||
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
||||
{selectedRegionalAgent !== undefined && onRegionalAgentChange && (
|
||||
<RegionalAgentFilter
|
||||
selectedAgent={selectedRegionalAgent}
|
||||
onAgentChange={onRegionalAgentChange}
|
||||
/>
|
||||
)}
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Heatmap
|
||||
</Button>
|
||||
{selectedRegionalAgent !== undefined && onRegionalAgentChange && (
|
||||
<RegionalAgentFilter
|
||||
selectedAgent={selectedRegionalAgent}
|
||||
onAgentChange={onRegionalAgentChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeatmapDialog
|
||||
open={showHeatmap}
|
||||
onOpenChange={setShowHeatmap}
|
||||
serviceName={service.name}
|
||||
uptimeData={uptimeData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +1,70 @@
|
||||
|
||||
import React from "react";
|
||||
import { Check, X, Pause, AlertTriangle } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
status: string;
|
||||
interface StatusBadgeProps {
|
||||
status: "up" | "down" | "paused" | "warning";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export const StatusBadge = ({ status, size = "sm" }: StatusBadgeProps) => {
|
||||
// Determine the sizing classes based on the size prop
|
||||
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":
|
||||
const StatusBadgeComponent = ({ status, size = "sm" }: StatusBadgeProps) => {
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return {
|
||||
variant: "default" as const,
|
||||
className: "bg-emerald-700 text-emerald-100 border-emerald-200 hover:bg-emerald-200",
|
||||
label:
|
||||
<span className="flex items-center gap-1">
|
||||
<Check className="w-4 h-4" /> Up
|
||||
</span>
|
||||
|
||||
};
|
||||
case "down":
|
||||
return {
|
||||
variant: "destructive" as const,
|
||||
className: "bg-red-700 text-red-100 border-red-200 hover:bg-red-200",
|
||||
label: "Down"
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
variant: "destructive" as const,
|
||||
className: "bg-amber-700 text-amber-100 border-amber-200 hover:bg-amber-200",
|
||||
label: "Warning"
|
||||
};
|
||||
case "paused":
|
||||
return {
|
||||
variant: "secondary" as const,
|
||||
className: "bg-gray-700 text-gray-100 border-gray-200 hover:bg-gray-200",
|
||||
label: "Paused"
|
||||
};
|
||||
default:
|
||||
return "px-2 py-0.5 text-xs gap-0.5";
|
||||
return {
|
||||
variant: "outline" as const,
|
||||
className: "bg-gray-700 text-gray-100 border-gray-200",
|
||||
label: "Unknown"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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 = {
|
||||
sm: "text-xs px-2 py-1",
|
||||
md: "text-sm px-3 py-1.5",
|
||||
lg: "text-base px-4 py-2"
|
||||
};
|
||||
|
||||
const sizeClasses = getSizeClasses();
|
||||
const iconSize = getIconSize();
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
switch (status) {
|
||||
case "up":
|
||||
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}`}>
|
||||
<Check className={iconSize} />
|
||||
<span>Up</span>
|
||||
</div>
|
||||
);
|
||||
case "down":
|
||||
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}`}>
|
||||
<X className={iconSize} />
|
||||
<span>Down</span>
|
||||
</div>
|
||||
);
|
||||
case "warning":
|
||||
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}`}>
|
||||
<AlertTriangle className={iconSize} />
|
||||
<span>Warning</span>
|
||||
</div>
|
||||
);
|
||||
case "paused":
|
||||
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}`}>
|
||||
<Pause className={iconSize} />
|
||||
<span>Paused</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
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 { useConsolidatedUptimeData } from "./hooks/useConsolidatedUptimeData";
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { UptimeSummary } from "./uptime/UptimeSummary";
|
||||
import { formatRelative } from "date-fns";
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { UptimeStatusItem } from './uptime/UptimeStatusItem';
|
||||
import { uptimeService } from '@/services/uptimeService';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
|
||||
interface UptimeBarProps {
|
||||
uptime: number;
|
||||
@@ -13,127 +14,81 @@ interface UptimeBarProps {
|
||||
serviceType?: string;
|
||||
}
|
||||
|
||||
export const UptimeBar = ({ uptime, status, serviceId, interval, serviceType }: UptimeBarProps) => {
|
||||
// Use consolidated hook to get properly merged data
|
||||
const { consolidatedItems, isLoading } = useConsolidatedUptimeData({
|
||||
serviceId,
|
||||
serviceType,
|
||||
status,
|
||||
interval
|
||||
const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType = "HTTP" }: UptimeBarProps) => {
|
||||
// Calculate date range for last 20 checks with much more aggressive caching
|
||||
const endDate = useMemo(() => new Date(), []);
|
||||
const startDate = useMemo(() => {
|
||||
const start = new Date(endDate);
|
||||
start.setHours(start.getHours() - Math.max(interval * 20 / 3600, 24)); // At least 24 hours
|
||||
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) => {
|
||||
if (!hasData) {
|
||||
return "bg-gray-400"; // No data color - grey
|
||||
// Memoize the uptime calculation to prevent unnecessary recalculations
|
||||
const { uptimePercentage, displayData } = useMemo(() => {
|
||||
if (!uptimeData || uptimeData.length === 0) {
|
||||
return {
|
||||
uptimePercentage: uptime || 0,
|
||||
displayData: []
|
||||
};
|
||||
}
|
||||
|
||||
switch (itemStatus) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
// 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;
|
||||
|
||||
// console.log('UptimeBar rendering consolidated items:', consolidatedItems.length);
|
||||
// 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 (
|
||||
<div className="w-52">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
{consolidatedItems.map((slot, index) => {
|
||||
// Check if we have actual monitoring data with valid status
|
||||
const hasValidData = slot.items.length > 0 && slot.items.some(item =>
|
||||
item.status && ['up', 'down', 'warning', 'paused'].includes(item.status)
|
||||
);
|
||||
|
||||
// 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 className="text-xs text-muted-foreground text-center">
|
||||
No monitoring data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex space-x-0.5 min-w-0">
|
||||
{statusItems.length > 0 ? statusItems : (
|
||||
// Fallback display when no data
|
||||
<div className="h-5 w-1.5 rounded-sm bg-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<UptimeSummary uptime={uptime} interval={interval} />
|
||||
</div>
|
||||
<span className="text-sm font-medium whitespace-nowrap">
|
||||
{uptimePercentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last 20 checks
|
||||
</span>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
// 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 { ServiceRegionalFields } from "./ServiceRegionalFields";
|
||||
import { getServiceFormDefaults, mapServiceToFormData, mapFormDataToServiceData } from "./serviceFormUtils";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface ServiceFormProps {
|
||||
onSuccess: () => void;
|
||||
@@ -31,6 +32,7 @@ export function ServiceForm({
|
||||
}: ServiceFormProps) {
|
||||
const { toast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Initialize form with default values
|
||||
const form = useForm<ServiceFormData>({
|
||||
@@ -68,10 +70,10 @@ export function ServiceForm({
|
||||
if (onSubmitStart) onSubmitStart();
|
||||
|
||||
try {
|
||||
console.log("Form data being submitted:", data);
|
||||
// console.log("Form data being submitted:", data);
|
||||
|
||||
const serviceData = mapFormDataToServiceData(data);
|
||||
console.log("Service data being sent:", serviceData);
|
||||
// console.log("Service data being sent:", serviceData);
|
||||
|
||||
if (isEdit && initialData) {
|
||||
// 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();
|
||||
if (!isEdit) {
|
||||
form.reset();
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
|
||||
placeholder={getPlaceholder()}
|
||||
{...field}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
@@ -93,7 +93,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
console.log("Port field changed:", e.target.value);
|
||||
// console.log("Port field changed:", e.target.value);
|
||||
field.onChange(e);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ interface UseUptimeDataProps {
|
||||
export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseUptimeDataProps) => {
|
||||
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({
|
||||
queryKey: ['allUptimeHistory', serviceId, serviceType],
|
||||
queryFn: async () => {
|
||||
@@ -34,8 +34,8 @@ export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseU
|
||||
return allMonitoringData;
|
||||
},
|
||||
enabled: !!serviceId,
|
||||
refetchInterval: 30000,
|
||||
staleTime: 15000,
|
||||
refetchInterval: 60000, // 1 minute polling
|
||||
staleTime: 30000, // Data is fresh for 30 seconds
|
||||
placeholderData: (previousData) => previousData,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||
|
||||
@@ -40,7 +40,6 @@ export const AboutSystem: React.FC = () => {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
|
||||
<p className="text-muted-foreground text-base leading-relaxed mt-2">
|
||||
{t('aboutCheckcle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -45,20 +45,20 @@ export const SSLCertificateActions = ({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onView(certificate)}>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onView(certificate); }}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t('view')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCheck}>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleCheck(); }}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Check
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(certificate)}>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onEdit(certificate); }}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{t('edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(certificate)}
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(certificate); }}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
@@ -67,4 +67,4 @@ export const SSLCertificateActions = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -37,10 +37,12 @@ import { fetchSSLCertificates, addSSLCertificate, deleteSSLCertificate } from "@
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { SSLCertificate } from "@/types/ssl.types";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const SSLCertificatesTable = () => {
|
||||
const { t } = useLanguage();
|
||||
const { theme } = useTheme();
|
||||
const queryClient = useQueryClient();
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
@@ -65,7 +67,7 @@ export const SSLCertificatesTable = () => {
|
||||
setShowAddDialog(false);
|
||||
toast.success(t('certificateAdded'));
|
||||
} catch (error) {
|
||||
console.error("Error adding certificate:", error);
|
||||
// console.error("Error adding certificate:", error);
|
||||
toast.error(t('failedToAddCertificate'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -87,7 +89,7 @@ export const SSLCertificatesTable = () => {
|
||||
setSelectedCertificate(null);
|
||||
toast.success(t('certificateUpdated'));
|
||||
} catch (error) {
|
||||
console.error("Error updating certificate:", error);
|
||||
// console.error("Error updating certificate:", error);
|
||||
toast.error(t('failedToUpdateCertificate'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -105,7 +107,7 @@ export const SSLCertificatesTable = () => {
|
||||
setSelectedCertificate(null);
|
||||
toast.success(t('certificateDeleted'));
|
||||
} catch (error) {
|
||||
console.error("Error deleting certificate:", error);
|
||||
// console.error("Error deleting certificate:", error);
|
||||
toast.error(t('failedToDeleteCertificate'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -128,24 +130,32 @@ export const SSLCertificatesTable = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
{certificates.length === 0 ? (
|
||||
<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>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('domain')}</TableHead>
|
||||
<TableHead>{t('status')}</TableHead>
|
||||
<TableHead>{t('issuer')}</TableHead>
|
||||
<TableHead>{t('validUntil')}</TableHead>
|
||||
<TableHead>{t('daysLeft')}</TableHead>
|
||||
<TableHead>Check Interval</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('domain')}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('status')}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('issuer')}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('validUntil')}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('daysLeft')}</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Check Interval</TableHead>
|
||||
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right w-[50px]`}>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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">
|
||||
{certificate.domain}
|
||||
</TableCell>
|
||||
@@ -164,7 +174,7 @@ export const SSLCertificatesTable = () => {
|
||||
<TableCell>
|
||||
{certificate.check_interval || 1} {t('days')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<SSLCertificateActions
|
||||
certificate={certificate}
|
||||
onView={openViewDialog}
|
||||
@@ -176,14 +186,8 @@ export const SSLCertificatesTable = () => {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{certificates.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t('noCertificatesFound')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Certificate Dialog */}
|
||||
<SSLCertificateDetailDialog
|
||||
@@ -250,6 +254,6 @@ export const SSLCertificatesTable = () => {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,12 +30,12 @@ export const SSLDomainContent = () => {
|
||||
queryKey: ['ssl-certificates'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// console.log("Fetching SSL certificates from SSLDomainContent...");
|
||||
console.log("Fetching SSL certificates from SSLDomainContent...");
|
||||
const result = await fetchSSLCertificates();
|
||||
// console.log("Received SSL certificates:", result);
|
||||
console.log("Received SSL certificates:", result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// console.error("Error fetching certificates:", error);
|
||||
console.error("Error fetching certificates:", error);
|
||||
toast.error(t('failedToLoadCertificates'));
|
||||
throw error;
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export const SSLDomainContent = () => {
|
||||
toast.success(t('sslCertificateAdded'));
|
||||
},
|
||||
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'));
|
||||
}
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export const SSLDomainContent = () => {
|
||||
// Edit certificate mutation - Updated to ensure thresholds are properly updated
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (certificate: SSLCertificate) => {
|
||||
// console.log("Updating certificate with data:", certificate);
|
||||
console.log("Updating certificate with data:", certificate);
|
||||
|
||||
// Create the update data object
|
||||
const updateData = {
|
||||
@@ -70,12 +70,12 @@ export const SSLDomainContent = () => {
|
||||
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
|
||||
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
|
||||
// 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'));
|
||||
},
|
||||
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'));
|
||||
}
|
||||
});
|
||||
@@ -103,33 +103,26 @@ export const SSLDomainContent = () => {
|
||||
toast.success(t('sslCertificateDeleted'));
|
||||
},
|
||||
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'));
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh certificate mutation - Updated to ensure notifications are sent
|
||||
// Refresh certificate mutation - Updated to remove individual toast notifications
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: checkAndUpdateCertificate,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
||||
setRefreshingId(null);
|
||||
toast.success(t('sslCertificateRefreshed').replace('{domain}', data.domain));
|
||||
// Removed individual success toast notification
|
||||
},
|
||||
onError: (error) => {
|
||||
// console.error("Error refreshing SSL certificate:", error);
|
||||
|
||||
let errorMessage = t('failedToCheckCertificate');
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
console.error("Error refreshing SSL certificate:", error);
|
||||
setRefreshingId(null);
|
||||
|
||||
// Still refresh the data to show any partial information that was saved
|
||||
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
|
||||
// Removed individual error toast notification
|
||||
}
|
||||
});
|
||||
|
||||
@@ -149,7 +142,7 @@ export const SSLDomainContent = () => {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// console.error("Error refreshing all certificates:", error);
|
||||
console.error("Error refreshing all certificates:", error);
|
||||
toast.error(t('failedToCheckCertificate'));
|
||||
setIsRefreshingAll(false);
|
||||
|
||||
@@ -174,7 +167,7 @@ export const SSLDomainContent = () => {
|
||||
};
|
||||
|
||||
const handleUpdateCertificate = (certificate: SSLCertificate) => {
|
||||
// console.log("Handling certificate update with data:", certificate);
|
||||
console.log("Handling certificate update with data:", certificate);
|
||||
editMutation.mutate(certificate);
|
||||
};
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const ContainerMonitoring = () => {
|
||||
});
|
||||
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
|
||||
|
||||
console.log('ContainerMonitoring component loaded with serverId:', serverId);
|
||||
// console.log('ContainerMonitoring component loaded with serverId:', serverId);
|
||||
|
||||
const {
|
||||
data: containers = [],
|
||||
@@ -39,26 +39,26 @@ const ContainerMonitoring = () => {
|
||||
} = useQuery({
|
||||
queryKey: ['docker-containers', serverId],
|
||||
queryFn: () => {
|
||||
console.log('Query function called with serverId:', serverId);
|
||||
// console.log('Query function called with serverId:', serverId);
|
||||
return serverId ? dockerService.getContainersByServerId(serverId) : dockerService.getContainers();
|
||||
},
|
||||
refetchInterval: 30000 // Refetch every 30 seconds
|
||||
});
|
||||
|
||||
console.log('Query state:', { containers, isLoading, error });
|
||||
// console.log('Query state:', { containers, isLoading, error });
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Containers changed:', containers);
|
||||
// console.log('Containers changed:', containers);
|
||||
if (containers.length > 0) {
|
||||
dockerService.getContainerStats(containers).then(newStats => {
|
||||
console.log('Stats calculated:', newStats);
|
||||
// console.log('Stats calculated:', newStats);
|
||||
setStats(newStats);
|
||||
});
|
||||
}
|
||||
}, [containers]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
console.log('Manual refresh triggered');
|
||||
// console.log('Manual refresh triggered');
|
||||
refetch();
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ const ContainerMonitoring = () => {
|
||||
};
|
||||
|
||||
if (error) {
|
||||
console.error('Container monitoring error:', error);
|
||||
// console.error('Container monitoring error:', error);
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
|
||||
@@ -20,7 +20,7 @@ const Dashboard = () => {
|
||||
|
||||
// For debugging user data
|
||||
useEffect(() => {
|
||||
// console.log("Current user data:", currentUser);
|
||||
// console.log("Current user data:", currentUser);
|
||||
}, [currentUser]);
|
||||
|
||||
// Handle logout
|
||||
@@ -29,22 +29,39 @@ const Dashboard = () => {
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
// Fetch all services
|
||||
// Fetch all services with 1-minute polling for real-time updates
|
||||
const { data: services = [], isLoading, error } = useQuery({
|
||||
queryKey: ['services'],
|
||||
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(() => {
|
||||
let hasStarted = false;
|
||||
|
||||
const startActiveServices = async () => {
|
||||
if (hasStarted) return;
|
||||
hasStarted = true;
|
||||
|
||||
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
|
||||
if (isLoading) {
|
||||
|
||||
@@ -7,11 +7,14 @@ import { Sidebar } from "@/components/dashboard/Sidebar";
|
||||
import { Header } from "@/components/dashboard/Header";
|
||||
import { ServerStatsCards } from "@/components/servers/ServerStatsCards";
|
||||
import { ServerTable } from "@/components/servers/ServerTable";
|
||||
import { AddServerAgentDialog } from "@/components/servers/AddServerAgentDialog";
|
||||
import { serverService } from "@/services/serverService";
|
||||
import { Server, ServerStats } from "@/types/server.types";
|
||||
import { useSidebar } from "@/contexts/SidebarContext";
|
||||
import { authService } from "@/services/authService";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
const InstanceMonitoring = () => {
|
||||
const { theme } = useTheme();
|
||||
@@ -27,6 +30,7 @@ const InstanceMonitoring = () => {
|
||||
});
|
||||
|
||||
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
|
||||
const { data: servers = [], isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['servers'],
|
||||
@@ -48,12 +52,16 @@ const InstanceMonitoring = () => {
|
||||
authService.logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleAgentAdded = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Header
|
||||
currentUser={currentUser}
|
||||
onLogout={handleLogout}
|
||||
@@ -82,7 +90,7 @@ const InstanceMonitoring = () => {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Header
|
||||
currentUser={currentUser}
|
||||
onLogout={handleLogout}
|
||||
@@ -90,33 +98,43 @@ const InstanceMonitoring = () => {
|
||||
toggleSidebar={toggleSidebar}
|
||||
/>
|
||||
<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 */}
|
||||
<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="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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setAddDialogOpen(true)} className="flex-shrink-0">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Server Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards Section */}
|
||||
<div className="mb-6 lg:mb-8">
|
||||
<div>
|
||||
<ServerStatsCards stats={stats} />
|
||||
</div>
|
||||
|
||||
{/* Server Table Section */}
|
||||
<div className="min-w-0">
|
||||
<div>
|
||||
<ServerTable servers={servers} isLoading={isLoading} onRefresh={handleRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<AddServerAgentDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
onAgentAdded={handleAgentAdded}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -10,8 +9,7 @@ import { Header } from "@/components/dashboard/Header";
|
||||
import { serverService } from "@/services/serverService";
|
||||
import { authService } from "@/services/authService";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Server, Database } from "lucide-react";
|
||||
import { ServerMetricsCharts } from "@/components/servers/ServerMetricsCharts";
|
||||
import { ArrowLeft, Server } from "lucide-react";
|
||||
import { ServerMetricsOverview } from "@/components/servers/ServerMetricsOverview";
|
||||
import { ServerHistoryCharts } from "@/components/servers/ServerHistoryCharts";
|
||||
import { ServerSystemInfoCard } from "@/components/servers/ServerSystemInfoCard";
|
||||
@@ -36,6 +34,70 @@ const ServerDetail = () => {
|
||||
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 = () => {
|
||||
authService.logout();
|
||||
navigate('/login');
|
||||
@@ -46,7 +108,6 @@ const ServerDetail = () => {
|
||||
};
|
||||
|
||||
if (serverError) {
|
||||
// console.error('Server detail error:', serverError);
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
@@ -101,7 +162,7 @@ const ServerDetail = () => {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Header
|
||||
currentUser={currentUser}
|
||||
onLogout={handleLogout}
|
||||
@@ -126,24 +187,32 @@ const ServerDetail = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Database className="h-4 w-4 text-primary" />
|
||||
<div className="h-12 w-12 rounded bg-primary/10 flex items-center justify-center p-2">
|
||||
{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>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{server?.name || 'Server Detail'}
|
||||
</h1>
|
||||
</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
|
||||
{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}
|
||||
</span>
|
||||
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* System Info Card */}
|
||||
|
||||
{/* System Info Card */}
|
||||
{server && (
|
||||
<div className="flex-shrink-0">
|
||||
<ServerSystemInfoCard server={server} />
|
||||
@@ -159,17 +228,10 @@ const ServerDetail = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historical Charts Section */}
|
||||
{server && (
|
||||
<div className="mb-6 lg:mb-8">
|
||||
<ServerHistoryCharts serverId={server.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics Charts Section */}
|
||||
{/* Historical Charts Section - Single comprehensive section */}
|
||||
{server && (
|
||||
<div className="min-w-0">
|
||||
<ServerMetricsCharts serverId={server.id} />
|
||||
<ServerHistoryCharts serverId={server.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,25 +5,25 @@ import { DockerContainer, DockerMetrics, DockerStats } from "@/types/docker.type
|
||||
class DockerService {
|
||||
async getContainers(): Promise<DockerContainer[]> {
|
||||
try {
|
||||
console.log('Fetching all Docker containers...');
|
||||
// console.log('Fetching all Docker containers...');
|
||||
const records = await pb.collection('dockers').getFullList({
|
||||
sort: '-created',
|
||||
});
|
||||
console.log('Docker containers fetched:', records);
|
||||
// console.log('Docker containers fetched:', records);
|
||||
return records as DockerContainer[];
|
||||
} catch (error) {
|
||||
console.error('Error fetching Docker containers:', error);
|
||||
// console.error('Error fetching Docker containers:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getContainersByServerId(serverId: string): Promise<DockerContainer[]> {
|
||||
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
|
||||
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
|
||||
const filters = [
|
||||
@@ -36,57 +36,57 @@ class DockerService {
|
||||
let containers: DockerContainer[] = [];
|
||||
|
||||
for (const filter of filters) {
|
||||
console.log('Trying filter:', filter);
|
||||
// console.log('Trying filter:', filter);
|
||||
try {
|
||||
const records = await pb.collection('dockers').getFullList({
|
||||
filter: filter,
|
||||
sort: '-created',
|
||||
});
|
||||
console.log(`Filter "${filter}" returned:`, records);
|
||||
// console.log(`Filter "${filter}" returned:`, records);
|
||||
|
||||
if (records.length > 0) {
|
||||
containers = records as DockerContainer[];
|
||||
break;
|
||||
}
|
||||
} catch (filterError) {
|
||||
console.warn(`Filter "${filter}" failed:`, filterError);
|
||||
// console.warn(`Filter "${filter}" failed:`, filterError);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If no containers found with filters, get all and log for debugging
|
||||
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({
|
||||
sort: '-created',
|
||||
});
|
||||
console.log('All available Docker containers:', allContainers);
|
||||
console.log('Looking for containers that might match server:', {
|
||||
serverId,
|
||||
serverHostname: server.hostname,
|
||||
serverIp: server.ip_address
|
||||
});
|
||||
// console.log('All available Docker containers:', allContainers);
|
||||
// console.log('Looking for containers that might match server:', {
|
||||
//// serverId,
|
||||
// serverHostname: server.hostname,
|
||||
// serverIp: server.ip_address
|
||||
// });
|
||||
}
|
||||
|
||||
return containers;
|
||||
} catch (error) {
|
||||
console.error('Error fetching Docker containers by server ID:', error);
|
||||
// console.error('Error fetching Docker containers by server ID:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getContainerMetrics(dockerId: string): Promise<DockerMetrics[]> {
|
||||
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({
|
||||
filter: `docker_id = "${dockerId}"`,
|
||||
sort: '-timestamp',
|
||||
perPage: 100,
|
||||
});
|
||||
console.log('Docker metrics fetched:', records);
|
||||
// console.log('Docker metrics fetched:', records);
|
||||
return records as DockerMetrics[];
|
||||
} catch (error) {
|
||||
console.error('Error fetching Docker metrics:', error);
|
||||
// console.error('Error fetching Docker metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function pauseMonitoring(serviceId: string): Promise<void> {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
monitoringIntervals.delete(serviceId);
|
||||
console.log(`Monitoring paused for service ${serviceId}`);
|
||||
// console.log(`Monitoring paused for service ${serviceId}`);
|
||||
}
|
||||
|
||||
// Get current timestamp formatted as a string
|
||||
|
||||
@@ -3,13 +3,13 @@ import { pb } from '@/lib/pocketbase';
|
||||
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> {
|
||||
try {
|
||||
// First check if the service is already being monitored
|
||||
if (monitoringIntervals.has(serviceId)) {
|
||||
// console.log(`Service ${serviceId} is already being monitored`);
|
||||
// console.log(`Service ${serviceId} is already being monitored`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function startMonitoringService(serviceId: string): Promise<void> {
|
||||
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
|
||||
await pb.collection('services').update(serviceId, {
|
||||
@@ -30,20 +30,24 @@ export async function startMonitoringService(serviceId: string): Promise<void> {
|
||||
});
|
||||
|
||||
// The actual service checking is now handled by the Go microservice
|
||||
// This frontend service just tracks the monitoring state
|
||||
const intervalMs = (service.heartbeat_interval || 60) * 1000;
|
||||
// console.log(`Service ${service.name} monitoring delegated to backend service`);
|
||||
// This frontend service just tracks the monitoring state with much less frequency
|
||||
const intervalMs = Math.max((service.heartbeat_interval || 60) * 1000, 60000); // Minimum 1 minute
|
||||
// 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
|
||||
// Significantly reduce frequency to prevent excessive logging and CPU usage
|
||||
const intervalId = window.setInterval(() => {
|
||||
// console.log(`Monitoring active for service ${service.name} (handled by backend)`);
|
||||
}, intervalMs);
|
||||
// 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)`);
|
||||
}
|
||||
}, Math.max(intervalMs, 300000)); // Minimum 5 minutes for logging
|
||||
|
||||
// Store the interval ID for this service
|
||||
monitoringIntervals.set(serviceId, intervalId);
|
||||
|
||||
// console.log(`Monitoring registered for service ${serviceId}`);
|
||||
// console.log(`Optimized monitoring registered for service ${serviceId}`);
|
||||
} 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();
|
||||
return result.items || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching operational pages:', error);
|
||||
// console.error('Error fetching operational pages:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -55,7 +55,7 @@ export const operationalPageService = {
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching operational page:', error);
|
||||
// console.error('Error fetching operational page:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -84,7 +84,7 @@ export const operationalPageService = {
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error updating operational page:', error);
|
||||
// console.error('Error updating operational page:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -113,7 +113,7 @@ export const operationalPageService = {
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error creating operational page:', error);
|
||||
// console.error('Error creating operational page:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -139,7 +139,7 @@ export const operationalPageService = {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting operational page:', error);
|
||||
// console.error('Error deleting operational page:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,183 +24,144 @@ export const serverService = {
|
||||
|
||||
async getServerMetrics(serverId: string, timeRange?: string): Promise<any[]> {
|
||||
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
|
||||
let server;
|
||||
try {
|
||||
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) {
|
||||
// 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 filter = '';
|
||||
let metricsServerId = serverId;
|
||||
|
||||
// Strategy 1: Use server.server_id if available
|
||||
if (server && server.server_id) {
|
||||
metricsServerId = server.server_id;
|
||||
filter = `server_id = "${metricsServerId}"`;
|
||||
// console.log('serverService.getServerMetrics: Strategy 1 - Using server.server_id for metrics:', metricsServerId);
|
||||
} else {
|
||||
// Strategy 2: Use the serverId directly
|
||||
filter = `server_id = "${serverId}"`;
|
||||
// console.log('serverService.getServerMetrics: Strategy 2 - Using serverId directly for metrics:', serverId);
|
||||
}
|
||||
|
||||
// Add agent_id filter if available in server data
|
||||
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();
|
||||
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();
|
||||
filter += ` && created >= "${cutoffISO}"`;
|
||||
// console.log('serverService.getServerMetrics: Using time filter from:', cutoffISO, 'to now');
|
||||
}
|
||||
|
||||
// console.log('serverService.getServerMetrics: Final filter:', filter);
|
||||
|
||||
// Fetch filtered records with proper sorting
|
||||
let records = await pb.collection('server_metrics').getFullList({
|
||||
filter: filter,
|
||||
// Let's first check what data exists in the database for this server
|
||||
// console.log('🔍 Checking all records for this server...');
|
||||
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
|
||||
});
|
||||
|
||||
// console.log('serverService.getServerMetrics: Found', records.length, 'records with primary filter');
|
||||
|
||||
// If no records found with primary strategy, try fallback strategies
|
||||
if (records.length === 0) {
|
||||
// console.log('serverService.getServerMetrics: No records found, trying fallback strategies...');
|
||||
// console.log('📊 Found total records for server:', allServerRecords.length);
|
||||
if (allServerRecords.length > 0) {
|
||||
// console.log('📅 Date range of all records:', {
|
||||
// newest: allServerRecords[0]?.created,
|
||||
// oldest: allServerRecords[allServerRecords.length - 1]?.created
|
||||
// });
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Check last 5 records
|
||||
// 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))
|
||||
// })));
|
||||
}
|
||||
|
||||
// Calculate time range for filtering
|
||||
const now = new Date();
|
||||
let cutoffTime;
|
||||
|
||||
if (timeRange === '60m') {
|
||||
cutoffTime = new Date(now.getTime() - (60 * 60 * 1000)); // Exactly 60 minutes
|
||||
// console.log('⏰ 60m filter: Looking for records newer than:', cutoffTime.toISOString());
|
||||
// console.log('⏰ Current time:', now.toISOString());
|
||||
// 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));
|
||||
} else if (timeRange === '7d') {
|
||||
cutoffTime = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
|
||||
} else if (timeRange === '1m') {
|
||||
cutoffTime = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
|
||||
} else if (timeRange === '3m') {
|
||||
cutoffTime = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
|
||||
} else {
|
||||
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();
|
||||
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);
|
||||
const filter = `server_id = "${strategy}" && created >= "${cutoffISO}"`;
|
||||
|
||||
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(`🔍 Trying filter: ${filter}`);
|
||||
|
||||
const records = await pb.collection('server_metrics').getFullList({
|
||||
filter: filter,
|
||||
sort: '-created',
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
// console.log(`📊 Strategy "${strategy}" found ${records.length} records within time range`);
|
||||
|
||||
if (records.length > 0) {
|
||||
filteredRecords = records;
|
||||
// console.log('✅ Using records from strategy:', strategy);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error(`❌ Error with strategy ${strategy}:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
// ));
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Return all recent records for 60m if we have any
|
||||
filteredRecords = records;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error(`❌ Error with 24h fallback for ${strategy}:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// console.log('serverService.getServerMetrics: Final result:', records.length, 'records found');
|
||||
if (records.length > 0) {
|
||||
// console.log('serverService.getServerMetrics: Sample record:', records[0]);
|
||||
// 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 records;
|
||||
return filteredRecords;
|
||||
} catch (error) {
|
||||
// console.error('Error fetching server metrics:', error);
|
||||
// console.error('❌ Error fetching server metrics:', 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 { SSLCertificate } from "../types";
|
||||
import { determineSSLStatus } from "../sslStatusUtils";
|
||||
import { sendSSLNotification } from "./sslNotificationSender";
|
||||
import { toast } from "sonner";
|
||||
import { SSLCertificate } from "@/types/ssl.types";
|
||||
|
||||
/**
|
||||
* Checks all SSL certificates and sends notifications for expiring ones
|
||||
* This should be called once per day
|
||||
* Check a single SSL certificate - notifications are now handled by the backend
|
||||
*/
|
||||
export async function checkAllCertificatesAndNotify(): Promise<void> {
|
||||
// console.log("Starting daily SSL certificates check...");
|
||||
|
||||
export const checkCertificateAndNotify = async (certificate: SSLCertificate): Promise<void> => {
|
||||
try {
|
||||
// Fetch all SSL certificates from database
|
||||
const response = await pb.collection('ssl_certificates').getList(1, 100, {});
|
||||
// Properly cast the items as SSLCertificate
|
||||
const certificates = response.items as unknown as SSLCertificate[];
|
||||
// console.log(`Checking certificate for ${certificate.domain}...`);
|
||||
|
||||
// console.log(`Found ${certificates.length} certificates to check`);
|
||||
// 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
|
||||
|
||||
// Check each certificate
|
||||
for (const cert of certificates) {
|
||||
await checkCertificateAndNotify(cert);
|
||||
}
|
||||
const now = new Date();
|
||||
|
||||
// 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 {
|
||||
// Use the current certificate data (updated by Go service)
|
||||
const daysLeft = certificate.days_left || 0;
|
||||
|
||||
// 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
|
||||
// Update check_at to trigger backend check
|
||||
await pb.collection('ssl_certificates').update(certificate.id, {
|
||||
status: status
|
||||
check_at: now.toISOString()
|
||||
});
|
||||
|
||||
// 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;
|
||||
} 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[];
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const cert of certificates) {
|
||||
try {
|
||||
await checkCertificateAndNotify(cert);
|
||||
success++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
}
|
||||
} 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;
|
||||
return { success, failed };
|
||||
|
||||
} catch (error) {
|
||||
// console.error(`Error checking certificate for ${certificate.domain}:`, error);
|
||||
toast.error(`Error checking certificate: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -27,7 +27,7 @@ export const statusPageComponentsService = {
|
||||
const result = await response.json();
|
||||
return result.items || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching status page components:', error);
|
||||
// console.error('Error fetching status page components:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -56,7 +56,7 @@ export const statusPageComponentsService = {
|
||||
const result = await response.json();
|
||||
return result.items || [];
|
||||
} 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;
|
||||
}
|
||||
},
|
||||
@@ -79,7 +79,7 @@ export const statusPageComponentsService = {
|
||||
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 response = await fetch(`${baseUrl}/api/collections/status_page_components/records`, {
|
||||
@@ -95,10 +95,10 @@ export const statusPageComponentsService = {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Created component:', result);
|
||||
// console.log('Created component:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error creating status page component:', error);
|
||||
// console.error('Error creating status page component:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -124,7 +124,7 @@ export const statusPageComponentsService = {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting status page component:', error);
|
||||
// console.error('Error deleting status page component:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
|
||||
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
export interface NotificationTemplate {
|
||||
@@ -37,62 +38,62 @@ export interface CreateUpdateTemplateData {
|
||||
export const templateService = {
|
||||
async getTemplates(): Promise<NotificationTemplate[]> {
|
||||
try {
|
||||
console.log("Fetching notification templates");
|
||||
const response = await pb.collection('notification_templates').getList(1, 50, {
|
||||
// console.log("Fetching server notification templates");
|
||||
const response = await pb.collection('server_notification_templates').getList(1, 50, {
|
||||
sort: '-created',
|
||||
});
|
||||
console.log("Templates response:", response);
|
||||
// console.log("Server templates response:", response);
|
||||
return response.items as unknown as NotificationTemplate[];
|
||||
} catch (error) {
|
||||
console.error("Error fetching templates:", error);
|
||||
// console.error("Error fetching server templates:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getTemplate(id: string): Promise<NotificationTemplate> {
|
||||
try {
|
||||
console.log(`Fetching template with id: ${id}`);
|
||||
const response = await pb.collection('notification_templates').getOne(id);
|
||||
console.log("Template response:", response);
|
||||
// console.log(`Fetching server template with id: ${id}`);
|
||||
const response = await pb.collection('server_notification_templates').getOne(id);
|
||||
// console.log("Server template response:", response);
|
||||
return response as unknown as NotificationTemplate;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching template with id ${id}:`, error);
|
||||
// console.error(`Error fetching server template with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async createTemplate(data: CreateUpdateTemplateData): Promise<NotificationTemplate> {
|
||||
try {
|
||||
console.log("Creating new template with data:", data);
|
||||
const response = await pb.collection('notification_templates').create(data);
|
||||
console.log("Create template response:", response);
|
||||
// console.log("Creating new server template with data:", data);
|
||||
const response = await pb.collection('server_notification_templates').create(data);
|
||||
// console.log("Create server template response:", response);
|
||||
return response as unknown as NotificationTemplate;
|
||||
} catch (error) {
|
||||
console.error("Error creating template:", error);
|
||||
// console.error("Error creating server template:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async updateTemplate(id: string, data: Partial<CreateUpdateTemplateData>): Promise<NotificationTemplate> {
|
||||
try {
|
||||
console.log(`Updating template with id: ${id}`, data);
|
||||
const response = await pb.collection('notification_templates').update(id, data);
|
||||
console.log("Update template response:", response);
|
||||
// console.log(`Updating server template with id: ${id}`, data);
|
||||
const response = await pb.collection('server_notification_templates').update(id, data);
|
||||
// console.log("Update server template response:", response);
|
||||
return response as unknown as NotificationTemplate;
|
||||
} catch (error) {
|
||||
console.error(`Error updating template with id ${id}:`, error);
|
||||
// console.error(`Error updating server template with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTemplate(id: string): Promise<boolean> {
|
||||
try {
|
||||
console.log(`Deleting template with id: ${id}`);
|
||||
await pb.collection('notification_templates').delete(id);
|
||||
console.log("Template deleted successfully");
|
||||
// console.log(`Deleting server template with id: ${id}`);
|
||||
await pb.collection('server_notification_templates').delete(id);
|
||||
// console.log("Server template deleted successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting template with id ${id}:`, error);
|
||||
// console.error(`Error deleting server template with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface Server {
|
||||
hostname: string;
|
||||
ip_address: string;
|
||||
os_type: string;
|
||||
status: 'up' | 'down' | 'warning';
|
||||
status: 'up' | 'down' | 'warning' | 'paused';
|
||||
uptime: string;
|
||||
ram_total: number;
|
||||
ram_used: number;
|
||||
@@ -19,6 +19,7 @@ export interface Server {
|
||||
last_checked: string;
|
||||
server_token: string;
|
||||
template_id: string;
|
||||
threshold_id: string;
|
||||
notification_id: string;
|
||||
timestamp: 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)
|
||||
})
|
||||