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