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