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