Merge pull request #79 from operacle/develop

feat: Add create instance monitoring agent and Add Uptime Heatmap to service detail page
This commit is contained in:
Tola Leng
2025-07-22 16:08:14 +07:00
committed by GitHub
67 changed files with 4245 additions and 1417 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
name: Ask for Help
name: Ask for Help
description: Ask a question or request guidance about this project.
title: "[Question]: "
labels: [question]
+1 -1
View File
@@ -1,4 +1,4 @@
name: Bug Report
name: 🐛 Bug Report
description: Report a reproducible bug to help us improve.
title: "[Bug]: "
labels: [bug]
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+1 -1
View File
@@ -1,4 +1,4 @@
name: Documentation Improvement
name: 📝 Documentation Improvement
description: Suggest improvements or report issues in documentation.
title: "[Docs]: "
labels: [documentation]
+1 -1
View File
@@ -1,4 +1,4 @@
name: Feature Request
name: 🚀 Feature Request
description: Suggest an idea to improve this project.
title: "[Feature]: "
labels: [enhancement]
+1 -1
View File
@@ -1,4 +1,4 @@
name: Security Issue
name: 🛡️ Security Issue
description: Report a potential security vulnerability.
title: "[Security]: "
labels: [security]
@@ -1,4 +1,4 @@
name: Translation Request
name: 🌎 Translation Request
description: Request a translation or report translation issues.
title: "[Translation]: "
labels: [translation]
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>
@@ -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);
+356 -195
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,214 @@ 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">
<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">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0" disabled={isProcessing}>
<span className="sr-only">Open menu</span>
{isProcessing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem onClick={() => handleViewDetails(server.id)}>
<Eye className="mr-2 h-4 w-4" />
View Server Detail
</DropdownMenuItem>
)}
<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={() => 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={() => handlePauseResume(server)}
disabled={isProcessing}
>
{isPaused ? (
<>
<Play className="mr-2 h-4 w-4" />
Resume Monitoring
</>
) : (
<>
<Pause className="mr-2 h-4 w-4" />
Pause Monitoring
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleEdit(server)}>
<Edit className="mr-2 h-4 w-4" />
Edit Server
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(server)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Server
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Edit Server Dialog */}
<EditServerDialog
server={selectedServer}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
onServerUpdated={onRefresh}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this server?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete{' '}
<span className="font-semibold text-foreground">
{selectedServer?.name}
</span>{' '}
and all of its monitoring data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
className="bg-red-600 text-white hover:bg-red-700"
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
@@ -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,
@@ -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);
@@ -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),
@@ -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,28 @@ 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">
<TableCell className="font-medium">
{certificate.domain}
</TableCell>
@@ -164,7 +170,7 @@ export const SSLCertificatesTable = () => {
<TableCell>
{certificate.check_interval || 1} {t('days')}
</TableCell>
<TableCell>
<TableCell className="text-right">
<SSLCertificateActions
certificate={certificate}
onView={openViewDialog}
@@ -176,14 +182,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 +250,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;
}
}
@@ -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);
}
}
+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;
}
}
};
+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 " PocketBase 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 " 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)
})