Merge branch 'operacle:develop' into develop

This commit is contained in:
gnsworks
2025-07-25 21:20:34 +09:00
committed by GitHub
85 changed files with 4856 additions and 1516 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# These are supported funding model platforms # These are supported funding model platforms
github: #[tolaleng] github: [tolaleng]
patreon: # Replace with a single Patreon username patreon: # Replace with a single Patreon username
open_collective: #checkcle open_collective: #checkcle
ko_fi: # Replace with a single Ko-fi username ko_fi: # Replace with a single Ko-fi username
+93
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
+49
View File
@@ -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 youd 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 hasnt been requested yet.
required: true
- label: I have described the feature clearly and provided supporting details.
required: true
+53
View File
@@ -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
+51
View File
@@ -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 youd 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
+81
View File
@@ -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
+10 -13
View File
@@ -39,6 +39,7 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
## 🎯 Live Demo ## 🎯 Live Demo
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io) 👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
User: admin@example.com | Passwd: Admin123456
## 🌟 Core Features ## 🌟 Core Features
@@ -110,11 +111,12 @@ services:
4. Follow the Quick Start Guide at https://docs.checkcle.io 4. Follow the Quick Start Guide at https://docs.checkcle.io
### ###
![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-monitoring.png) ![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-1.4.png)
![Service Detail Page](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-service-detail.png) ![Service Detail Page](https://cdn.checkcle.io/images/uptime/uptime-regional-detail.png)
![checkcle-server-instance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/instance-server-monitoring.png) ![checkcle-server-instance](https://cdn.checkcle.io/images/server/server-list.png)
![SSL Monitoring](https://cdn.checkcle.io/images/ssl-domain/ssl-list.png)
![Schedule Maintenance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-schedule-maintenance.png) ![Schedule Maintenance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-schedule-maintenance.png)
![SSL Monitoring](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/ssl-monitoring.png)
## 📝 Development Roadmap ## 📝 Development Roadmap
@@ -128,10 +130,10 @@ services:
- ✅ SSL & Domain Monitoring - ✅ SSL & Domain Monitoring
- ✅ Schedule Maintenance - ✅ Schedule Maintenance
- ✅ Incident Management - ✅ Incident Management
- [ ] Infrastructure Server Monitoring - Infrastructure Server Monitoring
- ✅ Operational Status / Public Status Pages - ✅ Operational Status / Public Status Pages
- ✅ Uptime monitoring (HTTP, TCP, PING, DNS) Full functionality - ✅ Uptime monitoring (HTTP, TCP, PING, DNS) Full functionality
- ✅ Distributed Regional Monitoring Agent [Support Operation](https://github.com/operacle/Distributed-Regional-Monitoring) - ✅ Distributed Regional Monitoring Agent
- ✅ System Setting Panel and Mail Settings - ✅ System Setting Panel and Mail Settings
- ✅ User Permission Roles - ✅ User Permission Roles
- [ ] Notifications (Email/Slack/Discord/Signal) - [ ] Notifications (Email/Slack/Discord/Signal)
@@ -161,7 +163,7 @@ Here are some ways you can help improve CheckCle:
- Website: [checkcle.io](https://checkcle.io) - Website: [checkcle.io](https://checkcle.io)
- Documentation: [docs.checkcle.io](https://docs.checkcle.io) - Documentation: [docs.checkcle.io](https://docs.checkcle.io)
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git) - GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
- Community Channels: Engage via discussions and issues! - Reddit Community: [reddit](https://www.reddit.com/r/checkcle)
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX) - Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
- X: [@tlengoss](https://x.com/tlengoss) - X: [@tlengoss](https://x.com/tlengoss)
@@ -171,12 +173,7 @@ CheckCle is released under the MIT License.
--- ---
## 👥 Contributors ## 👥 Contributors
Thank you for contributing and continuously making CheckCle better, you're awesome 🫶
[![](https://contrib.rocks/image?repo=operacle/checkcle)](https://github.com/operacle/checkcle/graphs/contributors) [![](https://contrib.rocks/image?repo=operacle/checkcle)](https://github.com/operacle/checkcle/graphs/contributors)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=operacle/checkcle&type=Date)](https://www.star-history.com/#operacle/checkcle&Date)
Stay informed, stay online, with CheckCle! 🌐
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
// This file handles realtime notifications in a client-side environment // This file handles realtime notifications in a client-side environment
// In a production app, this would be a server-side endpoint // In a production app, this would be a server-side endpoint
console.log("API Realtime endpoint loaded"); //console.log("API Realtime endpoint loaded");
// Simple implementation that simulates sending notifications // Simple implementation that simulates sending notifications
export default async function handler(req) { export default async function handler(req) {
@@ -13,7 +13,13 @@ export const Sidebar = ({ collapsed }: SidebarProps) => {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}> <div
className={`
${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'}
border-r flex flex-col h-full
${collapsed ? 'w-16' : 'w-64'}
`}
>
<SidebarHeader collapsed={collapsed} /> <SidebarHeader collapsed={collapsed} />
<MainNavigation collapsed={collapsed} /> <MainNavigation collapsed={collapsed} />
<SettingsPanel collapsed={collapsed} /> <SettingsPanel collapsed={collapsed} />
@@ -34,7 +34,6 @@ export const MenuItem: React.FC<MenuItemProps> = ({
e.stopPropagation(); e.stopPropagation();
if (hasNavigation && path) { if (hasNavigation && path) {
// Use navigate instead of window.location to prevent full page reload
navigate(path, { replace: false }); navigate(path, { replace: false });
} }
}; };
@@ -44,11 +43,22 @@ export const MenuItem: React.FC<MenuItemProps> = ({
return ( return (
<div <div
className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${isActive ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200 cursor-pointer`} className={`
${collapsed ? 'p-3' : 'p-2 pl-3'}
mb-1 rounded-lg
${isActive ? (theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent') : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`}
flex items-center
${collapsed ? 'justify-center' : ''}
cursor-pointer
`}
onClick={handleClick} onClick={handleClick}
> >
<Icon className={`${mainIconSize} ${color}`} /> <Icon className={`${mainIconSize} ${color}`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t(translationKey)}</span>} {!collapsed && (
<span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">
{t(translationKey)}
</span>
)}
</div> </div>
); );
}; };
@@ -13,7 +13,7 @@ export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}> <div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
<div className="h-8 w-8 bg-gray-600 rounded flex items-center justify-center mr-2"> <div className="h-8 w-8 bg-gray-600 rounded flex items-center justify-center mr-2">
<img <img
src="/favicon.ico" src="/favicon_sidebar.ico"
alt="CheckCle" alt="CheckCle"
className="h-6 w-6" className="h-6 w-6"
/> />
@@ -62,7 +62,7 @@ export const DockerContainersTable = ({ containers, isLoading, onRefresh }: Dock
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="min-w-full inline-block align-middle"> <div className="min-w-full inline-block align-middle">
<div className="overflow-hidden border border-border rounded-lg shadow-sm bg-card"> <div className="overflow-hidden border border-border rounded-lg shadow-sm">
<Table> <Table>
<DockerTableHeader /> <DockerTableHeader />
<TableBody> <TableBody>
@@ -3,44 +3,51 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Container, Play, Square, AlertTriangle } from "lucide-react"; import { Container, Play, Square, AlertTriangle } from "lucide-react";
import { DockerStats } from "@/types/docker.types"; import { DockerStats } from "@/types/docker.types";
import { useTheme } from "@/contexts/ThemeContext";
interface DockerStatsCardsProps { interface DockerStatsCardsProps {
stats: DockerStats; stats: DockerStats;
} }
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => { export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
const { theme } = useTheme();
const cards = [ const cards = [
{ {
title: "Total Containers", title: "Total Containers",
value: stats.total, value: stats.total,
icon: Container, icon: Container,
color: "text-blue-600", color: "text-blue-600",
bgColor: "bg-blue-50", gradient: theme === 'dark'
borderColor: "border-blue-200", ? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(59, 130, 246, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
}, },
{ {
title: "Running", title: "Running",
value: stats.running, value: stats.running,
icon: Play, icon: Play,
color: "text-green-600", color: "text-green-600",
bgColor: "bg-green-50", gradient: theme === 'dark'
borderColor: "border-green-200", ? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(16, 185, 129, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
}, },
{ {
title: "Stopped", title: "Stopped",
value: stats.stopped, value: stats.stopped,
icon: Square, icon: Square,
color: "text-gray-600", color: "text-gray-600",
bgColor: "bg-gray-50", gradient: theme === 'dark'
borderColor: "border-gray-200", ? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(107, 114, 128, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
}, },
{ {
title: "Warning", title: "Warning",
value: stats.warning, value: stats.warning,
icon: AlertTriangle, icon: AlertTriangle,
color: "text-amber-600", color: "text-amber-600",
bgColor: "bg-amber-50", gradient: theme === 'dark'
borderColor: "border-amber-200", ? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(245, 158, 11, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #f59e0b 100%)"
}, },
]; ];
@@ -49,23 +56,39 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
{cards.map((card) => { {cards.map((card) => {
const IconComponent = card.icon; const IconComponent = card.icon;
return ( return (
<Card key={card.title} className={`${card.borderColor} bg-card hover:shadow-md transition-shadow`}> <Card
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> key={card.title}
<CardTitle className="text-sm font-medium text-muted-foreground"> className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
style={{ background: card.gradient }}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div
className="w-full h-full"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
/>
</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white/70">
{card.title} {card.title}
</CardTitle> </CardTitle>
<div className={`${card.bgColor} ${card.color} p-2 rounded-md`}> <div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
<IconComponent className="h-4 w-4" /> <IconComponent className="h-4 w-4 text-white" />
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="relative z-10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-2xl font-bold text-foreground"> <div className="text-2xl font-bold text-white">
{card.value} {card.value}
</div> </div>
<Badge <Badge
variant="outline" variant="outline"
className={`${card.color} ${card.borderColor} text-xs`} className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30"
> >
Containers Containers
</Badge> </Badge>
@@ -1,3 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -6,7 +7,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Plus, X, Server, Shield, AlertTriangle } from 'lucide-react'; import { Plus, X, Server } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types'; import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { serviceService } from '@/services/serviceService'; import { serviceService } from '@/services/serviceService';
@@ -17,12 +18,6 @@ interface ComponentsSelectorProps {
onComponentDelete?: (componentId: string) => void; onComponentDelete?: (componentId: string) => void;
} }
const componentTypes = [
{ value: 'uptime', label: 'Uptime Service', icon: Server },
{ value: 'ssl', label: 'SSL Certificate', icon: Shield },
{ value: 'incident', label: 'Incident Monitoring', icon: AlertTriangle },
];
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => { export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newComponent, setNewComponent] = useState({ const [newComponent, setNewComponent] = useState({
@@ -169,7 +164,7 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select an uptime service" /> <SelectValue placeholder="Select an uptime service" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-50 bg-white border shadow-lg"> <SelectContent>
{services.map((service) => ( {services.map((service) => (
<SelectItem key={service.id} value={service.id}> <SelectItem key={service.id} value={service.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -206,37 +201,6 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
</div> </div>
</div> </div>
)} )}
<div className="mt-4">
<Label>Quick Add Templates</Label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-2">
{componentTypes.map((type) => {
const Icon = type.icon;
return (
<Button
key={type.value}
variant="outline"
size="sm"
onClick={() => {
const component: Partial<StatusPageComponentRecord> = {
name: type.label,
description: `Monitor ${type.label.toLowerCase()}`,
service_id: '',
server_id: '',
display_order: selectedComponents.length + 1,
operational_status_id: '',
};
onComponentsChange([...selectedComponents, component]);
}}
className="justify-start"
>
<Icon className="h-4 w-4 mr-2" />
{type.label}
</Button>
);
})}
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -67,13 +67,13 @@ export const CreateOperationalPageDialog = () => {
page_style: data.page_style || '', page_style: data.page_style || '',
}; };
console.log('Creating operational page with payload:', payload); // console.log('Creating operational page with payload:', payload);
const createdPage = await createMutation.mutateAsync(payload); const createdPage = await createMutation.mutateAsync(payload);
console.log('Created page:', createdPage); // console.log('Created page:', createdPage);
// Create components after page is created // Create components after page is created
if (selectedComponents.length > 0) { if (selectedComponents.length > 0) {
console.log('Creating components for page:', createdPage.id); // console.log('Creating components for page:', createdPage.id);
for (const component of selectedComponents) { for (const component of selectedComponents) {
const componentPayload = { const componentPayload = {
operational_status_id: createdPage.id, operational_status_id: createdPage.id,
@@ -84,7 +84,7 @@ export const CreateOperationalPageDialog = () => {
display_order: component.display_order || 1, display_order: component.display_order || 1,
}; };
console.log('Creating component with payload:', componentPayload); // console.log('Creating component with payload:', componentPayload);
await createComponentMutation.mutateAsync(componentPayload); await createComponentMutation.mutateAsync(componentPayload);
} }
} }
@@ -93,7 +93,7 @@ export const CreateOperationalPageDialog = () => {
form.reset(); form.reset();
setSelectedComponents([]); setSelectedComponents([]);
} catch (error) { } catch (error) {
console.error('Error creating operational page:', error); // console.error('Error creating operational page:', error);
} }
}; };
@@ -41,11 +41,11 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
</div> </div>
<div> <div>
<span className="font-medium text-muted-foreground">Public:</span> <span className="font-medium text-muted-foreground">Public:</span>
<p className="mt-1"> <div className="mt-1">
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}> <Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
{page.is_public === 'true' ? 'Yes' : 'No'} {page.is_public === 'true' ? 'Yes' : 'No'}
</Badge> </Badge>
</p> </div>
</div> </div>
<div> <div>
<span className="font-medium text-muted-foreground">Updated:</span> <span className="font-medium text-muted-foreground">Updated:</span>
@@ -12,7 +12,7 @@ import { PublicStatusPageFooter } from './PublicStatusPageFooter';
export const PublicStatusPage = () => { export const PublicStatusPage = () => {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
console.log('PublicStatusPage - slug from params:', slug); // console.log('PublicStatusPage - slug from params:', slug);
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug); const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
const [lastUpdated, setLastUpdated] = useState(new Date()); const [lastUpdated, setLastUpdated] = useState(new Date());
@@ -51,7 +51,7 @@ export const PublicStatusPage = () => {
}; };
}, [page?.theme]); }, [page?.theme]);
console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length }); // console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
if (loading) { if (loading) {
return ( return (
@@ -19,61 +19,61 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
useEffect(() => { useEffect(() => {
const fetchPublicPage = async () => { const fetchPublicPage = async () => {
if (!slug) { if (!slug) {
console.log('No slug provided'); // console.log('No slug provided');
setError('No status page slug provided'); setError('No status page slug provided');
setLoading(false); setLoading(false);
return; return;
} }
try { try {
console.log('Fetching public status page for slug:', slug); // console.log('Fetching public status page for slug:', slug);
setLoading(true); setLoading(true);
setError(null); setError(null);
// Fetch operational page // Fetch operational page
console.log('Fetching operational pages...'); // console.log('Fetching operational pages...');
const pages = await operationalPageService.getOperationalPages(); const pages = await operationalPageService.getOperationalPages();
console.log('All pages:', pages); // console.log('All pages:', pages);
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true'); const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
console.log('Found page:', foundPage); // console.log('Found page:', foundPage);
if (!foundPage) { if (!foundPage) {
console.log('Page not found or not public'); // console.log('Page not found or not public');
setError('Status page not found or not public'); setError('Status page not found or not public');
setLoading(false); setLoading(false);
return; return;
} }
setPage(foundPage); setPage(foundPage);
console.log('Page set successfully'); // console.log('Page set successfully');
// Fetch components for this page // Fetch components for this page
console.log('Fetching components for page:', foundPage.id); // console.log('Fetching components for page:', foundPage.id);
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id); const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
console.log('Components found:', pageComponents); // console.log('Components found:', pageComponents);
setComponents(pageComponents); setComponents(pageComponents);
// Fetch all services // Fetch all services
console.log('Fetching all services...'); // console.log('Fetching all services...');
const allServices = await serviceService.getServices(); const allServices = await serviceService.getServices();
console.log('Services found:', allServices); // console.log('Services found:', allServices);
setServices(allServices); setServices(allServices);
// Fetch uptime data for each component that has a service // Fetch uptime data for each component that has a service
console.log('Fetching uptime data...'); // console.log('Fetching uptime data...');
const uptimePromises = pageComponents const uptimePromises = pageComponents
.filter(component => component.service_id) .filter(component => component.service_id)
.map(async (component) => { .map(async (component) => {
try { try {
console.log('Fetching uptime for service:', component.service_id); // console.log('Fetching uptime for service:', component.service_id);
const endDate = new Date(); const endDate = new Date();
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate); const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
console.log(`Uptime history for ${component.service_id}:`, history.length, 'records'); // console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
return { serviceId: component.service_id, history }; return { serviceId: component.service_id, history };
} catch (error) { } catch (error) {
console.error(`Error fetching uptime for service ${component.service_id}:`, error); // console.error(`Error fetching uptime for service ${component.service_id}:`, error);
return { serviceId: component.service_id, history: [] }; return { serviceId: component.service_id, history: [] };
} }
}); });
@@ -84,12 +84,12 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
uptimeMap[result.serviceId] = result.history; uptimeMap[result.serviceId] = result.history;
}); });
setUptimeData(uptimeMap); setUptimeData(uptimeMap);
console.log('Uptime data set successfully'); // console.log('Uptime data set successfully');
console.log('All data fetched successfully'); // console.log('All data fetched successfully');
} catch (err) { } catch (err) {
console.error('Error fetching public page:', err); // console.error('Error fetching public page:', err);
setError(`Failed to load status page: ${err}`); setError(`Failed to load status page: ${err}`);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -18,6 +18,7 @@ interface OverviewCardProps {
valueClassName?: string; valueClassName?: string;
isLoading?: boolean; isLoading?: boolean;
color?: string; color?: string;
gradient?: string;
} }
export const OverviewCard = ({ export const OverviewCard = ({
@@ -30,11 +31,15 @@ export const OverviewCard = ({
valueClassName, valueClassName,
isLoading = false, isLoading = false,
color = "blue", color = "blue",
gradient,
}: OverviewCardProps) => { }: OverviewCardProps) => {
const { theme } = useTheme(); const { theme } = useTheme();
// Map color prop to gradient colors // Map color prop to gradient colors
const getGradientBackground = () => { const getGradientBackground = () => {
if (gradient) {
return gradient;
}
const colors = { const colors = {
blue: theme === 'dark' blue: theme === 'dark'
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)" ? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
@@ -3,6 +3,7 @@ import React from 'react';
import { AlertCircle, CheckCircle, Clock, AlertTriangle, Flag } from 'lucide-react'; import { AlertCircle, CheckCircle, Clock, AlertTriangle, Flag } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext'; import { useLanguage } from '@/contexts/LanguageContext';
import { OverviewCard } from '../common/OverviewCard'; import { OverviewCard } from '../common/OverviewCard';
import { useTheme } from '@/contexts/ThemeContext';
interface OverviewStatsProps { interface OverviewStatsProps {
unresolved: number; unresolved: number;
@@ -24,6 +25,7 @@ export const OverviewCards: React.FC<OverviewCardsProps> = ({
initialized initialized
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const { theme } = useTheme();
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
@@ -32,35 +34,55 @@ export const OverviewCards: React.FC<OverviewCardsProps> = ({
value={overviewStats.unresolved.toString()} value={overviewStats.unresolved.toString()}
icon={<AlertCircle className="h-5 w-5 text-white" />} icon={<AlertCircle className="h-5 w-5 text-white" />}
isLoading={loading && initialized} isLoading={loading && initialized}
color="red" gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(239, 83, 80, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(239, 83, 80, 0.6) 100%)"
}
/> />
<OverviewCard <OverviewCard
title={t('criticalIssues')} title={t('criticalIssues')}
value={overviewStats.critical.toString()} value={overviewStats.critical.toString()}
icon={<AlertTriangle className="h-5 w-5 text-white" />} icon={<AlertTriangle className="h-5 w-5 text-white" />}
isLoading={loading && initialized} isLoading={loading && initialized}
color="amber" gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(255, 183, 77, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(255, 183, 77, 0.6) 100%)"
}
/> />
<OverviewCard <OverviewCard
title={t('highPriority')} title={t('highPriority')}
value={overviewStats.highPriority.toString()} value={overviewStats.highPriority.toString()}
icon={<Flag className="h-5 w-5 text-white" />} icon={<Flag className="h-5 w-5 text-white" />}
isLoading={loading && initialized} isLoading={loading && initialized}
color="orange" gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(255, 109, 0, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(255, 109, 0, 0.6) 100%)"
}
/> />
<OverviewCard <OverviewCard
title={t('resolvedIncidents')} title={t('resolvedIncidents')}
value={overviewStats.resolved.toString()} value={overviewStats.resolved.toString()}
icon={<CheckCircle className="h-5 w-5 text-white" />} icon={<CheckCircle className="h-5 w-5 text-white" />}
isLoading={loading && initialized} isLoading={loading && initialized}
color="green" gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(102, 187, 106, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(102, 187, 106, 0.6) 100%)"
}
/> />
<OverviewCard <OverviewCard
title={t('avgResolutionTime')} title={t('avgResolutionTime')}
value={overviewStats.avgResolutionTime} value={overviewStats.avgResolutionTime}
icon={<Clock className="h-5 w-5 text-white" />} icon={<Clock className="h-5 w-5 text-white" />}
isLoading={loading && initialized} isLoading={loading && initialized}
color="blue" gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(66, 165, 245, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(66, 165, 245, 0.6) 100%)"
}
/> />
</div> </div>
); );
@@ -0,0 +1,167 @@
import React, { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { Server } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getCurrentEndpoint } from "@/lib/pocketbase";
import { ServerAgentConfigForm } from "./ServerAgentConfigForm";
import { OneClickInstallTab } from "./OneClickInstallTab";
import { ManualInstallTab } from "./ManualInstallTab";
interface AddServerAgentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAgentAdded: () => void;
}
export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
open,
onOpenChange,
onAgentAdded,
}) => {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState("configure");
// Get current PocketBase URL
const currentPocketBaseUrl = getCurrentEndpoint();
// Form state
const [formData, setFormData] = useState({
serverName: "",
description: "",
osType: "",
checkInterval: "60",
retryAttempt: "3",
dockerEnabled: false,
notificationEnabled: true,
});
// Generated server token and agent ID
const [serverToken] = useState(() =>
`srv_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
);
const [serverId] = useState(() =>
`agent_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
if (!formData.serverName || !formData.osType) {
toast({
title: "Validation Error",
description: "Please fill in all required fields.",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
// Here you would typically create the server monitoring configuration
// For now, we'll simulate the process
await new Promise(resolve => setTimeout(resolve, 1500));
toast({
title: "Server Agent Created",
description: `${formData.serverName} monitoring agent has been configured successfully.`,
});
// Switch to one-click install tab after successful creation
setActiveTab("one-click");
onAgentAdded();
} catch (error) {
toast({
title: "Error",
description: "Failed to create server monitoring agent.",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
const handleDialogClose = () => {
// Reset form and tab when dialog closes
setActiveTab("configure");
setFormData({
serverName: "",
description: "",
osType: "",
checkInterval: "60",
retryAttempt: "3",
dockerEnabled: false,
notificationEnabled: true,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent className="sm:max-w-[900px] max-w-[95vw] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Add Server Monitoring Agent
</DialogTitle>
<DialogDescription>
Configure a new server monitoring agent to track system metrics and performance.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="configure">Configure Agent</TabsTrigger>
<TabsTrigger value="one-click">One-Click Install</TabsTrigger>
<TabsTrigger value="manual">Manual Installation</TabsTrigger>
</TabsList>
<TabsContent value="configure" className="space-y-6">
<ServerAgentConfigForm
formData={formData}
setFormData={setFormData}
serverId={serverId}
serverToken={serverToken}
currentPocketBaseUrl={currentPocketBaseUrl}
isSubmitting={isSubmitting}
onSubmit={handleSubmit}
/>
</TabsContent>
<TabsContent value="one-click" className="space-y-6">
<OneClickInstallTab
serverToken={serverToken}
currentPocketBaseUrl={currentPocketBaseUrl}
formData={formData}
serverId={serverId}
onDialogClose={handleDialogClose}
/>
</TabsContent>
<TabsContent value="manual" className="space-y-6">
<ManualInstallTab
serverToken={serverToken}
currentPocketBaseUrl={currentPocketBaseUrl}
formData={formData}
serverId={serverId}
onDialogClose={handleDialogClose}
/>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,680 @@
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/hooks/use-toast";
import { pb } from "@/lib/pocketbase";
import { Server } from "@/types/server.types";
import { RefreshCw, X } from "lucide-react";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { templateService, NotificationTemplate } from "@/services/templateService";
import { serverThresholdService, ServerThreshold } from "@/services/serverThresholdService";
interface EditServerDialogProps {
server: Server | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onServerUpdated: () => void;
}
interface ServerFormData {
name: string;
check_interval: number;
retry_attempts: number;
docker_monitoring: boolean;
notification_enabled: boolean;
notification_channels: string[]; // Changed to array for multiple selections
threshold_id: string;
template_id: string;
}
interface ThresholdFormData {
cpu_threshold: number;
ram_threshold: number;
disk_threshold: number;
network_threshold: number;
}
export const EditServerDialog: React.FC<EditServerDialogProps> = ({
server,
open,
onOpenChange,
onServerUpdated,
}) => {
const [formData, setFormData] = useState<ServerFormData>({
name: "",
check_interval: 60,
retry_attempts: 3,
docker_monitoring: false,
notification_enabled: false,
notification_channels: [], // Changed to array
threshold_id: "none",
template_id: "none",
});
const [thresholdFormData, setThresholdFormData] = useState<ThresholdFormData>({
cpu_threshold: 80,
ram_threshold: 80,
disk_threshold: 80,
network_threshold: 80,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
const [thresholds, setThresholds] = useState<ServerThreshold[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<NotificationTemplate | null>(null);
const [selectedThreshold, setSelectedThreshold] = useState<ServerThreshold | null>(null);
const [loadingAlertConfigs, setLoadingAlertConfigs] = useState(false);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [loadingThresholds, setLoadingThresholds] = useState(false);
const { toast } = useToast();
// Initialize form data when server changes
useEffect(() => {
if (server) {
// console.log("Setting form data for server:", server);
// Parse comma-separated notification_id into array
const notificationChannels = server.notification_id
? server.notification_id.split(',').map(id => id.trim()).filter(id => id)
: [];
setFormData({
name: server.name || "",
check_interval: server.check_interval || 60,
retry_attempts: 3,
docker_monitoring: server.docker === "true",
notification_enabled: notificationChannels.length > 0,
notification_channels: notificationChannels,
threshold_id: server.threshold_id || "none",
template_id: server.template_id || "none",
});
}
}, [server]);
// Load data when dialog opens
useEffect(() => {
if (open) {
loadAlertConfigurations();
loadTemplates();
loadThresholds();
}
}, [open]);
// Load existing threshold data when thresholds are loaded and we have a server with threshold_id
useEffect(() => {
if (server && server.threshold_id && thresholds.length > 0) {
// console.log("Loading existing threshold data for server:", server.threshold_id);
const existingThreshold = thresholds.find(t => t.id === server.threshold_id);
if (existingThreshold) {
// console.log("Found existing threshold:", existingThreshold);
setSelectedThreshold(existingThreshold);
// Handle the API response format with proper field names and type conversion
setThresholdFormData({
cpu_threshold: parseInt(String(existingThreshold.cpu_threshold)) || 80,
ram_threshold: parseInt(String((existingThreshold as any).ram_threshold_message || existingThreshold.ram_threshold)) || 80,
disk_threshold: parseInt(String(existingThreshold.disk_threshold)) || 80,
network_threshold: parseInt(String(existingThreshold.network_threshold)) || 80,
});
}
}
}, [server, thresholds]);
// Update selected template when form data or templates change
useEffect(() => {
if (formData.template_id && formData.template_id !== "none" && templates.length > 0) {
const template = templates.find(t => t.id === formData.template_id);
setSelectedTemplate(template || null);
} else {
setSelectedTemplate(null);
}
}, [formData.template_id, templates]);
// Update selected threshold when threshold_id changes in form
useEffect(() => {
if (formData.threshold_id && formData.threshold_id !== "none" && thresholds.length > 0) {
const threshold = thresholds.find(t => t.id === formData.threshold_id);
setSelectedThreshold(threshold || null);
if (threshold) {
// Handle the API response format with proper field names and type conversion
setThresholdFormData({
cpu_threshold: parseInt(String(threshold.cpu_threshold)) || 80,
ram_threshold: parseInt(String((threshold as any).ram_threshold_message || threshold.ram_threshold)) || 80,
disk_threshold: parseInt(String(threshold.disk_threshold)) || 80,
network_threshold: parseInt(String(threshold.network_threshold)) || 80,
});
}
} else if (formData.threshold_id === "none") {
setSelectedThreshold(null);
setThresholdFormData({
cpu_threshold: 80,
ram_threshold: 80,
disk_threshold: 80,
network_threshold: 80,
});
}
}, [formData.threshold_id, thresholds]);
const loadAlertConfigurations = async () => {
try {
setLoadingAlertConfigs(true);
const configs = await alertConfigService.getAlertConfigurations();
setAlertConfigs(configs);
} catch (error) {
// console.error('Error loading alert configurations:', error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load notification channels",
});
} finally {
setLoadingAlertConfigs(false);
}
};
const loadTemplates = async () => {
try {
setLoadingTemplates(true);
const templateList = await templateService.getTemplates();
setTemplates(templateList);
} catch (error) {
// console.error('Error loading templates:', error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load templates",
});
} finally {
setLoadingTemplates(false);
}
};
const loadThresholds = async () => {
try {
setLoadingThresholds(true);
const thresholdList = await serverThresholdService.getServerThresholds();
setThresholds(thresholdList);
} catch (error) {
// console.error('Error loading server thresholds:', error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load server thresholds",
});
} finally {
setLoadingThresholds(false);
}
};
const handleThresholdUpdate = async () => {
if (!selectedThreshold) return;
try {
// Use the correct field name for RAM threshold
const updateData = {
cpu_threshold: thresholdFormData.cpu_threshold,
ram_threshold_message: thresholdFormData.ram_threshold, // Use the correct field name
disk_threshold: thresholdFormData.disk_threshold,
network_threshold: thresholdFormData.network_threshold,
};
await serverThresholdService.updateServerThreshold(selectedThreshold.id, updateData);
// Update local state
setSelectedThreshold({
...selectedThreshold,
...thresholdFormData,
});
// Update thresholds list
setThresholds(prev => prev.map(t =>
t.id === selectedThreshold.id
? { ...t, ...thresholdFormData }
: t
));
toast({
title: "Threshold updated",
description: "Server threshold values have been updated successfully.",
});
} catch (error) {
// console.error('Error updating threshold:', error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to update threshold values.",
});
}
};
const handleNotificationChannelToggle = (channelId: string, checked: boolean) => {
setFormData(prev => ({
...prev,
notification_channels: checked
? [...prev.notification_channels, channelId]
: prev.notification_channels.filter(id => id !== channelId)
}));
};
const removeNotificationChannel = (channelId: string) => {
setFormData(prev => ({
...prev,
notification_channels: prev.notification_channels.filter(id => id !== channelId)
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!server || isSubmitting) return;
try {
setIsSubmitting(true);
// Convert notification channels array to comma-separated string
const notificationChannelsString = formData.notification_enabled
? formData.notification_channels.join(',')
: "";
const updateData = {
name: formData.name,
check_interval: formData.check_interval,
docker: formData.docker_monitoring ? "true" : "false",
notification_id: notificationChannelsString,
threshold_id: formData.notification_enabled && formData.threshold_id !== "none" ? formData.threshold_id : "",
template_id: formData.notification_enabled && formData.template_id !== "none" ? formData.template_id : "",
updated: new Date().toISOString(),
};
await pb.collection('servers').update(server.id, updateData);
toast({
title: "Server updated",
description: `${formData.name} has been updated successfully.`,
});
onServerUpdated();
onOpenChange(false);
} catch (error) {
// console.error('Error updating server:', error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to update server. Please try again.",
});
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
if (server) {
const notificationChannels = server.notification_id
? server.notification_id.split(',').map(id => id.trim()).filter(id => id)
: [];
setFormData({
name: server.name || "",
check_interval: server.check_interval || 60,
retry_attempts: 3,
docker_monitoring: server.docker === "true",
notification_enabled: notificationChannels.length > 0,
notification_channels: notificationChannels,
threshold_id: server.threshold_id || "none",
template_id: server.template_id || "none",
});
}
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Server Configuration</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="serverName">Server Name *</Label>
<Input
id="serverName"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Enter server name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="checkInterval">Check Interval</Label>
<Select
value={formData.check_interval.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, check_interval: parseInt(value) }))}
>
<SelectTrigger>
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">30 seconds</SelectItem>
<SelectItem value="60">1 minute</SelectItem>
<SelectItem value="120">2 minutes</SelectItem>
<SelectItem value="300">5 minutes</SelectItem>
<SelectItem value="600">10 minutes</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="retryAttempts">Retry Attempts</Label>
<Select
value={formData.retry_attempts.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, retry_attempts: parseInt(value) }))}
>
<SelectTrigger>
<SelectValue placeholder="Select retry attempts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 attempt</SelectItem>
<SelectItem value="2">2 attempts</SelectItem>
<SelectItem value="3">3 attempts</SelectItem>
<SelectItem value="5">5 attempts</SelectItem>
<SelectItem value="10">10 attempts</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="dockerMonitoring">Docker Monitoring</Label>
<div className="flex items-center space-x-2">
<Switch
id="dockerMonitoring"
checked={formData.docker_monitoring}
onCheckedChange={(checked) => setFormData(prev => ({
...prev,
docker_monitoring: checked
}))}
/>
<Label htmlFor="dockerMonitoring" className="text-sm text-muted-foreground">
{formData.docker_monitoring ? "Enabled" : "Disabled"}
</Label>
</div>
</div>
</div>
{/* Notification Status Toggle */}
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="notificationEnabled"
checked={formData.notification_enabled}
onCheckedChange={(checked) => setFormData(prev => ({
...prev,
notification_enabled: checked,
notification_channels: checked ? prev.notification_channels : [],
threshold_id: checked ? prev.threshold_id : "none",
template_id: checked ? prev.template_id : "none"
}))}
/>
<Label htmlFor="notificationEnabled">Enable Notifications</Label>
</div>
{/* Expanded Notification Settings */}
{formData.notification_enabled && (
<Card className="border-l-4 border-l-blue-500">
<CardHeader>
<CardTitle className="text-lg">Notification Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Multiple Notification Channels Selection */}
<div className="space-y-2">
<Label>Notification Channels</Label>
<div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3">
{loadingAlertConfigs ? (
<div className="text-sm text-muted-foreground">Loading channels...</div>
) : alertConfigs.length > 0 ? (
alertConfigs.map((config) => (
<div key={config.id} className="flex items-center space-x-2">
<Checkbox
id={`channel-${config.id}`}
checked={formData.notification_channels.includes(config.id || "")}
onCheckedChange={(checked) =>
handleNotificationChannelToggle(config.id || "", checked as boolean)
}
/>
<Label
htmlFor={`channel-${config.id}`}
className="flex-1 text-sm cursor-pointer"
>
{config.notify_name} ({config.notification_type})
</Label>
</div>
))
) : (
<div className="text-sm text-muted-foreground">No notification channels available</div>
)}
</div>
{/* Selected Channels Display */}
{formData.notification_channels.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Selected Channels:</Label>
<div className="flex flex-wrap gap-2">
{formData.notification_channels.map((channelId) => {
const channel = alertConfigs.find(c => c.id === channelId);
return (
<div
key={channelId}
className="flex items-center gap-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
>
<span>{channel?.notify_name || channelId}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => removeNotificationChannel(channelId)}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Server Set Threshold Selection */}
<div className="space-y-2">
<Label htmlFor="thresholdId">Server Set Threshold</Label>
<Select
value={formData.threshold_id}
onValueChange={(value) => setFormData(prev => ({ ...prev, threshold_id: value }))}
disabled={loadingThresholds}
>
<SelectTrigger>
<SelectValue placeholder={loadingThresholds ? "Loading thresholds..." : "Select server threshold"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No threshold (use default)</SelectItem>
{thresholds.map((threshold) => (
<SelectItem key={threshold.id} value={threshold.id}>
{threshold.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Editable Threshold Details */}
{selectedThreshold && (
<Card className="bg-muted/50">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Threshold Details: {selectedThreshold.name}</CardTitle>
<Button
type="button"
onClick={handleThresholdUpdate}
size="sm"
variant="outline"
>
Update Thresholds
</Button>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<Label className="text-xs font-medium text-muted-foreground">CPU Threshold (%)</Label>
<Input
type="number"
min="0"
max="100"
value={thresholdFormData.cpu_threshold}
onChange={(e) => setThresholdFormData(prev => ({
...prev,
cpu_threshold: parseInt(e.target.value) || 0
}))}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">RAM Threshold (%)</Label>
<Input
type="number"
min="0"
max="100"
value={thresholdFormData.ram_threshold}
onChange={(e) => setThresholdFormData(prev => ({
...prev,
ram_threshold: parseInt(e.target.value) || 0
}))}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Disk Threshold (%)</Label>
<Input
type="number"
min="0"
max="100"
value={thresholdFormData.disk_threshold}
onChange={(e) => setThresholdFormData(prev => ({
...prev,
disk_threshold: parseInt(e.target.value) || 0
}))}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Network Threshold (%)</Label>
<Input
type="number"
min="0"
max="100"
value={thresholdFormData.network_threshold}
onChange={(e) => setThresholdFormData(prev => ({
...prev,
network_threshold: parseInt(e.target.value) || 0
}))}
className="mt-1"
/>
</div>
</div>
</CardContent>
</Card>
)}
{/* Server Template Selection */}
<div className="space-y-2">
<Label htmlFor="templateId">Server Template</Label>
<Select
value={formData.template_id}
onValueChange={(value) => setFormData(prev => ({ ...prev, template_id: value }))}
disabled={loadingTemplates}
>
<SelectTrigger>
<SelectValue placeholder={loadingTemplates ? "Loading templates..." : "Select server template"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No template (use default)</SelectItem>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Template Details */}
{selectedTemplate && (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Template Details: {selectedTemplate.name}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 text-sm">
<div>
<Label className="text-xs font-medium text-muted-foreground">RAM Threshold Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.up_message || "No RAM threshold message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">CPU Threshold Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.down_message || "No CPU threshold message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Disk Threshold Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.incident_message || "No disk threshold message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Network Threshold Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.maintenance_message || "No network threshold message defined"}
</p>
</div>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
)}
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Updating...
</>
) : (
"Update Server"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,125 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Copy, Terminal } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils";
interface ManualInstallTabProps {
serverToken: string;
currentPocketBaseUrl: string;
formData: {
serverName: string;
osType: string;
checkInterval: string;
};
serverId: string;
onDialogClose: () => void;
}
export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
serverToken,
currentPocketBaseUrl,
formData,
serverId,
onDialogClose,
}) => {
const getManualInstallSteps = () => {
const scriptUrl = "https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/server-agent.sh";
return [
{
title: "Download the installation script",
command: `curl -L -o server-agent.sh "${scriptUrl}"`
},
{
title: "Make the script executable",
command: `chmod +x server-agent.sh`
},
{
title: "Run the installation with your configuration",
command: `SERVER_TOKEN="${serverToken}" POCKETBASE_URL="${currentPocketBaseUrl}" SERVER_NAME="${formData.serverName}" AGENT_ID="${serverId}" sudo bash server-agent.sh`
}
];
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
Manual Installation Steps
</CardTitle>
<CardDescription>
Step-by-step installation process
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Server Name:</span> {formData.serverName}
</div>
<div>
<span className="font-medium">Agent ID:</span> {serverId}
</div>
<div>
<span className="font-medium">OS Type:</span> {formData.osType}
</div>
<div>
<span className="font-medium">Check Interval:</span> {formData.checkInterval}s
</div>
</div>
</div>
<div className="space-y-4">
{getManualInstallSteps().map((step, index) => (
<div key={index} className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm font-medium">
{index + 1}
</span>
<span className="font-medium">{step.title}</span>
</div>
<div className="ml-8 relative">
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all">
<code>{step.command}</code>
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="absolute top-2 right-2"
onClick={() => copyToClipboard(step.command)}
>
<Copy className="h-4 w-4 mr-1" />
Copy
</Button>
</div>
</div>
))}
</div>
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">Prerequisites:</h4>
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1 list-disc list-inside mb-3">
<li>Ensure you have root/sudo access on the target server</li>
<li>Make sure curl is installed for downloading files</li>
<li>Internet connection required for downloading script</li>
</ul>
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">After Installation:</h4>
<p className="text-sm text-blue-800 dark:text-blue-200">
The agent will start automatically and appear in your dashboard within a few minutes.
</p>
</div>
<div className="flex justify-end pt-4">
<Button onClick={onDialogClose}>
Done
</Button>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,51 @@
import React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface OSOption {
value: string;
name: string;
logo: string; // now it's a path to image, e.g., /logos/ubuntu.svg
}
const osOptions: OSOption[] = [
{ value: "ubuntu", name: "Ubuntu", logo: "/upload/os/ubuntu.png" },
{ value: "debian", name: "Debian", logo: "/upload/os/debian.png" },
{ value: "centos", name: "CentOS", logo: "/upload/os/centos.png" },
{ value: "rhel", name: "Red Hat Enterprise Linux", logo: "/upload/os/rhel.png" },
{ value: "linux", name: "Linux (Generic)", logo: "/upload/os/linux.png" },
{ value: "windows", name: "Windows Server", logo: "/upload/os/windows.png" },
];
interface OSSelectorProps {
value: string;
onValueChange: (value: string) => void;
}
export const OSSelector: React.FC<OSSelectorProps> = ({ value, onValueChange }) => {
return (
<div className="grid gap-2">
{osOptions.map((os) => (
<button
key={os.value}
type="button"
onClick={() => onValueChange(os.value)}
className={cn(
"flex items-center justify-between w-full rounded-md border px-4 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground transition",
value === os.value ? "bg-accent text-accent-foreground border-ring" : "border-input"
)}
>
<div className="flex items-center gap-3">
<img
src={os.logo}
alt={`${os.name} logo`}
className="w-6 h-6 object-contain"
/>
<span>{os.name}</span>
</div>
{value === os.value && <Check className="h-4 w-4" />}
</button>
))}
</div>
);
};
@@ -0,0 +1,101 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Copy, Download } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils";
interface OneClickInstallTabProps {
serverToken: string;
currentPocketBaseUrl: string;
formData: {
serverName: string;
osType: string;
checkInterval: string;
retryAttempt: string;
};
serverId: string;
onDialogClose: () => void;
}
export const OneClickInstallTab: React.FC<OneClickInstallTabProps> = ({
serverToken,
currentPocketBaseUrl,
formData,
serverId,
onDialogClose,
}) => {
const getOneClickInstallCommand = () => {
const scriptUrl = "https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/server-agent.sh";
return `curl -L -o server-agent.sh "${scriptUrl}"
chmod +x server-agent.sh
SERVER_TOKEN="${serverToken}" \\
POCKETBASE_URL="${currentPocketBaseUrl}" \\
SERVER_NAME="${formData.serverName}" \\
AGENT_ID="${serverId}" \\
OS_TYPE="${formData.osType}" \\
CHECK_INTERVAL="${formData.checkInterval}" \\
RETRY_ATTEMPTS="${formData.retryAttempt}" \\
sudo -E bash ./server-agent.sh`;
};
const handleCopyCommand = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
console.log('Copy button clicked'); // Debug log
const command = getOneClickInstallCommand();
console.log('Copying command:', command); // Debug log
await copyToClipboard(command);
};
return (
<Card className="border-green-500/20 bg-green-0/50 dark:bg-green-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400">
<Download className="h-5 w-5" />
One-Click Install
</CardTitle>
<CardDescription className="text-green-600 dark:text-green-300">
Copy and paste this single command to install the monitoring agent instantly
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-green-700 dark:text-green-400">Quick Install Command</Label>
<div className="relative">
<pre className="bg-black-50 dark:bg-green-100/950 border border-green-200 dark:border-green-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-green-800 dark:text-green-200">
<code>{getOneClickInstallCommand()}</code>
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="absolute top-2 right-2 bg-green-50 dark:bg-green-950/50 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900 text-green-700 dark:text-green-400"
onClick={handleCopyCommand}
>
<Copy className="h-4 w-4 mr-1" />
Copy
</Button>
</div>
</div>
<div className="text-sm text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 p-3 rounded-md">
<p className="font-medium mb-1">Simply run this command on your server:</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>SSH into your target server</li>
<li>Paste and run the command above</li>
<li>The agent will be installed and started automatically</li>
</ol>
</div>
<div className="flex justify-end pt-4">
<Button onClick={onDialogClose}>
Done
</Button>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,172 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Copy } from "lucide-react";
import { copyToClipboard } from "@/utils/copyUtils";
import { OSSelector } from "./OSSelector";
interface ServerAgentConfigFormProps {
formData: {
serverName: string;
description: string;
osType: string;
checkInterval: string;
retryAttempt: string;
dockerEnabled: boolean;
notificationEnabled: boolean;
};
setFormData: React.Dispatch<React.SetStateAction<{
serverName: string;
description: string;
osType: string;
checkInterval: string;
retryAttempt: string;
dockerEnabled: boolean;
notificationEnabled: boolean;
}>>;
serverId: string;
serverToken: string;
currentPocketBaseUrl: string;
isSubmitting: boolean;
onSubmit: (e: React.FormEvent) => void;
}
export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
formData,
setFormData,
serverId,
serverToken,
currentPocketBaseUrl,
isSubmitting,
onSubmit,
}) => {
return (
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="serverName">Server Name *</Label>
<Input
id="serverName"
placeholder="e.g., web-server-01"
value={formData.serverName}
onChange={(e) => setFormData(prev => ({ ...prev, serverName: e.target.value }))}
required
/>
<p className="text-xs text-muted-foreground">What is the name or label used as the identifier</p>
</div>
<div className="space-y-2">
<Label htmlFor="serverId">Server Agent ID</Label>
<div className="flex gap-2">
<Input
id="serverId"
value={serverId}
readOnly
className="font-mono text-sm bg-muted"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyToClipboard(serverId)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">Auto-generated unique identifier</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Operating System *</Label>
<OSSelector
value={formData.osType}
onValueChange={(value) => setFormData(prev => ({ ...prev, osType: value }))}
/>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="checkInterval">Check Interval</Label>
<Select
value={formData.checkInterval}
onValueChange={(value) => setFormData(prev => ({ ...prev, checkInterval: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">30 seconds</SelectItem>
<SelectItem value="60">1 minute</SelectItem>
<SelectItem value="120">2 minutes</SelectItem>
<SelectItem value="300">5 minutes</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">How often to check the server and metric status</p>
</div>
<div className="space-y-2">
<Label htmlFor="retryAttempt">Retry Attempts</Label>
<Select
value={formData.retryAttempt}
onValueChange={(value) => setFormData(prev => ({ ...prev, retryAttempt: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select retry attempts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 attempt</SelectItem>
<SelectItem value="2">2 attempts</SelectItem>
<SelectItem value="3">3 attempts</SelectItem>
<SelectItem value="5">5 attempts</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Number of retry attempts before marking as down</p>
</div>
<div className="space-y-2">
<Label>Server Token</Label>
<div className="flex gap-2">
<Input value={serverToken} readOnly className="font-mono text-sm bg-muted" />
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyToClipboard(serverToken)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">Auto-generated authentication token</p>
</div>
<div className="space-y-2">
<Label>System URL</Label>
<div className="flex gap-2">
<Input value={currentPocketBaseUrl} readOnly className="font-mono text-sm bg-muted" />
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyToClipboard(currentPocketBaseUrl)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">Current system API URL</p>
</div>
</div>
</div>
<div className="pt-4">
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? "Creating Agent..." : "Create Server Agent"}
</Button>
</div>
</form>
);
};
@@ -24,7 +24,7 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
isFetching isFetching
} = useServerHistoryData(serverId); } = useServerHistoryData(serverId);
//console.log('ServerHistoryCharts: Rendering with serverId:', serverId, 'timeRange:', timeRange); // console.log('ServerHistoryCharts: Rendering with serverId:', serverId, 'timeRange:', timeRange);
// Memoize latest data calculation to prevent unnecessary recalculations // Memoize latest data calculation to prevent unnecessary recalculations
const latestData = useMemo(() => { const latestData = useMemo(() => {
@@ -48,10 +48,10 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
</div> </div>
{/* Skeleton loading cards */} {/* Skeleton loading cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
{[1, 2, 3, 4].map((index) => ( {[1, 2, 3, 4].map((index) => (
<Card key={index} className="bg-gradient-to-br from-background to-muted/20"> <Card key={index} className="bg-gradient-to-br from-background to-muted/20">
<CardContent className="p-6"> <CardContent className="p-4 lg:p-6">
<div className="animate-pulse"> <div className="animate-pulse">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -60,7 +60,7 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
</div> </div>
<div className="w-16 h-4 bg-muted rounded"></div> <div className="w-16 h-4 bg-muted rounded"></div>
</div> </div>
<div className="w-full h-80 bg-muted rounded"></div> <div className="w-full h-64 lg:h-80 bg-muted rounded"></div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -122,11 +122,11 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
); );
} }
// console.log('ServerHistoryCharts: Rendering charts with', chartData.length, 'data points for time range:', timeRange); // console.log('ServerHistoryCharts: Rendering charts with', chartData.length, 'data points for time range:', timeRange);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
<h2 className="text-lg font-medium">Historical Performance</h2> <h2 className="text-lg font-medium">Historical Performance</h2>
@@ -143,13 +143,21 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<TimeRangeSelector value={timeRange} onChange={setTimeRange} /> <TimeRangeSelector value={timeRange} onChange={setTimeRange} />
</div> </div>
{/* Use CSS Grid for better performance than flexbox */} {/* Improved responsive grid layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6 auto-rows-fr">
<div className="w-full min-w-0">
<CPUChart data={chartData} latestData={latestData} /> <CPUChart data={chartData} latestData={latestData} />
</div>
<div className="w-full min-w-0">
<MemoryChart data={chartData} latestData={latestData} /> <MemoryChart data={chartData} latestData={latestData} />
</div>
<div className="w-full min-w-0">
<DiskChart data={chartData} latestData={latestData} /> <DiskChart data={chartData} latestData={latestData} />
</div>
<div className="w-full min-w-0">
<NetworkChart data={chartData} latestData={latestData} /> <NetworkChart data={chartData} latestData={latestData} />
</div> </div>
</div> </div>
</div>
); );
}; };
@@ -28,7 +28,10 @@ export const ServerMetricsCharts = ({ serverId }: ServerMetricsChartsProps) => {
queryKey: ['server-metrics', serverId, timeRange], queryKey: ['server-metrics', serverId, timeRange],
queryFn: () => serverService.getServerMetrics(serverId, timeRange), queryFn: () => serverService.getServerMetrics(serverId, timeRange),
enabled: !!serverId, enabled: !!serverId,
refetchInterval: 30000 refetchInterval: timeRange === '60m' ? 60000 : timeRange === '1d' ? 120000 : 300000, // Increased intervals
staleTime: timeRange === '60m' ? 30000 : timeRange === '1d' ? 60000 : 120000, // Increased stale time
gcTime: 10 * 60 * 1000, // 10 minutes cache
refetchOnWindowFocus: false, // Prevent refetch on window focus
}); });
const chartData = formatChartData(metrics, timeRange); const chartData = formatChartData(metrics, timeRange);
@@ -1,4 +1,3 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -6,13 +5,17 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { RefreshCw, Search, Eye, Activity, MoreHorizontal, Pause, Play, Edit, Trash2 } from "lucide-react"; import { RefreshCw, Search, Eye, Activity, MoreHorizontal, Pause, Play, Edit, Trash2 } from "lucide-react";
import { Server } from "@/types/server.types"; import { Server } from "@/types/server.types";
import { ServerStatusBadge } from "./ServerStatusBadge"; import { ServerStatusBadge } from "./ServerStatusBadge";
import { OSTypeIcon } from "./OSTypeIcon"; import { OSTypeIcon } from "./OSTypeIcon";
import { EditServerDialog } from "./EditServerDialog";
import { serverService } from "@/services/serverService"; import { serverService } from "@/services/serverService";
import { useToast } from "@/hooks/use-toast";
import { pb } from "@/lib/pocketbase";
import { useTheme } from "@/contexts/ThemeContext";
interface ServerTableProps { interface ServerTableProps {
servers: Server[]; servers: Server[];
@@ -21,9 +24,15 @@ interface ServerTableProps {
} }
export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => { export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => {
const { theme } = useTheme();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [pausedServers, setPausedServers] = useState<Set<string>>(new Set()); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [pausingServers, setPausingServers] = useState<Set<string>>(new Set());
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast();
const filteredServers = servers.filter(server => const filteredServers = servers.filter(server =>
server.name.toLowerCase().includes(searchTerm.toLowerCase()) || server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -39,38 +48,165 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
navigate(`/container-monitoring/${serverId}`); navigate(`/container-monitoring/${serverId}`);
}; };
const handlePauseResume = (serverId: string) => { const handlePauseResume = async (server: Server) => {
const isPaused = pausedServers.has(serverId); const serverId = server.id;
if (isPaused) { const isPaused = server.status === "paused";
setPausedServers(prev => {
if (pausingServers.has(serverId)) {
return; // Already processing this server
}
try {
setPausingServers(prev => new Set(prev).add(serverId));
// Only update the status field, preserving all other server configuration
const updateData = {
status: isPaused ? "up" : "paused",
last_checked: new Date().toISOString()
};
await pb.collection('servers').update(serverId, updateData);
toast({
title: isPaused ? "Server resumed" : "Server paused",
description: `Monitoring ${isPaused ? 'resumed' : 'paused'} for ${server.name}`,
});
// console.log(`${isPaused ? 'Resume' : 'Pause'} server monitoring: ${serverId}`);
// Refresh the server list to show updated status
onRefresh();
} catch (error) {
// console.error('Error updating server status:', error);
toast({
variant: "destructive",
title: "Error",
description: `Failed to ${isPaused ? 'resume' : 'pause'} server monitoring. Please try again.`,
});
} finally {
setPausingServers(prev => {
const newSet = new Set(prev); const newSet = new Set(prev);
newSet.delete(serverId); newSet.delete(serverId);
return newSet; return newSet;
}); });
// console.log('Resume server monitoring:', serverId);
} else {
setPausedServers(prev => new Set(prev).add(serverId));
// console.log('Pause server monitoring:', serverId);
} }
}; };
const handleEdit = (serverId: string) => { const handleEdit = (server: Server) => {
// TODO: Implement edit functionality setSelectedServer(server);
// console.log('Edit server:', serverId); setEditDialogOpen(true);
}; };
const handleDelete = (serverId: string) => { const handleDelete = (server: Server) => {
// TODO: Implement delete functionality setSelectedServer(server);
// console.log('Delete server:', serverId); setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!selectedServer || isDeleting) return;
try {
setIsDeleting(true);
// Delete the server from the database
await pb.collection('servers').delete(selectedServer.id);
toast({
title: "Server deleted",
description: `${selectedServer.name} has been deleted successfully.`,
});
// Refresh the server list
onRefresh();
// Close the dialog
setDeleteDialogOpen(false);
setSelectedServer(null);
} catch (error) {
// console.error('Error deleting server:', error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete server. Please try again.",
});
} finally {
setIsDeleting(false);
}
};
const CustomProgressBar = ({
value,
label,
subtitle,
type
}: {
value: number;
label: string;
subtitle: string;
type: 'cpu' | 'memory' | 'disk'
}) => {
const getGradientColors = (type: string, value: number) => {
if (type === 'cpu') {
if (value > 90) return 'from-red-500 to-red-600';
if (value > 75) return 'from-orange-500 to-orange-600';
if (value > 60) return 'from-yellow-500 to-yellow-600';
return 'from-green-500 to-green-600';
}
if (type === 'memory') {
if (value > 90) return 'from-red-500 to-red-600';
if (value > 75) return 'from-yellow-500 to-yellow-600';
return 'from-blue-500 to-blue-600';
}
if (type === 'disk') {
if (value > 95) return 'from-red-500 to-red-600';
if (value > 85) return 'from-yellow-500 to-yellow-600';
return 'from-orange-500 to-orange-600';
}
return 'from-gray-500 to-gray-600';
};
const getTextColor = (value: number) => {
if (value > 90) return 'text-red-600 dark:text-red-400';
if (value > 75) return 'text-orange-600 dark:text-orange-400';
if (value > 60) return 'text-yellow-600 dark:text-yellow-400';
return 'text-green-600 dark:text-green-400';
};
return (
<div className="space-y-2 min-w-[120px]">
<div className="flex justify-between items-center">
<span className={`text-sm font-semibold ${getTextColor(value)}`}>
{label}
</span>
<span className="text-xs text-muted-foreground">
{subtitle}
</span>
</div>
<div className="relative">
<div className="w-full h-3 bg-muted/30 rounded-full overflow-hidden shadow-inner">
<div
className={`h-full bg-gradient-to-r ${getGradientColors(type, value)} rounded-full transition-all duration-700 ease-out relative overflow-hidden`}
style={{ width: `${Math.min(value, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse opacity-60" />
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-white/10" />
</div>
</div>
</div>
</div>
);
}; };
if (isLoading) { if (isLoading) {
return ( return (
<Card> <Card className="flex-1 flex flex-col">
<CardHeader> <CardHeader className="flex-shrink-0">
<CardTitle>Servers</CardTitle> <CardTitle>Servers</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex items-center justify-center">
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<RefreshCw className="h-6 w-6 animate-spin" /> <RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2">Loading servers...</span> <span className="ml-2">Loading servers...</span>
@@ -81,8 +217,9 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
} }
return ( return (
<Card> <>
<CardHeader> <Card className="bg-transparent border-0 shadow-none">
<CardHeader className="pb-4 px-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle className="text-xl font-semibold">Servers</CardTitle> <CardTitle className="text-xl font-semibold">Servers</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -101,26 +238,26 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
{filteredServers.length === 0 ? ( {filteredServers.length === 0 ? (
<div className="text-center py-8"> <div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">No servers found</p> <p className="text-muted-foreground">No servers found</p>
</div> </div>
) : ( ) : (
<div className="rounded-md border overflow-x-auto"> <div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
<Table> <Table>
<TableHeader> <TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
<TableRow> <TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
<TableHead>Name</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Name</TableHead>
<TableHead>Status</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Status</TableHead>
<TableHead>OS</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>OS</TableHead>
<TableHead>IP Address</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>IP Address</TableHead>
<TableHead>CPU</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>CPU</TableHead>
<TableHead>Memory</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Memory</TableHead>
<TableHead>Disk</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Disk</TableHead>
<TableHead>Uptime</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Uptime</TableHead>
<TableHead>Last Checked</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Last Checked</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right`}>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -128,14 +265,18 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
const cpuUsage = server.cpu_usage || 0; const cpuUsage = server.cpu_usage || 0;
const memoryUsage = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 0; const memoryUsage = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 0;
const diskUsage = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0; const diskUsage = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0;
const isPaused = pausedServers.has(server.id); const isPaused = server.status === "paused";
const isProcessing = pausingServers.has(server.id);
return ( return (
<TableRow key={server.id} className="hover:bg-muted/50"> <TableRow
<TableCell> key={server.id}
<div> className="hover:bg-muted/50 cursor-pointer"
<div className="font-medium">{server.name}</div> onClick={() => handleViewDetails(server.id)}
>
<TableCell className="font-medium">
<div className="truncate" title={server.name}>
{server.name}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -144,91 +285,78 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<OSTypeIcon osType={server.os_type} /> <OSTypeIcon osType={server.os_type} />
<span className="text-sm">{server.os_type}</span> <span className="text-sm truncate" title={server.os_type}>
{server.os_type}
</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<code className="text-sm bg-muted px-1 py-0.5 rounded">{server.ip_address}</code> <code className="text-sm bg-muted px-1 py-0.5 rounded text-xs">
{server.ip_address}
</code>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="space-y-1 min-w-[120px]"> <CustomProgressBar
<div className="flex justify-between text-sm">
<span>{cpuUsage.toFixed(1)}%</span>
<span className="text-muted-foreground">{server.cpu_cores} cores</span>
</div>
<Progress
value={cpuUsage} value={cpuUsage}
className="h-2" label={`${cpuUsage.toFixed(1)}%`}
indicatorClassName={ subtitle={`${server.cpu_cores} cores`}
cpuUsage > 90 ? "bg-red-00" : type="cpu"
cpuUsage > 75 ? "bg-orange-500" :
cpuUsage > 60 ? "bg-yellow-500" : "bg-green-500"
}
/> />
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="space-y-1 min-w-[120px]"> <CustomProgressBar
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{memoryUsage.toFixed(1)}%</span>
<span>{serverService.formatBytes(server.ram_total)}</span>
</div>
<Progress
value={memoryUsage} value={memoryUsage}
className="h-2" label={`${memoryUsage.toFixed(1)}%`}
indicatorClassName={ subtitle={serverService.formatBytes(server.ram_total)}
memoryUsage > 90 ? "bg-red-500" : type="memory"
memoryUsage > 75 ? "bg-yellow-500" : "bg-blue-500"
}
/> />
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="space-y-1 min-w-[120px]"> <CustomProgressBar
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{diskUsage.toFixed(1)}%</span>
<span>{serverService.formatBytes(server.disk_total)}</span>
</div>
<Progress
value={diskUsage} value={diskUsage}
className="h-2" label={`${diskUsage.toFixed(1)}%`}
indicatorClassName={ subtitle={serverService.formatBytes(server.disk_total)}
diskUsage > 95 ? "bg-red-500" : type="disk"
diskUsage > 85 ? "bg-yellow-500" : "bg-orange-500"
}
/> />
</TableCell>
<TableCell>
<div className="text-sm truncate" title={server.uptime}>
{server.uptime}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm">{server.uptime}</div> <div className="text-sm text-muted-foreground text-xs">
</TableCell>
<TableCell>
<div className="text-sm text-muted-foreground">
{new Date(server.last_checked).toLocaleString()} {new Date(server.last_checked).toLocaleString()}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button variant="ghost" className="h-8 w-8 p-0" disabled={isProcessing}>
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
{isProcessing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
)}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]"> <DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem onClick={() => handleViewDetails(server.id)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(server.id); }}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
View Server Detail View Server Detail
</DropdownMenuItem> </DropdownMenuItem>
{server.docker === 'true' && ( {server.docker === 'true' && (
<DropdownMenuItem onClick={() => handleViewContainers(server.id)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewContainers(server.id); }}>
<Activity className="mr-2 h-4 w-4" /> <Activity className="mr-2 h-4 w-4" />
Container Monitoring Container Monitoring
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handlePauseResume(server.id)}> <DropdownMenuItem
onClick={(e) => { e.stopPropagation(); handlePauseResume(server); }}
disabled={isProcessing}
>
{isPaused ? ( {isPaused ? (
<> <>
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
@@ -242,12 +370,12 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleEdit(server.id)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleEdit(server); }}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Server Edit Server
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleDelete(server.id)} onClick={(e) => { e.stopPropagation(); handleDelete(server); }}
className="text-red-600 focus:text-red-600" className="text-red-600 focus:text-red-600"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
@@ -265,5 +393,42 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Edit Server Dialog */}
<EditServerDialog
server={selectedServer}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
onServerUpdated={onRefresh}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this server?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete{' '}
<span className="font-semibold text-foreground">
{selectedServer?.name}
</span>{' '}
and all of its monitoring data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
className="bg-red-600 text-white hover:bg-red-700"
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
); );
}; };
@@ -1,7 +1,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
import { AreaChart, Area, XAxis, YAxis, CartesianGrid } from "recharts"; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
import { Cpu } from "lucide-react"; import { Cpu } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent"; import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
@@ -18,31 +18,27 @@ export const CPUChart = ({ data, latestData }: CPUChartProps) => {
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280'; const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
return ( return (
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm"> <Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
<CardHeader className="pb-2"> <CardHeader className="pb-2 flex-shrink-0">
<CardTitle className="flex items-center justify-between text-foreground"> <CardTitle className="flex items-center justify-between text-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-blue-500/15"> <div className="p-2 rounded-lg bg-blue-500/15">
<Cpu className="h-5 w-5 text-blue-500" /> <Cpu className="h-4 w-4 lg:h-5 lg:w-5 text-blue-500" />
</div> </div>
CPU Usage <span className="text-sm lg:text-base">CPU Usage</span>
</div> </div>
{latestData && ( {latestData && (
<div className="text-right text-sm"> <div className="text-right text-xs lg:text-sm">
<div className="text-blue-500 font-semibold">{latestData.cpuUsage}%</div> <div className="text-blue-500 font-semibold">{latestData.cpuUsage}%</div>
<div className="text-xs text-muted-foreground">{latestData.cpuCores} cores</div> <div className="text-xs text-muted-foreground">{latestData.cpuCores} cores</div>
</div> </div>
)} )}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-2"> <CardContent className="pt-2 flex-1 min-h-0">
<ChartContainer config={{ <div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
cpuUsage: { <ResponsiveContainer width="100%" height="100%">
label: "CPU Usage (%)", <AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
color: theme === 'dark' ? "#3b82f6" : "#2563eb",
}
}} className="h-80">
<AreaChart data={data}>
<defs> <defs>
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={theme === 'dark' ? "#3b82f6" : "#2563eb"} stopOpacity={0.4}/> <stop offset="5%" stopColor={theme === 'dark' ? "#3b82f6" : "#2563eb"} stopOpacity={0.4}/>
@@ -69,14 +65,15 @@ export const CPUChart = ({ data, latestData }: CPUChartProps) => {
type="monotone" type="monotone"
dataKey="cpuUsage" dataKey="cpuUsage"
stroke={theme === 'dark' ? "#60a5fa" : "#2563eb"} stroke={theme === 'dark' ? "#60a5fa" : "#2563eb"}
strokeWidth={3} strokeWidth={2}
dot={false} dot={false}
fill="url(#cpuGradient)" fill="url(#cpuGradient)"
fillOpacity={1} fillOpacity={1}
name="CPU Usage (%)" name="CPU Usage (%)"
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -1,7 +1,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
import { AreaChart, Area, XAxis, YAxis, CartesianGrid } from "recharts"; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
import { HardDrive } from "lucide-react"; import { HardDrive } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent"; import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
@@ -18,31 +18,27 @@ export const DiskChart = ({ data, latestData }: DiskChartProps) => {
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280'; const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
return ( return (
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm"> <Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
<CardHeader className="pb-2"> <CardHeader className="pb-2 flex-shrink-0">
<CardTitle className="flex items-center justify-between text-foreground"> <CardTitle className="flex items-center justify-between text-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-amber-500/15"> <div className="p-2 rounded-lg bg-amber-500/15">
<HardDrive className="h-5 w-5 text-amber-500" /> <HardDrive className="h-4 w-4 lg:h-5 lg:w-5 text-amber-500" />
</div> </div>
Disk Usage <span className="text-sm lg:text-base">Disk Usage</span>
</div> </div>
{latestData && ( {latestData && (
<div className="text-right text-sm"> <div className="text-right text-xs lg:text-sm">
<div className="text-amber-500 font-semibold">{latestData.diskUsagePercent}%</div> <div className="text-amber-500 font-semibold">{latestData.diskUsagePercent}%</div>
<div className="text-xs text-muted-foreground">{latestData.diskUsed} / {latestData.diskTotal}</div> <div className="text-xs text-muted-foreground">{latestData.diskUsed} / {latestData.diskTotal}</div>
</div> </div>
)} )}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-2"> <CardContent className="pt-2 flex-1 min-h-0">
<ChartContainer config={{ <div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
diskUsagePercent: { <ResponsiveContainer width="100%" height="100%">
label: "Disk Usage (%)", <AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
color: theme === 'dark' ? "#f59e0b" : "#d97706",
}
}} className="h-80">
<AreaChart data={data}>
<defs> <defs>
<linearGradient id="diskGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="diskGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={theme === 'dark' ? "#f59e0b" : "#d97706"} stopOpacity={0.6}/> <stop offset="5%" stopColor={theme === 'dark' ? "#f59e0b" : "#d97706"} stopOpacity={0.6}/>
@@ -69,14 +65,15 @@ export const DiskChart = ({ data, latestData }: DiskChartProps) => {
type="step" type="step"
dataKey="diskUsagePercent" dataKey="diskUsagePercent"
stroke={theme === 'dark' ? "#fbbf24" : "#d97706"} stroke={theme === 'dark' ? "#fbbf24" : "#d97706"}
strokeWidth={3} strokeWidth={2}
dot={false} dot={false}
fill="url(#diskGradient)" fill="url(#diskGradient)"
fillOpacity={1} fillOpacity={1}
name="Disk Usage (%)" name="Disk Usage (%)"
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -1,7 +1,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
import { AreaChart, Area, XAxis, YAxis, CartesianGrid } from "recharts"; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
import { MemoryStick } from "lucide-react"; import { MemoryStick } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent"; import { DetailedTooltipContent } from "./tooltips/DetailedTooltipContent";
@@ -18,31 +18,27 @@ export const MemoryChart = ({ data, latestData }: MemoryChartProps) => {
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280'; const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
return ( return (
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm"> <Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
<CardHeader className="pb-2"> <CardHeader className="pb-2 flex-shrink-0">
<CardTitle className="flex items-center justify-between text-foreground"> <CardTitle className="flex items-center justify-between text-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-green-500/15"> <div className="p-2 rounded-lg bg-green-500/15">
<MemoryStick className="h-5 w-5 text-green-500" /> <MemoryStick className="h-4 w-4 lg:h-5 lg:w-5 text-green-500" />
</div> </div>
Memory Usage <span className="text-sm lg:text-base">Memory Usage</span>
</div> </div>
{latestData && ( {latestData && (
<div className="text-right text-sm"> <div className="text-right text-xs lg:text-sm">
<div className="text-green-500 font-semibold">{latestData.ramUsagePercent}%</div> <div className="text-green-500 font-semibold">{latestData.ramUsagePercent}%</div>
<div className="text-xs text-muted-foreground">{latestData.ramUsed} / {latestData.ramTotal}</div> <div className="text-xs text-muted-foreground">{latestData.ramUsed} / {latestData.ramTotal}</div>
</div> </div>
)} )}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-2"> <CardContent className="pt-2 flex-1 min-h-0">
<ChartContainer config={{ <div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
ramUsagePercent: { <ResponsiveContainer width="100%" height="100%">
label: "Memory Usage (%)", <AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
color: theme === 'dark' ? "#10b981" : "#059669",
}
}} className="h-80">
<AreaChart data={data}>
<defs> <defs>
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={theme === 'dark' ? "#10b981" : "#059669"} stopOpacity={0.5}/> <stop offset="5%" stopColor={theme === 'dark' ? "#10b981" : "#059669"} stopOpacity={0.5}/>
@@ -69,14 +65,15 @@ export const MemoryChart = ({ data, latestData }: MemoryChartProps) => {
type="basis" type="basis"
dataKey="ramUsagePercent" dataKey="ramUsagePercent"
stroke={theme === 'dark' ? "#34d399" : "#059669"} stroke={theme === 'dark' ? "#34d399" : "#059669"}
strokeWidth={3} strokeWidth={2}
dot={false} dot={false}
fill="url(#memoryGradient)" fill="url(#memoryGradient)"
fillOpacity={1} fillOpacity={1}
name="Memory Usage (%)" name="Memory Usage (%)"
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -1,7 +1,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
import { LineChart, Line, XAxis, YAxis, CartesianGrid } from "recharts"; import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from "recharts";
import { Wifi } from "lucide-react"; import { Wifi } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { NetworkTooltipContent } from "./tooltips/NetworkTooltipContent"; import { NetworkTooltipContent } from "./tooltips/NetworkTooltipContent";
@@ -18,35 +18,27 @@ export const NetworkChart = ({ data, latestData }: NetworkChartProps) => {
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280'; const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
return ( return (
<Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm"> <Card className="bg-gradient-to-br from-background to-muted/20 border-border/50 shadow-lg hover:shadow-xl transition-all duration-300 backdrop-blur-sm h-full flex flex-col">
<CardHeader className="pb-2"> <CardHeader className="pb-2 flex-shrink-0">
<CardTitle className="flex items-center justify-between text-foreground"> <CardTitle className="flex items-center justify-between text-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-purple-500/15"> <div className="p-2 rounded-lg bg-purple-500/15">
<Wifi className="h-5 w-5 text-purple-500" /> <Wifi className="h-4 w-4 lg:h-5 lg:w-5 text-purple-500" />
</div> </div>
Network Traffic <span className="text-sm lg:text-base">Network Traffic</span>
</div> </div>
{latestData && ( {latestData && (
<div className="text-right text-sm"> <div className="text-right text-xs lg:text-sm">
<div className="text-purple-500 font-semibold">{latestData.networkRxSpeed} KB/s </div> <div className="text-purple-500 font-semibold">{latestData.networkRxSpeed} KB/s </div>
<div className="text-red-500 font-semibold">{latestData.networkTxSpeed} KB/s </div> <div className="text-red-500 font-semibold">{latestData.networkTxSpeed} KB/s </div>
</div> </div>
)} )}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-2"> <CardContent className="pt-2 flex-1 min-h-0">
<ChartContainer config={{ <div className="w-full h-full min-h-[240px] lg:min-h-[320px]">
networkRxSpeed: { <ResponsiveContainer width="100%" height="100%">
label: "RX Speed", <LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
color: theme === 'dark' ? "#8b5cf6" : "#7c3aed",
},
networkTxSpeed: {
label: "TX Speed",
color: theme === 'dark' ? "#ef4444" : "#dc2626",
}
}} className="h-80">
<LineChart data={data}>
<defs> <defs>
<linearGradient id="networkGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="networkGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={theme === 'dark' ? "#8b5cf6" : "#7c3aed"} stopOpacity={0.2}/> <stop offset="5%" stopColor={theme === 'dark' ? "#8b5cf6" : "#7c3aed"} stopOpacity={0.2}/>
@@ -79,7 +71,7 @@ export const NetworkChart = ({ data, latestData }: NetworkChartProps) => {
type="monotone" type="monotone"
dataKey="networkRxSpeed" dataKey="networkRxSpeed"
stroke={theme === 'dark' ? "#a78bfa" : "#7c3aed"} stroke={theme === 'dark' ? "#a78bfa" : "#7c3aed"}
strokeWidth={3} strokeWidth={2}
dot={false} dot={false}
name="RX Speed" name="RX Speed"
filter="url(#glow)" filter="url(#glow)"
@@ -89,13 +81,14 @@ export const NetworkChart = ({ data, latestData }: NetworkChartProps) => {
type="monotone" type="monotone"
dataKey="networkTxSpeed" dataKey="networkTxSpeed"
stroke={theme === 'dark' ? "#f87171" : "#dc2626"} stroke={theme === 'dark' ? "#f87171" : "#dc2626"}
strokeWidth={3} strokeWidth={2}
dot={false} dot={false}
name="TX Speed" name="TX Speed"
strokeDasharray="5 5" strokeDasharray="5 5"
/> />
</LineChart> </LineChart>
</ChartContainer> </ResponsiveContainer>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -1,4 +1,3 @@
export const formatBytes = (bytes: number, decimals = 2) => { export const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
@@ -49,34 +48,82 @@ export const timeRangeOptions = [
{ value: '3m' as TimeRange, label: 'Last 90 days', hours: 24 * 90 }, { value: '3m' as TimeRange, label: 'Last 90 days', hours: 24 * 90 },
]; ];
// Optimized time range filtering with early returns
export const filterMetricsByTimeRange = (metrics: any[], timeRange: TimeRange): any[] => { export const filterMetricsByTimeRange = (metrics: any[], timeRange: TimeRange): any[] => {
if (!metrics?.length) return []; if (!metrics?.length) {
// console.log('🔍 filterMetricsByTimeRange: No metrics provided');
return [];
}
//console.log('🔍 filterMetricsByTimeRange: Starting with', metrics.length, 'metrics for', timeRange);
const now = new Date(); const now = new Date();
// For 60m, let's be very specific about what we're looking for
if (timeRange === '60m') {
// console.log('⏰ 60m filter: Current time:', now.toISOString());
// Try exact 60 minutes first
const cutoffTime60m = new Date(now.getTime() - (60 * 60 * 1000));
// console.log('⏰ 60m filter: Cutoff time:', cutoffTime60m.toISOString());
const filtered60m = metrics.filter(metric => {
const metricTime = new Date(metric.created || metric.timestamp);
const isWithinRange = metricTime >= cutoffTime60m && metricTime <= now;
if (!isWithinRange) {
const ageMinutes = Math.round((now.getTime() - metricTime.getTime()) / (1000 * 60));
// console.log('⏰ Excluding record:', {
// created: metric.created || metric.timestamp,
// ageMinutes: ageMinutes,
// reason: ageMinutes > 60 ? 'too old' : 'future date'
// });
}
return isWithinRange;
});
// console.log('✅ 60m strict filter result:', filtered60m.length, 'records');
if (filtered60m.length > 0) {
return filtered60m.sort((a, b) =>
new Date(a.created || a.timestamp).getTime() - new Date(b.created || b.timestamp).getTime()
);
}
// If no data in exactly 60m, show what we have and return it anyway for debugging
// console.log('⚠️ No data in 60m range, showing all available data ages:');
metrics.forEach((metric, index) => {
const metricTime = new Date(metric.created || metric.timestamp);
const ageMinutes = Math.round((now.getTime() - metricTime.getTime()) / (1000 * 60));
// console.log(`Record ${index}: ${metric.created || metric.timestamp} (${ageMinutes} minutes ago)`);
});
// Return the most recent data regardless of age
const sorted = metrics.sort((a, b) =>
new Date(b.created || b.timestamp).getTime() - new Date(a.created || a.timestamp).getTime()
);
// console.log('🔄 Returning most recent available data for 60m view');
return sorted.slice(0, 20); // Show last 20 records
}
// For other time ranges, use normal filtering
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange); const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
if (!selectedRange) return metrics; if (!selectedRange) return metrics;
// Add small buffer to avoid edge cases const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
const bufferMinutes = timeRange === '60m' ? 5 : timeRange === '1d' ? 30 : 60;
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000) - (bufferMinutes * 60 * 1000));
// console.log('filterMetricsByTimeRange: timeRange:', timeRange, 'cutoffTime:', cutoffTime.toISOString());
const filtered = metrics.filter(metric => { const filtered = metrics.filter(metric => {
const metricTime = new Date(metric.created || metric.timestamp); const metricTime = new Date(metric.created || metric.timestamp);
return metricTime >= cutoffTime && metricTime <= now; return metricTime >= cutoffTime && metricTime <= now;
}); });
// console.log('filterMetricsByTimeRange: Filtered', metrics.length, 'to', filtered.length, 'metrics'); // console.log(' Filtered', metrics.length, 'to', filtered.length, 'metrics for', timeRange);
// Sort by timestamp for proper chart display
return filtered.sort((a, b) => return filtered.sort((a, b) =>
new Date(a.created || a.timestamp).getTime() - new Date(b.created || b.timestamp).getTime() new Date(a.created || a.timestamp).getTime() - new Date(b.created || b.timestamp).getTime()
); );
}; };
// Optimized timestamp formatting with caching
const timestampCache = new Map<string, string>(); const timestampCache = new Map<string, string>();
const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => { const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => {
@@ -118,7 +165,6 @@ const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => {
}); });
} }
// Cache the result (limit cache size)
if (timestampCache.size > 1000) { if (timestampCache.size > 1000) {
timestampCache.clear(); timestampCache.clear();
} }
@@ -127,37 +173,49 @@ const formatTimestamp = (timestamp: string, timeRange: TimeRange): string => {
return formatted; return formatted;
}; };
// Optimized chart data formatting with better performance
export const formatChartData = (metrics: any[], timeRange: TimeRange) => { export const formatChartData = (metrics: any[], timeRange: TimeRange) => {
// console.log('formatChartData: Input metrics count:', metrics?.length || 0, 'timeRange:', timeRange); // console.log('📊 formatChartData: Processing', {
// inputCount: metrics?.length || 0,
// timeRange,
// sampleMetric: metrics?.[0] ? {
// id: metrics[0].id,
// created: metrics[0].created,
// timestamp: metrics[0].timestamp,
// server_id: metrics[0].server_id
// } : null
// });
if (!metrics?.length) return []; if (!metrics?.length) {
// console.log('❌ formatChartData: No metrics provided');
return [];
}
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange); const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
// console.log('formatChartData: After time filtering:', filteredMetrics?.length || 0, 'metrics'); // console.log('📊 formatChartData: After time filtering:', filteredMetrics?.length || 0, 'metrics');
if (!filteredMetrics.length) return []; if (!filteredMetrics.length) {
// console.log('❌ formatChartData: No metrics after time filtering');
return [];
}
// Dynamic sampling based on time range and data volume // Dynamic sampling based on time range and data volume
let maxDataPoints: number; let maxDataPoints: number;
switch (timeRange) { switch (timeRange) {
case '60m': maxDataPoints = 60; break; // 1 point per minute max case '60m': maxDataPoints = 60; break;
case '1d': maxDataPoints = 144; break; // 1 point per 10 minutes max case '1d': maxDataPoints = 144; break;
case '7d': maxDataPoints = 168; break; // 1 point per hour max case '7d': maxDataPoints = 168; break;
case '1m': maxDataPoints = 120; break; // 1 point per 6 hours max case '1m': maxDataPoints = 120; break;
case '3m': maxDataPoints = 90; break; // 1 point per day max case '3m': maxDataPoints = 90; break;
default: maxDataPoints = 100; default: maxDataPoints = 100;
} }
// Smart sampling - only sample if we have significantly more data
const sampledMetrics = filteredMetrics.length > maxDataPoints * 1.2 const sampledMetrics = filteredMetrics.length > maxDataPoints * 1.2
? filteredMetrics.filter((_, index) => index % Math.ceil(filteredMetrics.length / maxDataPoints) === 0) ? filteredMetrics.filter((_, index) => index % Math.ceil(filteredMetrics.length / maxDataPoints) === 0)
: filteredMetrics; : filteredMetrics;
// console.log('formatChartData: After sampling:', sampledMetrics.length, 'metrics for display'); // console.log('📊 formatChartData: After sampling:', sampledMetrics.length, 'metrics for display');
// Batch process the data transformation for better performance const formattedData = sampledMetrics.map((metric) => {
return sampledMetrics.map((metric) => {
const cpuUsage = typeof metric.cpu_usage === 'string' ? const cpuUsage = typeof metric.cpu_usage === 'string' ?
parseFloat(metric.cpu_usage.replace('%', '')) : parseFloat(metric.cpu_usage.replace('%', '')) :
parseFloat(metric.cpu_usage) || 0; parseFloat(metric.cpu_usage) || 0;
@@ -210,4 +268,7 @@ export const formatChartData = (metrics: any[], timeRange: TimeRange) => {
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100, networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
}; };
}); });
// console.log('✅ formatChartData: Final formatted data count:', formattedData.length);
return formattedData;
}; };
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { serverService } from "@/services/serverService"; import { serverService } from "@/services/serverService";
import { formatChartData } from "../dataUtils"; import { formatChartData } from "../dataUtils";
@@ -7,7 +7,7 @@ import { formatChartData } from "../dataUtils";
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m'; type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
export const useServerHistoryData = (serverId: string) => { export const useServerHistoryData = (serverId: string) => {
const [timeRange, setTimeRange] = useState<TimeRange>("1d"); const [timeRange, setTimeRange] = useState<TimeRange>("60m");
const { const {
data: metrics = [], data: metrics = [],
@@ -23,15 +23,19 @@ export const useServerHistoryData = (serverId: string) => {
return result || []; return result || [];
}, },
enabled: !!serverId, enabled: !!serverId,
refetchInterval: timeRange === '60m' ? 30000 : timeRange === '1d' ? 60000 : 120000, // Reduced frequency refetchInterval: timeRange === '60m' ? 60000 : timeRange === '1d' ? 120000 : 300000, // Significantly increased intervals
staleTime: timeRange === '60m' ? 15000 : timeRange === '1d' ? 30000 : 60000, // Increased stale time staleTime: timeRange === '60m' ? 30000 : timeRange === '1d' ? 60000 : 120000, // Increased stale time
retry: 2, // Reduced retries retry: 1, // Reduced retries to prevent excessive requests
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 3000), // Faster retry retryDelay: 2000, // Slower retry
gcTime: 5 * 60 * 1000, // 5 minutes cache gcTime: 10 * 60 * 1000, // 10 minutes cache
refetchOnWindowFocus: false, // Prevent refetch on window focus
refetchOnMount: false, // Prevent refetch on mount if data exists
}); });
// Memoize chart data formatting to prevent unnecessary recalculations // Memoize chart data formatting to prevent unnecessary recalculations
const chartData = formatChartData(metrics, timeRange); const chartData = useMemo(() => {
return formatChartData(metrics, timeRange);
}, [metrics, timeRange]);
return { return {
timeRange, timeRange,
@@ -8,6 +8,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { ServiceForm } from "./ServiceForm"; import { ServiceForm } from "./ServiceForm";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useQueryClient } from "@tanstack/react-query";
interface AddServiceDialogProps { interface AddServiceDialogProps {
open: boolean; open: boolean;
@@ -15,7 +16,11 @@ interface AddServiceDialogProps {
} }
export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) { export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) {
const handleSuccess = () => { const queryClient = useQueryClient();
const handleSuccess = async () => {
// Immediately invalidate and refetch services data
await queryClient.invalidateQueries({ queryKey: ["services"] });
await queryClient.refetchQueries({ queryKey: ["services"] });
onOpenChange(false); onOpenChange(false);
}; };
@@ -0,0 +1,174 @@
import { UptimeData } from "@/types/service.types";
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay } from "date-fns";
interface HeatmapChartProps {
uptimeData: UptimeData[];
selectedMonth: Date;
}
export const HeatmapChart = ({ uptimeData, selectedMonth }: HeatmapChartProps) => {
const monthStart = startOfMonth(selectedMonth);
const monthEnd = endOfMonth(selectedMonth);
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd });
const getStatusForDay = (day: Date) => {
const dayData = uptimeData.filter(data =>
isSameDay(new Date(data.timestamp), day)
);
if (dayData.length === 0) return 'no-data';
// Calculate the predominant status for the day
const statusCounts = dayData.reduce((acc, data) => {
acc[data.status] = (acc[data.status] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const predominantStatus = Object.entries(statusCounts)
.sort(([,a], [,b]) => b - a)[0][0];
return predominantStatus;
};
const getStatusColor = (status: string) => {
switch (status) {
case 'up':
return 'bg-emerald-500';
case 'down':
return 'bg-red-500';
case 'warning':
return 'bg-amber-500';
case 'paused':
return 'bg-slate-500';
default:
return 'bg-slate-700';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'up':
return 'Up';
case 'down':
return 'Down';
case 'warning':
return 'Warning';
case 'paused':
return 'Paused';
default:
return 'No Data';
}
};
// Group days by weeks
const weeks: Date[][] = [];
let currentWeek: Date[] = [];
daysInMonth.forEach((day, index) => {
if (index === 0) {
// Fill empty days at the start of the month
const dayOfWeek = day.getDay();
for (let i = 0; i < dayOfWeek; i++) {
currentWeek.push(new Date(0)); // Placeholder for empty cells
}
}
currentWeek.push(day);
if (currentWeek.length === 7) {
weeks.push([...currentWeek]);
currentWeek = [];
}
});
// Add remaining days to last week
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push(new Date(0)); // Placeholder for empty cells
}
weeks.push(currentWeek);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center">
<h3 className="text-xl font-semibold text-white mb-2">
Service Health - {format(selectedMonth, 'MMMM yyyy')}
</h3>
<p className="text-gray-400 text-sm">
Daily status overview for the current month
</p>
</div>
{/* Calendar Grid */}
<div className="space-y-4">
{/* Day labels */}
<div className="grid grid-cols-7 gap-2 text-sm text-gray-400 font-medium">
<div className="text-center py-2">Sun</div>
<div className="text-center py-2">Mon</div>
<div className="text-center py-2">Tue</div>
<div className="text-center py-2">Wed</div>
<div className="text-center py-2">Thu</div>
<div className="text-center py-2">Fri</div>
<div className="text-center py-2">Sat</div>
</div>
{/* Calendar days */}
<div className="space-y-2">
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="grid grid-cols-7 gap-2">
{week.map((day, dayIndex) => {
const isPlaceholder = day.getTime() === 0;
const status = isPlaceholder ? 'no-data' : getStatusForDay(day);
const dayData = isPlaceholder ? [] : uptimeData.filter(data =>
isSameDay(new Date(data.timestamp), day)
);
return (
<div
key={dayIndex}
className={`
relative aspect-square rounded-lg flex items-center justify-center text-sm font-medium text-white
transition-all duration-200 cursor-pointer
${isPlaceholder ? 'invisible' : `
${getStatusColor(status)} hover:scale-110 hover:shadow-lg
`}
`}
title={isPlaceholder ? '' : `${format(day, 'MMM d, yyyy')}: ${getStatusLabel(status)}${dayData.length > 0 ? ` (${dayData.length} checks)` : ''}`}
>
{isPlaceholder ? '' : format(day, 'd')}
</div>
);
})}
</div>
))}
</div>
</div>
{/* Status Legend */}
<div className="flex items-center justify-center gap-6 pt-4 border-t border-gray-700">
<span className="text-gray-400 text-sm font-medium">Status:</span>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-emerald-500 rounded-full"></div>
<span className="text-gray-300 text-sm">Up</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-amber-500 rounded-full"></div>
<span className="text-gray-300 text-sm">Warning</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<span className="text-gray-300 text-sm">Down</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-slate-500 rounded-full"></div>
<span className="text-gray-300 text-sm">Paused</span>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,97 @@
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { HeatmapChart } from "./HeatmapChart";
import { UptimeData } from "@/types/service.types";
import { addMonths, subMonths, format } from "date-fns";
interface HeatmapDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serviceName: string;
uptimeData: UptimeData[];
}
export const HeatmapDialog = ({
open,
onOpenChange,
serviceName,
uptimeData
}: HeatmapDialogProps) => {
const [selectedMonth, setSelectedMonth] = useState(new Date());
const handlePreviousMonth = () => {
setSelectedMonth(prev => subMonths(prev, 1));
};
const handleNextMonth = () => {
setSelectedMonth(prev => addMonths(prev, 1));
};
const handleCurrentMonth = () => {
setSelectedMonth(new Date());
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-slate-900 border-slate-700">
<DialogHeader className="pb-4">
<DialogTitle className="text-xl font-semibold text-white">
Health Heatmap - {serviceName}
</DialogTitle>
<DialogDescription className="text-gray-400">
Monthly overview of service health status with daily breakdown
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Month Navigation */}
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={handlePreviousMonth}
className="flex items-center gap-2 bg-slate-800 border-slate-600 text-white hover:bg-slate-700"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleCurrentMonth}
className="bg-slate-700 text-white hover:bg-slate-600"
>
Current Month
</Button>
<Button
variant="outline"
size="sm"
onClick={handleNextMonth}
className="flex items-center gap-2 bg-slate-800 border-slate-600 text-white hover:bg-slate-700"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Heatmap Chart */}
<HeatmapChart
uptimeData={uptimeData}
selectedMonth={selectedMonth}
/>
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,27 +1,34 @@
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, LineChart, Line } from "recharts"; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, LineChart, Line, BarChart, Bar } from "recharts";
import { UptimeData } from "@/types/service.types"; import { UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { AreaChart as AreaChartIcon, BarChart3, TrendingUp } from "lucide-react";
interface ResponseTimeChartProps { interface ResponseTimeChartProps {
uptimeData: UptimeData[]; uptimeData: UptimeData[];
} }
type ChartType = 'area' | 'line' | 'bar';
export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) { export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const [chartType, setChartType] = useState<ChartType>('area');
// Modern color palette for different chart lines with solid colors at 90-100% opacity // Fixed color palette - consistent colors that don't change
const modernColors = [ const fixedColors = [
{ stroke: '#f59e0b', fill: 'rgba(111, 86, 63, 0.95)' }, // Yellow (changed from blue) { stroke: '#3b82f6', fill: 'rgba(59, 130, 246, 0.2)', name: 'Ocean Blue' },
{ stroke: '#10b981', fill: 'rgba(0, 84, 56, 0.95)' }, // Emerald { stroke: '#10b981', fill: 'rgba(16, 185, 129, 0.2)', name: 'Emerald Green' },
{ stroke: '#3b82f6', fill: 'rgba(59, 130, 246, 0.95)' }, // Blue (moved to second position) { stroke: '#f59e0b', fill: 'rgba(245, 158, 11, 0.2)', name: 'Golden Amber' },
{ stroke: '#ef4444', fill: 'rgba(239, 68, 68, 0.95)' }, // Red { stroke: '#ef4444', fill: 'rgba(239, 68, 68, 0.2)', name: 'Ruby Red' },
{ stroke: '#8b5cf6', fill: 'rgba(139, 92, 246, 0.95)' }, // Violet { stroke: '#8b5cf6', fill: 'rgba(139, 92, 246, 0.2)', name: 'Royal Purple' },
{ stroke: '#06b6d4', fill: 'rgba(6, 182, 212, 0.95)' }, // Cyan { stroke: '#06b6d4', fill: 'rgba(6, 182, 212, 0.2)', name: 'Sky Cyan' },
{ stroke: '#f97316', fill: 'rgba(231, 148, 89, 0.95)' }, // Orange { stroke: '#f97316', fill: 'rgba(249, 115, 22, 0.2)', name: 'Sunset Orange' },
{ stroke: '#84cc16', fill: 'rgba(132, 204, 22, 0.95)' }, // Lime { stroke: '#84cc16', fill: 'rgba(132, 204, 22, 0.2)', name: 'Fresh Lime' },
{ stroke: '#ec4899', fill: 'rgba(236, 72, 153, 0.2)', name: 'Vibrant Pink' },
{ stroke: '#14b8a6', fill: 'rgba(20, 184, 166, 0.2)', name: 'Ocean Teal' },
]; ];
// Check if we have data from multiple sources // Check if we have data from multiple sources
@@ -160,7 +167,7 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
return [Math.max(0, minValue - padding), maxValue + padding]; return [Math.max(0, minValue - padding), maxValue + padding];
}, [chartData, hasMultipleSources]); }, [chartData, hasMultipleSources]);
// Get unique sources for legend with proper labeling and colors // Get unique sources for legend with consistent fixed colors
const sources = useMemo(() => { const sources = useMemo(() => {
if (!hasMultipleSources) return []; if (!hasMultipleSources) return [];
@@ -175,25 +182,29 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
} }
}); });
const regionalSources = Array.from(sourceSet).map((source, index) => { // Sort sources to ensure consistent ordering
const sortedSources = Array.from(sourceSet).sort();
const regionalSources = sortedSources.map((source, index) => {
const data = sourceInfo.get(source); const data = sourceInfo.get(source);
const [regionName, agentId] = source.split('|'); const [regionName, agentId] = source.split('|');
// Create proper label with region and agent info // Create proper label with region and agent info
let label = regionName; let label = regionName;
if (data?.agent_id && data.agent_id !== '1') { if (data?.agent_id && data.agent_id !== '1') {
label = `${regionName} (${data.agent_id})`; label = `${regionName} (Agent ${data.agent_id})`;
} else if (regionName === 'Default' && data?.agent_id === '1') { } else if (regionName === 'Default' && data?.agent_id === '1') {
label = `Default System Check (Agent 1)`; label = `Default System Check`;
} }
const colorIndex = index % modernColors.length; const colorIndex = index % fixedColors.length;
return { return {
key: `regional_${source.replace('|', '_')}`, key: `regional_${source.replace('|', '_')}`,
label: label, label: label,
stroke: modernColors[colorIndex].stroke, stroke: fixedColors[colorIndex].stroke,
fill: modernColors[colorIndex].fill fill: fixedColors[colorIndex].fill,
colorName: fixedColors[colorIndex].name
}; };
}); });
@@ -205,9 +216,10 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
const defaultSources = (hasPureDefault && !hasRegionalDefault) ? [{ const defaultSources = (hasPureDefault && !hasRegionalDefault) ? [{
key: 'default', key: 'default',
label: 'Default', label: 'Default System',
stroke: modernColors[regionalSources.length % modernColors.length].stroke, stroke: fixedColors[regionalSources.length % fixedColors.length].stroke,
fill: modernColors[regionalSources.length % modernColors.length].fill fill: fixedColors[regionalSources.length % fixedColors.length].fill,
colorName: fixedColors[regionalSources.length % fixedColors.length].name
}] : []; }] : [];
return [...defaultSources, ...regionalSources]; return [...defaultSources, ...regionalSources];
@@ -219,13 +231,13 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
const data = payload[0].payload; const data = payload[0].payload;
return ( return (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} p-3 border ${theme === 'dark' ? 'border-gray-700' : 'border-gray-200'} rounded shadow-md min-w-48`}> <div className={`${theme === 'dark' ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'} p-4 border rounded-lg shadow-lg min-w-64`}>
<p className="text-sm font-medium">{String(label)}</p> <p className="text-sm font-semibold mb-1">{String(label)}</p>
<p className="text-xs text-muted-foreground mb-2">{String(data.date)}</p> <p className="text-xs text-muted-foreground mb-3">{String(data.date)}</p>
{hasMultipleSources ? ( {hasMultipleSources ? (
// Multi-source tooltip // Multi-source tooltip
<div className="space-y-2"> <div className="space-y-3">
{sources.map(source => { {sources.map(source => {
const valueKey = source.key === 'default' ? 'defaultValue' : `${source.key}_value`; const valueKey = source.key === 'default' ? 'defaultValue' : `${source.key}_value`;
const statusKey = source.key === 'default' ? 'defaultStatus' : `${source.key}_status`; const statusKey = source.key === 'default' ? 'defaultStatus' : `${source.key}_status`;
@@ -234,56 +246,66 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
if (value === undefined && status === undefined) return null; if (value === undefined && status === undefined) return null;
let statusColor = "bg-gray-800"; let statusBadgeClass = "px-2 py-1 rounded-full text-xs font-medium";
let statusText = "No Data"; let statusText = "No Data";
if (status === "up") { if (status === "up") {
statusColor = "bg-emerald-800"; statusBadgeClass += " bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
statusText = "Up"; statusText = "Up";
} else if (status === "down") { } else if (status === "down") {
statusColor = "bg-red-800"; statusBadgeClass += " bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
statusText = "Down"; statusText = "Down";
} else if (status === "warning") { } else if (status === "warning") {
statusColor = "bg-yellow-800"; statusBadgeClass += " bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
statusText = "Warning"; statusText = "Warning";
} else if (status === "paused") { } else if (status === "paused") {
statusColor = "bg-gray-800"; statusBadgeClass += " bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200";
statusText = "Paused"; statusText = "Paused";
} else {
statusBadgeClass += " bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400";
} }
return ( return (
<div key={source.key} className="flex items-center justify-between"> <div key={source.key} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3 min-w-0 flex-1">
<div <div
className="w-3 h-3 rounded-full" className="w-4 h-4 rounded-full border-2 border-white shadow-sm flex-shrink-0"
style={{ backgroundColor: source.stroke }} style={{ backgroundColor: source.stroke }}
></div> ></div>
<span className="text-xs">{String(source.label)}</span> <div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">{String(source.label)}</span>
<span className="text-xs text-muted-foreground">{source.colorName}</span>
</div> </div>
<div className="text-xs font-mono"> </div>
<div className="flex flex-col items-end gap-1">
<span className={statusBadgeClass}>
{statusText}
</span>
<div className="text-sm font-mono">
{status === "paused" ? "Paused" : {status === "paused" ? "Paused" :
value !== null && value !== undefined ? `${value} ms` : "No data"} value !== null && value !== undefined ? `${value} ms` : "No data"}
</div> </div>
</div> </div>
</div>
); );
})} })}
</div> </div>
) : ( ) : (
// Single source tooltip - showing status with color // Single source tooltip - showing status with color
<> <>
<div className={`flex items-center gap-2 mt-1 ${data.status === "paused" ? "opacity-70" : ""}`}> <div className={`flex items-center gap-3 mt-2 ${data.status === "paused" ? "opacity-70" : ""}`}>
<div className={`w-3 h-3 rounded-full ${ <div className={`w-4 h-4 rounded-full ${
data.status === "up" ? "bg-emerald-800" : data.status === "up" ? "bg-green-500" :
data.status === "down" ? "bg-red-800" : data.status === "down" ? "bg-red-500" :
data.status === "warning" ? "bg-yellow-800" : "bg-gray-800" data.status === "warning" ? "bg-yellow-500" : "bg-gray-500"
}`}></div> }`}></div>
<span>{ <span className="font-medium">{
data.status === "up" ? "Up" : data.status === "up" ? "Up" :
data.status === "down" ? "Down" : data.status === "down" ? "Down" :
data.status === "warning" ? "Warning" : "Paused" data.status === "warning" ? "Warning" : "Paused"
}</span> }</span>
</div> </div>
<p className="mt-1 font-mono text-sm"> <p className="mt-2 font-mono text-lg">
{data.status === "paused" ? "Monitoring paused" : {data.status === "paused" ? "Monitoring paused" :
data.value !== null ? `${data.value} ms` : "No data"} data.value !== null ? `${data.value} ms` : "No data"}
</p> </p>
@@ -295,6 +317,182 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
return null; return null;
}; };
// Render different chart types
const renderChart = () => {
const commonProps = {
data: chartData,
margin: { top: 10, right: 30, left: 0, bottom: 30 }
};
const commonAxisProps = {
xAxis: {
dataKey: "time",
stroke: theme === 'dark' ? '#666' : '#9ca3af',
angle: -45,
textAnchor: "end" as const,
tick: { fontSize: 10 },
height: 60,
interval: "preserveStartEnd" as const,
minTickGap: 5
},
yAxis: {
stroke: theme === 'dark' ? '#666' : '#9ca3af',
allowDecimals: false,
domain: yAxisDomain
}
};
if (hasMultipleSources) {
switch (chartType) {
case 'line':
return (
<LineChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
<XAxis {...commonAxisProps.xAxis} />
<YAxis {...commonAxisProps.yAxis} />
<Tooltip content={<CustomTooltip />} />
{sources.find(s => s.key === 'default') && (
<Line
type="monotone"
dataKey="defaultValue"
stroke={sources.find(s => s.key === 'default')?.stroke}
strokeWidth={3}
dot={{ r: 4, strokeWidth: 2 }}
connectNulls={false}
/>
)}
{sources.filter(s => s.key !== 'default').map((source) => (
<Line
key={source.key}
type="monotone"
dataKey={`${source.key}_value`}
stroke={source.stroke}
strokeWidth={3}
dot={{ r: 4, strokeWidth: 2 }}
connectNulls={false}
/>
))}
</LineChart>
);
case 'bar':
return (
<BarChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
<XAxis {...commonAxisProps.xAxis} />
<YAxis {...commonAxisProps.yAxis} />
<Tooltip content={<CustomTooltip />} />
{sources.find(s => s.key === 'default') && (
<Bar
dataKey="defaultValue"
fill={sources.find(s => s.key === 'default')?.stroke}
opacity={0.8}
/>
)}
{sources.filter(s => s.key !== 'default').map((source) => (
<Bar
key={source.key}
dataKey={`${source.key}_value`}
fill={source.stroke}
opacity={0.8}
/>
))}
</BarChart>
);
default: // area
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
<XAxis {...commonAxisProps.xAxis} />
<YAxis {...commonAxisProps.yAxis} />
<Tooltip content={<CustomTooltip />} />
{sources.find(s => s.key === 'default') && (
<Area
type="monotone"
dataKey="defaultValue"
stroke={sources.find(s => s.key === 'default')?.stroke}
fill={sources.find(s => s.key === 'default')?.fill}
strokeWidth={3}
dot={false}
connectNulls={false}
/>
)}
{sources.filter(s => s.key !== 'default').map((source) => (
<Area
key={source.key}
type="monotone"
dataKey={`${source.key}_value`}
stroke={source.stroke}
fill={source.fill}
strokeWidth={3}
dot={false}
connectNulls={false}
/>
))}
</AreaChart>
);
}
} else {
// Single source charts remain the same
return (
<AreaChart {...commonProps}>
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
<XAxis {...commonAxisProps.xAxis} />
<YAxis {...commonAxisProps.yAxis} />
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="upValue"
stroke="#10b981"
fill="rgba(16, 185, 129, 0.2)"
strokeWidth={3}
dot={false}
connectNulls={false}
/>
<Area
type="monotone"
dataKey="downValue"
stroke="#ef4444"
fill="rgba(239, 68, 68, 0.2)"
strokeWidth={3}
dot={false}
connectNulls={false}
/>
<Area
type="monotone"
dataKey="warningValue"
stroke="#f59e0b"
fill="rgba(245, 158, 11, 0.2)"
strokeWidth={3}
dot={false}
connectNulls={false}
/>
{chartData.map((entry, index) =>
entry.status === 'paused' ? (
<ReferenceLine
key={`ref-${index}`}
x={entry.time}
stroke="#9ca3af"
strokeDasharray="3 3"
/>
) : null
)}
</AreaChart>
);
}
};
// Check if we have any data to display // Check if we have any data to display
const hasData = uptimeData.length > 0; const hasData = uptimeData.length > 0;
@@ -319,21 +517,55 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
<CardHeader> <CardHeader>
<CardTitle className="flex flex-col md:flex-row md:items-center gap-2 justify-between"> <CardTitle className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
<span>Response Time History</span> <span>Response Time History</span>
<div className="flex items-center gap-4">
{hasMultipleSources && (
<div className="flex items-center gap-2 bg-muted/30 p-1 rounded-lg border">
<Button
variant={chartType === 'area' ? 'default' : 'ghost'}
size="sm"
onClick={() => setChartType('area')}
className="h-8 px-2"
>
<AreaChartIcon className="h-4 w-4" />
</Button>
<Button
variant={chartType === 'line' ? 'default' : 'ghost'}
size="sm"
onClick={() => setChartType('line')}
className="h-8 px-2"
>
<TrendingUp className="h-4 w-4" />
</Button>
<Button
variant={chartType === 'bar' ? 'default' : 'ghost'}
size="sm"
onClick={() => setChartType('bar')}
className="h-8 px-2"
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
)}
{hasData && ( {hasData && (
<span className="text-sm font-normal text-muted-foreground"> <span className="text-sm font-normal text-muted-foreground">
{dateRangeDisplay} {dateRangeDisplay}
</span> </span>
)} )}
</div>
</CardTitle> </CardTitle>
{hasMultipleSources && ( {hasMultipleSources && (
<div className="flex flex-wrap gap-4 text-sm"> <div className="flex flex-wrap gap-4 text-sm bg-muted/30 p-3 rounded-lg border">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2 w-full">
<span>Monitoring Sources:</span>
</div>
{sources.map(source => ( {sources.map(source => (
<div key={source.key} className="flex items-center gap-2"> <div key={source.key} className="flex items-center gap-2 bg-background px-3 py-1.5 rounded-md border shadow-sm">
<div <div
className="w-3 h-3 rounded-full" className="w-3 h-3 rounded-full border border-white shadow-sm"
style={{ backgroundColor: source.stroke }} style={{ backgroundColor: source.stroke }}
></div> ></div>
<span>{String(source.label)}</span> <span className="font-medium">{String(source.label)}</span>
<span className="text-xs text-muted-foreground">({source.colorName})</span>
</div> </div>
))} ))}
</div> </div>
@@ -347,118 +579,7 @@ export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
) : ( ) : (
<div className="h-80"> <div className="h-80">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
{hasMultipleSources ? ( {renderChart()}
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
<XAxis
dataKey="time"
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
angle={-45}
textAnchor="end"
tick={{ fontSize: 10 }}
height={60}
interval="preserveStartEnd"
minTickGap={5}
/>
<YAxis
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
allowDecimals={false}
domain={yAxisDomain}
/>
<Tooltip content={<CustomTooltip />} />
{/* Default monitoring area with modern solid background */}
{sources.find(s => s.key === 'default') && (
<Area
type="monotone"
dataKey="defaultValue"
stroke={sources.find(s => s.key === 'default')?.stroke}
fill={sources.find(s => s.key === 'default')?.fill}
strokeWidth={2.5}
dot={false}
connectNulls={false}
/>
)}
{/* Regional monitoring areas with modern solid backgrounds */}
{sources.filter(s => s.key !== 'default').map((source) => (
<Area
key={source.key}
type="monotone"
dataKey={`${source.key}_value`}
stroke={source.stroke}
fill={source.fill}
strokeWidth={2.5}
dot={false}
connectNulls={false}
/>
))}
</AreaChart>
) : (
// For single regional agent or default monitoring only - use solid background colors
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
<XAxis
dataKey="time"
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
angle={-45}
textAnchor="end"
tick={{ fontSize: 10 }}
height={60}
interval="preserveStartEnd"
minTickGap={5}
/>
<YAxis
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
allowDecimals={false}
domain={yAxisDomain}
/>
<Tooltip content={<CustomTooltip />} />
{/* Modern solid background areas for different statuses */}
<Area
type="monotone"
dataKey="upValue"
stroke="#10b981"
fill="rgba(16, 185, 129, 0.9)"
strokeWidth={2.5}
dot={false}
connectNulls={false}
/>
<Area
type="monotone"
dataKey="downValue"
stroke="#ef4444"
fill="rgba(239, 68, 68, 0.9)"
strokeWidth={2.5}
dot={false}
connectNulls={false}
/>
<Area
type="monotone"
dataKey="warningValue"
stroke="#f59e0b"
fill="rgba(245, 158, 11, 0.9)"
strokeWidth={2.5}
dot={false}
connectNulls={false}
/>
{/* Reference lines for paused status */}
{chartData.map((entry, index) =>
entry.status === 'paused' ? (
<ReferenceLine
key={`ref-${index}`}
x={entry.time}
stroke="#9ca3af"
strokeDasharray="3 3"
/>
) : null
)}
</AreaChart>
)}
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
@@ -1,5 +1,5 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { Service, UptimeData } from "@/types/service.types"; import { Service, UptimeData } from "@/types/service.types";
@@ -18,16 +18,40 @@ export const useRealTimeUpdates = ({
setService, setService,
setUptimeData setUptimeData
}: UseRealTimeUpdatesProps) => { }: UseRealTimeUpdatesProps) => {
// Listen for real-time updates to this service const subscriptionsRef = useRef<{
service?: () => void;
uptime?: () => void;
}>({});
const lastUpdateRef = useRef<number>(0);
const updateThrottleMs = 30000; // Throttle to once per 30 seconds for better performance
// Listen for real-time updates to this service with throttling
useEffect(() => { useEffect(() => {
if (!serviceId) return; if (!serviceId) return;
// console.log(`Setting up real-time updates for service: ${serviceId}`); // console.log(`Setting up real-time updates for service: ${serviceId}`);
const setupSubscriptions = async () => {
try { try {
// Subscribe to the service record for real-time updates // Clean up existing subscriptions first
const subscription = pb.collection('services').subscribe(serviceId, function(e) { if (subscriptionsRef.current.service) {
// console.log("Service updated:", e.record); subscriptionsRef.current.service();
}
if (subscriptionsRef.current.uptime) {
subscriptionsRef.current.uptime();
}
// Subscribe to the service record with throttling
const serviceUnsubscribe = await pb.collection('services').subscribe(serviceId, function(e) {
const now = Date.now();
if (now - lastUpdateRef.current < updateThrottleMs) {
// console.log("Service update throttled");
return;
}
lastUpdateRef.current = now;
// console.log("Service updated (throttled):", e.record);
// Update our local state with the new data // Update our local state with the new data
if (e.record) { if (e.record) {
@@ -44,45 +68,67 @@ export const useRealTimeUpdates = ({
} }
}); });
// Subscribe to uptime data updates subscriptionsRef.current.service = serviceUnsubscribe;
const uptimeSubscription = pb.collection('uptime_data').subscribe('*', function(e) {
if (e.record && e.record.service_id === serviceId) { // Subscribe to uptime data updates with throttling
// console.log("New uptime data:", e.record); const uptimeUnsubscribe = await pb.collection('uptime_data').subscribe('*', function(e) {
if (!e.record || e.record.service_id !== serviceId) return;
const now = Date.now();
if (now - lastUpdateRef.current < updateThrottleMs) {
// console.log("Uptime data update throttled");
return;
}
lastUpdateRef.current = now;
// console.log("New uptime data (throttled):", e.record);
// Add the new uptime data to our list if it's within the selected date range // Add the new uptime data to our list if it's within the selected date range
const timestamp = new Date(e.record.timestamp); const timestamp = new Date(e.record.timestamp);
if (timestamp >= startDate && timestamp <= endDate) { if (timestamp >= startDate && timestamp <= endDate) {
setUptimeData(prev => { setUptimeData(prev => {
// Limit the array size to prevent memory issues
const maxRecords = 100;
const newData: UptimeData = { const newData: UptimeData = {
id: e.record.id, id: e.record.id,
service_id: e.record.service_id, // Include service_id service_id: e.record.service_id,
serviceId: e.record.service_id, // Keep for backward compatibility serviceId: e.record.service_id,
timestamp: e.record.timestamp, timestamp: e.record.timestamp,
status: e.record.status, status: e.record.status,
responseTime: e.record.response_time || 0, responseTime: e.record.response_time || 0,
date: e.record.timestamp, // Adding required date property date: e.record.timestamp,
uptime: e.record.uptime || 0 // Adding required uptime property uptime: e.record.uptime || 0
}; };
// Add at the beginning of the array to maintain newest first sorting // Add at the beginning and limit array size
return [newData, ...prev]; const updatedData = [newData, ...prev];
return updatedData.slice(0, maxRecords);
}); });
} }
}
}); });
// Clean up the subscriptions subscriptionsRef.current.uptime = uptimeUnsubscribe;
} catch (error) {
// console.error("Error setting up real-time updates:", error);
}
};
setupSubscriptions();
// Return cleanup function
return () => { return () => {
// console.log(`Cleaning up subscriptions for service: ${serviceId}`); // console.log(`Cleaning up subscriptions for service: ${serviceId}`);
try { try {
pb.collection('services').unsubscribe(serviceId); if (subscriptionsRef.current.service) {
pb.collection('uptime_data').unsubscribe('*'); subscriptionsRef.current.service();
}
if (subscriptionsRef.current.uptime) {
subscriptionsRef.current.uptime();
}
} catch (error) { } catch (error) {
// console.error("Error cleaning up subscriptions:", error); // console.error("Error cleaning up subscriptions:", error);
} }
}; };
} catch (error) {
// console.error("Error setting up real-time updates:", error);
}
}, [serviceId, startDate, endDate, setService, setUptimeData]); }, [serviceId, startDate, endDate, setService, setUptimeData]);
}; };
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { Service, UptimeData } from "@/types/service.types"; import { Service, UptimeData } from "@/types/service.types";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -16,14 +17,17 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
// Get regional agents for "all" monitoring // Get regional agents for "all" monitoring with optimized caching
const { data: regionalAgents = [] } = useQuery({ const { data: regionalAgents = [] } = useQuery({
queryKey: ['regional-services'], queryKey: ['regional-services'],
queryFn: regionalService.getRegionalServices, queryFn: regionalService.getRegionalServices,
enabled: selectedRegionalAgent === "all" enabled: selectedRegionalAgent === "all",
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
refetchOnWindowFocus: false,
}); });
const handleStatusChange = async (newStatus: "up" | "down" | "paused" | "warning") => { const handleStatusChange = useCallback(async (newStatus: "up" | "down" | "paused" | "warning") => {
if (!service || !serviceId) return; if (!service || !serviceId) return;
try { try {
@@ -47,9 +51,9 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
description: "Could not update service status. Please try again.", description: "Could not update service status. Please try again.",
}); });
} }
}; }, [service, serviceId, toast]);
const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange?: DateRangeOption | string, regionalAgent?: string) => { const fetchUptimeData = useCallback(async (serviceId: string, start: Date, end: Date, selectedRange?: DateRangeOption | string, regionalAgent?: string) => {
try { try {
if (!service) { if (!service) {
// console.log('No service data available for uptime fetch'); // console.log('No service data available for uptime fetch');
@@ -121,7 +125,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
} else { } else {
// Fetch regional agent specific data // Fetch regional agent specific data
const [regionName, agentId] = currentAgent.split("|"); const [regionName, agentId] = currentAgent.split("|");
// console.log(`Fetching regional agent data for region: ${regionName}, agent: ${agentId} from ${service.type} collection`); console.log(`Fetching regional agent data for region: ${regionName}, agent: ${agentId} from ${service.type} collection`);
history = await uptimeService.getUptimeHistoryByRegionalAgent(serviceId, limit, start, end, service.type, regionName, agentId); history = await uptimeService.getUptimeHistoryByRegionalAgent(serviceId, limit, start, end, service.type, regionName, agentId);
// console.log(`Retrieved ${history.length} regional monitoring records`); // console.log(`Retrieved ${history.length} regional monitoring records`);
} }
@@ -131,7 +135,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
); );
//console.log(`Final dataset: ${filteredHistory.length} records for ${currentAgent === "all" ? "all sources" : "regional"} monitoring`); // console.log(`Final dataset: ${filteredHistory.length} records for ${currentAgent === "all" ? "all sources" : "regional"} monitoring`);
setUptimeData(filteredHistory); setUptimeData(filteredHistory);
return filteredHistory; return filteredHistory;
} catch (error) { } catch (error) {
@@ -143,9 +147,9 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
}); });
return []; return [];
} }
}; }, [service, selectedRegionalAgent, regionalAgents, toast]);
const handleRegionalAgentChange = (agent: string) => { const handleRegionalAgentChange = useCallback((agent: string) => {
// console.log(`Regional agent changed from ${selectedRegionalAgent} to: ${agent}`); // console.log(`Regional agent changed from ${selectedRegionalAgent} to: ${agent}`);
// Clear data immediately when switching // Clear data immediately when switching
@@ -157,11 +161,10 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
// console.log(`Refetching data for new agent: ${agent}`); // console.log(`Refetching data for new agent: ${agent}`);
fetchUptimeData(serviceId, startDate, endDate, '24h', agent); fetchUptimeData(serviceId, startDate, endDate, '24h', agent);
} }
}; }, [selectedRegionalAgent, serviceId, isLoading, service, fetchUptimeData, startDate, endDate]);
// Initial data loading // Memoize the service data fetching to prevent unnecessary re-runs
useEffect(() => { const fetchServiceData = useCallback(async () => {
const fetchServiceData = async () => {
try { try {
if (!serviceId) { if (!serviceId) {
setIsLoading(false); setIsLoading(false);
@@ -212,20 +215,26 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
};
fetchServiceData();
}, [serviceId, navigate, toast]); }, [serviceId, navigate, toast]);
// Update data when date range changes or when service is loaded // Initial data loading
useEffect(() => {
fetchServiceData();
}, [fetchServiceData]);
// Update data when date range changes or when service is loaded - with debouncing
useEffect(() => { useEffect(() => {
if (serviceId && !isLoading && service) { if (serviceId && !isLoading && service) {
const timeoutId = setTimeout(() => {
// console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`); // console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`);
fetchUptimeData(serviceId, startDate, endDate, '24h', selectedRegionalAgent); fetchUptimeData(serviceId, startDate, endDate, '24h', selectedRegionalAgent);
} }, 500); // Debounce API calls by 500ms
}, [startDate, endDate, serviceId, isLoading, service, selectedRegionalAgent, regionalAgents]);
return { return () => clearTimeout(timeoutId);
}
}, [startDate, endDate, serviceId, isLoading, service, selectedRegionalAgent, regionalAgents, fetchUptimeData]);
return useMemo(() => ({
service, service,
setService, setService,
uptimeData, uptimeData,
@@ -235,5 +244,5 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
fetchUptimeData, fetchUptimeData,
selectedRegionalAgent, selectedRegionalAgent,
handleRegionalAgentChange handleRegionalAgentChange
}; }), [service, uptimeData, isLoading, handleStatusChange, fetchUptimeData, selectedRegionalAgent, handleRegionalAgentChange]);
}; };
@@ -1,4 +1,3 @@
import { Service, UptimeData } from "@/types/service.types"; import { Service, UptimeData } from "@/types/service.types";
import { ServiceHeader } from "@/components/services/ServiceHeader"; import { ServiceHeader } from "@/components/services/ServiceHeader";
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards"; import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
@@ -37,6 +36,7 @@ export const ServiceDetailContent = ({
onStatusChange={onStatusChange} onStatusChange={onStatusChange}
selectedRegionalAgent={selectedRegionalAgent} selectedRegionalAgent={selectedRegionalAgent}
onRegionalAgentChange={onRegionalAgentChange} onRegionalAgentChange={onRegionalAgentChange}
uptimeData={uptimeData}
/> />
<ServiceStatsCards service={service} uptimeData={uptimeData} /> <ServiceStatsCards service={service} uptimeData={uptimeData} />
@@ -1,31 +1,37 @@
import { ArrowLeft, Globe } from "lucide-react"; import { ArrowLeft, Globe, BarChart3 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/services/StatusBadge"; import { StatusBadge } from "@/components/services/StatusBadge";
import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton"; import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton";
import { RegionalAgentFilter } from "@/components/services/RegionalAgentFilter"; import { RegionalAgentFilter } from "@/components/services/RegionalAgentFilter";
import { Service } from "@/types/service.types"; import { HeatmapDialog } from "./HeatmapDialog";
import { Service, UptimeData } from "@/types/service.types";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useState } from "react";
interface ServiceHeaderProps { interface ServiceHeaderProps {
service: Service; service: Service;
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void; onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
selectedRegionalAgent?: string; selectedRegionalAgent?: string;
onRegionalAgentChange?: (agent: string) => void; onRegionalAgentChange?: (agent: string) => void;
uptimeData?: UptimeData[];
} }
export function ServiceHeader({ export function ServiceHeader({
service, service,
onStatusChange, onStatusChange,
selectedRegionalAgent, selectedRegionalAgent,
onRegionalAgentChange onRegionalAgentChange,
uptimeData = []
}: ServiceHeaderProps) { }: ServiceHeaderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useLanguage(); const { t } = useLanguage();
const [showHeatmap, setShowHeatmap] = useState(false);
return ( return (
<>
<div className="mb-6"> <div className="mb-6">
<Button <Button
variant="ghost" variant="ghost"
@@ -80,6 +86,15 @@ export function ServiceHeader({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<StatusBadge status={service.status} size="lg" /> <StatusBadge status={service.status} size="lg" />
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} /> <ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
<Button
variant="outline"
size="sm"
onClick={() => setShowHeatmap(true)}
className="bg-blue-900/20 hover:bg-blue-900/30"
>
<BarChart3 className="h-4 w-4 mr-2" />
Heatmap
</Button>
{selectedRegionalAgent !== undefined && onRegionalAgentChange && ( {selectedRegionalAgent !== undefined && onRegionalAgentChange && (
<RegionalAgentFilter <RegionalAgentFilter
selectedAgent={selectedRegionalAgent} selectedAgent={selectedRegionalAgent}
@@ -89,5 +104,13 @@ export function ServiceHeader({
</div> </div>
</div> </div>
</div> </div>
<HeatmapDialog
open={showHeatmap}
onOpenChange={setShowHeatmap}
serviceName={service.name}
uptimeData={uptimeData}
/>
</>
); );
} }
@@ -1,71 +1,70 @@
import React from "react"; import React, { memo } from "react";
import { Check, X, Pause, AlertTriangle } from "lucide-react"; import { Badge } from "@/components/ui/badge";
import { Check } from "lucide-react";
export interface StatusBadgeProps { interface StatusBadgeProps {
status: string; status: "up" | "down" | "paused" | "warning";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
} }
export const StatusBadge = ({ status, size = "sm" }: StatusBadgeProps) => { const StatusBadgeComponent = ({ status, size = "sm" }: StatusBadgeProps) => {
// Determine the sizing classes based on the size prop const getStatusConfig = (status: string) => {
const getSizeClasses = () => {
switch (size) {
case "lg":
return "px-3 py-1.5 text-sm gap-1.5";
case "md":
return "px-2.5 py-1 text-sm gap-1.5";
case "sm":
default:
return "px-2 py-0.5 text-xs gap-0.5";
}
};
const getIconSize = () => {
switch (size) {
case "lg":
return "h-4 w-4";
case "md":
return "h-4 w-4";
case "sm":
default:
return "h-3 w-3";
}
};
const sizeClasses = getSizeClasses();
const iconSize = getIconSize();
switch (status) { switch (status) {
case "up": case "up":
return ( return {
<div className={`flex items-center bg-emerald-950/60 dark:bg-emerald-950/60 text-emerald-400 font-medium rounded-full w-fit ${sizeClasses}`}> variant: "default" as const,
<Check className={iconSize} /> className: "bg-emerald-700 text-emerald-100 border-emerald-200 hover:bg-emerald-200",
<span>Up</span> label:
</div> <span className="flex items-center gap-1">
); <Check className="w-4 h-4" /> Up
</span>
};
case "down": case "down":
return ( return {
<div className={`flex items-center bg-red-950/60 dark:bg-red-950/60 text-red-400 font-medium rounded-full w-fit ${sizeClasses}`}> variant: "destructive" as const,
<X className={iconSize} /> className: "bg-red-700 text-red-100 border-red-200 hover:bg-red-200",
<span>Down</span> label: "Down"
</div> };
);
case "warning": case "warning":
return ( return {
<div className={`flex items-center bg-yellow-950/60 dark:bg-yellow-950/60 text-yellow-400 font-medium rounded-full w-fit ${sizeClasses}`}> variant: "destructive" as const,
<AlertTriangle className={iconSize} /> className: "bg-amber-700 text-amber-100 border-amber-200 hover:bg-amber-200",
<span>Warning</span> label: "Warning"
</div> };
);
case "paused": case "paused":
return ( return {
<div className={`flex items-center bg-gray-200/30 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 font-medium rounded-full w-fit ${sizeClasses}`}> variant: "secondary" as const,
<Pause className={iconSize} /> className: "bg-gray-700 text-gray-100 border-gray-200 hover:bg-gray-200",
<span>Paused</span> label: "Paused"
</div> };
);
default: default:
return null; return {
variant: "outline" as const,
className: "bg-gray-700 text-gray-100 border-gray-200",
label: "Unknown"
};
} }
};
const sizeClasses = {
sm: "text-xs px-2 py-1",
md: "text-sm px-3 py-1.5",
lg: "text-base px-4 py-2"
};
const config = getStatusConfig(status);
return (
<Badge
variant={config.variant}
className={`${config.className} ${sizeClasses[size]} font-medium`}
>
{config.label}
</Badge>
);
}; };
// Memoize the component to prevent unnecessary re-renders
export const StatusBadge = memo(StatusBadgeComponent);
+70 -115
View File
@@ -1,9 +1,10 @@
import React from "react"; import React, { memo, useMemo } from 'react';
import { useConsolidatedUptimeData } from "./hooks/useConsolidatedUptimeData"; import { useQuery } from '@tanstack/react-query';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { TooltipProvider } from '@/components/ui/tooltip';
import { UptimeSummary } from "./uptime/UptimeSummary"; import { UptimeStatusItem } from './uptime/UptimeStatusItem';
import { formatRelative } from "date-fns"; import { uptimeService } from '@/services/uptimeService';
import { UptimeData } from '@/types/service.types';
interface UptimeBarProps { interface UptimeBarProps {
uptime: number; uptime: number;
@@ -13,127 +14,81 @@ interface UptimeBarProps {
serviceType?: string; serviceType?: string;
} }
export const UptimeBar = ({ uptime, status, serviceId, interval, serviceType }: UptimeBarProps) => { const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType = "HTTP" }: UptimeBarProps) => {
// Use consolidated hook to get properly merged data // Calculate date range for last 20 checks with much more aggressive caching
const { consolidatedItems, isLoading } = useConsolidatedUptimeData({ const endDate = useMemo(() => new Date(), []);
serviceId, const startDate = useMemo(() => {
serviceType, const start = new Date(endDate);
status, start.setHours(start.getHours() - Math.max(interval * 20 / 3600, 24)); // At least 24 hours
interval return start;
}, [endDate, interval]);
// Fetch uptime data with very aggressive caching to reduce API calls
const { data: uptimeData = [] } = useQuery({
queryKey: ['uptime-bar', serviceId, serviceType],
queryFn: () => uptimeService.getUptimeHistory(serviceId, 20, startDate, endDate, serviceType),
enabled: !!serviceId,
staleTime: 30000, // Data is fresh for 30 seconds
gcTime: 600000, // Keep in cache for 10 minutes
refetchInterval: 60000, // 1 minute polling
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
}); });
const getStatusColor = (itemStatus: string, hasData: boolean = true) => { // Memoize the uptime calculation to prevent unnecessary recalculations
if (!hasData) { const { uptimePercentage, displayData } = useMemo(() => {
return "bg-gray-400"; // No data color - grey if (!uptimeData || uptimeData.length === 0) {
} return {
uptimePercentage: uptime || 0,
switch (itemStatus) { displayData: []
case "up":
return "bg-emerald-500";
case "down":
return "bg-red-500";
case "warning":
return "bg-yellow-500";
case "paused":
return "bg-gray-400"; // Paused status - grey
case "unknown":
case "NA":
default:
return "bg-gray-400"; // Unknown/NA/default - grey
}
}; };
if (isLoading) {
return (
<div className="w-52">
<div className="flex items-center gap-1">
{Array(20).fill(null).map((_, index) => (
<div key={index} className="h-6 w-1 bg-gray-200 rounded animate-pulse" />
))}
</div>
<UptimeSummary uptime={uptime} interval={interval} />
</div>
);
} }
// console.log('UptimeBar rendering consolidated items:', consolidatedItems.length); // Calculate uptime percentage from actual data
const upCount = uptimeData.filter(item => item.status === 'up').length;
const totalCount = uptimeData.length;
const calculatedUptime = totalCount > 0 ? (upCount / totalCount) * 100 : 0;
// Limit display to last 20 items for performance
const limitedData = uptimeData.slice(0, 20);
return {
uptimePercentage: Math.round(calculatedUptime * 100) / 100,
displayData: limitedData
};
}, [uptimeData, uptime]);
// Memoize the status items to prevent unnecessary re-renders
const statusItems = useMemo(() =>
displayData.map((item, index) => (
<UptimeStatusItem key={`${item.id}-${index}`} item={item} index={index} />
)),
[displayData]
);
return ( return (
<div className="w-52">
<TooltipProvider> <TooltipProvider>
<div className="flex items-center gap-1"> <div className="flex items-center space-x-3">
{consolidatedItems.map((slot, index) => { <div className="flex space-x-0.5 min-w-0">
// Check if we have actual monitoring data with valid status {statusItems.length > 0 ? statusItems : (
const hasValidData = slot.items.length > 0 && slot.items.some(item => // Fallback display when no data
item.status && ['up', 'down', 'warning', 'paused'].includes(item.status) <div className="h-5 w-1.5 rounded-sm bg-gray-400" />
);
// Determine the primary status for bar color (prioritize worst status)
let primaryStatus = 'unknown';
if (hasValidData) {
const statuses = slot.items.map(item => item.status);
primaryStatus = statuses.includes('down') ? 'down' :
statuses.includes('warning') ? 'warning' :
statuses.includes('up') ? 'up' :
statuses.includes('paused') ? 'paused' : 'unknown';
}
// console.log(`Bar ${index} - Timestamp: ${slot.timestamp}, Items: ${slot.items.length}, Primary Status: ${primaryStatus}, Has Valid Data: ${hasValidData}`);
slot.items.forEach((item, itemIndex) => {
// console.log(` Item ${itemIndex}: Source=${item.source}, Status=${item.status}, ResponseTime=${item.responseTime}, IsDefault=${item.isDefault}`);
});
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<div
className={`h-6 w-1 rounded cursor-pointer ${getStatusColor(primaryStatus, hasValidData)}`}
/>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-sm">
<div className="font-medium mb-2 text-center">
{formatRelative(new Date(slot.timestamp), new Date())}
</div>
{hasValidData ? (
<div className="space-y-2">
{slot.items.map((item, itemIndex) => (
<div key={itemIndex} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
item.status === 'up' ? 'bg-emerald-500' :
item.status === 'down' ? 'bg-red-500' :
item.status === 'warning' ? 'bg-yellow-500' :
item.status === 'paused' ? 'bg-gray-400' : 'bg-gray-400'
}`} />
<span className="text-xs font-medium truncate">{item.source}</span>
</div>
<div className="text-xs font-mono text-right flex-shrink-0">
{item.status === 'paused' ? 'Paused' :
item.responseTime && item.responseTime > 0 ? `${item.responseTime}ms` :
'No response'}
</div>
</div>
))}
{slot.items.length > 1 && (
<div className="border-t pt-1 mt-2 text-xs text-muted-foreground text-center">
{slot.items.length} monitoring sources
</div>
)} )}
</div> </div>
) : ( <span className="text-sm font-medium whitespace-nowrap">
<div className="text-xs text-muted-foreground text-center"> {uptimePercentage.toFixed(1)}%
No monitoring data available </span>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
);
})}
</div> </div>
<span className="text-xs text-muted-foreground">
Last 20 checks
</span>
</TooltipProvider> </TooltipProvider>
<UptimeSummary uptime={uptime} interval={interval} />
</div>
); );
}; };
// Memoize the component to prevent unnecessary re-renders
export const UptimeBar = memo(UptimeBarComponent);
@@ -13,6 +13,7 @@ import { serviceService } from "@/services/serviceService";
import { Service } from "@/types/service.types"; import { Service } from "@/types/service.types";
import { ServiceRegionalFields } from "./ServiceRegionalFields"; import { ServiceRegionalFields } from "./ServiceRegionalFields";
import { getServiceFormDefaults, mapServiceToFormData, mapFormDataToServiceData } from "./serviceFormUtils"; import { getServiceFormDefaults, mapServiceToFormData, mapFormDataToServiceData } from "./serviceFormUtils";
import { useQueryClient } from "@tanstack/react-query";
interface ServiceFormProps { interface ServiceFormProps {
onSuccess: () => void; onSuccess: () => void;
@@ -31,6 +32,7 @@ export function ServiceForm({
}: ServiceFormProps) { }: ServiceFormProps) {
const { toast } = useToast(); const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const queryClient = useQueryClient();
// Initialize form with default values // Initialize form with default values
const form = useForm<ServiceFormData>({ const form = useForm<ServiceFormData>({
@@ -68,10 +70,10 @@ export function ServiceForm({
if (onSubmitStart) onSubmitStart(); if (onSubmitStart) onSubmitStart();
try { try {
console.log("Form data being submitted:", data); // console.log("Form data being submitted:", data);
const serviceData = mapFormDataToServiceData(data); const serviceData = mapFormDataToServiceData(data);
console.log("Service data being sent:", serviceData); // console.log("Service data being sent:", serviceData);
if (isEdit && initialData) { if (isEdit && initialData) {
// Update existing service // Update existing service
@@ -91,6 +93,16 @@ export function ServiceForm({
}); });
} }
// Force immediate refresh of services data
queryClient.setQueryData(["services"], (oldData: any) => {
// Invalidate the cache to force a fresh fetch
return undefined;
});
// Invalidate and refetch services query
await queryClient.invalidateQueries({ queryKey: ["services"] });
await queryClient.refetchQueries({ queryKey: ["services"] });
onSuccess(); onSuccess();
if (!isEdit) { if (!isEdit) {
form.reset(); form.reset();
@@ -67,7 +67,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
placeholder={getPlaceholder()} placeholder={getPlaceholder()}
{...field} {...field}
onChange={(e) => { onChange={(e) => {
console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value); // console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value);
field.onChange(e); field.onChange(e);
}} }}
/> />
@@ -93,7 +93,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
type="number" type="number"
{...field} {...field}
onChange={(e) => { onChange={(e) => {
console.log("Port field changed:", e.target.value); // console.log("Port field changed:", e.target.value);
field.onChange(e); field.onChange(e);
}} }}
/> />
@@ -14,7 +14,7 @@ interface UseUptimeDataProps {
export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseUptimeDataProps) => { export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseUptimeDataProps) => {
const [historyItems, setHistoryItems] = useState<UptimeData[]>([]); const [historyItems, setHistoryItems] = useState<UptimeData[]>([]);
// Fetch ALL uptime history data including regional monitoring data // Fetch ALL uptime history data including regional monitoring data with 1-minute polling
const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({ const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({
queryKey: ['allUptimeHistory', serviceId, serviceType], queryKey: ['allUptimeHistory', serviceId, serviceType],
queryFn: async () => { queryFn: async () => {
@@ -34,8 +34,8 @@ export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseU
return allMonitoringData; return allMonitoringData;
}, },
enabled: !!serviceId, enabled: !!serviceId,
refetchInterval: 30000, refetchInterval: 60000, // 1 minute polling
staleTime: 15000, staleTime: 30000, // Data is fresh for 30 seconds
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
retry: 3, retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
@@ -40,7 +40,6 @@ export const AboutSystem: React.FC = () => {
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1> <h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
<p className="text-muted-foreground text-base leading-relaxed mt-2"> <p className="text-muted-foreground text-base leading-relaxed mt-2">
{t('aboutCheckcle')}
</p> </p>
</div> </div>
@@ -45,20 +45,20 @@ export const SSLCertificateActions = ({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(certificate)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); onView(certificate); }}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
{t('view')} {t('view')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handleCheck}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleCheck(); }}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Check Check
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(certificate)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); onEdit(certificate); }}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
{t('edit')} {t('edit')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onDelete(certificate)} onClick={(e) => { e.stopPropagation(); onDelete(certificate); }}
className="text-destructive" className="text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
@@ -1,8 +1,8 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Table, Table,
TableBody, TableBody,
@@ -37,10 +37,12 @@ import { fetchSSLCertificates, addSSLCertificate, deleteSSLCertificate } from "@
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { SSLCertificate } from "@/types/ssl.types"; import { SSLCertificate } from "@/types/ssl.types";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { useTheme } from "@/contexts/ThemeContext";
import { toast } from "sonner"; import { toast } from "sonner";
export const SSLCertificatesTable = () => { export const SSLCertificatesTable = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { theme } = useTheme();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showAddDialog, setShowAddDialog] = useState(false); const [showAddDialog, setShowAddDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false); const [showEditDialog, setShowEditDialog] = useState(false);
@@ -65,7 +67,7 @@ export const SSLCertificatesTable = () => {
setShowAddDialog(false); setShowAddDialog(false);
toast.success(t('certificateAdded')); toast.success(t('certificateAdded'));
} catch (error) { } catch (error) {
console.error("Error adding certificate:", error); // console.error("Error adding certificate:", error);
toast.error(t('failedToAddCertificate')); toast.error(t('failedToAddCertificate'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -87,7 +89,7 @@ export const SSLCertificatesTable = () => {
setSelectedCertificate(null); setSelectedCertificate(null);
toast.success(t('certificateUpdated')); toast.success(t('certificateUpdated'));
} catch (error) { } catch (error) {
console.error("Error updating certificate:", error); // console.error("Error updating certificate:", error);
toast.error(t('failedToUpdateCertificate')); toast.error(t('failedToUpdateCertificate'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -105,7 +107,7 @@ export const SSLCertificatesTable = () => {
setSelectedCertificate(null); setSelectedCertificate(null);
toast.success(t('certificateDeleted')); toast.success(t('certificateDeleted'));
} catch (error) { } catch (error) {
console.error("Error deleting certificate:", error); // console.error("Error deleting certificate:", error);
toast.error(t('failedToDeleteCertificate')); toast.error(t('failedToDeleteCertificate'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -128,24 +130,32 @@ export const SSLCertificatesTable = () => {
}; };
return ( return (
<> <div className="flex-1 flex flex-col h-full">
<Card> {certificates.length === 0 ? (
<CardContent> <div className="text-center py-8 text-muted-foreground">
{t('noCertificatesFound')}
</div>
) : (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
<Table> <Table>
<TableHeader> <TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
<TableRow> <TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
<TableHead>{t('domain')}</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('domain')}</TableHead>
<TableHead>{t('status')}</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('status')}</TableHead>
<TableHead>{t('issuer')}</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('issuer')}</TableHead>
<TableHead>{t('validUntil')}</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('validUntil')}</TableHead>
<TableHead>{t('daysLeft')}</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('daysLeft')}</TableHead>
<TableHead>Check Interval</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Check Interval</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right w-[50px]`}>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{certificates.map((certificate) => ( {certificates.map((certificate) => (
<TableRow key={certificate.id}> <TableRow
key={certificate.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => openViewDialog(certificate)}
>
<TableCell className="font-medium"> <TableCell className="font-medium">
{certificate.domain} {certificate.domain}
</TableCell> </TableCell>
@@ -164,7 +174,7 @@ export const SSLCertificatesTable = () => {
<TableCell> <TableCell>
{certificate.check_interval || 1} {t('days')} {certificate.check_interval || 1} {t('days')}
</TableCell> </TableCell>
<TableCell> <TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<SSLCertificateActions <SSLCertificateActions
certificate={certificate} certificate={certificate}
onView={openViewDialog} onView={openViewDialog}
@@ -176,14 +186,8 @@ export const SSLCertificatesTable = () => {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
{certificates.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
{t('noCertificatesFound')}
</div> </div>
)} )}
</CardContent>
</Card>
{/* View Certificate Dialog */} {/* View Certificate Dialog */}
<SSLCertificateDetailDialog <SSLCertificateDetailDialog
@@ -250,6 +254,6 @@ export const SSLCertificatesTable = () => {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</> </div>
); );
}; };
@@ -30,12 +30,12 @@ export const SSLDomainContent = () => {
queryKey: ['ssl-certificates'], queryKey: ['ssl-certificates'],
queryFn: async () => { queryFn: async () => {
try { try {
// console.log("Fetching SSL certificates from SSLDomainContent..."); console.log("Fetching SSL certificates from SSLDomainContent...");
const result = await fetchSSLCertificates(); const result = await fetchSSLCertificates();
// console.log("Received SSL certificates:", result); console.log("Received SSL certificates:", result);
return result; return result;
} catch (error) { } catch (error) {
// console.error("Error fetching certificates:", error); console.error("Error fetching certificates:", error);
toast.error(t('failedToLoadCertificates')); toast.error(t('failedToLoadCertificates'));
throw error; throw error;
} }
@@ -53,7 +53,7 @@ export const SSLDomainContent = () => {
toast.success(t('sslCertificateAdded')); toast.success(t('sslCertificateAdded'));
}, },
onError: (error) => { onError: (error) => {
// console.error("Error adding SSL certificate:", error); console.error("Error adding SSL certificate:", error);
toast.error(error instanceof Error ? error.message : t('failedToAddCertificate')); toast.error(error instanceof Error ? error.message : t('failedToAddCertificate'));
} }
}); });
@@ -61,7 +61,7 @@ export const SSLDomainContent = () => {
// Edit certificate mutation - Updated to ensure thresholds are properly updated // Edit certificate mutation - Updated to ensure thresholds are properly updated
const editMutation = useMutation({ const editMutation = useMutation({
mutationFn: async (certificate: SSLCertificate) => { mutationFn: async (certificate: SSLCertificate) => {
// console.log("Updating certificate with data:", certificate); console.log("Updating certificate with data:", certificate);
// Create the update data object // Create the update data object
const updateData = { const updateData = {
@@ -70,12 +70,12 @@ export const SSLDomainContent = () => {
notification_channel: certificate.notification_channel, notification_channel: certificate.notification_channel,
}; };
// console.log("Update data to be sent:", updateData); console.log("Update data to be sent:", updateData);
// Update certificate in the database using PocketBase directly // Update certificate in the database using PocketBase directly
const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData); const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData);
// console.log("PocketBase update response:", updated); console.log("PocketBase update response:", updated);
// After updating the settings, refresh the certificate to ensure it's up to date // After updating the settings, refresh the certificate to ensure it's up to date
// This will also check if notification needs to be sent based on updated thresholds // This will also check if notification needs to be sent based on updated thresholds
@@ -90,7 +90,7 @@ export const SSLDomainContent = () => {
toast.success(t('sslCertificateUpdated')); toast.success(t('sslCertificateUpdated'));
}, },
onError: (error) => { onError: (error) => {
// console.error("Error updating SSL certificate:", error); console.error("Error updating SSL certificate:", error);
toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate')); toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate'));
} }
}); });
@@ -103,33 +103,26 @@ export const SSLDomainContent = () => {
toast.success(t('sslCertificateDeleted')); toast.success(t('sslCertificateDeleted'));
}, },
onError: (error) => { onError: (error) => {
// console.error("Error deleting SSL certificate:", error); console.error("Error deleting SSL certificate:", error);
toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate')); toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate'));
} }
}); });
// Refresh certificate mutation - Updated to ensure notifications are sent // Refresh certificate mutation - Updated to remove individual toast notifications
const refreshMutation = useMutation({ const refreshMutation = useMutation({
mutationFn: checkAndUpdateCertificate, mutationFn: checkAndUpdateCertificate,
onSuccess: (data) => { onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] }); queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
setRefreshingId(null); setRefreshingId(null);
toast.success(t('sslCertificateRefreshed').replace('{domain}', data.domain)); // Removed individual success toast notification
}, },
onError: (error) => { onError: (error) => {
// console.error("Error refreshing SSL certificate:", error); console.error("Error refreshing SSL certificate:", error);
let errorMessage = t('failedToCheckCertificate');
if (error instanceof Error) {
errorMessage = error.message;
}
toast.error(errorMessage);
setRefreshingId(null); setRefreshingId(null);
// Still refresh the data to show any partial information that was saved // Still refresh the data to show any partial information that was saved
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] }); queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
// Removed individual error toast notification
} }
}); });
@@ -149,7 +142,7 @@ export const SSLDomainContent = () => {
} }
}, },
onError: (error) => { onError: (error) => {
// console.error("Error refreshing all certificates:", error); console.error("Error refreshing all certificates:", error);
toast.error(t('failedToCheckCertificate')); toast.error(t('failedToCheckCertificate'));
setIsRefreshingAll(false); setIsRefreshingAll(false);
@@ -174,7 +167,7 @@ export const SSLDomainContent = () => {
}; };
const handleUpdateCertificate = (certificate: SSLCertificate) => { const handleUpdateCertificate = (certificate: SSLCertificate) => {
// console.log("Handling certificate update with data:", certificate); console.log("Handling certificate update with data:", certificate);
editMutation.mutate(certificate); editMutation.mutate(certificate);
}; };
@@ -29,7 +29,7 @@ const ContainerMonitoring = () => {
}); });
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser()); const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
console.log('ContainerMonitoring component loaded with serverId:', serverId); // console.log('ContainerMonitoring component loaded with serverId:', serverId);
const { const {
data: containers = [], data: containers = [],
@@ -39,26 +39,26 @@ const ContainerMonitoring = () => {
} = useQuery({ } = useQuery({
queryKey: ['docker-containers', serverId], queryKey: ['docker-containers', serverId],
queryFn: () => { queryFn: () => {
console.log('Query function called with serverId:', serverId); // console.log('Query function called with serverId:', serverId);
return serverId ? dockerService.getContainersByServerId(serverId) : dockerService.getContainers(); return serverId ? dockerService.getContainersByServerId(serverId) : dockerService.getContainers();
}, },
refetchInterval: 30000 // Refetch every 30 seconds refetchInterval: 30000 // Refetch every 30 seconds
}); });
console.log('Query state:', { containers, isLoading, error }); // console.log('Query state:', { containers, isLoading, error });
useEffect(() => { useEffect(() => {
console.log('Containers changed:', containers); // console.log('Containers changed:', containers);
if (containers.length > 0) { if (containers.length > 0) {
dockerService.getContainerStats(containers).then(newStats => { dockerService.getContainerStats(containers).then(newStats => {
console.log('Stats calculated:', newStats); // console.log('Stats calculated:', newStats);
setStats(newStats); setStats(newStats);
}); });
} }
}, [containers]); }, [containers]);
const handleRefresh = () => { const handleRefresh = () => {
console.log('Manual refresh triggered'); // console.log('Manual refresh triggered');
refetch(); refetch();
}; };
@@ -72,7 +72,7 @@ const ContainerMonitoring = () => {
}; };
if (error) { if (error) {
console.error('Container monitoring error:', error); // console.error('Container monitoring error:', error);
return ( return (
<div className="flex h-screen overflow-hidden bg-background text-foreground"> <div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} /> <Sidebar collapsed={sidebarCollapsed} />
+22 -5
View File
@@ -29,22 +29,39 @@ const Dashboard = () => {
navigate("/login"); navigate("/login");
}; };
// Fetch all services // Fetch all services with 1-minute polling for real-time updates
const { data: services = [], isLoading, error } = useQuery({ const { data: services = [], isLoading, error } = useQuery({
queryKey: ['services'], queryKey: ['services'],
queryFn: serviceService.getServices, queryFn: serviceService.getServices,
refetchInterval: 10000, // Refresh data every 10 seconds refetchInterval: 60000, // 1 minute as requested
staleTime: 30000, // Data is fresh for 30 seconds
gcTime: 120000, // Keep in cache for 2 minutes
refetchOnWindowFocus: true, // Refetch when window gains focus
refetchOnMount: true, // Refetch on mount
refetchOnReconnect: true, // Refetch on reconnect
retry: 2,
retryDelay: 3000,
}); });
// Start monitoring all active services when the dashboard loads // Start monitoring all active services when the dashboard loads - only once
useEffect(() => { useEffect(() => {
let hasStarted = false;
const startActiveServices = async () => { const startActiveServices = async () => {
if (hasStarted) return;
hasStarted = true;
await serviceService.startAllActiveServices(); await serviceService.startAllActiveServices();
// console.log("Active services monitoring started"); // console.log("Active services monitoring started");
}; };
startActiveServices(); // Only start once and add a delay to prevent immediate execution
}, []); const timeoutId = setTimeout(startActiveServices, 2000);
return () => {
clearTimeout(timeoutId);
};
}, []); // Remove services dependency to prevent re-runs
// Show the loading state while fetching data // Show the loading state while fetching data
if (isLoading) { if (isLoading) {
+26 -8
View File
@@ -7,11 +7,14 @@ import { Sidebar } from "@/components/dashboard/Sidebar";
import { Header } from "@/components/dashboard/Header"; import { Header } from "@/components/dashboard/Header";
import { ServerStatsCards } from "@/components/servers/ServerStatsCards"; import { ServerStatsCards } from "@/components/servers/ServerStatsCards";
import { ServerTable } from "@/components/servers/ServerTable"; import { ServerTable } from "@/components/servers/ServerTable";
import { AddServerAgentDialog } from "@/components/servers/AddServerAgentDialog";
import { serverService } from "@/services/serverService"; import { serverService } from "@/services/serverService";
import { Server, ServerStats } from "@/types/server.types"; import { Server, ServerStats } from "@/types/server.types";
import { useSidebar } from "@/contexts/SidebarContext"; import { useSidebar } from "@/contexts/SidebarContext";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
const InstanceMonitoring = () => { const InstanceMonitoring = () => {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -27,6 +30,7 @@ const InstanceMonitoring = () => {
}); });
const [currentUser, setCurrentUser] = useState(authService.getCurrentUser()); const [currentUser, setCurrentUser] = useState(authService.getCurrentUser());
const [addDialogOpen, setAddDialogOpen] = useState(false);
const { data: servers = [], isLoading, error, refetch } = useQuery({ const { data: servers = [], isLoading, error, refetch } = useQuery({
queryKey: ['servers'], queryKey: ['servers'],
@@ -49,11 +53,15 @@ const InstanceMonitoring = () => {
navigate('/login'); navigate('/login');
}; };
const handleAgentAdded = () => {
refetch();
};
if (error) { if (error) {
return ( return (
<div className="flex h-screen overflow-hidden bg-background text-foreground"> <div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} /> <Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1 min-w-0">
<Header <Header
currentUser={currentUser} currentUser={currentUser}
onLogout={handleLogout} onLogout={handleLogout}
@@ -82,7 +90,7 @@ const InstanceMonitoring = () => {
return ( return (
<div className="flex h-screen overflow-hidden bg-background text-foreground"> <div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} /> <Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1 min-w-0">
<Header <Header
currentUser={currentUser} currentUser={currentUser}
onLogout={handleLogout} onLogout={handleLogout}
@@ -90,33 +98,43 @@ const InstanceMonitoring = () => {
toggleSidebar={toggleSidebar} toggleSidebar={toggleSidebar}
/> />
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">
<div className="mx-[20px] my-[20px]"> <div className="p-4 sm:p-6 lg:p-8 space-y-6">
{/* Header Section */} {/* Header Section */}
<div className="mb-6 lg:mb-8"> <div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h1 className="text-2xl font-bold text-foreground"> <h1 className="text-2xl lg:text-2xl font-bold text-foreground">
Instance Monitoring Instance Monitoring
</h1> </h1>
<p className={`text-muted-foreground mt-1 sm:mt-2 transition-all duration-300 ${sidebarCollapsed ? 'text-base sm:text-lg' : 'text-sm sm:text-base'}`}> <p className="text-muted-foreground mt-1 text-xs sm:text-sm">
Monitor and manage your server instances in real-time Monitor and manage your server instances in real-time
</p> </p>
</div> </div>
<Button onClick={() => setAddDialogOpen(true)} className="flex-shrink-0">
<Plus className="mr-2 h-4 w-4" />
Add Server Agent
</Button>
</div> </div>
</div> </div>
{/* Stats Cards Section */} {/* Stats Cards Section */}
<div className="mb-6 lg:mb-8"> <div>
<ServerStatsCards stats={stats} /> <ServerStatsCards stats={stats} />
</div> </div>
{/* Server Table Section */} {/* Server Table Section */}
<div className="min-w-0"> <div>
<ServerTable servers={servers} isLoading={isLoading} onRefresh={handleRefresh} /> <ServerTable servers={servers} isLoading={isLoading} onRefresh={handleRefresh} />
</div> </div>
</div> </div>
</main> </main>
</div> </div>
<AddServerAgentDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
onAgentAdded={handleAgentAdded}
/>
</div> </div>
); );
}; };
+81 -19
View File
@@ -1,4 +1,3 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -10,8 +9,7 @@ import { Header } from "@/components/dashboard/Header";
import { serverService } from "@/services/serverService"; import { serverService } from "@/services/serverService";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft, Server, Database } from "lucide-react"; import { ArrowLeft, Server } from "lucide-react";
import { ServerMetricsCharts } from "@/components/servers/ServerMetricsCharts";
import { ServerMetricsOverview } from "@/components/servers/ServerMetricsOverview"; import { ServerMetricsOverview } from "@/components/servers/ServerMetricsOverview";
import { ServerHistoryCharts } from "@/components/servers/ServerHistoryCharts"; import { ServerHistoryCharts } from "@/components/servers/ServerHistoryCharts";
import { ServerSystemInfoCard } from "@/components/servers/ServerSystemInfoCard"; import { ServerSystemInfoCard } from "@/components/servers/ServerSystemInfoCard";
@@ -36,6 +34,70 @@ const ServerDetail = () => {
enabled: !!serverId enabled: !!serverId
}); });
const getOSLogo = (server: any) => {
if (!server) return null;
// Parse system_info if it's a string, but handle both JSON and plain text
let systemInfo: any = {};
let systemInfoText = '';
if (server.system_info) {
if (typeof server.system_info === 'string') {
// Try to parse as JSON first
try {
systemInfo = JSON.parse(server.system_info);
} catch (error) {
// If JSON parsing fails, treat it as plain text
// console.log('system_info is plain text:', server.system_info);
systemInfoText = server.system_info.toLowerCase();
}
} else {
systemInfo = server.system_info;
}
}
// Check system_info (both JSON and plain text), then fallback to os_type
const osFromJson = systemInfo.OSName || '';
const osFromText = systemInfoText;
const osFromType = server.os_type || '';
// Combine all OS information for detection
const combinedOSInfo = `${osFromJson} ${osFromText} ${osFromType}`.toLowerCase();
// console.log('OS detection info:', { osFromJson, osFromText, osFromType, combinedOSInfo });
// Check for specific OS distributions first (most specific to least specific)
if (combinedOSInfo.includes('ubuntu')) {
return '/upload/os/ubuntu.png';
} else if (combinedOSInfo.includes('debian')) {
return '/upload/os/debian.png';
} else if (combinedOSInfo.includes('centos')) {
return '/upload/os/centos.png';
} else if (combinedOSInfo.includes('rhel') || combinedOSInfo.includes('red hat')) {
return '/upload/os/rhel.png';
} else if (combinedOSInfo.includes('fedora')) {
return '/upload/os/fedora.png';
} else if (combinedOSInfo.includes('suse') || combinedOSInfo.includes('opensuse')) {
return '/upload/os/suse.png';
} else if (combinedOSInfo.includes('arch')) {
return '/upload/os/arch.png';
} else if (combinedOSInfo.includes('alpine')) {
return '/upload/os/alpine.png';
} else if (combinedOSInfo.includes('windows')) {
return '/upload/os/windows.png';
} else if (combinedOSInfo.includes('macos') || combinedOSInfo.includes('darwin') || combinedOSInfo.includes('mac os')) {
return '/upload/os/macos.png';
} else if (combinedOSInfo.includes('freebsd')) {
return '/upload/os/freebsd.png';
} else if (combinedOSInfo.includes('linux') || combinedOSInfo.includes('gnu')) {
// Default to linux.png for any Linux-based system that doesn't match specific distributions
return '/upload/os/linux.png';
}
// Final fallback - if we can't determine the OS, default to linux.png
return '/upload/os/linux.png';
};
const handleLogout = () => { const handleLogout = () => {
authService.logout(); authService.logout();
navigate('/login'); navigate('/login');
@@ -46,7 +108,6 @@ const ServerDetail = () => {
}; };
if (serverError) { if (serverError) {
// console.error('Server detail error:', serverError);
return ( return (
<div className="flex h-screen overflow-hidden bg-background text-foreground"> <div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} /> <Sidebar collapsed={sidebarCollapsed} />
@@ -101,7 +162,7 @@ const ServerDetail = () => {
return ( return (
<div className="flex h-screen overflow-hidden bg-background text-foreground"> <div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} /> <Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1 min-w-0">
<Header <Header
currentUser={currentUser} currentUser={currentUser}
onLogout={handleLogout} onLogout={handleLogout}
@@ -126,23 +187,31 @@ const ServerDetail = () => {
</Button> </Button>
</div> </div>
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center"> <div className="h-12 w-12 rounded bg-primary/10 flex items-center justify-center p-2">
<Database className="h-4 w-4 text-primary" /> {server && getOSLogo(server) ? (
<img
src={getOSLogo(server)}
alt="OS Logo"
className="w-full h-full object-contain"
/>
) : (
<Server className="h-6 w-6 text-primary" />
)}
</div> </div>
<h1 className="text-2xl font-bold text-foreground"> <h1 className="text-2xl font-bold text-foreground">
{server?.name || 'Server Detail'} {server?.name || 'Server Detail'}
</h1> </h1>
</div> </div>
<p className={`text-muted-foreground mt-1 sm:mt-2 transition-all duration-300 ${sidebarCollapsed ? 'text-base sm:text-lg' : 'text-sm sm:text-base'}`}> <p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
Monitor server performance metrics and system health Monitor server performance metrics and system health
{server && ( {server && (
<span className="block text-xs text-foreground/80 mt-1"> <span className="block text-xs text-muted-foreground/70 mt-1">
{server.hostname} {server.ip_address} {server.os_type} {server.hostname} {server.ip_address} {server.os_type}
</span> </span>
)} )}
</p> </p>
</div> </div>
{/* System Info Card */} {/* System Info Card */}
{server && ( {server && (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -159,17 +228,10 @@ const ServerDetail = () => {
</div> </div>
)} )}
{/* Historical Charts Section */} {/* Historical Charts Section - Single comprehensive section */}
{server && (
<div className="mb-6 lg:mb-8">
<ServerHistoryCharts serverId={server.id} />
</div>
)}
{/* Metrics Charts Section */}
{server && ( {server && (
<div className="min-w-0"> <div className="min-w-0">
<ServerMetricsCharts serverId={server.id} /> <ServerHistoryCharts serverId={server.id} />
</div> </div>
)} )}
</div> </div>
+19 -19
View File
@@ -5,25 +5,25 @@ import { DockerContainer, DockerMetrics, DockerStats } from "@/types/docker.type
class DockerService { class DockerService {
async getContainers(): Promise<DockerContainer[]> { async getContainers(): Promise<DockerContainer[]> {
try { try {
console.log('Fetching all Docker containers...'); // console.log('Fetching all Docker containers...');
const records = await pb.collection('dockers').getFullList({ const records = await pb.collection('dockers').getFullList({
sort: '-created', sort: '-created',
}); });
console.log('Docker containers fetched:', records); // console.log('Docker containers fetched:', records);
return records as DockerContainer[]; return records as DockerContainer[];
} catch (error) { } catch (error) {
console.error('Error fetching Docker containers:', error); // console.error('Error fetching Docker containers:', error);
throw error; throw error;
} }
} }
async getContainersByServerId(serverId: string): Promise<DockerContainer[]> { async getContainersByServerId(serverId: string): Promise<DockerContainer[]> {
try { try {
console.log('Fetching Docker containers for server ID:', serverId); // console.log('Fetching Docker containers for server ID:', serverId);
// First, try to get the server details to find the correct hostname // First, try to get the server details to find the correct hostname
const server = await pb.collection('servers').getOne(serverId); const server = await pb.collection('servers').getOne(serverId);
console.log('Server details:', server); // console.log('Server details:', server);
// Try multiple filter approaches to find containers // Try multiple filter approaches to find containers
const filters = [ const filters = [
@@ -36,57 +36,57 @@ class DockerService {
let containers: DockerContainer[] = []; let containers: DockerContainer[] = [];
for (const filter of filters) { for (const filter of filters) {
console.log('Trying filter:', filter); // console.log('Trying filter:', filter);
try { try {
const records = await pb.collection('dockers').getFullList({ const records = await pb.collection('dockers').getFullList({
filter: filter, filter: filter,
sort: '-created', sort: '-created',
}); });
console.log(`Filter "${filter}" returned:`, records); // console.log(`Filter "${filter}" returned:`, records);
if (records.length > 0) { if (records.length > 0) {
containers = records as DockerContainer[]; containers = records as DockerContainer[];
break; break;
} }
} catch (filterError) { } catch (filterError) {
console.warn(`Filter "${filter}" failed:`, filterError); // console.warn(`Filter "${filter}" failed:`, filterError);
continue; continue;
} }
} }
// If no containers found with filters, get all and log for debugging // If no containers found with filters, get all and log for debugging
if (containers.length === 0) { if (containers.length === 0) {
console.log('No containers found with filters, fetching all for debugging...'); // console.log('No containers found with filters, fetching all for debugging...');
const allContainers = await pb.collection('dockers').getFullList({ const allContainers = await pb.collection('dockers').getFullList({
sort: '-created', sort: '-created',
}); });
console.log('All available Docker containers:', allContainers); // console.log('All available Docker containers:', allContainers);
console.log('Looking for containers that might match server:', { // console.log('Looking for containers that might match server:', {
serverId, //// serverId,
serverHostname: server.hostname, // serverHostname: server.hostname,
serverIp: server.ip_address // serverIp: server.ip_address
}); // });
} }
return containers; return containers;
} catch (error) { } catch (error) {
console.error('Error fetching Docker containers by server ID:', error); // console.error('Error fetching Docker containers by server ID:', error);
throw error; throw error;
} }
} }
async getContainerMetrics(dockerId: string): Promise<DockerMetrics[]> { async getContainerMetrics(dockerId: string): Promise<DockerMetrics[]> {
try { try {
console.log('Fetching metrics for docker ID:', dockerId); // console.log('Fetching metrics for docker ID:', dockerId);
const records = await pb.collection('docker_metrics').getFullList({ const records = await pb.collection('docker_metrics').getFullList({
filter: `docker_id = "${dockerId}"`, filter: `docker_id = "${dockerId}"`,
sort: '-timestamp', sort: '-timestamp',
perPage: 100, perPage: 100,
}); });
console.log('Docker metrics fetched:', records); // console.log('Docker metrics fetched:', records);
return records as DockerMetrics[]; return records as DockerMetrics[];
} catch (error) { } catch (error) {
console.error('Error fetching Docker metrics:', error); // console.error('Error fetching Docker metrics:', error);
throw error; throw error;
} }
} }
@@ -13,7 +13,7 @@ export async function pauseMonitoring(serviceId: string): Promise<void> {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
monitoringIntervals.delete(serviceId); monitoringIntervals.delete(serviceId);
console.log(`Monitoring paused for service ${serviceId}`); // console.log(`Monitoring paused for service ${serviceId}`);
} }
// Get current timestamp formatted as a string // Get current timestamp formatted as a string
@@ -3,7 +3,7 @@ import { pb } from '@/lib/pocketbase';
import { monitoringIntervals } from '../monitoringIntervals'; import { monitoringIntervals } from '../monitoringIntervals';
/** /**
* Start monitoring for a specific service * Start monitoring for a specific service with optimized performance
*/ */
export async function startMonitoringService(serviceId: string): Promise<void> { export async function startMonitoringService(serviceId: string): Promise<void> {
try { try {
@@ -22,7 +22,7 @@ export async function startMonitoringService(serviceId: string): Promise<void> {
return; return;
} }
// console.log(`Starting monitoring for service ${serviceId} (${service.name})`); // console.log(`Starting optimized monitoring for service ${serviceId} (${service.name})`);
// Update the service status to active/up in the database // Update the service status to active/up in the database
await pb.collection('services').update(serviceId, { await pb.collection('services').update(serviceId, {
@@ -30,19 +30,23 @@ export async function startMonitoringService(serviceId: string): Promise<void> {
}); });
// The actual service checking is now handled by the Go microservice // The actual service checking is now handled by the Go microservice
// This frontend service just tracks the monitoring state // This frontend service just tracks the monitoring state with much less frequency
const intervalMs = (service.heartbeat_interval || 60) * 1000; const intervalMs = Math.max((service.heartbeat_interval || 60) * 1000, 60000); // Minimum 1 minute
// console.log(`Service ${service.name} monitoring delegated to backend service`); // console.log(`Service ${service.name} monitoring delegated to backend service with interval ${intervalMs}ms`);
// Store a placeholder interval to track that this service is being monitored // Store a placeholder interval to track that this service is being monitored
// Significantly reduce frequency to prevent excessive logging and CPU usage
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
// Only log every 5 minutes to reduce console spam
if (Date.now() % (5 * 60 * 1000) < intervalMs) {
// console.log(`Monitoring active for service ${service.name} (handled by backend)`); // console.log(`Monitoring active for service ${service.name} (handled by backend)`);
}, intervalMs); }
}, Math.max(intervalMs, 300000)); // Minimum 5 minutes for logging
// Store the interval ID for this service // Store the interval ID for this service
monitoringIntervals.set(serviceId, intervalId); monitoringIntervals.set(serviceId, intervalId);
// console.log(`Monitoring registered for service ${serviceId}`); // console.log(`Optimized monitoring registered for service ${serviceId}`);
} catch (error) { } catch (error) {
// console.error("Error starting service monitoring:", error); // console.error("Error starting service monitoring:", error);
} }
@@ -27,7 +27,7 @@ export const operationalPageService = {
const result = await response.json(); const result = await response.json();
return result.items || []; return result.items || [];
} catch (error) { } catch (error) {
console.error('Error fetching operational pages:', error); // console.error('Error fetching operational pages:', error);
throw error; throw error;
} }
}, },
@@ -55,7 +55,7 @@ export const operationalPageService = {
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Error fetching operational page:', error); // console.error('Error fetching operational page:', error);
throw error; throw error;
} }
}, },
@@ -84,7 +84,7 @@ export const operationalPageService = {
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Error updating operational page:', error); // console.error('Error updating operational page:', error);
throw error; throw error;
} }
}, },
@@ -113,7 +113,7 @@ export const operationalPageService = {
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Error creating operational page:', error); // console.error('Error creating operational page:', error);
throw error; throw error;
} }
}, },
@@ -139,7 +139,7 @@ export const operationalPageService = {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
} catch (error) { } catch (error) {
console.error('Error deleting operational page:', error); // console.error('Error deleting operational page:', error);
throw error; throw error;
} }
} }
+104 -143
View File
@@ -24,183 +24,144 @@ export const serverService = {
async getServerMetrics(serverId: string, timeRange?: string): Promise<any[]> { async getServerMetrics(serverId: string, timeRange?: string): Promise<any[]> {
try { try {
// console.log('serverService.getServerMetrics: Fetching metrics for serverId:', serverId, 'timeRange:', timeRange); // console.log('🔍 serverService.getServerMetrics: Starting with serverId:', serverId, 'timeRange:', timeRange);
// First, get the server to find the correct server_id for metrics // First, get the server to find the correct server_id for metrics
let server; let server;
try { try {
server = await this.getServer(serverId); server = await this.getServer(serverId);
// console.log('serverService.getServerMetrics: Found server:', server); // console.log('serverService.getServerMetrics: Found server:', {
// id: server.id,
// server_id: server.server_id,
// name: server.name
// });
} catch (error) { } catch (error) {
// console.log('serverService.getServerMetrics: Could not fetch server details:', error); // console.log('serverService.getServerMetrics: Could not fetch server details:', error);
} }
// Try multiple filter strategies to find data // Let's first check what data exists in the database for this server
let filter = ''; // console.log('🔍 Checking all records for this server...');
let metricsServerId = serverId; const allServerRecords = await pb.collection('server_metrics').getFullList({
filter: `server_id = "${serverId}" || server_id = "${server?.server_id}" || server_id = "${server?.id}"`,
sort: '-created',
requestKey: null
});
// Strategy 1: Use server.server_id if available // console.log('📊 Found total records for server:', allServerRecords.length);
if (server && server.server_id) { if (allServerRecords.length > 0) {
metricsServerId = server.server_id; // console.log('📅 Date range of all records:', {
filter = `server_id = "${metricsServerId}"`; // newest: allServerRecords[0]?.created,
// console.log('serverService.getServerMetrics: Strategy 1 - Using server.server_id for metrics:', metricsServerId); // oldest: allServerRecords[allServerRecords.length - 1]?.created
} else { // });
// Strategy 2: Use the serverId directly
filter = `server_id = "${serverId}"`; // Check last 5 records
// console.log('serverService.getServerMetrics: Strategy 2 - Using serverId directly for metrics:', serverId); // console.log('🔄 Last 5 records timestamps:', allServerRecords.slice(0, 5).map(r => ({
// created: r.created,
// age_minutes: Math.round((new Date().getTime() - new Date(r.created).getTime()) / (1000 * 60))
// })));
} }
// Add agent_id filter if available in server data // Calculate time range for filtering
if (server && server.agent_id) {
filter += ` && agent_id = "${server.agent_id}"`;
// console.log('serverService.getServerMetrics: Added agent_id filter:', server.agent_id);
}
// Add time range filter
if (timeRange) {
const now = new Date(); const now = new Date();
let cutoffTime; let cutoffTime;
switch (timeRange) { if (timeRange === '60m') {
case '60m': cutoffTime = new Date(now.getTime() - (60 * 60 * 1000)); // Exactly 60 minutes
cutoffTime = new Date(now.getTime() - (60 * 60 * 1000)); // console.log('⏰ 60m filter: Looking for records newer than:', cutoffTime.toISOString());
break; // console.log('⏰ Current time:', now.toISOString());
case '1d': // console.log('⏰ Time difference in minutes:', Math.round((now.getTime() - cutoffTime.getTime()) / (1000 * 60)));
} else if (timeRange === '1d') {
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000)); cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
break; } else if (timeRange === '7d') {
case '7d':
cutoffTime = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000)); cutoffTime = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
break; } else if (timeRange === '1m') {
case '1m':
cutoffTime = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000)); cutoffTime = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
break; } else if (timeRange === '3m') {
case '3m':
cutoffTime = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000)); cutoffTime = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
break; } else {
default:
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000)); cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
} }
// Try to get filtered records
const searchStrategies = [
server?.server_id,
serverId,
server?.id
].filter(Boolean);
let filteredRecords: any[] = [];
for (const strategy of searchStrategies) {
try {
const cutoffISO = cutoffTime.toISOString(); const cutoffISO = cutoffTime.toISOString();
filter += ` && created >= "${cutoffISO}"`; const filter = `server_id = "${strategy}" && created >= "${cutoffISO}"`;
// console.log('serverService.getServerMetrics: Using time filter from:', cutoffISO, 'to now');
}
// console.log('serverService.getServerMetrics: Final filter:', filter); // console.log(`🔍 Trying filter: ${filter}`);
// Fetch filtered records with proper sorting const records = await pb.collection('server_metrics').getFullList({
let records = await pb.collection('server_metrics').getFullList({
filter: filter, filter: filter,
sort: '-created', sort: '-created',
requestKey: null requestKey: null
}); });
// console.log('serverService.getServerMetrics: Found', records.length, 'records with primary filter'); // console.log(`📊 Strategy "${strategy}" found ${records.length} records within time range`);
// If no records found with primary strategy, try fallback strategies
if (records.length === 0) {
// console.log('serverService.getServerMetrics: No records found, trying fallback strategies...');
// Fallback 1: Try without agent_id filter
let fallbackFilter = `server_id = "${metricsServerId}"`;
if (timeRange) {
const now = new Date();
let cutoffTime;
switch (timeRange) {
case '60m':
cutoffTime = new Date(now.getTime() - (60 * 60 * 1000));
break;
case '1d':
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
break;
case '7d':
cutoffTime = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
break;
case '1m':
cutoffTime = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
break;
case '3m':
cutoffTime = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
break;
default:
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
}
const cutoffISO = cutoffTime.toISOString();
fallbackFilter += ` && created >= "${cutoffISO}"`;
}
// console.log('serverService.getServerMetrics: Trying fallback filter without agent_id:', fallbackFilter);
records = await pb.collection('server_metrics').getFullList({
filter: fallbackFilter,
sort: '-created',
requestKey: null
});
// console.log('serverService.getServerMetrics: Fallback found', records.length, 'records');
// Fallback 2: Try with different server_id strategies
if (records.length === 0) {
const alternativeIds = [serverId, server?.server_id, server?.id].filter(Boolean);
// console.log('serverService.getServerMetrics: Trying alternative server IDs:', alternativeIds);
for (const altId of alternativeIds) {
if (altId && altId !== metricsServerId) {
let altFilter = `server_id = "${altId}"`;
if (timeRange) {
const now = new Date();
let cutoffTime;
switch (timeRange) {
case '60m':
cutoffTime = new Date(now.getTime() - (60 * 60 * 1000));
break;
case '1d':
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
break;
case '7d':
cutoffTime = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
break;
case '1m':
cutoffTime = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
break;
case '3m':
cutoffTime = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
break;
default:
cutoffTime = new Date(now.getTime() - (24 * 60 * 60 * 1000));
}
const cutoffISO = cutoffTime.toISOString();
altFilter += ` && created >= "${cutoffISO}"`;
}
// console.log('serverService.getServerMetrics: Trying alternative ID filter:', altFilter);
const altRecords = await pb.collection('server_metrics').getFullList({
filter: altFilter,
sort: '-created',
requestKey: null
});
if (altRecords.length > 0) {
// console.log('serverService.getServerMetrics: Alternative ID found', altRecords.length, 'records');
records = altRecords;
break;
}
}
}
}
}
// console.log('serverService.getServerMetrics: Final result:', records.length, 'records found');
if (records.length > 0) { if (records.length > 0) {
// console.log('serverService.getServerMetrics: Sample record:', records[0]); filteredRecords = records;
// console.log('✅ Using records from strategy:', strategy);
break;
}
} catch (error) {
// console.error(`❌ Error with strategy ${strategy}:`, error);
continue;
}
} }
return records; // If no filtered records found and it's 60m, let's see what we have in a larger window
if (filteredRecords.length === 0 && timeRange === '60m') {
// console.log('⚠️ No records found in 60m window, checking last 24 hours...');
const last24h = new Date(now.getTime() - (24 * 60 * 60 * 1000));
for (const strategy of searchStrategies) {
try {
const filter = `server_id = "${strategy}" && created >= "${last24h.toISOString()}"`;
const records = await pb.collection('server_metrics').getFullList({
filter: filter,
sort: '-created',
requestKey: null
});
// console.log(`📊 Last 24h check for "${strategy}": ${records.length} records`);
if (records.length > 0) {
// console.log('📅 Sample record ages (minutes ago):', records.slice(0, 3).map(r =>
// Math.round((now.getTime() - new Date(r.created).getTime()) / (1000 * 60))
// ));
// Return all recent records for 60m if we have any
filteredRecords = records;
break;
}
} catch (error) { } catch (error) {
// console.error('Error fetching server metrics:', error); // console.error(`❌ Error with 24h fallback for ${strategy}:`, error);
continue;
}
}
}
// console.log('🎯 Final result:', filteredRecords.length, 'records found for', timeRange);
if (filteredRecords.length > 0) {
// console.log('📅 Returned records age range (minutes ago):', {
// newest: Math.round((now.getTime() - new Date(filteredRecords[0].created).getTime()) / (1000 * 60)),
// oldest: Math.round((now.getTime() - new Date(filteredRecords[filteredRecords.length - 1].created).getTime()) / (1000 * 60))
// });
}
return filteredRecords;
} catch (error) {
// console.error('❌ Error fetching server metrics:', error);
throw error; throw error;
} }
}, },
@@ -0,0 +1,85 @@
import { pb } from "@/lib/pocketbase";
export interface ServerThreshold {
id: string;
name: string;
cpu_threshold: number;
ram_threshold: number;
disk_threshold: number;
network_threshold: number;
created: string;
updated: string;
}
export interface CreateUpdateServerThresholdData {
name: string;
cpu_threshold: number;
ram_threshold: number;
disk_threshold: number;
network_threshold: number;
}
export const serverThresholdService = {
async getServerThresholds(): Promise<ServerThreshold[]> {
try {
// console.log("Fetching server threshold templates");
const response = await pb.collection('server_threshold_templates').getList(1, 50, {
sort: '-created',
});
// console.log("Server threshold templates response:", response);
return response.items as unknown as ServerThreshold[];
} catch (error) {
// console.error("Error fetching server threshold templates:", error);
throw error;
}
},
async getServerThreshold(id: string): Promise<ServerThreshold> {
try {
// console.log(`Fetching server threshold template with id: ${id}`);
const response = await pb.collection('server_threshold_templates').getOne(id);
// console.log("Server threshold template response:", response);
return response as unknown as ServerThreshold;
} catch (error) {
// console.error(`Error fetching server threshold template with id ${id}:`, error);
throw error;
}
},
async createServerThreshold(data: CreateUpdateServerThresholdData): Promise<ServerThreshold> {
try {
// console.log("Creating new server threshold template with data:", data);
const response = await pb.collection('server_threshold_templates').create(data);
// console.log("Create server threshold template response:", response);
return response as unknown as ServerThreshold;
} catch (error) {
// console.error("Error creating server threshold template:", error);
throw error;
}
},
async updateServerThreshold(id: string, data: Partial<CreateUpdateServerThresholdData>): Promise<ServerThreshold> {
try {
// console.log(`Updating server threshold template with id: ${id}`, data);
const response = await pb.collection('server_threshold_templates').update(id, data);
// console.log("Update server threshold template response:", response);
return response as unknown as ServerThreshold;
} catch (error) {
// console.error(`Error updating server threshold template with id ${id}:`, error);
throw error;
}
},
async deleteServerThreshold(id: string): Promise<boolean> {
try {
// console.log(`Deleting server threshold template with id: ${id}`);
await pb.collection('server_threshold_templates').delete(id);
// console.log("Server threshold template deleted successfully");
return true;
} catch (error) {
// console.error(`Error deleting server threshold template with id ${id}:`, error);
throw error;
}
}
};
@@ -1,120 +1,53 @@
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { SSLCertificate } from "../types"; import { SSLCertificate } from "@/types/ssl.types";
import { determineSSLStatus } from "../sslStatusUtils";
import { sendSSLNotification } from "./sslNotificationSender";
import { toast } from "sonner";
/** /**
* Checks all SSL certificates and sends notifications for expiring ones * Check a single SSL certificate - notifications are now handled by the backend
* This should be called once per day
*/ */
export async function checkAllCertificatesAndNotify(): Promise<void> { export const checkCertificateAndNotify = async (certificate: SSLCertificate): Promise<void> => {
// console.log("Starting daily SSL certificates check...");
try { try {
// Fetch all SSL certificates from database // console.log(`Checking certificate for ${certificate.domain}...`);
const response = await pb.collection('ssl_certificates').getList(1, 100, {});
// Properly cast the items as SSLCertificate // The actual SSL checking and notifications are now handled by the Go service
// We just need to trigger a check by updating the check_at timestamp
const now = new Date();
// Update check_at to trigger backend check
await pb.collection('ssl_certificates').update(certificate.id, {
check_at: now.toISOString()
});
} catch (error) {
throw error;
}
};
/**
* Check all SSL certificates - backend handles the actual checking and notifications
*/
export const checkAllCertificatesAndNotify = async (): Promise<{ success: number; failed: number }> => {
try {
const response = await pb.collection('ssl_certificates').getList(1, 100);
const certificates = response.items as unknown as SSLCertificate[]; const certificates = response.items as unknown as SSLCertificate[];
// console.log(`Found ${certificates.length} certificates to check`); let success = 0;
let failed = 0;
// Check each certificate
for (const cert of certificates) { for (const cert of certificates) {
await checkCertificateAndNotify(cert);
}
// console.log("Daily SSL certificates check completed");
} catch (error) {
// console.error("Error during SSL certificates daily check:", error);
}
}
/**
* Checks a specific SSL certificate and sends notification if needed
* This respects the Warning and Expiry Thresholds set on the certificate
* Note: SSL checking is now handled by the Go service, this function focuses on notifications
*/
export async function checkCertificateAndNotify(certificate: SSLCertificate): Promise<boolean> {
// console.log(`Checking certificate for ${certificate.domain}...`);
try { try {
// Use the current certificate data (updated by Go service) await checkCertificateAndNotify(cert);
const daysLeft = certificate.days_left || 0; success++;
// Get threshold values (ensure they are numbers)
const warningThreshold = Number(certificate.warning_threshold) || 30;
const expiryThreshold = Number(certificate.expiry_threshold) || 7;
// console.log(`Certificate ${certificate.domain} thresholds: warning=${warningThreshold}, expiry=${expiryThreshold}, days left=${daysLeft}`);
// Update status based on thresholds
const status = determineSSLStatus(daysLeft, warningThreshold, expiryThreshold);
// Check if we should send a notification based on thresholds
let shouldNotify = false;
let isCritical = false;
// Critical notifications - when days left is less than or equal to expiry threshold
if (daysLeft <= expiryThreshold) {
shouldNotify = true;
isCritical = true;
}
// Warning notifications - when days left is less than or equal to warning threshold but greater than expiry threshold
else if (daysLeft <= warningThreshold) {
shouldNotify = true;
isCritical = false;
}
// console.log(`${certificate.domain}: ${daysLeft} days left, status: ${status}, should notify: ${shouldNotify}, critical: ${isCritical}`);
// Update certificate status in database
await pb.collection('ssl_certificates').update(certificate.id, {
status: status
});
// Send notification if needed
if (shouldNotify && certificate.notification_channel) {
// console.log(`Sending notification for ${certificate.domain}`);
// Different message based on expiry threshold
const message = isCritical
? `🚨 CRITICAL: SSL Certificate for ${certificate.domain} will expire in ${daysLeft} days!`
: `⚠️ WARNING: SSL Certificate for ${certificate.domain} will expire in ${daysLeft} days.`;
// Send the notification using our specialized SSL notification sender
const notificationSent = await sendSSLNotification(certificate, message, isCritical);
if (notificationSent) {
// Update last_notified timestamp
await pb.collection('ssl_certificates').update(certificate.id, {
last_notified: new Date().toISOString()
});
// console.log(`Notification sent for ${certificate.domain}`);
// Show toast for manual checks
toast.success(`Notification sent for ${certificate.domain}`);
return true;
} else {
// console.error(`Failed to send notification for ${certificate.domain}`);
// Show error toast for manual checks
toast.error(`Failed to send notification for ${certificate.domain}`);
return false;
}
} else if (shouldNotify && !certificate.notification_channel) {
// console.log(`No notification channel set for ${certificate.domain}, skipping notification`);
toast.info(`No notification channel set for ${certificate.domain}, skipping notification`);
} else {
// console.log(`No notification needed for ${certificate.domain} (${daysLeft} days left)`);
// For manual checks, inform the user that thresholds weren't met
toast.info(`Certificate for ${certificate.domain} is valid (${daysLeft} days left)`);
}
return true;
} catch (error) { } catch (error) {
// console.error(`Error checking certificate for ${certificate.domain}:`, error); failed++;
toast.error(`Error checking certificate: ${error instanceof Error ? error.message : "Unknown error"}`);
return false;
} }
} }
return { success, failed };
} catch (error) {
throw error;
}
};
@@ -27,7 +27,7 @@ export const statusPageComponentsService = {
const result = await response.json(); const result = await response.json();
return result.items || []; return result.items || [];
} catch (error) { } catch (error) {
console.error('Error fetching status page components:', error); // console.error('Error fetching status page components:', error);
throw error; throw error;
} }
}, },
@@ -56,7 +56,7 @@ export const statusPageComponentsService = {
const result = await response.json(); const result = await response.json();
return result.items || []; return result.items || [];
} catch (error) { } catch (error) {
console.error('Error fetching status page components by operational ID:', error); // console.error('Error fetching status page components by operational ID:', error);
throw error; throw error;
} }
}, },
@@ -79,7 +79,7 @@ export const statusPageComponentsService = {
headers['Authorization'] = `Bearer ${authToken}`; headers['Authorization'] = `Bearer ${authToken}`;
} }
console.log('Creating status page component with data:', data); // console.log('Creating status page component with data:', data);
const baseUrl = getCurrentEndpoint(); const baseUrl = getCurrentEndpoint();
const response = await fetch(`${baseUrl}/api/collections/status_page_components/records`, { const response = await fetch(`${baseUrl}/api/collections/status_page_components/records`, {
@@ -95,10 +95,10 @@ export const statusPageComponentsService = {
} }
const result = await response.json(); const result = await response.json();
console.log('Created component:', result); // console.log('Created component:', result);
return result; return result;
} catch (error) { } catch (error) {
console.error('Error creating status page component:', error); // console.error('Error creating status page component:', error);
throw error; throw error;
} }
}, },
@@ -124,7 +124,7 @@ export const statusPageComponentsService = {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
} catch (error) { } catch (error) {
console.error('Error deleting status page component:', error); // console.error('Error deleting status page component:', error);
throw error; throw error;
} }
} }
+21 -20
View File
@@ -1,4 +1,5 @@
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
export interface NotificationTemplate { export interface NotificationTemplate {
@@ -37,62 +38,62 @@ export interface CreateUpdateTemplateData {
export const templateService = { export const templateService = {
async getTemplates(): Promise<NotificationTemplate[]> { async getTemplates(): Promise<NotificationTemplate[]> {
try { try {
console.log("Fetching notification templates"); // console.log("Fetching server notification templates");
const response = await pb.collection('notification_templates').getList(1, 50, { const response = await pb.collection('server_notification_templates').getList(1, 50, {
sort: '-created', sort: '-created',
}); });
console.log("Templates response:", response); // console.log("Server templates response:", response);
return response.items as unknown as NotificationTemplate[]; return response.items as unknown as NotificationTemplate[];
} catch (error) { } catch (error) {
console.error("Error fetching templates:", error); // console.error("Error fetching server templates:", error);
throw error; throw error;
} }
}, },
async getTemplate(id: string): Promise<NotificationTemplate> { async getTemplate(id: string): Promise<NotificationTemplate> {
try { try {
console.log(`Fetching template with id: ${id}`); // console.log(`Fetching server template with id: ${id}`);
const response = await pb.collection('notification_templates').getOne(id); const response = await pb.collection('server_notification_templates').getOne(id);
console.log("Template response:", response); // console.log("Server template response:", response);
return response as unknown as NotificationTemplate; return response as unknown as NotificationTemplate;
} catch (error) { } catch (error) {
console.error(`Error fetching template with id ${id}:`, error); // console.error(`Error fetching server template with id ${id}:`, error);
throw error; throw error;
} }
}, },
async createTemplate(data: CreateUpdateTemplateData): Promise<NotificationTemplate> { async createTemplate(data: CreateUpdateTemplateData): Promise<NotificationTemplate> {
try { try {
console.log("Creating new template with data:", data); // console.log("Creating new server template with data:", data);
const response = await pb.collection('notification_templates').create(data); const response = await pb.collection('server_notification_templates').create(data);
console.log("Create template response:", response); // console.log("Create server template response:", response);
return response as unknown as NotificationTemplate; return response as unknown as NotificationTemplate;
} catch (error) { } catch (error) {
console.error("Error creating template:", error); // console.error("Error creating server template:", error);
throw error; throw error;
} }
}, },
async updateTemplate(id: string, data: Partial<CreateUpdateTemplateData>): Promise<NotificationTemplate> { async updateTemplate(id: string, data: Partial<CreateUpdateTemplateData>): Promise<NotificationTemplate> {
try { try {
console.log(`Updating template with id: ${id}`, data); // console.log(`Updating server template with id: ${id}`, data);
const response = await pb.collection('notification_templates').update(id, data); const response = await pb.collection('server_notification_templates').update(id, data);
console.log("Update template response:", response); // console.log("Update server template response:", response);
return response as unknown as NotificationTemplate; return response as unknown as NotificationTemplate;
} catch (error) { } catch (error) {
console.error(`Error updating template with id ${id}:`, error); // console.error(`Error updating server template with id ${id}:`, error);
throw error; throw error;
} }
}, },
async deleteTemplate(id: string): Promise<boolean> { async deleteTemplate(id: string): Promise<boolean> {
try { try {
console.log(`Deleting template with id: ${id}`); // console.log(`Deleting server template with id: ${id}`);
await pb.collection('notification_templates').delete(id); await pb.collection('server_notification_templates').delete(id);
console.log("Template deleted successfully"); // console.log("Server template deleted successfully");
return true; return true;
} catch (error) { } catch (error) {
console.error(`Error deleting template with id ${id}:`, error); // console.error(`Error deleting server template with id ${id}:`, error);
throw error; throw error;
} }
} }
+2 -1
View File
@@ -8,7 +8,7 @@ export interface Server {
hostname: string; hostname: string;
ip_address: string; ip_address: string;
os_type: string; os_type: string;
status: 'up' | 'down' | 'warning'; status: 'up' | 'down' | 'warning' | 'paused';
uptime: string; uptime: string;
ram_total: number; ram_total: number;
ram_used: number; ram_used: number;
@@ -19,6 +19,7 @@ export interface Server {
last_checked: string; last_checked: string;
server_token: string; server_token: string;
template_id: string; template_id: string;
threshold_id: string;
notification_id: string; notification_id: string;
timestamp: string; timestamp: string;
connection: string; connection: string;
+60
View File
@@ -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",
});
}
};
+460
View File
@@ -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)
})