Initial Release

This commit is contained in:
Tola Leng
2025-05-09 21:28:07 +07:00
commit 0c6cd18e6f
244 changed files with 50633 additions and 0 deletions
@@ -0,0 +1,73 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { Service } from "@/services/serviceService";
import { StatusCards } from "./StatusCards";
import { ServiceFilters } from "./ServiceFilters";
import { ServicesTable } from "./ServicesTable";
import { AddServiceDialog } from "@/components/services/AddServiceDialog";
interface DashboardContentProps {
services: Service[];
isLoading: boolean;
error: Error | null;
}
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
const [filter, setFilter] = useState<string>("all");
const [searchTerm, setSearchTerm] = useState<string>("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
// Filter services based on search term and type filter
const filteredServices = services.filter(service => {
const matchesSearch = service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(service.url && service.url.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesFilter = filter === 'all' || service.type.toLowerCase() === filter.toLowerCase();
return matchesSearch && matchesFilter;
});
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
<p>Error loading service data.</p>
<Button onClick={() => window.location.reload()}>Retry</Button>
</div>
);
}
return (
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
<div className="flex flex-col flex-1">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-foreground">Overview</h2>
<Button
className="text-primary-foreground"
onClick={() => setIsAddDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-2" /> New Service
</Button>
</div>
<StatusCards services={services} />
<ServiceFilters
filter={filter}
setFilter={setFilter}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
servicesCount={filteredServices.length}
/>
<div className="flex-1 flex flex-col pb-6">
<ServicesTable services={filteredServices} />
</div>
</div>
<AddServiceDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
/>
</main>
);
};
@@ -0,0 +1,190 @@
import { Button } from "@/components/ui/button";
import { AuthUser } from "@/services/authService";
import { useTheme } from "@/contexts/ThemeContext";
import { Moon, PanelLeft, PanelLeftClose, Sun, Globe, FileText, Github, Twitter, MessageSquare, Bell, User, Settings, LogOut, Grid3x3 } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useNavigate } from "react-router-dom";
import { useSystemSettings } from "@/hooks/useSystemSettings";
interface HeaderProps {
currentUser: AuthUser | null;
onLogout: () => void;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
}
export const Header = ({
currentUser,
onLogout,
sidebarCollapsed,
toggleSidebar
}: HeaderProps) => {
const { theme, toggleTheme } = useTheme();
const { language, setLanguage, t } = useLanguage();
const [greeting, setGreeting] = useState<string>("");
const { systemName } = useSystemSettings();
const navigate = useNavigate();
// Set greeting based on time of day
useEffect(() => {
const updateGreeting = () => {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) {
setGreeting(t("goodMorning"));
} else if (hour >= 12 && hour < 18) {
setGreeting(t("goodAfternoon"));
} else {
setGreeting(t("goodEvening"));
}
};
updateGreeting();
// Update greeting if language changes
// You could add a timer to update the greeting throughout the day
// but that's typically unnecessary since most sessions aren't active across time periods
}, [language, t]);
// Log avatar data for debugging
useEffect(() => {
if (currentUser) {
console.log("Avatar URL in Header:", currentUser.avatar);
}
}, [currentUser]);
// Prepare avatar URL - ensure it displays correctly if it's a local profile image
let avatarUrl = '';
if (currentUser?.avatar) {
// If it's a relative path from the public folder, make sure it's resolved properly
if (currentUser.avatar.startsWith('/upload/profile/')) {
avatarUrl = currentUser.avatar;
} else {
avatarUrl = currentUser.avatar;
}
console.log("Final avatar URL:", avatarUrl);
}
return (
<header className="relative bg-background border-b border-border px-6 flex justify-between items-center py-[12px] overflow-hidden">
{/* Grid Pattern Overlay - Similar to StatusCards */}
<div className="absolute inset-0 z-0">
<div
className="w-full h-full"
style={{
backgroundImage: `linear-gradient(${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px),
linear-gradient(90deg, ${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
>
<div className="w-full h-full backdrop-blur-[1px]"></div>
</div>
</div>
{/* Header Content */}
<div className="flex items-center gap-4 z-10">
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</Button>
<div className="flex items-center space-x-2">
<Grid3x3 className="h-5 w-5 text-green-500" />
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 </h1>
</div>
</div>
<div className="flex items-center space-x-4 z-10">
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border" onClick={toggleTheme}>
<span className="sr-only">Toggle theme</span>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">{t("language")}</span>
<Globe className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
{t("english")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
{t("khmer")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Documentation */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">{t("documentation")}</span>
<FileText className="w-4 h-4" />
</Button>
{/* GitHub */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">GitHub</span>
<Github className="w-4 h-4" />
</Button>
{/* X (Twitter) */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">X (Twitter)</span>
<Twitter className="w-4 h-4" />
</Button>
{/* Discord (replaced with MessageSquare) */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">Discord</span>
<MessageSquare className="w-4 h-4" />
</Button>
{/* Notifications */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">{t("notifications")}</span>
<Bell className="w-4 h-4" />
</Button>
{/* User Profile Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="h-8 w-8 cursor-pointer ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
</AvatarFallback>}
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-3 p-2">
<Avatar className="h-10 w-10">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
</AvatarFallback>}
</Avatar>
<div className="flex flex-col space-y-1">
<span className="font-medium">{currentUser?.name || 'User'}</span>
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/profile")}>
<User className="mr-2 h-4 w-4" />
<span>{t("profile")}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/settings")}>
<Settings className="mr-2 h-4 w-4" />
<span>{t("settings")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout} className="text-red-500 focus:text-red-500">
<LogOut className="mr-2 h-4 w-4" />
<span>{t("logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
};
@@ -0,0 +1,54 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Plus } from "lucide-react";
interface ServiceFiltersProps {
filter: string;
setFilter: (value: string) => void;
searchTerm: string;
setSearchTerm: (value: string) => void;
servicesCount: number;
}
export const ServiceFilters = ({
filter,
setFilter,
searchTerm,
setSearchTerm,
servicesCount
}: ServiceFiltersProps) => {
return (
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center">
<h3 className="text-xl font-semibold mr-2 text-foreground">Currently Monitoring</h3>
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
{servicesCount}
</span>
</div>
<div className="flex space-x-4">
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-40 bg-card border-border">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="HTTP">HTTP</SelectItem>
<SelectItem value="PING">PING</SelectItem>
<SelectItem value="TCP">TCP</SelectItem>
<SelectItem value="DNS">DNS</SelectItem>
</SelectContent>
</Select>
<div className="relative">
<Input
className="w-72 bg-card border-border"
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</div>
);
};
@@ -0,0 +1,15 @@
import { Service } from "@/types/service.types";
import { ServicesTableContainer } from "@/components/services/ServicesTableContainer";
interface ServicesTableProps {
services: Service[];
}
export const ServicesTable = ({ services }: ServicesTableProps) => {
return (
<div className="flex-1 flex flex-col h-full">
<ServicesTableContainer services={services} />
</div>
);
};
@@ -0,0 +1,141 @@
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, Settings, User, UserCog, Bell, FileClock, Database, RefreshCw, Info, ChevronDown, BookOpen } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import { Link, useLocation } from "react-router-dom";
import { useState, useEffect } from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useLanguage } from "@/contexts/LanguageContext";
interface SidebarProps {
collapsed: boolean;
}
export const Sidebar = ({
collapsed
}: SidebarProps) => {
const {
theme
} = useTheme();
const {
t
} = useLanguage();
const location = useLocation();
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
// Update active settings item based on URL
useEffect(() => {
if (location.pathname === '/settings') {
const params = new URLSearchParams(location.search);
const panel = params.get('panel');
if (panel) {
setActiveSettingsItem(panel);
}
}
}, [location]);
const handleSettingsItemClick = (item: string) => {
setActiveSettingsItem(item);
};
const getMenuItemClasses = (isActive: boolean) => {
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
};
// New larger icon size for the main menu
const mainIconSize = "h-6 w-6";
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={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
<span className="text-white font-bold">C</span>
</div>
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
</div>
<nav className="my-2 mx-1 py-1 px-1">
<Link to="/dashboard" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/dashboard' ? 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`}>
<Globe className={`${mainIconSize} text-purple-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("uptimeMonitoring")}</span>}
</Link>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Boxes className={`${mainIconSize} text-blue-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Radar className={`${mainIconSize} text-cyan-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Calendar className={`${mainIconSize} text-emerald-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<BarChart2 className={`${mainIconSize} text-amber-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<LineChart className={`${mainIconSize} text-rose-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("reports")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<FileText className={`${mainIconSize} text-indigo-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("apiDocumentation")}</span>}
</div>
</nav>
{!collapsed && <div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
<div className="flex items-center">
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
</div>
<div className="flex items-center">
<Settings className="h-4 w-4 mr-1" />
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
<ScrollArea className="h-full">
<div className="space-y-2 pr-4">
<Link to={`/settings?panel=general`} className={getMenuItemClasses(activeSettingsItem === 'general')} onClick={() => handleSettingsItemClick('general')}>
<Settings className="h-4 w-4 mr-2" />
<span className="text-sm">{t("generalSettings")}</span>
</Link>
<Link to={`/settings?panel=users`} className={getMenuItemClasses(activeSettingsItem === 'users')} onClick={() => handleSettingsItemClick('users')}>
<User className="h-4 w-4 mr-2" />
<span className="text-sm">{t("userManagement")}</span>
</Link>
<Link to={`/settings?panel=notifications`} className={getMenuItemClasses(activeSettingsItem === 'notifications')} onClick={() => handleSettingsItemClick('notifications')}>
<Bell className="h-4 w-4 mr-2" />
<span className="text-sm">{t("notificationSettings")}</span>
</Link>
<Link to={`/settings?panel=templates`} className={getMenuItemClasses(activeSettingsItem === 'templates')} onClick={() => handleSettingsItemClick('templates')}>
<BookOpen className="h-4 w-4 mr-2" />
<span className="text-sm">{t("alertsTemplates")}</span>
</Link>
<div className={getMenuItemClasses(false)}>
<Database className="h-4 w-4 mr-2" />
<span className="text-sm">{t("dataRetention")}</span>
</div>
<Link to={`/settings?panel=about`} className={getMenuItemClasses(activeSettingsItem === 'about')} onClick={() => handleSettingsItemClick('about')}>
<Info className="h-4 w-4 mr-2" />
<span className="text-sm">{t("aboutSystem")}</span>
</Link>
</div>
</ScrollArea>
</div>
</CollapsibleContent>
</Collapsible>
</div>}
{collapsed && <div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
<Link to="/settings">
<Settings className={`${mainIconSize} text-purple-400`} />
</Link>
</div>}
</div>;
};
@@ -0,0 +1,152 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react";
import { Service } from "@/services/serviceService";
import { useTheme } from "@/contexts/ThemeContext";
interface StatusCardsProps {
services: Service[];
}
export const StatusCards = ({ services }: StatusCardsProps) => {
// Count services by status
const upServices = services.filter(s => s.status === "up").length;
const downServices = services.filter(s => s.status === "down").length;
const pausedServices = services.filter(s => s.status === "paused").length;
const warningServices = services.filter(s => s.responseTime > 1000).length;
// Get current theme to adjust card styles
const { theme } = useTheme();
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 w-full">
{/* Up Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
: "linear-gradient(135deg, #43a047 0%, #66bb6a 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">UP SERVICES</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{upServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<ArrowUp className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
{/* Down Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
: "linear-gradient(135deg, #e53935 0%, #ef5350 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">DOWN SERVICES</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{downServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<ArrowDown className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
{/* Paused Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
: "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">PAUSED SERVICES</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{pausedServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<Pause className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
{/* Warning Services Card */}
<Card
className={`border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 ${
theme === 'dark' ? 'dark-card' : ''
} relative z-10`}
style={{
background: theme === 'dark'
? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
: "linear-gradient(135deg, #ff9800 0%, #ffb74d 100%)"
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">WARNING SERVICES</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{warningServices}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
<AlertTriangle className="h-6 w-6 text-white" />
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,191 @@
import { useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { Eye, EyeOff } from "lucide-react";
import { pb } from "@/lib/pocketbase";
import { authService } from "@/services/authService";
// Password change form schema
const passwordFormSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string().min(8, "Confirm password is required"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type PasswordFormValues = z.infer<typeof passwordFormSchema>;
interface ChangePasswordFormProps {
userId: string;
}
export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const { toast } = useToast();
const form = useForm<PasswordFormValues>({
resolver: zodResolver(passwordFormSchema),
defaultValues: {
currentPassword: "",
newPassword: "",
confirmPassword: "",
},
});
async function onSubmit(data: PasswordFormValues) {
setIsSubmitting(true);
try {
// PocketBase requires the old password along with the new one
await pb.collection('users').update(userId, {
oldPassword: data.currentPassword,
password: data.newPassword,
passwordConfirm: data.confirmPassword,
});
// Refresh auth data to ensure token remains valid
await authService.refreshUserData();
toast({
title: "Password updated",
description: "Your password has been changed successfully.",
});
// Reset the form
form.reset();
} catch (error) {
console.error("Password change error:", error);
let errorMessage = "Failed to update password. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
}
toast({
title: "Password change failed",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showCurrentPassword ? "text" : "password"}
placeholder="Your current password"
{...field}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
{showCurrentPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">
{showCurrentPassword ? "Hide password" : "Show password"}
</span>
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder="New password"
{...field}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowNewPassword(!showNewPassword)}
>
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">
{showNewPassword ? "Hide password" : "Show password"}
</span>
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm new password"
{...field}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">
{showConfirmPassword ? "Hide password" : "Show password"}
</span>
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Change Password"}
</Button>
</form>
</Form>
);
}
@@ -0,0 +1,74 @@
import { useState } from "react";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { User } from "@/services/userService";
import { UserProfileDetails } from "./UserProfileDetails";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ChangePasswordForm } from "./ChangePasswordForm";
import { UpdateProfileForm } from "./UpdateProfileForm";
interface ProfileContentProps {
currentUser: User | null;
}
export function ProfileContent({ currentUser }: ProfileContentProps) {
const [activeTab, setActiveTab] = useState("details");
if (!currentUser) {
return (
<Card>
<CardHeader>
<CardTitle>User Profile</CardTitle>
<CardDescription>Your profile information could not be loaded</CardDescription>
</CardHeader>
</Card>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">My Profile</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left column - Profile summary card */}
<Card className="md:col-span-1">
<CardHeader>
<CardTitle>Profile Summary</CardTitle>
</CardHeader>
<CardContent>
<UserProfileDetails user={currentUser} />
</CardContent>
</Card>
{/* Right column - Profile tabs for edit and password change */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>My Account</CardTitle>
<CardDescription>Manage your account settings</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="details">Profile Details</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
</TabsList>
<TabsContent value="details" className="pt-4">
<UpdateProfileForm user={currentUser} />
</TabsContent>
<TabsContent value="security" className="pt-4">
<ChangePasswordForm userId={currentUser.id} />
</TabsContent>
</Tabs>
</CardContent>
<CardFooter className="text-sm text-muted-foreground">
Last updated: {new Date(currentUser.updated).toLocaleString()}
</CardFooter>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
import { useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { User, userService } from "@/services/userService";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { authService } from "@/services/authService";
// Profile update form schema
const profileFormSchema = z.object({
full_name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
username: z.string().min(3, {
message: "Username must be at least 3 characters.",
}),
email: z.string().email({
message: "Please enter a valid email address.",
}),
});
type ProfileFormValues = z.infer<typeof profileFormSchema>;
interface UpdateProfileFormProps {
user: User;
}
export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
// Initialize the form with current user data
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema),
defaultValues: {
full_name: user.full_name || "",
username: user.username || "",
email: user.email || "",
},
});
async function onSubmit(data: ProfileFormValues) {
setIsSubmitting(true);
try {
await userService.updateUser(user.id, {
full_name: data.full_name,
username: data.username,
email: data.email,
});
// Refresh user data in auth context
await authService.refreshUserData();
toast({
title: "Profile updated",
description: "Your profile information has been updated successfully.",
});
} catch (error) {
console.error("Profile update error:", error);
let errorMessage = "Failed to update profile. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
}
toast({
title: "Update failed",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Your full name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="your.email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</form>
</Form>
);
}
@@ -0,0 +1,75 @@
import { User } from "@/services/userService";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Mail, User as UserIcon } from "lucide-react";
interface UserProfileDetailsProps {
user: User;
}
export function UserProfileDetails({ user }: UserProfileDetailsProps) {
// Format dates
const createdDate = new Date(user.created).toLocaleDateString();
// Get avatar or initials
const getInitials = () => {
if (user.full_name) {
return user.full_name.split(' ')
.map(name => name[0])
.join('')
.toUpperCase();
}
return user.username[0].toUpperCase();
};
return (
<div className="flex flex-col items-center space-y-4">
<Avatar className="h-32 w-32">
{user.avatar ? (
<AvatarImage src={user.avatar} alt={user.full_name || user.username} />
) : (
<AvatarFallback className="text-3xl bg-primary/20 text-primary">
{getInitials()}
</AvatarFallback>
)}
</Avatar>
<div className="space-y-1 text-center">
<h2 className="text-xl font-bold">{user.full_name || user.username}</h2>
<div className="flex items-center justify-center text-sm text-muted-foreground gap-1">
<Mail className="h-3 w-3" />
<span>{user.email}</span>
</div>
<div className="flex items-center justify-center text-sm text-muted-foreground gap-1">
<UserIcon className="h-3 w-3" />
<span>@{user.username}</span>
</div>
</div>
<div className="w-full pt-2">
<div className="flex flex-wrap justify-center gap-2 pt-2">
{user.role && (
<Badge variant="secondary" className="px-2 py-1">
{user.role}
</Badge>
)}
<Badge variant={user.isActive ? "default" : "outline"} className="px-2 py-1">
{user.isActive ? "Active" : "Inactive"}
</Badge>
{user.verified && (
<Badge className="bg-green-600 hover:bg-green-700 px-2 py-1">
Verified
</Badge>
)}
</div>
<div className="border-t border-border mt-4 pt-4">
<p className="text-sm text-muted-foreground">
Member since: {createdDate}
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ServiceForm } from "./ServiceForm";
import { ScrollArea } from "@/components/ui/scroll-area";
interface AddServiceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) {
const handleSuccess = () => {
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl">Create New Service</DialogTitle>
<DialogDescription className="text-gray-400">
Fill in the details to create a new service to monitor.
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="pr-2">
<ServiceForm onSuccess={handleSuccess} onCancel={handleCancel} />
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,156 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { format, subDays, subHours, subMonths, subWeeks, subYears } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export type DateRangeOption = '60min' | '24h' | '7d' | '30d' | '1y' | 'custom';
interface DateRangeFilterProps {
onRangeChange: (startDate: Date, endDate: Date, option: DateRangeOption) => void;
selectedOption?: DateRangeOption;
}
// Define a proper type for the date range
interface DateRange {
from: Date | undefined;
to: Date | undefined;
}
export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateRangeFilterProps) {
const [currentOption, setCurrentOption] = useState<DateRangeOption>(selectedOption);
const [customDateRange, setCustomDateRange] = useState<DateRange>({
from: undefined,
to: undefined,
});
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const handleOptionChange = (value: string) => {
const option = value as DateRangeOption;
setCurrentOption(option);
const now = new Date();
let startDate: Date;
switch (option) {
case '60min':
// Ensure we're getting exactly 60 minutes ago
startDate = new Date(now.getTime() - 60 * 60 * 1000);
console.log(`60min option selected: ${startDate.toISOString()} to ${now.toISOString()}`);
break;
case '24h':
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
break;
case '7d':
startDate = subDays(now, 7);
break;
case '30d':
startDate = subDays(now, 30);
break;
case '1y':
startDate = subYears(now, 1);
break;
case 'custom':
// Don't trigger onRangeChange for custom until both dates are selected
setIsCalendarOpen(true);
return;
default:
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
}
console.log(`DateRangeFilter: Option changed to ${option}, date range: ${startDate.toISOString()} to ${now.toISOString()}`);
onRangeChange(startDate, now, option);
};
// Handle custom date range selection
const handleCustomRangeSelect = (range: DateRange | undefined) => {
if (!range) {
return;
}
setCustomDateRange(range);
if (range.from && range.to) {
// Ensure that we have both from and to dates before triggering the change
const startOfDay = new Date(range.from);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(range.to);
endOfDay.setHours(23, 59, 59, 999);
console.log(`DateRangeFilter: Custom range selected: ${startOfDay.toISOString()} to ${endOfDay.toISOString()}`);
onRangeChange(startOfDay, endOfDay, 'custom');
setIsCalendarOpen(false);
}
};
return (
<div className="flex items-center space-x-2">
<Select value={currentOption} onValueChange={handleOptionChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="60min">Last 60 minutes</SelectItem>
<SelectItem value="24h">Last 24 hours</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="1y">Last year</SelectItem>
<SelectItem value="custom">Custom range</SelectItem>
</SelectContent>
</Select>
{currentOption === 'custom' && (
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
className={cn(
"w-[280px] justify-start text-left font-normal",
!customDateRange.from && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{customDateRange.from ? (
customDateRange.to ? (
<>
{format(customDateRange.from, "LLL dd, y")} -{" "}
{format(customDateRange.to, "LLL dd, y")}
</>
) : (
format(customDateRange.from, "LLL dd, y")
)
) : (
"Pick a date range"
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center">
<Calendar
mode="range"
selected={customDateRange}
onSelect={handleCustomRangeSelect}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
)}
</div>
);
}
@@ -0,0 +1,87 @@
import React from "react";
import { Clock, TimerOff } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface LastCheckedTimeProps {
lastCheckedTime: string;
status?: string;
interval?: number;
}
export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => {
// Format the time without seconds to display a static time
const formatTimeWithoutSeconds = (timeString: string) => {
try {
const date = new Date(timeString);
// Check if it's a valid date
if (isNaN(date.getTime())) {
// If it's already in HH:MM format, just return it
if (timeString.includes(':') && !timeString.includes(':00:')) {
return timeString;
}
return timeString;
}
// Format to only show hours and minutes (HH:MM)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch (e) {
return timeString;
}
};
// Get the formatted time without creating a new Date for paused services
const formattedTime = formatTimeWithoutSeconds(lastCheckedTime);
// Explicitly prevent real-time updates for paused services
const isPaused = status === "paused";
// Format the interval for display
const formatInterval = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
return `${minutes}min`;
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 text-sm text-gray-400 cursor-help">
{isPaused ? (
<TimerOff className="h-4 w-4" />
) : (
<Clock className="h-4 w-4" />
)}
<span>
{isPaused ? "Paused at " : ""}
{formattedTime}
</span>
</div>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">
{isPaused ? "Monitoring Paused" : "Last Check Details"}
</div>
<div>
{isPaused ? "No automatic checks" : `Checked at ${formattedTime}`}
</div>
{interval && !isPaused && (
<div>
Check interval: {formatInterval(interval)}
</div>
)}
<div className="text-gray-400 text-[10px]">
{new Date(lastCheckedTime).toLocaleString()}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -0,0 +1,15 @@
export function LoadingState() {
return (
<div className="flex items-center justify-center h-screen bg-background text-foreground">
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
<div className="relative flex items-center justify-center mb-4">
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
<h3 className="text-xl font-medium mb-1">Loading server data</h3>
<p className="text-muted-foreground">Please wait while we retrieve your information...</p>
</div>
</div>
);
}
@@ -0,0 +1,234 @@
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } 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 { format } from "date-fns";
interface ResponseTimeChartProps {
uptimeData: UptimeData[];
}
export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
const { theme } = useTheme();
// Format data for the chart with enhanced time formatting
const chartData = useMemo(() => {
if (!uptimeData || uptimeData.length === 0) return [];
// Sort by timestamp ascending (oldest to newest)
const sortedData = [...uptimeData].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Get time format based on data density
const isShortTimeRange = uptimeData.length > 0 &&
(new Date(uptimeData[0].timestamp).getTime() - new Date(uptimeData[uptimeData.length - 1].timestamp).getTime()) < 2 * 60 * 60 * 1000;
return sortedData.map(data => {
const timestamp = new Date(data.timestamp);
return {
time: isShortTimeRange
? format(timestamp, 'HH:mm:ss') // Include seconds for short time ranges like 60min
: format(timestamp, 'HH:mm'),
rawTime: timestamp.getTime(),
date: format(timestamp, 'MMM dd, yyyy'),
value: data.status === "paused" ? null : data.responseTime,
status: data.status,
};
});
}, [uptimeData]);
// Create a custom tooltip for the chart
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
// Set background color based on status
let statusColor = "bg-emerald-800";
let statusText = "Up";
if (data.status === "down") {
statusColor = "bg-red-800";
statusText = "Down";
} else if (data.status === "warning") {
statusColor = "bg-yellow-800";
statusText = "Warning";
} else if (data.status === "paused") {
statusColor = "bg-gray-800";
statusText = "Paused";
}
return (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} p-2 border ${theme === 'dark' ? 'border-gray-700' : 'border-gray-200'} rounded shadow-md`}>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{data.date}</p>
<div className={`flex items-center gap-2 mt-1 ${data.status === "paused" ? "opacity-70" : ""}`}>
<div className={`w-3 h-3 rounded-full ${statusColor}`}></div>
<span>{statusText}</span>
</div>
<p className="mt-1 font-mono text-sm">
{data.status === "paused" ? "Monitoring paused" :
data.value !== null ? `${data.value} ms` : "No data"}
</p>
</div>
);
}
return null;
};
// Compute status segments for different areas
const getStatusSegments = () => {
const segments = {
up: [] as any[],
down: [] as any[],
warning: [] as any[]
};
chartData.forEach(point => {
if (point.status === "paused") return;
if (point.status === "up") {
segments.up.push(point);
} else if (point.status === "down") {
segments.down.push(point);
} else if (point.status === "warning") {
segments.warning.push(point);
}
});
return segments;
};
const segments = getStatusSegments();
// Check if we have any data to display - be more lenient by checking raw uptimeData
const hasData = uptimeData.length > 0;
// Get date range for display
const dateRange = useMemo(() => {
if (!chartData.length) return { start: "", end: "" };
const sortedData = [...chartData].sort((a, b) => a.rawTime - b.rawTime);
return {
start: sortedData[0]?.date || "",
end: sortedData[sortedData.length - 1]?.date || ""
};
}, [chartData]);
// Display date range if different dates
const dateRangeDisplay = dateRange.start === dateRange.end
? dateRange.start
: `${dateRange.start} - ${dateRange.end}`;
return (
<Card className="mb-8">
<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>
)}
</CardTitle>
</CardHeader>
<CardContent>
{!hasData ? (
<div className="h-80 flex items-center justify-center text-muted-foreground">
<p>No data available for the selected time period.</p>
</div>
) : (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
<defs>
<linearGradient id="colorUp" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0.2}/>
</linearGradient>
<linearGradient id="colorDown" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#ef4444" stopOpacity={0.2}/>
</linearGradient>
<linearGradient id="colorWarning" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0.2}/>
</linearGradient>
</defs>
<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={['dataMin - 10', 'dataMax + 10']}
/>
<Tooltip content={<CustomTooltip />} />
{/* Area charts for different statuses */}
{segments.up.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.up}
stroke="#10b981"
fillOpacity={1}
fill="url(#colorUp)"
connectNulls
/>
)}
{segments.down.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.down}
stroke="#ef4444"
fillOpacity={1}
fill="url(#colorDown)"
connectNulls
/>
)}
{segments.warning.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.warning}
stroke="#f59e0b"
fillOpacity={1}
fill="url(#colorWarning)"
connectNulls
/>
)}
{/* Add reference lines for paused periods */}
{chartData.map((entry, index) =>
entry.status === 'paused' ? (
<ReferenceLine
key={`ref-${index}`}
x={entry.time}
stroke="#9ca3af"
strokeDasharray="3 3"
/>
) : null
)}
</AreaChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,77 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Service } from "@/types/service.types";
import { useTheme } from "@/contexts/ThemeContext";
import { Loader2 } from "lucide-react";
interface ServiceDeleteDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
selectedService: Service | null;
onConfirmDelete: () => Promise<void>;
isDeleting?: boolean;
}
export const ServiceDeleteDialog = ({
isOpen,
onOpenChange,
selectedService,
onConfirmDelete,
isDeleting = false,
}: ServiceDeleteDialogProps) => {
const { theme } = useTheme();
const handleConfirm = async () => {
if (!isDeleting) {
await onConfirmDelete();
}
};
return (
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
<AlertDialogContent className={`${theme === 'dark' ? 'bg-gray-900 text-white border-gray-800' : 'bg-background text-foreground border-border'}`}>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this service?</AlertDialogTitle>
<AlertDialogDescription className={theme === 'dark' ? 'text-gray-400' : 'text-muted-foreground'}>
This action cannot be undone. This will permanently delete{' '}
<span className={theme === 'dark' ? 'font-semibold text-white' : 'font-semibold text-foreground'}>
{selectedService?.name}
</span>{' '}
and all of its uptime records.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className={theme === 'dark' ? 'bg-gray-800 text-white border-gray-700 hover:bg-gray-700' : 'bg-secondary'}
disabled={isDeleting}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isDeleting}
className={theme === 'dark' ? 'bg-red-900 text-white hover:bg-red-800' : 'bg-red-600 text-white hover:bg-red-700'}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,6 @@
import { ServiceDetailContainer as NewServiceDetailContainer } from "./ServiceDetailContainer/index";
export const ServiceDetailContainer = () => {
return <NewServiceDetailContainer />;
};
@@ -0,0 +1,51 @@
import React from "react";
import { Sidebar } from "@/components/dashboard/Sidebar";
import { Header } from "@/components/dashboard/Header";
import { LoadingState } from "@/components/services/LoadingState";
import { ServiceNotFound } from "@/components/services/ServiceNotFound";
import { Service } from "@/types/service.types";
interface ServiceDetailWrapperProps {
children: React.ReactNode;
isLoading: boolean;
service: Service | null;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
currentUser: any;
handleLogout: () => void;
}
export const ServiceDetailWrapper = ({
children,
isLoading,
service,
sidebarCollapsed,
toggleSidebar,
currentUser,
handleLogout
}: ServiceDetailWrapperProps) => {
return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-col flex-1 min-w-0">
<Header
currentUser={currentUser}
onLogout={handleLogout}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/>
{isLoading ? (
<LoadingState />
) : !service ? (
<ServiceNotFound />
) : (
<div className="flex-1 overflow-auto">
{children}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,3 @@
export * from './useServiceData';
export * from './useRealTimeUpdates';
@@ -0,0 +1,87 @@
import { useEffect } from "react";
import { pb } from "@/lib/pocketbase";
import { Service, UptimeData } from "@/types/service.types";
interface UseRealTimeUpdatesProps {
serviceId: string | undefined;
startDate: Date;
endDate: Date;
setService: React.Dispatch<React.SetStateAction<Service | null>>;
setUptimeData: React.Dispatch<React.SetStateAction<UptimeData[]>>;
}
export const useRealTimeUpdates = ({
serviceId,
startDate,
endDate,
setService,
setUptimeData
}: UseRealTimeUpdatesProps) => {
// Listen for real-time updates to this service
useEffect(() => {
if (!serviceId) return;
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,
};
});
}
});
// 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);
// 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 => {
const newData: UptimeData = {
id: e.record.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
};
// Add at the beginning of the array to maintain newest first sorting
return [newData, ...prev];
});
}
}
});
// 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);
}
};
} catch (error) {
console.error("Error setting up real-time updates:", error);
}
}, [serviceId, startDate, endDate, setService, setUptimeData]);
};
@@ -0,0 +1,173 @@
import { useState, useEffect } from "react";
import { pb } from "@/lib/pocketbase";
import { Service, UptimeData } from "@/types/service.types";
import { useToast } from "@/hooks/use-toast";
import { useNavigate } from "react-router-dom";
import { uptimeService } from "@/services/uptimeService";
export const useServiceData = (serviceId: string | undefined, startDate: Date, endDate: Date) => {
const [service, setService] = useState<Service | null>(null);
const [uptimeData, setUptimeData] = useState<UptimeData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
const navigate = useNavigate();
// Handler for service status changes
const handleStatusChange = async (newStatus: "up" | "down" | "paused" | "warning") => {
if (!service || !serviceId) return;
try {
// Optimistic UI update
setService({ ...service, status: newStatus as Service["status"] });
// Update the service status in PocketBase
await pb.collection('services').update(serviceId, {
status: newStatus
});
toast({
title: "Status updated",
description: `Service status changed to ${newStatus}`,
});
} catch (error) {
console.error("Failed to update service status:", error);
// Revert the optimistic update
setService(prevService => prevService);
toast({
variant: "destructive",
title: "Update failed",
description: "Could not update service status. Please try again.",
});
}
};
// Function to fetch uptime data with date filters
const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange: string) => {
try {
console.log(`Fetching uptime data from ${start.toISOString()} to ${end.toISOString()}`);
// Set appropriate limits based on time range to ensure enough granularity
let limit = 200; // default
// Adjust limits based on selected range
if (selectedRange === '60min') {
limit = 300; // More points for shorter time ranges
} else if (selectedRange === '24h') {
limit = 200;
} else if (selectedRange === '7d') {
limit = 250;
} else if (selectedRange === '30d' || selectedRange === '1y') {
limit = 300; // More points for longer time ranges
}
console.log(`Using limit ${limit} for range ${selectedRange}`);
const history = await uptimeService.getUptimeHistory(serviceId, limit, start, end);
console.log(`Fetched ${history.length} uptime records for time range ${selectedRange}`);
if (history.length === 0) {
console.log("No data returned from API, checking if we need to fetch with a higher limit");
// If no data, try with a higher limit as fallback
if (limit < 500) {
const extendedHistory = await uptimeService.getUptimeHistory(serviceId, 500, start, end);
console.log(`Fallback: Fetched ${extendedHistory.length} uptime records with higher limit`);
if (extendedHistory.length > 0) {
setUptimeData(extendedHistory);
return extendedHistory;
}
}
}
// Sort data by timestamp (newest first)
const sortedHistory = [...history].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
setUptimeData(sortedHistory);
return sortedHistory;
} catch (error) {
console.error("Error fetching uptime data:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load uptime history. Please try again.",
});
return [];
}
};
// Initial data loading
useEffect(() => {
const fetchServiceData = async () => {
try {
if (!serviceId) {
setIsLoading(false);
return;
}
setIsLoading(true);
// Add a timeout to prevent hanging
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 || "",
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"
};
setService(formattedService);
// Fetch uptime history with date range
await fetchUptimeData(serviceId, startDate, endDate, '24h');
} 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]);
// Update data when date range changes
useEffect(() => {
if (serviceId && !isLoading) {
fetchUptimeData(serviceId, startDate, endDate, '24h');
}
}, [startDate, endDate]);
return {
service,
setService,
uptimeData,
setUptimeData,
isLoading,
handleStatusChange,
fetchUptimeData
};
};
@@ -0,0 +1,126 @@
import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { DateRangeOption } from "../DateRangeFilter";
import { authService } from "@/services/authService";
import { ServiceDetailContent } from "../ServiceDetailContent";
import { ServiceDetailWrapper } from "./ServiceDetailWrapper";
import { useServiceData, useRealTimeUpdates } from "./hooks";
import { toast } from "@/components/ui/use-toast";
export const ServiceDetailContainer = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// Ensure we use exact timestamp for startDate
const [startDate, setStartDate] = useState<Date>(() => {
const date = new Date();
date.setHours(date.getHours() - 24);
return date;
});
const [endDate, setEndDate] = useState<Date>(new Date());
const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h');
// State for sidebar collapse functionality (shared with Dashboard)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
// Check if there's a saved preference in localStorage
const saved = localStorage.getItem("sidebarCollapsed");
return saved ? JSON.parse(saved) : window.innerWidth < 768;
});
// Toggle sidebar and save preference
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(prev => {
const newState = !prev;
localStorage.setItem("sidebarCollapsed", JSON.stringify(newState));
return newState;
});
}, []);
// Get current user for header
const currentUser = authService.getCurrentUser();
useEffect(() => {
// Verify user is authenticated
if (!authService.isAuthenticated()) {
toast({
variant: "destructive",
title: "Authentication required",
description: "Please log in to view service details",
});
navigate("/login");
}
// Auto-collapse sidebar on small screens
const handleResize = () => {
if (window.innerWidth < 768 && !sidebarCollapsed) {
setSidebarCollapsed(true);
localStorage.setItem("sidebarCollapsed", JSON.stringify(true));
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [navigate, sidebarCollapsed]);
// Handler for logout (same as Dashboard)
const handleLogout = () => {
authService.logout();
navigate("/login");
};
// Use our custom hooks
const {
service,
uptimeData,
isLoading,
handleStatusChange,
fetchUptimeData,
setService,
setUptimeData
} = useServiceData(id, startDate, endDate);
// Set up real-time updates
useRealTimeUpdates({
serviceId: id,
startDate,
endDate,
setService,
setUptimeData
});
// Handle date range filter changes
const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => {
console.log(`Date range changed: ${start.toISOString()} to ${end.toISOString()}, option: ${option}`);
setStartDate(start);
setEndDate(end);
setSelectedRange(option);
// Refetch uptime data with new date range, passing the selected range option
if (id) {
fetchUptimeData(id, start, end, option);
}
}, [id, fetchUptimeData]);
return (
<ServiceDetailWrapper
isLoading={isLoading}
service={service}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
currentUser={currentUser}
handleLogout={handleLogout}
>
{service && (
<ServiceDetailContent
service={service}
uptimeData={uptimeData}
onDateRangeChange={handleDateRangeChange}
onStatusChange={handleStatusChange}
selectedDateOption={selectedRange}
/>
)}
</ServiceDetailWrapper>
);
};
@@ -0,0 +1,64 @@
import { Service, UptimeData } from "@/types/service.types";
import { ServiceHeader } from "@/components/services/ServiceHeader";
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
import { ResponseTimeChart } from "@/components/services/ResponseTimeChart";
import { LatestChecksTable } from "@/components/services/incident-history";
import { DateRangeFilter, DateRangeOption } from "@/components/services/DateRangeFilter";
import { Card, CardContent } from "@/components/ui/card";
import { AlertTriangle } from "lucide-react";
interface ServiceDetailContentProps {
service: Service;
uptimeData: UptimeData[];
onDateRangeChange: (start: Date, end: Date, option: DateRangeOption) => void;
onStatusChange: (newStatus: "up" | "down" | "paused" | "warning") => void;
selectedDateOption: DateRangeOption;
}
export const ServiceDetailContent = ({
service,
uptimeData,
onDateRangeChange,
onStatusChange,
selectedDateOption
}: ServiceDetailContentProps) => {
// Check if data is available
const hasUptimeData = uptimeData && uptimeData.length > 0;
return (
<div className="p-4 md:p-6 pb-0 h-full overflow-auto">
<ServiceHeader service={service} onStatusChange={onStatusChange} />
<ServiceStatsCards service={service} uptimeData={uptimeData} />
<div className="mb-4 md:mb-6 mt-6 md:mt-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 md:gap-0">
<h2 className="text-lg md:text-xl font-medium">Response Time History</h2>
<DateRangeFilter
onRangeChange={onDateRangeChange}
selectedOption={selectedDateOption}
/>
</div>
{!hasUptimeData && (
<Card className="mb-6 md:mb-8">
<CardContent className="flex items-center justify-center py-8 md:py-12">
<div className="text-center">
<AlertTriangle className="h-10 w-10 md:h-12 md:w-12 text-amber-500 mx-auto mb-2 md:mb-3 opacity-70" />
<h3 className="text-base md:text-lg font-medium mb-1">No uptime data available</h3>
<p className="text-muted-foreground max-w-md text-sm md:text-base">
There's no monitoring data for this service in the selected time period.
This could be because the service was recently added or monitoring is paused.
</p>
</div>
</CardContent>
</Card>
)}
{hasUptimeData && <ResponseTimeChart uptimeData={uptimeData} />}
<div className="pb-6">
<LatestChecksTable uptimeData={uptimeData} />
</div>
</div>
);
};
@@ -0,0 +1,77 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ServiceForm } from "./ServiceForm";
import { Service } from "@/types/service.types";
import { useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ServiceEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
service: Service | null;
}
export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) {
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset submission state when dialog opens/closes
useEffect(() => {
if (!open) {
setIsSubmitting(false);
}
}, [open]);
const handleSuccess = () => {
// Invalidate the services query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["services"] });
setIsSubmitting(false);
onOpenChange(false);
};
const handleCancel = () => {
if (!isSubmitting) {
onOpenChange(false);
}
};
// Only render the form if dialog is open and service data exists
// This prevents form validation errors when dialog is closed
return (
<Dialog open={open} onOpenChange={(newOpen) => {
// Only allow closing if not currently submitting
if (!isSubmitting || !newOpen) {
onOpenChange(newOpen);
}
}}>
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl">Edit Service</DialogTitle>
<DialogDescription className="text-gray-400">
Update the details of your monitored service.
</DialogDescription>
</DialogHeader>
{open && service && (
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="pr-2">
<ServiceForm
onSuccess={handleSuccess}
onCancel={handleCancel}
initialData={service}
isEdit={true}
onSubmitStart={() => setIsSubmitting(true)}
/>
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,142 @@
import { Form } from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { serviceSchema, ServiceFormData } from "./add-service/types";
import { ServiceBasicFields } from "./add-service/ServiceBasicFields";
import { ServiceTypeField } from "./add-service/ServiceTypeField";
import { ServiceConfigFields } from "./add-service/ServiceConfigFields";
import { ServiceNotificationFields } from "./add-service/ServiceNotificationFields";
import { ServiceFormActions } from "./add-service/ServiceFormActions";
import { serviceService } from "@/services/serviceService";
import { Service } from "@/types/service.types";
interface ServiceFormProps {
onSuccess: () => void;
onCancel: () => void;
initialData?: Service | null;
isEdit?: boolean;
onSubmitStart?: () => void;
}
export function ServiceForm({
onSuccess,
onCancel,
initialData,
isEdit = false,
onSubmitStart
}: ServiceFormProps) {
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form with default values
const form = useForm<ServiceFormData>({
resolver: zodResolver(serviceSchema),
defaultValues: {
name: "",
type: "http",
url: "",
interval: "60",
retries: "3",
notificationChannel: "",
alertTemplate: "",
},
mode: "onBlur",
});
// Populate form when initialData changes (separate from initialization)
useEffect(() => {
if (initialData && isEdit) {
// Reset the form with initial data values
form.reset({
name: initialData.name || "",
type: (initialData.type || "http").toLowerCase(),
url: initialData.url || "",
interval: String(initialData.interval || 60),
retries: String(initialData.retries || 3),
notificationChannel: initialData.notificationChannel || "",
alertTemplate: initialData.alertTemplate || "",
});
// Log for debugging
console.log("Populating form with URL:", initialData.url);
}
}, [initialData, isEdit, form]);
const handleSubmit = async (data: ServiceFormData) => {
if (isSubmitting) return;
setIsSubmitting(true);
if (onSubmitStart) onSubmitStart();
try {
console.log("Form data being submitted:", data); // Debug log for submitted data
if (isEdit && initialData) {
// Update existing service
await serviceService.updateService(initialData.id, {
name: data.name,
type: data.type,
url: data.url,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel === "none" ? "" : data.notificationChannel,
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
});
toast({
title: "Service updated",
description: `${data.name} has been updated successfully.`,
});
} else {
// Create new service
await serviceService.createService({
name: data.name,
type: data.type,
url: data.url,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel === "none" ? undefined : data.notificationChannel,
alertTemplate: data.alertTemplate === "default" ? undefined : data.alertTemplate,
});
toast({
title: "Service created",
description: `${data.name} has been added to monitoring.`,
});
}
onSuccess();
if (!isEdit) {
form.reset();
}
} catch (error) {
console.error(`Error ${isEdit ? 'updating' : 'creating'} service:`, error);
toast({
title: `Failed to ${isEdit ? 'update' : 'create'} service`,
description: `An error occurred while ${isEdit ? 'updating' : 'creating'} the service.`,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6">
<ServiceBasicFields form={form} />
<ServiceTypeField form={form} />
<ServiceConfigFields form={form} />
<ServiceNotificationFields form={form} />
<ServiceFormActions
isSubmitting={isSubmitting}
onCancel={onCancel}
submitLabel={isEdit ? "Update Service" : "Create Service"}
/>
</form>
</Form>
);
}
@@ -0,0 +1,83 @@
import { ArrowLeft, Globe, MoreVertical, FileText, Github, Twitter, MessageSquare, Bell } 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 { Service } from "@/types/service.types";
import { useLanguage } from "@/contexts/LanguageContext";
import { cn } from "@/lib/utils";
interface ServiceHeaderProps {
service: Service;
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
}
export function ServiceHeader({ service, onStatusChange }: ServiceHeaderProps) {
const navigate = useNavigate();
const { t } = useLanguage();
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>
{service.url && (
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
>
<Globe className="h-3 w-3 mr-1" />
{service.url}
</a>
)}
</div>
<div className="flex items-center space-x-4">
<StatusBadge status={service.status} size="lg" />
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">More options</span>
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from "@/components/ui/dialog";
import { Service } from "@/types/service.types";
import { ServiceUptimeHistory } from "@/components/services/ServiceUptimeHistory";
import { useTheme } from "@/contexts/ThemeContext";
import { DateRangeFilter, DateRangeOption } from "@/components/services/DateRangeFilter";
interface ServiceHistoryDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
selectedService: Service | null;
}
export const ServiceHistoryDialog = ({
isOpen,
onOpenChange,
selectedService,
}: ServiceHistoryDialogProps) => {
const { theme } = useTheme();
const [startDate, setStartDate] = useState<Date>(new Date(Date.now() - 24 * 60 * 60 * 1000)); // Default to 24h ago
const [endDate, setEndDate] = useState<Date>(new Date());
// Reset date range when dialog opens to ensure fresh data
useEffect(() => {
if (isOpen) {
setStartDate(new Date(Date.now() - 24 * 60 * 60 * 1000));
setEndDate(new Date());
}
}, [isOpen]);
// Handle date range filter changes
const handleDateRangeChange = (start: Date, end: Date, option: DateRangeOption) => {
console.log(`ServiceHistoryDialog: Date range changed to ${start.toISOString()} - ${end.toISOString()}`);
setStartDate(start);
setEndDate(end);
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className={`${theme === 'dark' ? 'bg-black text-white border-gray-800' : 'bg-background text-foreground border-border'} sm:max-w-[800px]`}>
<DialogHeader>
<DialogTitle className="text-xl">
{selectedService?.name} - Uptime History
</DialogTitle>
<DialogDescription className={theme === 'dark' ? 'text-gray-400' : 'text-muted-foreground'}>
Showing the most recent uptime checks for this service.
{selectedService?.interval && (
<span> Checked every {selectedService.interval} seconds.</span>
)}
</DialogDescription>
</DialogHeader>
<div className="mb-4">
<DateRangeFilter onRangeChange={handleDateRangeChange} />
</div>
{selectedService && (
<ServiceUptimeHistory
serviceId={selectedService.id}
startDate={startDate}
endDate={endDate}
/>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,103 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Play, Pause } from "lucide-react";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast";
import { notificationService } from "@/services/notificationService";
interface ServiceMonitoringButtonProps {
service: Service;
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
}
export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMonitoringButtonProps) {
const [isMonitoring, setIsMonitoring] = useState(service.status !== "paused");
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
// Update local state when service prop changes
useEffect(() => {
setIsMonitoring(service.status !== "paused");
}, [service.status]);
const handleToggleMonitoring = async () => {
try {
setIsLoading(true);
if (isMonitoring) {
// Pause monitoring
console.log(`Pausing monitoring for service ${service.id} (${service.name})`);
await serviceService.pauseMonitoring(service.id);
setIsMonitoring(false);
if (onStatusChange) onStatusChange("paused");
// Send notification for paused status (only here, not in pauseMonitoring.ts)
if (service.alerts !== "muted") {
console.log("Sending pause notification from UI component");
// IMPORTANT: Direct call to the notification service to ensure a message is sent
await notificationService.sendNotification({
service: service,
status: "paused",
timestamp: new Date().toISOString(),
});
}
toast({
title: "Monitoring paused",
description: `Monitoring for ${service.name} has been paused.`,
});
} else {
// Start/resume monitoring
console.log(`Starting monitoring for service ${service.id} (${service.name})`);
// First ensure we update the status in the database to not be paused anymore
await serviceService.resumeMonitoring(service.id);
setIsMonitoring(true);
// Perform an immediate check
await serviceService.startMonitoringService(service.id);
toast({
title: "Monitoring resumed",
description: `Monitoring for ${service.name} has been resumed. First check is running now.`,
});
}
} catch (error) {
console.error("Error toggling monitoring:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to change monitoring status. Please try again.",
});
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="outline"
size="sm"
onClick={handleToggleMonitoring}
disabled={isLoading}
className={isMonitoring ? "bg-red-900/20 hover:bg-red-900/30" : "bg-green-900/20 hover:bg-green-900/30"}
>
{isLoading ? (
"Processing..."
) : isMonitoring ? (
<>
<Pause className="h-4 w-4 mr-2" />
Pause Monitoring
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Start Monitoring
</>
)}
</Button>
);
}
@@ -0,0 +1,25 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom";
export function ServiceNotFound() {
const navigate = useNavigate();
return (
<div className="flex items-center justify-center h-screen bg-background text-foreground">
<div className="text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-yellow-500" />
<h2 className="text-xl font-bold mt-4">Service Not Found</h2>
<p className="mt-2 text-muted-foreground">The service you're looking for doesn't exist or has been deleted.</p>
<Button
className="mt-4"
variant="outline"
onClick={() => navigate("/dashboard")}
>
Back to Dashboard
</Button>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import React from "react";
import { TableRow, TableCell } from "@/components/ui/table";
import { Service } from "@/types/service.types";
import { StatusBadge } from "./StatusBadge";
import { UptimeBar } from "./UptimeBar";
import { LastCheckedTime } from "./LastCheckedTime";
import {
ServiceRowActions,
ServiceRowHeader,
ServiceRowResponseTime
} from "./service-row";
import { useTheme } from "@/contexts/ThemeContext";
interface ServiceRowProps {
service: Service;
onViewDetail: (service: Service) => void;
onPauseResume: (service: Service) => Promise<void>;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
onMuteAlerts?: (service: Service) => Promise<void>;
}
export const ServiceRow = ({
service,
onViewDetail,
onPauseResume,
onEdit,
onDelete,
onMuteAlerts
}: ServiceRowProps) => {
const { theme } = useTheme();
const handleRowClick = () => {
onViewDetail(service);
};
// Get the timestamp to display - use only lastChecked since that's what's defined in the Service type
const displayTimestamp = service.lastChecked || new Date().toLocaleString();
return (
<TableRow
key={service.id}
className={`border-b ${theme === 'dark' ? 'border-gray-800 hover:bg-gray-900/60' : 'border-gray-200 hover:bg-gray-50'} cursor-pointer`}
onClick={handleRowClick}
>
<TableCell className="font-medium py-4" onClick={(e) => e.stopPropagation()}>
<ServiceRowHeader service={service} />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className={`text-base py-4 ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
{service.type}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<StatusBadge status={service.status} size="md" />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<ServiceRowResponseTime responseTime={service.responseTime} />
</TableCell>
<TableCell className="w-52 py-4" onClick={(e) => e.stopPropagation()}>
<UptimeBar uptime={service.uptime} status={service.status} serviceId={service.id} />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<LastCheckedTime
lastCheckedTime={displayTimestamp}
status={service.status}
interval={service.interval}
/>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<ServiceRowActions
service={service}
onViewDetail={onViewDetail}
onPauseResume={onPauseResume}
onEdit={onEdit}
onDelete={onDelete}
onMuteAlerts={onMuteAlerts}
/>
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,171 @@
import { Clock, Server, ArrowUp, ArrowDown } from "lucide-react";
import { Service, UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useMemo } from "react";
import { formatDistanceStrict } from "date-fns";
interface ServiceStatsCardsProps {
service: Service;
uptimeData: UptimeData[];
}
export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) {
// Calculate uptime percentage
const calculateUptimePercentage = () => {
if (uptimeData.length === 0) return 100;
const totalChecks = uptimeData.length;
const upChecks = uptimeData.filter(data => data.status === "up").length;
return Math.round((upChecks / totalChecks) * 100);
};
// Calculate total uptime/downtime
const uptimeStats = useMemo(() => {
if (uptimeData.length === 0) {
return {
totalUptimeFormatted: "N/A",
totalDowntimeFormatted: "N/A",
currentStatusDuration: "N/A"
};
}
// Sort data by timestamp for chronological analysis
const sortedData = [...uptimeData].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Get the status change events
const statusChanges = sortedData.reduce((changes, check, index) => {
if (index === 0 || (index > 0 && sortedData[index-1].status !== check.status)) {
changes.push(check);
}
return changes;
}, [] as UptimeData[]);
let totalUptime = 0;
let totalDowntime = 0;
let currentStatus = service.status;
let lastChangeTime = new Date();
// Calculate durations between status changes
for (let i = 0; i < statusChanges.length; i++) {
const currentChange = statusChanges[i];
const nextChange = statusChanges[i + 1];
if (nextChange) {
const duration = new Date(nextChange.timestamp).getTime() - new Date(currentChange.timestamp).getTime();
if (currentChange.status === "up") {
totalUptime += duration;
} else {
totalDowntime += duration;
}
} else {
// For the last status, calculate time until now
lastChangeTime = new Date(currentChange.timestamp);
currentStatus = currentChange.status;
}
}
// Add time from last change until now for current status
const now = new Date();
const sinceLastChange = now.getTime() - lastChangeTime.getTime();
if (currentStatus === "up") {
totalUptime += sinceLastChange;
} else if (currentStatus === "down") {
totalDowntime += sinceLastChange;
}
// Format durations
const formatDuration = (ms: number): string => {
if (ms < 1000) return "0s";
return formatDistanceStrict(0, ms, { addSuffix: false });
};
return {
totalUptimeFormatted: formatDuration(totalUptime),
totalDowntimeFormatted: formatDuration(totalDowntime),
currentStatusDuration: formatDuration(sinceLastChange)
};
}, [uptimeData, service.status]);
// Calculate average response time from recent checks
const calculateAverageResponseTime = () => {
const upChecks = uptimeData.filter(data => data.status === "up" && data.responseTime > 0);
if (upChecks.length === 0) return service.responseTime || 0;
const sum = upChecks.reduce((total, check) => total + check.responseTime, 0);
return Math.round((sum / upChecks.length) * 100) / 100; // Two decimal precision
};
const uptimePercentage = calculateUptimePercentage();
const avgResponseTime = calculateAverageResponseTime();
// Define upChecks here so we can use it in both the calculation and the JSX
const upChecks = uptimeData.filter(data => data.status === "up" && data.responseTime > 0);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card className="border-blue-400 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">Response Time</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-700 dark:text-white">
{service.responseTime}ms
</div>
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Last checked at {service.lastChecked}</p>
{avgResponseTime > 0 && avgResponseTime !== service.responseTime && (
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Avg: {avgResponseTime}ms (last {upChecks.length} up checks)</p>
)}
</CardContent>
</Card>
<Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">Uptime</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-700 dark:text-white">
{uptimePercentage}%
</div>
<p className="text-xs text-green-600 dark:text-green-200 mt-1">Based on last {uptimeData.length} checks</p>
<div className="flex flex-col space-y-1 mt-2">
<div className="flex items-center text-xs text-green-600 dark:text-green-200">
<ArrowUp className="h-3 w-3 mr-1 text-green-600 dark:text-green-400" />
<span>Total uptime: {uptimeStats.totalUptimeFormatted}</span>
</div>
<div className="flex items-center text-xs text-red-600 dark:text-red-200">
<ArrowDown className="h-3 w-3 mr-1 text-red-600 dark:text-red-400" />
<span>Total downtime: {uptimeStats.totalDowntimeFormatted}</span>
</div>
{service.status !== "paused" && (
<div className="flex items-center text-xs font-medium mt-1">
<span className={service.status === "up" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{service.status === "up" ? "Up" : "Down"} for {uptimeStats.currentStatusDuration}
</span>
</div>
)}
</div>
</CardContent>
</Card>
<Card className="border-purple-400 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">Monitoring Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200">
<Clock className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>Checked every {service.interval} seconds</span>
</div>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200 mt-1">
<Server className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>{service.type} monitoring</span>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,138 @@
import { useQuery } from "@tanstack/react-query";
import { UptimeData } from "@/types/service.types";
import { uptimeService } from "@/services/uptimeService";
import { format, parseISO } from "date-fns";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { useTheme } from "@/contexts/ThemeContext";
import { Check, X, AlertTriangle, Pause } from "lucide-react";
interface ServiceUptimeHistoryProps {
serviceId: string;
startDate?: Date;
endDate?: Date;
}
export function ServiceUptimeHistory({
serviceId,
startDate = new Date(Date.now() - 24 * 60 * 60 * 1000),
endDate = new Date()
}: ServiceUptimeHistoryProps) {
const { theme } = useTheme();
const { data: uptimeHistory, isLoading, error } = useQuery({
queryKey: ['uptimeHistory', serviceId, startDate?.toISOString(), endDate?.toISOString()],
queryFn: () => uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate),
enabled: !!serviceId,
refetchInterval: 5000, // Refresh UI every 5 seconds
});
if (isLoading) {
return (
<div className="py-4 text-center">
<p>Loading uptime history...</p>
</div>
);
}
if (error) {
return (
<div className="py-4 text-center">
<p>Error loading uptime history.</p>
</div>
);
}
if (!uptimeHistory || uptimeHistory.length === 0) {
return (
<div className="py-4 text-center">
<p>No uptime history available for the selected time period.</p>
</div>
);
}
// Function to get appropriate status badge styling
const getStatusBadge = (status: string) => {
switch(status) {
case 'up':
return {
variant: 'default' as const,
className: 'bg-emerald-800 text-white hover:bg-emerald-700',
icon: <Check className="h-3 w-3 mr-1" />
};
case 'warning':
return {
variant: 'outline' as const,
className: 'bg-yellow-800/80 text-yellow-300 border-yellow-700 hover:bg-yellow-800',
icon: <AlertTriangle className="h-3 w-3 mr-1" />
};
case 'paused':
return {
variant: 'outline' as const,
className: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800',
icon: <Pause className="h-3 w-3 mr-1" />
};
default:
return {
variant: 'destructive' as const,
className: '',
icon: <X className="h-3 w-3 mr-1" />
};
}
};
return (
<div className="mt-4 bg-card rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-b border-border">
<TableHead className="text-muted-foreground font-medium">Time</TableHead>
<TableHead className="text-muted-foreground font-medium">Status</TableHead>
<TableHead className="text-muted-foreground font-medium">Response Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uptimeHistory.map((record) => {
const statusBadge = getStatusBadge(record.status);
const isResponseTimeHigh = record.responseTime >= 1000;
return (
<TableRow key={record.id} className="border-b border-border">
<TableCell>
{format(parseISO(record.timestamp), 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell>
<Badge
variant={statusBadge.variant}
className={statusBadge.className}
>
<span className="flex items-center">
{statusBadge.icon}
{record.status === 'up' ? 'Up' :
record.status === 'down' ? 'Down' :
record.status === 'warning' ? 'Warning' : 'Paused'}
</span>
</Badge>
</TableCell>
<TableCell>
<div className="font-mono flex items-center gap-1.5">
{record.responseTime > 0 ? (
<>
<span className={isResponseTimeHigh ? "text-amber-500 font-semibold" : ""}>
{record.responseTime}ms
</span>
{isResponseTimeHigh && (
<AlertTriangle className="h-3 w-3 text-amber-500" />
)}
</>
) : 'N/A'}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,94 @@
import { useEffect } from "react";
import { Service } from "@/types/service.types";
import { ServicesTableView } from "./ServicesTableView";
import { ServiceDeleteDialog } from "./ServiceDeleteDialog";
import { ServiceHistoryDialog } from "./ServiceHistoryDialog";
import { ServiceEditDialog } from "./ServiceEditDialog";
import { useServiceActions, useDialogState } from "./hooks";
interface ServicesTableContainerProps {
services: Service[];
}
export const ServicesTableContainer = ({ services }: ServicesTableContainerProps) => {
const {
services: localServices,
selectedService,
isDeleting,
setSelectedService,
updateServices,
handleViewDetail,
handlePauseResume,
handleEdit,
handleDelete,
confirmDelete,
handleMuteAlerts
} = useServiceActions(services);
const {
isHistoryDialogOpen,
isDeleteDialogOpen,
isEditDialogOpen,
setIsHistoryDialogOpen,
setIsDeleteDialogOpen,
handleEditDialogChange,
handleDeleteDialogChange
} = useDialogState();
// Update local services state when props change
useEffect(() => {
updateServices(services);
}, [services]);
// Handler functions that combine local state management
const onEdit = (service: Service) => {
const selectedService = handleEdit(service);
setTimeout(() => {
handleEditDialogChange(true);
}, 0);
};
const onDelete = (service: Service) => {
handleDelete(service);
setIsDeleteDialogOpen(true);
};
const openHistoryDialog = (service: Service) => {
setSelectedService(service);
setIsHistoryDialogOpen(true);
};
return (
<div className="flex-1 flex flex-col h-full">
<ServicesTableView
services={localServices}
onViewDetail={handleViewDetail}
onPauseResume={handlePauseResume}
onEdit={onEdit}
onDelete={onDelete}
onMuteAlerts={handleMuteAlerts}
/>
<ServiceHistoryDialog
isOpen={isHistoryDialogOpen}
onOpenChange={setIsHistoryDialogOpen}
selectedService={selectedService}
/>
<ServiceDeleteDialog
isOpen={isDeleteDialogOpen}
onOpenChange={(open) => handleDeleteDialogChange(open, isDeleting)}
selectedService={selectedService}
onConfirmDelete={confirmDelete}
isDeleting={isDeleting}
/>
<ServiceEditDialog
open={isEditDialogOpen}
onOpenChange={handleEditDialogChange}
service={selectedService}
/>
</div>
);
}
@@ -0,0 +1,68 @@
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Service } from "@/types/service.types";
import { ServiceRow } from "@/components/services/ServiceRow";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServicesTableViewProps {
services: Service[];
onViewDetail: (service: Service) => void;
onPauseResume: (service: Service) => Promise<void>;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
onMuteAlerts?: (service: Service) => Promise<void>;
}
export const ServicesTableView = ({
services,
onViewDetail,
onPauseResume,
onEdit,
onDelete,
onMuteAlerts
}: ServicesTableViewProps) => {
const { theme } = useTheme();
const { t } = useLanguage();
return (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg overflow-hidden border border-border flex-1 flex flex-col shadow-sm`}>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'} sticky top-0 z-10`}>
<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("serviceName")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceType")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceStatus")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("responseTime")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("uptime")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("lastChecked")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.length > 0 ? (
services.map((service) => (
<ServiceRow
key={service.id}
service={service}
onViewDetail={onViewDetail}
onPauseResume={onPauseResume}
onEdit={onEdit}
onDelete={onDelete}
onMuteAlerts={onMuteAlerts}
/>
))
) : (
<TableRow>
<TableCell colSpan={7} className={`text-center py-8 text-base ${theme === 'dark' ? 'text-gray-300' : 'text-gray-500'}`}>
{t("noServices")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
@@ -0,0 +1,71 @@
import React from "react";
import { Check, X, Pause, AlertTriangle } from "lucide-react";
export interface StatusBadgeProps {
status: string;
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":
default:
return "px-2 py-0.5 text-xs gap-0.5";
}
};
const getIconSize = () => {
switch (size) {
case "lg":
return "h-4 w-4";
case "md":
return "h-4 w-4";
case "sm":
default:
return "h-3 w-3";
}
};
const sizeClasses = getSizeClasses();
const iconSize = getIconSize();
switch (status) {
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;
}
};
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from "react";
import { Progress } from "@/components/ui/progress";
import { Check, X, AlertTriangle, Pause, Clock, Info, RefreshCcw } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger
} from "@/components/ui/hover-card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { uptimeService } from "@/services/uptimeService";
import { UptimeData } from "@/types/service.types";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
interface UptimeBarProps {
uptime: number;
status: string;
serviceId?: string; // Optional serviceId to fetch history
}
export const UptimeBar = ({ uptime, status, serviceId }: UptimeBarProps) => {
const { theme } = useTheme();
const [historyItems, setHistoryItems] = useState<UptimeData[]>([]);
// Fetch real uptime history data if serviceId is provided with improved caching and error handling
const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({
queryKey: ['uptimeHistory', serviceId],
queryFn: () => serviceId ? uptimeService.getUptimeHistory(serviceId, 20) : Promise.resolve([]),
enabled: !!serviceId,
refetchInterval: 30000, // Refresh every 30 seconds
staleTime: 15000, // Consider data fresh for 15 seconds
placeholderData: (previousData) => previousData, // Show previous data while refetching
retry: 3, // Retry failed requests three times
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff with max 10s
});
// Update history items when data changes
useEffect(() => {
if (uptimeData && uptimeData.length > 0) {
setHistoryItems(uptimeData);
} else if (status === "paused" || (uptimeData && uptimeData.length === 0)) {
// For paused services with no history, or empty history data, show all as paused
const statusValue = (status === "up" || status === "down" || status === "warning" || status === "paused")
? status
: "paused"; // Default to paused if not a valid status
const placeholderHistory: UptimeData[] = Array(20).fill(null).map((_, index) => ({
id: `placeholder-${index}`,
serviceId: serviceId || "",
timestamp: new Date().toISOString(),
status: statusValue as "up" | "down" | "warning" | "paused",
responseTime: 0
}));
setHistoryItems(placeholderHistory);
}
}, [uptimeData, serviceId, status]);
// Get appropriate color classes for each status type
const getStatusColor = (itemStatus: string) => {
switch(itemStatus) {
case "up":
return theme === "dark" ? "bg-emerald-500" : "bg-emerald-500";
case "down":
return theme === "dark" ? "bg-red-500" : "bg-red-500";
case "warning":
return theme === "dark" ? "bg-yellow-500" : "bg-yellow-500";
case "paused":
default:
return theme === "dark" ? "bg-gray-500" : "bg-gray-400";
}
};
// Get status label
const getStatusLabel = (itemStatus: string): string => {
switch(itemStatus) {
case "up": return "Online";
case "down": return "Offline";
case "warning": return "Degraded";
case "paused": return "Paused";
default: return "Unknown";
}
};
// Format timestamp for display
const formatTimestamp = (timestamp: string): string => {
try {
return new Date(timestamp).toLocaleString([], {
hour: '2-digit',
minute: '2-digit',
month: 'short',
day: 'numeric'
});
} catch (e) {
return timestamp;
}
};
// If still loading and no history, show improved loading state
if ((isLoading || isFetching) && historyItems.length === 0) {
// Show skeleton loading UI instead of text
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`skeleton-${index}`}
className={`h-5 w-1.5 rounded-sm bg-muted animate-pulse`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground w-16 h-4 bg-muted animate-pulse rounded"></span>
<span className="text-muted-foreground w-24 h-4 bg-muted animate-pulse rounded"></span>
</div>
</div>
);
}
// If there's an error and no history, show improved error state with retry button
if (error && historyItems.length === 0) {
// Provide visual error state that matches the design system
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`error-${index}`}
className={`h-5 w-1.5 rounded-sm bg-gray-700 opacity-40`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{Math.round(uptime)}% uptime</span>
<button
onClick={() => refetch()}
className="text-xs text-red-400 flex items-center gap-1 hover:text-red-300 transition-colors"
>
<X className="h-3 w-3" /> Connection error
<RefreshCcw className="h-3 w-3 ml-1" />
</button>
</div>
</div>
);
}
// Ensure we always have 20 items by padding with the last known status
const displayItems = [...historyItems];
if (displayItems.length < 20) {
const lastItem = displayItems.length > 0 ? displayItems[displayItems.length - 1] : null;
const lastStatus = lastItem ? lastItem.status :
(status === "up" || status === "down" || status === "warning" || status === "paused") ?
status as "up" | "down" | "warning" | "paused" : "paused";
const paddingItems: UptimeData[] = Array(20 - displayItems.length).fill(null).map((_, index) => ({
id: `padding-${index}`,
serviceId: serviceId || "",
timestamp: new Date().toISOString(),
status: lastStatus,
responseTime: 0
}));
displayItems.push(...paddingItems);
}
// Limit to 20 items for display
const limitedItems = displayItems.slice(0, 20);
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{limitedItems.map((item, index) => (
<Tooltip key={item.id || `status-${index}`}>
<TooltipTrigger asChild>
<div
className={`h-5 w-1.5 rounded-sm ${getStatusColor(item.status)} cursor-pointer hover:opacity-80 transition-opacity`}
/>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">{getStatusLabel(item.status)}</div>
<div>
{item.status !== "paused" && item.status !== "down" ?
`${item.responseTime}ms` :
"No response"}
</div>
<div className="text-gray-400">
{formatTimestamp(item.timestamp)}
</div>
</div>
</TooltipContent>
</Tooltip>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{Math.round(uptime)}% uptime
</span>
<span className="text-xs text-muted-foreground">
Last 20 checks
</span>
</div>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,55 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceBasicFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) {
return (
<>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Service Name</FormLabel>
<FormControl>
<Input
placeholder="Service Name"
className="bg-black border-gray-700"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Service URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com"
className="bg-black border-gray-700"
{...field}
onChange={(e) => {
console.log("URL field changed:", e.target.value);
field.onChange(e);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
@@ -0,0 +1,51 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceConfigFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
return (
<>
<FormField
control={form.control}
name="interval"
render={({ field }) => (
<FormItem>
<FormLabel>Heartbeat Interval</FormLabel>
<FormControl>
<Input
type="number"
placeholder="60"
className="bg-black border-gray-700"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="retries"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum Retries</FormLabel>
<FormControl>
<Input
type="number"
placeholder="3"
className="bg-black border-gray-700"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</>
);
}
@@ -0,0 +1,143 @@
import { Form } from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { serviceSchema, ServiceFormData } from "./types";
import { ServiceBasicFields } from "./ServiceBasicFields";
import { ServiceTypeField } from "./ServiceTypeField";
import { ServiceConfigFields } from "./ServiceConfigFields";
import { ServiceNotificationFields } from "./ServiceNotificationFields";
import { ServiceFormActions } from "./ServiceFormActions";
import { serviceService } from "@/services/serviceService";
import { Service } from "@/types/service.types";
interface ServiceFormProps {
onSuccess: () => void;
onCancel: () => void;
initialData?: Service | null;
isEdit?: boolean;
onSubmitStart?: () => void;
}
export function ServiceForm({
onSuccess,
onCancel,
initialData,
isEdit = false,
onSubmitStart
}: ServiceFormProps) {
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form with default values
const form = useForm<ServiceFormData>({
resolver: zodResolver(serviceSchema),
defaultValues: {
name: "",
type: "http",
url: "",
interval: "60",
retries: "3",
notificationChannel: "",
alertTemplate: "",
},
mode: "onBlur",
});
// Populate form when initialData changes (separate from initialization)
useEffect(() => {
if (initialData && isEdit) {
// Reset the form with initial data values
form.reset({
name: initialData.name || "",
type: (initialData.type || "http").toLowerCase(),
url: initialData.url || "",
interval: String(initialData.interval || 60),
retries: String(initialData.retries || 3),
notificationChannel: initialData.notificationChannel || "",
alertTemplate: initialData.alertTemplate || "",
});
// Log for debugging
console.log("Populating form with URL:", initialData.url);
}
}, [initialData, isEdit, form]);
const handleSubmit = async (data: ServiceFormData) => {
if (isSubmitting) return;
setIsSubmitting(true);
if (onSubmitStart) onSubmitStart();
try {
console.log("Form data being submitted:", data); // Debug log for submitted data
if (isEdit && initialData) {
// Update existing service
await serviceService.updateService(initialData.id, {
name: data.name,
type: data.type,
url: data.url, // Ensure URL is included here
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel || undefined,
alertTemplate: data.alertTemplate || undefined,
});
toast({
title: "Service updated",
description: `${data.name} has been updated successfully.`,
});
} else {
// Create new service
await serviceService.createService({
name: data.name,
type: data.type,
url: data.url, // Ensure URL is included here
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel || undefined,
alertTemplate: data.alertTemplate || undefined,
});
toast({
title: "Service created",
description: `${data.name} has been added to monitoring.`,
});
}
onSuccess();
if (!isEdit) {
form.reset();
}
} catch (error) {
console.error(`Error ${isEdit ? 'updating' : 'creating'} service:`, error);
toast({
title: `Failed to ${isEdit ? 'update' : 'create'} service`,
description: `An error occurred while ${isEdit ? 'updating' : 'creating'} the service.`,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-4">
<ServiceBasicFields form={form} />
<ServiceTypeField form={form} />
<ServiceConfigFields form={form} />
<ServiceNotificationFields form={form} />
<ServiceFormActions
isSubmitting={isSubmitting}
onCancel={onCancel}
submitLabel={isEdit ? "Update Service" : "Create Service"}
/>
</form>
</Form>
);
}
@@ -0,0 +1,51 @@
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { MouseEvent } from "react";
interface ServiceFormActionsProps {
isSubmitting: boolean;
onCancel: () => void;
submitLabel?: string;
}
export function ServiceFormActions({
isSubmitting,
onCancel,
submitLabel = "Create Service"
}: ServiceFormActionsProps) {
const handleCancel = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!isSubmitting) {
onCancel();
}
};
return (
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
onClick={handleCancel}
variant="outline"
disabled={isSubmitting}
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
submitLabel
)}
</Button>
</div>
);
}
@@ -0,0 +1,139 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
import { useQuery } from "@tanstack/react-query";
import { templateService } from "@/services/templateService";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { useState, useEffect } from "react";
interface ServiceNotificationFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceNotificationFields({ form }: ServiceNotificationFieldsProps) {
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
// Get the current form values for debugging
const notificationChannel = form.watch("notificationChannel");
const alertTemplate = form.watch("alertTemplate");
console.log("Current notification values:", {
notificationChannel,
alertTemplate
});
// Fetch alert configurations for notification channels
const { data: alertConfigsData } = useQuery({
queryKey: ['alertConfigs'],
queryFn: () => alertConfigService.getAlertConfigurations(),
});
// Fetch templates for template selection
const { data: templates } = useQuery({
queryKey: ['templates'],
queryFn: () => templateService.getTemplates(),
});
// Update alert configs when data is loaded
useEffect(() => {
if (alertConfigsData) {
setAlertConfigs(alertConfigsData);
// Debug log to check what alert configs are loaded
console.log("Loaded alert configurations:", alertConfigsData);
}
}, [alertConfigsData]);
// Log when form values change to debug
useEffect(() => {
console.log("Notification values changed:", {
notificationChannel: form.getValues("notificationChannel"),
alertTemplate: form.getValues("alertTemplate")
});
}, [form.watch("notificationChannel"), form.watch("alertTemplate")]);
return (
<>
<FormField
control={form.control}
name="notificationChannel"
render={({ field }) => {
// Important: We need to preserve the actual value for notification channel
const fieldValue = field.value || "";
const displayValue = fieldValue === "" ? "none" : fieldValue;
console.log("Rendering notification channel field with value:", {
fieldValue,
displayValue
});
return (
<FormItem>
<FormLabel>Notification Channel</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
console.log("Notification channel changed to:", value);
field.onChange(value === "none" ? "" : value);
}}
value={displayValue}
>
<SelectTrigger className="bg-black border-gray-700">
<SelectValue placeholder="Select a notification channel" />
</SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700">
<SelectItem value="none">None</SelectItem>
{alertConfigs.map((config) => (
<SelectItem key={config.id} value={config.id || ""}>
{config.notify_name} ({config.notification_type})
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="alertTemplate"
render={({ field }) => {
// Don't convert existing values to "default"
const displayValue = field.value || "default";
console.log("Rendering alert template field with value:", displayValue);
return (
<FormItem>
<FormLabel>Alert Template</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
console.log("Alert template changed to:", value);
field.onChange(value === "default" ? "" : value);
}}
value={displayValue}
>
<SelectTrigger className="bg-black border-gray-700">
<SelectValue placeholder="Select an alert template" />
</SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700">
<SelectItem value="default">Default</SelectItem>
{templates?.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
);
}}
/>
</>
);
}
@@ -0,0 +1,58 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Globe } from "lucide-react";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceTypeFieldProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
return (
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Service Type</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger className="bg-black border-gray-700">
<SelectValue>
{field.value === "http" && (
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
<span>HTTP/S</span>
</div>
)}
{field.value !== "http" && "Select a service type"}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700">
<SelectItem value="http">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
<span>HTTP/S</span>
</div>
<p className="text-xs text-gray-400 mt-1">
Monitor websites and REST APIs with HTTP/HTTPS protocol
</p>
</div>
</SelectItem>
<SelectItem value="ping">PING</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="dns">DNS</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
);
}
@@ -0,0 +1,3 @@
export * from "./ServiceForm";
export * from "./types";
@@ -0,0 +1,14 @@
import { z } from "zod";
export const serviceSchema = z.object({
name: z.string().min(1, "Service name is required"),
type: z.string().min(1, "Service type is required"),
url: z.string().min(1, "Service URL is required"),
interval: z.string().min(1, "Heartbeat interval is required"),
retries: z.string().min(1, "Maximum retries is required"),
notificationChannel: z.string().optional(),
alertTemplate: z.string().optional(),
});
export type ServiceFormData = z.infer<typeof serviceSchema>;
@@ -0,0 +1,3 @@
export * from './useServiceActions';
export * from './useDialogState';
@@ -0,0 +1,30 @@
import { useState } from "react";
export function useDialogState() {
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const handleEditDialogChange = (open: boolean) => {
setIsEditDialogOpen(open);
};
const handleDeleteDialogChange = (open: boolean, isDeleting: boolean = false) => {
// Only allow closing if not currently deleting
if (!isDeleting || !open) {
setIsDeleteDialogOpen(open);
}
};
return {
isHistoryDialogOpen,
isDeleteDialogOpen,
isEditDialogOpen,
setIsHistoryDialogOpen,
setIsDeleteDialogOpen,
setIsEditDialogOpen,
handleEditDialogChange,
handleDeleteDialogChange
};
}
@@ -0,0 +1,205 @@
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { pb } from "@/lib/pocketbase";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { recordMuteStatusChange } from "@/services/monitoring/utils/notificationUtils";
export function useServiceActions(initialServices: Service[]) {
const [services, setServices] = useState<Service[]>(initialServices);
const [selectedService, setSelectedService] = useState<Service | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
// Update services state when props change
const updateServices = (newServices: Service[]) => {
if (JSON.stringify(services) !== JSON.stringify(newServices)) {
setServices(newServices);
}
};
const handleViewDetail = (service: Service) => {
navigate(`/service/${service.id}`);
};
const handlePauseResume = async (service: Service) => {
try {
if (service.status === "paused") {
// Resume monitoring
await serviceService.startMonitoringService(service.id);
toast({
title: "Service resumed",
description: `${service.name} monitoring has been resumed successfully.`,
});
// Update local state - ensure status is properly typed as "up"
const updatedServices = services.map(s =>
s.id === service.id ? { ...s, status: "up" as const } : s
);
setServices(updatedServices);
} else {
// Pause monitoring
await serviceService.pauseMonitoring(service.id);
// Get the pause time and update local state
const pauseTime = new Date().toISOString();
const updatedServices = services.map(s =>
s.id === service.id ? { ...s, status: "paused" as const, lastChecked: pauseTime } : s
);
setServices(updatedServices);
toast({
title: "Service paused",
description: `${service.name} monitoring has been paused successfully.`,
});
}
// Invalidate the services query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["services"] });
} catch (error) {
console.error("Error updating service status:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to update service status. Please try again.",
});
}
};
const handleEdit = (service: Service) => {
setSelectedService({...service}); // Create a copy to avoid reference issues
return service;
};
const handleDelete = (service: Service) => {
setSelectedService(service);
return service;
};
// Modified to return Promise<void> instead of Promise<boolean>
const confirmDelete = async (): Promise<void> => {
if (!selectedService || isDeleting) return;
try {
setIsDeleting(true);
// First try to pause monitoring for this service to prevent any concurrency issues
if (selectedService.status !== "paused") {
await serviceService.pauseMonitoring(selectedService.id);
}
// Set a timeout to prevent hanging UI
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Delete request timed out")), 10000);
});
const deletePromise = pb.collection('services').delete(selectedService.id);
await Promise.race([deletePromise, timeoutPromise]);
toast({
title: "Service deleted",
description: `${selectedService.name} has been deleted successfully.`,
});
// Update local state
const updatedServices = services.filter(s => s.id !== selectedService.id);
setServices(updatedServices);
// Invalidate the services query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["services"] });
setSelectedService(null);
} catch (error) {
console.error("Error deleting service:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete service. Please try again.",
});
} finally {
setIsDeleting(false);
}
};
const handleMuteAlerts = async (service: Service) => {
try {
// Check alerts status - check both fields for backward compatibility
const isMuted = service.alerts === "muted" || service.muteAlerts === true;
// Toggle the mute alerts status for this specific service
const newMuteStatus = !isMuted;
console.log(`${newMuteStatus ? "Muting" : "Unmuting"} alerts for service ${service.id} (${service.name})`);
// First update the local state immediately for better UI responsiveness
// Using proper type casting to ensure TypeScript knows we're creating valid Service objects
const updatedServices = services.map(s => {
if (s.id === service.id) {
return {
...s,
muteAlerts: newMuteStatus,
alerts: newMuteStatus ? "muted" as const : "unmuted" as const
};
}
return s;
});
setServices(updatedServices);
// Record the mute status change (this will also update the service record)
await recordMuteStatusChange(service.id, service.name, newMuteStatus);
// Show a toast message
toast({
title: newMuteStatus ? "Alerts muted" : "Alerts unmuted",
description: `Notifications for ${service.name} are now ${newMuteStatus ? "muted" : "enabled"}.`,
});
// Immediately invalidate the services query to trigger a refetch
// This ensures our local state matches the database state
await queryClient.invalidateQueries({ queryKey: ["services"] });
} catch (error) {
console.error("Error updating alert settings:", error);
// Revert the local state change if the server update failed
const revertedServices = services.map(s => {
if (s.id === service.id) {
return {
...s,
muteAlerts: service.muteAlerts,
alerts: service.alerts
};
}
return s;
});
setServices(revertedServices);
toast({
variant: "destructive",
title: "Error",
description: `Failed to ${!service.muteAlerts ? "mute" : "unmute"} alerts for ${service.name}. Please try again.`,
});
}
};
return {
services,
selectedService,
isDeleting,
setSelectedService,
updateServices,
handleViewDetail,
handlePauseResume,
handleEdit,
handleDelete,
confirmDelete,
handleMuteAlerts
};
}
@@ -0,0 +1,19 @@
interface EmptyStateProps {
statusFilter: string;
}
export function EmptyState({ statusFilter }: EmptyStateProps) {
return (
<div className="text-center py-8 text-muted-foreground">
{statusFilter === "all"
? "No incidents recorded in selected time period"
: `No ${
statusFilter === "up" ? "uptime" :
statusFilter === "down" ? "downtime" :
statusFilter === "warning" ? "warning" :
"paused"
} incidents recorded in selected time period`}
</div>
);
}
@@ -0,0 +1,61 @@
import { format } from "date-fns";
import { UptimeData } from "@/types/service.types";
import { getStatusInfo } from "./utils";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface IncidentTableProps {
incidents: UptimeData[];
}
export function IncidentTable({ incidents }: IncidentTableProps) {
const { theme } = useTheme();
const { t } = useLanguage();
if (incidents.length === 0) {
return null;
}
return (
<Table className={theme === 'dark' ? 'text-gray-200' : 'text-gray-700'}>
<TableHeader className={theme === 'dark' ? 'bg-gray-800/50' : 'bg-gray-50'}>
<TableRow className={theme === 'dark' ? 'border-gray-700' : 'border-gray-200'}>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{incidents.map((check, index) => {
const statusInfo = getStatusInfo(check.status);
const timestamp = new Date(check.timestamp);
return (
<TableRow key={check.id || `incident-${index}`} className={theme === 'dark' ? 'border-gray-800 hover:bg-gray-800/30' : 'border-gray-200 hover:bg-gray-50'}>
<TableCell>
<div className="flex flex-col">
<span>{format(timestamp, 'MMM dd, yyyy')}</span>
<span className={`text-xs ${theme === 'dark' ? 'text-gray-400' : 'text-gray-500'}`}>
{format(timestamp, 'h:mm a')}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{statusInfo.badge}
</div>
</TableCell>
<TableCell className={`font-mono ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
{check.status !== "paused" && check.responseTime > 0
? `${check.responseTime}ms`
: "N/A"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
@@ -0,0 +1,94 @@
import { useState, useEffect, useMemo } from "react";
import { UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { StatusFilterTabs } from "./StatusFilterTabs";
import { TablePagination } from "./TablePagination";
import { EmptyState } from "./EmptyState";
import { IncidentTable } from "./IncidentTable";
import { StatusFilter, PageSize } from "./types";
import { getStatusChangeEvents } from "./utils";
import { useTheme } from "@/contexts/ThemeContext";
export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] }) {
// Get current theme
const { theme } = useTheme();
// Filter state
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState<PageSize>("25");
// Reset to first page when filters change
useEffect(() => {
setCurrentPage(1);
}, [statusFilter, pageSize]);
// Filter incidents by status
const incidents = useMemo(() => {
const statusChanges = getStatusChangeEvents(uptimeData);
console.log(`Total status changes: ${statusChanges.length}`);
console.log(`Status types in incidents: ${[...new Set(statusChanges.map(i => i.status))].join(', ')}`);
if (statusFilter === "all") return statusChanges;
return statusChanges.filter(incident => incident.status === statusFilter);
}, [uptimeData, statusFilter]);
// Calculate pagination
const { paginatedIncidents, totalPages } = useMemo(() => {
if (pageSize === "all") {
return {
paginatedIncidents: incidents,
totalPages: 1,
};
}
const itemsPerPage = parseInt(pageSize, 10);
const pages = Math.ceil(incidents.length / itemsPerPage);
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
return {
paginatedIncidents: incidents.slice(start, end),
totalPages: Math.max(1, pages),
};
}, [incidents, currentPage, pageSize]);
// Calculate items per page for pagination display
const itemsPerPage = pageSize === "all" ? incidents.length : parseInt(pageSize, 10);
console.log(`Status Filter: ${statusFilter}, Incidents: ${incidents.length}, Includes paused: ${incidents.some(i => i.status === 'paused')}`);
return (
<Card className={`mb-6 transition-colors ${theme === 'dark' ? 'bg-card border-border' : 'bg-white border-gray-200'}`}>
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<CardTitle className="text-card-foreground">
<span>Incident History</span>
</CardTitle>
<StatusFilterTabs statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
</div>
</CardHeader>
<CardContent>
{incidents.length === 0 ? (
<EmptyState statusFilter={statusFilter} />
) : (
<>
<IncidentTable incidents={paginatedIncidents} />
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
totalItems={incidents.length}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
/>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,66 @@
import { Activity, AlertTriangle, CheckCircle, Pause, X } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusFilter } from "./types";
import { useTheme } from "@/contexts/ThemeContext";
interface StatusFilterTabsProps {
statusFilter: StatusFilter;
onStatusFilterChange: (value: StatusFilter) => void;
}
export function StatusFilterTabs({
statusFilter,
onStatusFilterChange
}: StatusFilterTabsProps) {
// Get current theme to apply appropriate styling
const { theme } = useTheme();
return (
<Tabs
value={statusFilter}
onValueChange={value => onStatusFilterChange(value as StatusFilter)}
className="w-full"
>
<TabsList className={`grid grid-cols-5 w-full max-w-md rounded-full ${
theme === 'dark' ? 'bg-secondary' : 'bg-slate-100'
}`}>
<TabsTrigger
value="all"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<Activity className="h-4 w-4" />
<span>All</span>
</TabsTrigger>
<TabsTrigger
value="up"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<CheckCircle className="h-4 w-4" />
<span>Up</span>
</TabsTrigger>
<TabsTrigger
value="down"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<X className="h-4 w-4" />
<span>Down</span>
</TabsTrigger>
<TabsTrigger
value="warning"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<AlertTriangle className="h-4 w-4" />
<span>Warning</span>
</TabsTrigger>
<TabsTrigger
value="paused"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<Pause className="h-4 w-4" />
<span>Paused</span>
</TabsTrigger>
</TabsList>
</Tabs>
);
}
@@ -0,0 +1,118 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { PageSize } from "./types";
interface TablePaginationProps {
currentPage: number;
totalPages: number;
pageSize: PageSize;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: PageSize) => void;
}
export function TablePagination({
currentPage,
totalPages,
pageSize,
totalItems,
itemsPerPage,
onPageChange,
onPageSizeChange,
}: TablePaginationProps) {
// Generate page numbers
const getPageNumbers = () => {
const pages = [];
const maxVisiblePages = 5;
const halfVisible = Math.floor(maxVisiblePages / 2);
let startPage = Math.max(1, currentPage - halfVisible);
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
return (
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<Select
value={pageSize}
onValueChange={(value) => onPageSizeChange(value as PageSize)}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder="25" />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="250">250</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{pageSize === 'all'
? `Showing all ${totalItems} items`
: `Showing ${Math.min((currentPage - 1) * itemsPerPage + 1, totalItems)}-${Math.min(currentPage * itemsPerPage, totalItems)} of ${totalItems} items`}
</span>
</div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{getPageNumbers().map((page) => (
<PaginationItem key={page}>
<PaginationLink
isActive={page === currentPage}
onClick={() => onPageChange(page)}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}
@@ -0,0 +1,11 @@
// Export components
export { LatestChecksTable } from './LatestChecksTable';
export { StatusFilterTabs } from './StatusFilterTabs';
export { TablePagination } from './TablePagination';
export { IncidentTable } from './IncidentTable';
export { EmptyState } from './EmptyState';
// Export types and utils
export * from './types';
export * from './utils';
@@ -0,0 +1,16 @@
import { UptimeData } from "@/types/service.types";
export type StatusFilter = "all" | "up" | "down" | "paused" | "warning";
export type PageSize = "10" | "25" | "50" | "100" | "250" | "all";
export interface LatestChecksTableProps {
uptimeData: UptimeData[];
}
export interface StatusInfo {
icon: JSX.Element;
text: string;
textColor: string;
badge: JSX.Element;
}
@@ -0,0 +1,89 @@
import { CheckCircle, AlertTriangle, X, Pause } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { UptimeData } from "@/types/service.types";
import { format } from "date-fns";
import { StatusInfo } from "./types";
// Get appropriate icon and style for each status
export const getStatusInfo = (status: string): StatusInfo => {
switch (status) {
case "up":
return {
icon: <CheckCircle className="h-4 w-4 text-green-500 mr-2" />,
text: "Up",
textColor: "text-green-500",
badge: <Badge variant="default" className="bg-emerald-800 text-white hover:bg-emerald-700">Up</Badge>
};
case "warning":
return {
icon: <AlertTriangle className="h-4 w-4 text-yellow-500 mr-2" />,
text: "Warning",
textColor: "text-yellow-500",
badge: <Badge variant="outline" className="bg-yellow-800/80 text-yellow-300 border-yellow-700 hover:bg-yellow-800">Warning</Badge>
};
case "paused":
return {
icon: <Pause className="h-4 w-4 text-gray-500 mr-2" />,
text: "Paused",
textColor: "text-gray-500",
badge: <Badge variant="outline" className="bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800">Paused</Badge>
};
default: // down
return {
icon: <X className="h-4 w-4 text-red-500 mr-2" />,
text: "Down",
textColor: "text-red-500",
badge: <Badge variant="destructive">Down</Badge>
};
}
};
// Filter the uptimeData to only include status changes (incidents)
export const getStatusChangeEvents = (uptimeData: UptimeData[]): UptimeData[] => {
if (!uptimeData.length) return [];
// First sort the data by timestamp (newest first for display)
const sortedData = [...uptimeData].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// We need to find all status changes, including to/from paused
const statusChanges: UptimeData[] = [];
// Always include the most recent check as a baseline
if (sortedData.length > 0) {
statusChanges.push(sortedData[0]);
}
// Compare each check with the previous one to detect status changes
for (let i = 0; i < sortedData.length - 1; i++) {
const currentCheck = sortedData[i];
const nextCheck = sortedData[i + 1]; // This is actually the "older" check
// If the status changed, add the "older" check to our incidents list
// This shows what the status changed TO
if (currentCheck.status !== nextCheck.status) {
statusChanges.push(nextCheck);
}
}
console.log(`Found ${statusChanges.length} status changes, including paused status changes: ${statusChanges.some(i => i.status === 'paused')}`);
return statusChanges;
};
// Get date range for display
export const getDateRangeDisplay = (incidents: UptimeData[]): string => {
if (!incidents.length) return "";
const sortedData = [...incidents].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
const start = format(new Date(sortedData[0].timestamp), 'MMM dd, yyyy');
const end = format(new Date(sortedData[sortedData.length - 1].timestamp), 'MMM dd, yyyy');
// Display date range if different dates
return start === end ? start : `${start} - ${end}`;
};
@@ -0,0 +1,15 @@
// Export all service components for easier imports
export * from './ServiceHeader';
export * from './ServiceStatsCards';
export * from './ResponseTimeChart';
export * from './incident-history/LatestChecksTable';
export * from './LoadingState';
export * from './ServiceNotFound';
export * from './ServiceMonitoringButton';
export * from './AddServiceDialog';
export * from './ServicesTableContainer';
export * from './ServicesTableView';
export * from './ServiceDeleteDialog';
export * from './ServiceHistoryDialog';
export * from './ServiceEditDialog';
@@ -0,0 +1,184 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { MoreHorizontal, Eye, Play, Pause, Edit, Bell, BellOff, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast";
interface ServiceRowActionsProps {
service: Service;
onViewDetail: (service: Service) => void;
onPauseResume: (service: Service) => Promise<void>;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
onMuteAlerts?: (service: Service) => Promise<void>;
}
export const ServiceRowActions = ({
service,
onViewDetail,
onPauseResume,
onEdit,
onDelete,
onMuteAlerts
}: ServiceRowActionsProps) => {
const { toast } = useToast();
// Handle pause/resume directly from dropdown
const handlePauseResume = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
if (service.status === "paused") {
// Resume monitoring
console.log(`Resuming monitoring for service ${service.id} (${service.name}) from dropdown`);
// First ensure we update the status
await serviceService.resumeMonitoring(service.id);
// Then start monitoring service (performs an immediate check)
await serviceService.startMonitoringService(service.id);
toast({
title: "Monitoring resumed",
description: `Monitoring for ${service.name} has been resumed. First check is running now.`,
});
} else {
// Pause monitoring
console.log(`Pausing monitoring for service ${service.id} (${service.name}) from dropdown`);
await serviceService.pauseMonitoring(service.id);
toast({
title: "Monitoring paused",
description: `Monitoring for ${service.name} has been paused.`,
});
}
// Call the parent handler to refresh the UI
onPauseResume(service);
} catch (error) {
console.error("Error toggling monitoring:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to change monitoring status. Please try again.",
});
}
};
// Check alerts status - check both fields for backward compatibility
const alertsMuted = service.alerts === "muted" || service.muteAlerts === true;
// Handle mute/unmute alerts
const handleMuteAlerts = async (e: React.MouseEvent) => {
e.stopPropagation();
if (onMuteAlerts) {
try {
console.log(`Attempting to ${alertsMuted ? 'unmute' : 'mute'} alerts for service ${service.id} (${service.name})`);
await onMuteAlerts(service);
} catch (error) {
console.error("Error toggling alerts:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to change alert settings. Please try again.",
});
}
}
};
return (
<div className="flex space-x-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title="More options"
className="opacity-70 hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48 bg-gray-900 border border-gray-800 text-white"
>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={(e) => {
e.stopPropagation();
onViewDetail(service);
}}
>
<Eye className="h-4 w-4" />
<span>View Detail</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={handlePauseResume}
>
{service.status === "paused" ? (
<>
<Play className="h-4 w-4" />
<span>Resume Monitoring</span>
</>
) : (
<>
<Pause className="h-4 w-4" />
<span>Pause Monitoring</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit(service);
}}
>
<Edit className="h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={handleMuteAlerts}
>
{alertsMuted ? (
<>
<Bell className="h-4 w-4" />
<span>Unmute Alerts</span>
</>
) : (
<>
<BellOff className="h-4 w-4" />
<span>Mute Alerts</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
className="flex items-center gap-2 text-red-500 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={(e) => {
e.stopPropagation();
onDelete(service);
}}
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
@@ -0,0 +1,61 @@
import React from "react";
import { BellOff } from "lucide-react";
import { Service } from "@/types/service.types";
interface ServiceRowHeaderProps {
service: Service;
}
export const ServiceRowHeader = ({ service }: ServiceRowHeaderProps) => {
// Display URL for HTTP services, hostname for others
const shouldDisplayFullUrl = service.type.toLowerCase() === "http";
let serviceSubtitle = "";
// Check alerts status - check both fields for backward compatibility
const alertsMuted = service.alerts === "muted" || service.muteAlerts === true;
if (service.url) {
try {
const url = service.url;
// If the URL doesn't start with http:// or https://, add https:// prefix
const formattedUrl = (!url.startsWith('http://') && !url.startsWith('https://'))
? `https://${url}`
: url;
try {
// Now try to parse it as a URL
const urlObj = new URL(formattedUrl);
if (shouldDisplayFullUrl) {
serviceSubtitle = formattedUrl;
} else {
serviceSubtitle = urlObj.hostname;
}
} catch (urlError) {
// If URL parsing still fails, just show the original URL
serviceSubtitle = url;
}
} catch (e) {
// If any other error occurs, just show the original URL
serviceSubtitle = service.url;
console.log("Error processing URL:", e);
}
}
return (
<div className="flex items-center gap-2">
<div>
<div className="text-base font-medium">{service.name}</div>
{service.url && (
<div className="text-sm text-gray-500 mt-1">{serviceSubtitle}</div>
)}
</div>
{/* Add a visual indicator if alerts are muted for this service */}
{alertsMuted && (
<div className="ml-1" title="Alerts muted">
<BellOff className="h-4 w-4 text-gray-400" />
</div>
)}
</div>
);
};
@@ -0,0 +1,27 @@
import React from "react";
import { AlertTriangle } from "lucide-react";
interface ServiceRowResponseTimeProps {
responseTime: number;
}
export const ServiceRowResponseTime = ({ responseTime }: ServiceRowResponseTimeProps) => {
// Determine if response time is high (≥ 1000ms)
const isResponseTimeHigh = responseTime >= 1000;
return (
<div className="font-mono text-base flex items-center gap-1.5">
{responseTime > 0 ? (
<>
<span className={isResponseTimeHigh ? "text-amber-500 font-semibold" : ""}>
{responseTime}ms
</span>
{isResponseTimeHigh && (
<AlertTriangle className="h-4 w-4 text-amber-500" />
)}
</>
) : 'N/A'}
</div>
);
};
@@ -0,0 +1,4 @@
export * from './ServiceRowActions';
export * from './ServiceRowHeader';
export * from './ServiceRowResponseTime';
@@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Settings } from "lucide-react";
import { settingsService, type GeneralSettings } from "@/services/settingsService";
import { useToast } from "@/hooks/use-toast";
const GeneralSettingsPanel = () => {
const { toast } = useToast();
const [formData, setFormData] = useState<Partial<GeneralSettings>>({});
const [isEditing, setIsEditing] = useState(false);
const { data: settings, isLoading, error, refetch } = useQuery({
queryKey: ['generalSettings'],
queryFn: settingsService.getGeneralSettings,
});
useEffect(() => {
if (settings) {
setFormData(settings);
}
}, [settings]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSave = async () => {
if (!settings?.id || !formData) return;
try {
const result = await settingsService.updateGeneralSettings(settings.id, formData);
if (result) {
toast({
title: "Settings updated",
description: "Your settings have been updated successfully.",
variant: "default",
});
refetch();
setIsEditing(false);
}
} catch (error) {
console.error("Error updating settings:", error);
toast({
title: "Update failed",
description: "There was a problem updating your settings.",
variant: "destructive",
});
}
};
if (isLoading) {
return <div className="p-4">Loading settings...</div>;
}
if (error) {
return <div className="p-4 text-red-500">Error loading settings</div>;
}
return (
<div className="p-4">
<Card>
<CardHeader>
<CardTitle>System Settings</CardTitle>
<CardDescription>
Configure your system settings and preferences
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="system_name">System Name</Label>
<Input
id="system_name"
name="system_name"
value={formData?.system_name || ''}
onChange={handleChange}
disabled={!isEditing}
placeholder="My Monitoring System"
/>
</div>
</CardContent>
<CardFooter className="flex justify-between">
{isEditing ? (
<>
<Button variant="outline" onClick={() => setIsEditing(false)}>Cancel</Button>
<Button onClick={handleSave}>Save Changes</Button>
</>
) : (
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
)}
</CardFooter>
</Card>
</div>
);
};
export default GeneralSettingsPanel;
@@ -0,0 +1,91 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Github, FileText, Twitter, MessageCircle, Code2, ServerIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/contexts/LanguageContext";
import { useTheme } from "@/contexts/ThemeContext";
import { useSystemSettings } from "@/hooks/useSystemSettings";
export const AboutSystem: React.FC = () => {
const {
t
} = useLanguage();
const {
theme
} = useTheme();
const {
systemName
} = useSystemSettings();
return <div className="space-y-6 animate-fade-in">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
<p className="text-muted-foreground text-base leading-relaxed mt-2">{t('aboutCheckCle')}</p>
</div>
<Separator />
<div className="grid gap-8 md:grid-cols-2">
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
<CardHeader className="bg-muted/50 pb-4">
<CardTitle className="flex items-center gap-2">
<ServerIcon className={`h-5 w-5 ${theme === 'dark' ? 'text-sky-400' : 'text-sky-600'}`} />
<span className="font-thin text-xl">{t('systemDescription')}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-3 pt-2">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('systemVersion')}</span>
<span className="text-foreground font-medium">{t('version')} 1.0.0</span>
</div>
<Separator className="my-1" />
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('license')}</span>
<span className="text-foreground font-medium">{t('mitLicense')}</span>
</div>
<Separator className="my-1" />
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('releasedOn')}</span>
<span className="text-foreground font-medium">May 10, 2025</span>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
<CardHeader className="bg-muted/50 pb-4">
<CardTitle className="flex items-center gap-2">
<Code2 className={`h-5 w-5 ${theme === 'dark' ? 'text-emerald-400' : 'text-emerald-600'}`} />
<span>{t('links')}</span>
</CardTitle>
<CardDescription className="font-medium text-base">{systemName || 'CheckCle'} {t('resources').toLowerCase()}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="grid grid-cols-1 gap-3">
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://github.com/operacle/checkcle", "_blank")}>
<Github className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('viewOnGithub')}</span>
</Button>
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://docs.checkcle.io", "_blank")}>
<FileText className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('viewDocumentation')}</span>
</Button>
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://x.com/tlengoss", "_blank")}>
<Twitter className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('followOnX')}</span>
</Button>
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("#", "_blank")}>
<MessageCircle className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('joinDiscord')}</span>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>;
};
export default AboutSystem;
@@ -0,0 +1,2 @@
export { default as AboutSystem } from './AboutSystem';
@@ -0,0 +1,89 @@
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { templateService } from "@/services/templateService";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCcw } from "lucide-react";
import { TemplateList } from "./TemplateList";
import { TemplateDialog } from "./TemplateDialog";
import { useToast } from "@/hooks/use-toast";
export const AlertsTemplates = () => {
const { toast } = useToast();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<string | null>(null);
const {
data: templates = [],
isLoading,
error,
refetch
} = useQuery({
queryKey: ['notification_templates'],
queryFn: templateService.getTemplates,
});
const handleAddTemplate = () => {
setEditingTemplate(null);
setIsDialogOpen(true);
};
const handleEditTemplate = (id: string) => {
setEditingTemplate(id);
setIsDialogOpen(true);
};
const handleRefresh = () => {
refetch();
toast({
title: "Refreshing",
description: "Updating template list...",
});
};
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Alert Templates</CardTitle>
<div className="flex space-x-2">
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
<RefreshCcw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button onClick={handleAddTemplate}>
<Plus className="h-4 w-4 mr-2" />
Add Template
</Button>
</div>
</CardHeader>
<CardContent>
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={handleEditTemplate}
refetchTemplates={refetch}
/>
)}
</CardContent>
<TemplateDialog
open={isDialogOpen}
templateId={editingTemplate}
onOpenChange={setIsDialogOpen}
onSuccess={() => {
refetch();
setIsDialogOpen(false);
}}
/>
</Card>
);
};
@@ -0,0 +1,123 @@
import React, { useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTemplateForm } from "./hooks/useTemplateForm";
import { BasicTemplateFields } from "./form/BasicTemplateFields";
import { MessagesTabContent } from "./form/MessagesTabContent";
import { PlaceholdersTabContent } from "./form/PlaceholdersTabContent";
import { Loader2, ChevronDown } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
interface TemplateDialogProps {
open: boolean;
templateId: string | null;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export const TemplateDialog: React.FC<TemplateDialogProps> = ({
open,
templateId,
onOpenChange,
onSuccess,
}) => {
const {
form,
isEditMode,
isLoadingTemplate,
isSubmitting,
onSubmit
} = useTemplateForm({
templateId,
open,
onOpenChange,
onSuccess
});
// For debugging purposes
useEffect(() => {
if (open) {
console.log("Template dialog opened. Edit mode:", isEditMode, "Template ID:", templateId);
// Log form values when they change
const subscription = form.watch((value) => {
console.log("Current form values:", value);
});
return () => subscription.unsubscribe();
}
}, [open, isEditMode, templateId, form]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{isEditMode ? "Edit Template" : "Add Template"}</DialogTitle>
</DialogHeader>
{isLoadingTemplate ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2">Loading template data...</span>
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 flex-1 flex flex-col">
<div className="relative flex-1">
<ScrollArea className="pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="space-y-6 pb-6 pr-4">
<BasicTemplateFields control={form.control} />
<Tabs defaultValue="messages">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="messages">Messages</TabsTrigger>
<TabsTrigger value="placeholders">Placeholders</TabsTrigger>
</TabsList>
<TabsContent value="messages" className="pt-4">
<MessagesTabContent control={form.control} />
</TabsContent>
<TabsContent value="placeholders" className="pt-4">
<PlaceholdersTabContent control={form.control} />
</TabsContent>
</Tabs>
</div>
</ScrollArea>
<div className="absolute bottom-2 right-4 text-muted-foreground opacity-60">
<ChevronDown className="h-4 w-4 animate-bounce" />
</div>
</div>
<DialogFooter className="mt-2 pt-2 border-t border-border">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || isLoadingTemplate}
className="relative"
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isSubmitting
? (isEditMode ? "Updating..." : "Creating...")
: (isEditMode ? "Update Template" : "Create Template")}
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,168 @@
import React, { useState } from "react";
import { NotificationTemplate, templateService } from "@/services/templateService";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Edit, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
interface TemplateListProps {
templates: NotificationTemplate[];
isLoading: boolean;
onEdit: (id: string) => void;
refetchTemplates: () => void;
}
export const TemplateList: React.FC<TemplateListProps> = ({
templates,
isLoading,
onEdit,
refetchTemplates,
}) => {
const { toast } = useToast();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<NotificationTemplate | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeletePrompt = (template: NotificationTemplate) => {
setTemplateToDelete(template);
setDeleteDialogOpen(true);
};
const handleDeleteTemplate = async () => {
if (!templateToDelete) return;
setIsDeleting(true);
try {
await templateService.deleteTemplate(templateToDelete.id);
toast({
title: "Template deleted",
description: `Template "${templateToDelete.name}" has been removed.`,
});
refetchTemplates();
} catch (error) {
console.error("Error deleting template:", error);
toast({
title: "Error",
description: "Failed to delete template. Please try again.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
setTemplateToDelete(null);
}
};
if (isLoading) {
return (
<div className="py-8 flex justify-center">
<div className="animate-pulse flex flex-col items-center">
<div className="h-4 bg-gray-200 rounded w-32 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-64"></div>
</div>
</div>
);
}
if (templates.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground mb-2">No templates found</p>
<p className="text-sm text-muted-foreground">
Create your first notification template to get started.
</p>
</div>
);
}
return (
<>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium">{template.name}</TableCell>
<TableCell>
<Badge variant="outline">{template.type}</Badge>
</TableCell>
<TableCell>
{new Date(template.created).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
size="sm"
variant="ghost"
onClick={() => onEdit(template.id)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeletePrompt(template)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Template</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the template "{templateToDelete?.name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDeleteTemplate();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
@@ -0,0 +1,61 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Control } from "react-hook-form";
interface BasicTemplateFieldsProps {
control: Control<any>;
}
export const BasicTemplateFields: React.FC<BasicTemplateFieldsProps> = ({ control }) => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Template Name</FormLabel>
<FormControl>
<Input
placeholder="Enter template name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Template Type</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select template type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="service">Service</SelectItem>
<SelectItem value="incident">Incident</SelectItem>
<SelectItem value="maintenance">Maintenance</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,124 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Control } from "react-hook-form";
import { Card, CardContent } from "@/components/ui/card";
interface MessagesTabContentProps {
control: Control<any>;
}
export const MessagesTabContent: React.FC<MessagesTabContentProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<FormField
control={control}
name="up_message"
render={({ field }) => (
<FormItem>
<FormLabel>Up Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is UP. Response time: ${response_time}ms"
className="min-h-24"
{...field}
/>
</FormControl>
<FormDescription>
Message sent when a service returns to UP status
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="down_message"
render={({ field }) => (
<FormItem>
<FormLabel>Down Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is DOWN. Status: ${status}"
className="min-h-24"
{...field}
/>
</FormControl>
<FormDescription>
Message sent when a service goes DOWN
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={control}
name="maintenance_message"
render={({ field }) => (
<FormItem>
<FormLabel>Maintenance Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is under maintenance"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="incident_message"
render={({ field }) => (
<FormItem>
<FormLabel>Incident Message</FormLabel>
<FormControl>
<Textarea
placeholder="Warning: Service ${service_name} has an incident"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="resolved_message"
render={({ field }) => (
<FormItem>
<FormLabel>Resolved Message</FormLabel>
<FormControl>
<Textarea
placeholder="Issue with service ${service_name} has been resolved"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,132 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface PlaceholdersTabContentProps {
control: Control<any>;
}
export const PlaceholdersTabContent: React.FC<PlaceholdersTabContentProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Template Placeholders</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="service_name_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Service Name Placeholder</FormLabel>
<FormControl>
<Input placeholder="${service_name}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service name in messages
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="response_time_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Response Time Placeholder</FormLabel>
<FormControl>
<Input placeholder="${response_time}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for response time in milliseconds
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="status_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Status Placeholder</FormLabel>
<FormControl>
<Input placeholder="${status}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service status (UP, DOWN, etc.)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="threshold_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Threshold Placeholder</FormLabel>
<FormControl>
<Input placeholder="${threshold}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for threshold values in alerts
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Placeholder Usage Guide</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
These placeholders will be replaced with actual values when notifications are sent:
</p>
<div className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-2">
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{service_name}"}</code>
<p className="text-xs text-muted-foreground mt-1">The name of the service</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{response_time}"}</code>
<p className="text-xs text-muted-foreground mt-1">Response time in milliseconds</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{status}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service status (UP, DOWN)</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{threshold}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service threshold value</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{url}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service URL</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{time}"}</code>
<p className="text-xs text-muted-foreground mt-1">Current date and time</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,4 @@
export * from './BasicTemplateFields';
export * from './MessagesTabContent';
export * from './PlaceholdersTabContent';
@@ -0,0 +1,2 @@
export * from './useTemplateForm';
@@ -0,0 +1,199 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useToast } from "@/hooks/use-toast";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { templateService, CreateUpdateTemplateData } from "@/services/templateService";
import { useEffect } from "react";
// Template form schema
export const templateFormSchema = z.object({
name: z.string().min(2, "Name is required and must be at least 2 characters"),
type: z.string().min(1, "Type is required"),
up_message: z.string().min(1, "Up message is required"),
down_message: z.string().min(1, "Down message is required"),
maintenance_message: z.string().min(1, "Maintenance message is required"),
incident_message: z.string().min(1, "Incident message is required"),
resolved_message: z.string().min(1, "Resolved message is required"),
service_name_placeholder: z.string().min(1, "Service name placeholder is required"),
response_time_placeholder: z.string().min(1, "Response time placeholder is required"),
status_placeholder: z.string().min(1, "Status placeholder is required"),
threshold_placeholder: z.string().min(1, "Threshold placeholder is required"),
});
// Define the form data type from the schema
export type TemplateFormData = z.infer<typeof templateFormSchema>;
// Default form values
const defaultFormValues: TemplateFormData = {
name: "",
type: "default",
up_message: "Service ${service_name} is UP. Response time: ${response_time}ms",
down_message: "Service ${service_name} is DOWN. Status: ${status}",
maintenance_message: "Service ${service_name} is under maintenance",
incident_message: "Service ${service_name} has an incident",
resolved_message: "Service ${service_name} issue has been resolved",
service_name_placeholder: "${service_name}",
response_time_placeholder: "${response_time}",
status_placeholder: "${status}",
threshold_placeholder: "${threshold}",
};
export interface UseTemplateFormProps {
templateId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: UseTemplateFormProps) => {
const { toast } = useToast();
const queryClient = useQueryClient();
const isEditMode = !!templateId;
console.log("Template form initialized with templateId:", templateId, "isEditMode:", isEditMode);
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateFormSchema),
defaultValues: defaultFormValues,
mode: "onChange"
});
// Query to fetch template data for editing
const { isLoading: isLoadingTemplate, data: templateData } = useQuery({
queryKey: ['template', templateId],
queryFn: () => {
if (!templateId) return null;
console.log("Fetching template data for ID:", templateId);
return templateService.getTemplate(templateId);
},
enabled: !!templateId && open,
});
// Set form values when template data is loaded
useEffect(() => {
if (templateData && open) {
console.log("Setting form values with template data:", templateData);
form.reset({
name: templateData.name || "",
type: templateData.type || "default",
up_message: templateData.up_message || "",
down_message: templateData.down_message || "",
maintenance_message: templateData.maintenance_message || "",
incident_message: templateData.incident_message || "",
resolved_message: templateData.resolved_message || "",
service_name_placeholder: templateData.service_name_placeholder || "${service_name}",
response_time_placeholder: templateData.response_time_placeholder || "${response_time}",
status_placeholder: templateData.status_placeholder || "${status}",
threshold_placeholder: templateData.threshold_placeholder || "${threshold}",
});
}
}, [templateData, open, form]);
// Handle form errors
useEffect(() => {
const subscription = form.formState.errors.root?.message &&
toast({
title: "Error",
description: form.formState.errors.root.message,
variant: "destructive",
});
return () => {
if (subscription) {
// Clean up if needed
}
};
}, [form.formState.errors.root, toast]);
// Create mutation
const createMutation = useMutation({
mutationFn: (data: CreateUpdateTemplateData) => templateService.createTemplate(data),
onSuccess: () => {
toast({
title: "Template created",
description: "Your notification template has been created successfully.",
});
queryClient.invalidateQueries({ queryKey: ['notification_templates'] });
onSuccess();
},
onError: (error) => {
console.error("Error creating template:", error);
toast({
title: "Error",
description: "Failed to create template. Please check your inputs and try again.",
variant: "destructive",
});
},
});
// Update mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: CreateUpdateTemplateData }) =>
templateService.updateTemplate(id, data),
onSuccess: () => {
toast({
title: "Template updated",
description: "Your notification template has been updated successfully.",
});
queryClient.invalidateQueries({ queryKey: ['notification_templates'] });
onSuccess();
},
onError: (error) => {
console.error("Error updating template:", error);
toast({
title: "Error",
description: "Failed to update template. Please check your inputs and try again.",
variant: "destructive",
});
},
});
const isSubmitting = createMutation.isPending || updateMutation.isPending;
// Handle form submission
const onSubmit = (formData: TemplateFormData) => {
console.log("Submitting form data:", formData);
// Ensure all required fields are present
const completeData: CreateUpdateTemplateData = {
name: formData.name,
type: formData.type,
up_message: formData.up_message,
down_message: formData.down_message,
maintenance_message: formData.maintenance_message,
incident_message: formData.incident_message,
resolved_message: formData.resolved_message,
service_name_placeholder: formData.service_name_placeholder,
response_time_placeholder: formData.response_time_placeholder,
status_placeholder: formData.status_placeholder,
threshold_placeholder: formData.threshold_placeholder,
};
if (isEditMode && templateId) {
console.log("Updating template with ID:", templateId);
updateMutation.mutate({ id: templateId, data: completeData });
} else {
console.log("Creating new template");
createMutation.mutate(completeData);
}
};
// Reset form when dialog closes
useEffect(() => {
if (!open) {
console.log("Dialog closed, resetting form");
form.reset(defaultFormValues);
}
}, [open, form]);
return {
form,
isEditMode,
isLoadingTemplate,
isSubmitting,
onSubmit
};
};
@@ -0,0 +1,2 @@
export { AlertsTemplates } from "./AlertsTemplates";
@@ -0,0 +1,349 @@
import React, { useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Loader2 } from "lucide-react";
interface NotificationChannelDialogProps {
open: boolean;
onClose: (refreshList: boolean) => void;
editingConfig: AlertConfiguration | null;
}
const baseSchema = z.object({
notify_name: z.string().min(1, "Name is required"),
notification_type: z.enum(["telegram", "discord", "slack", "signal", "email"]),
enabled: z.boolean().default(true),
service_id: z.string().default("global"), // Assuming global for now, could be linked to specific services
template_id: z.string().optional(),
});
const telegramSchema = baseSchema.extend({
notification_type: z.literal("telegram"),
telegram_chat_id: z.string().min(1, "Chat ID is required"),
bot_token: z.string().min(1, "Bot token is required"),
});
const discordSchema = baseSchema.extend({
notification_type: z.literal("discord"),
discord_webhook_url: z.string().url("Must be a valid URL"),
});
const slackSchema = baseSchema.extend({
notification_type: z.literal("slack"),
slack_webhook_url: z.string().url("Must be a valid URL"),
});
const signalSchema = baseSchema.extend({
notification_type: z.literal("signal"),
signal_number: z.string().min(1, "Signal number is required"),
});
const emailSchema = baseSchema.extend({
notification_type: z.literal("email"),
// Email specific fields could be added here
});
const formSchema = z.discriminatedUnion("notification_type", [
telegramSchema,
discordSchema,
slackSchema,
signalSchema,
emailSchema
]);
type FormValues = z.infer<typeof formSchema>;
export const NotificationChannelDialog = ({
open,
onClose,
editingConfig
}: NotificationChannelDialogProps) => {
const isEditing = !!editingConfig;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
notify_name: "",
notification_type: "telegram" as const,
enabled: true,
service_id: "global",
template_id: "",
},
});
const { watch, reset } = form;
const notificationType = watch("notification_type");
const [isSubmitting, setIsSubmitting] = React.useState(false);
useEffect(() => {
if (editingConfig) {
// Handle string vs boolean for enabled field
const enabled = typeof editingConfig.enabled === 'string'
? editingConfig.enabled === "true"
: !!editingConfig.enabled;
reset({
...editingConfig,
enabled
});
} else if (open) {
reset({
notify_name: "",
notification_type: "telegram" as const,
enabled: true,
service_id: "global",
template_id: "",
});
}
}, [editingConfig, open, reset]);
const handleClose = () => {
onClose(false);
};
const onSubmit = async (values: FormValues) => {
setIsSubmitting(true);
try {
// Ensure service_id is always present
const configData = {
...values,
service_id: values.service_id || "global",
};
if (isEditing && editingConfig?.id) {
await alertConfigService.updateAlertConfiguration(editingConfig.id, configData);
} else {
await alertConfigService.createAlertConfiguration(configData as any);
}
onClose(true); // Close with refresh
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={() => handleClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{isEditing ? "Edit Notification Channel" : "Add Notification Channel"}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="notify_name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My Notification Channel" {...field} />
</FormControl>
<FormDescription>
A name to identify this notification channel
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notification_type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Channel Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="telegram" />
</FormControl>
<FormLabel className="font-normal">
Telegram
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="discord" />
</FormControl>
<FormLabel className="font-normal">
Discord
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="slack" />
</FormControl>
<FormLabel className="font-normal">
Slack
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="signal" />
</FormControl>
<FormLabel className="font-normal">
Signal
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="email" />
</FormControl>
<FormLabel className="font-normal">
Email
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Show different fields based on notification type */}
{notificationType === "telegram" && (
<>
<FormField
control={form.control}
name="telegram_chat_id"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input placeholder="Telegram Chat ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<Input placeholder="Telegram Bot Token" {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "discord" && (
<FormField
control={form.control}
name="discord_webhook_url"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input placeholder="Discord Webhook URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{notificationType === "slack" && (
<FormField
control={form.control}
name="slack_webhook_url"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input placeholder="Slack Webhook URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{notificationType === "signal" && (
<FormField
control={form.control}
name="signal_number"
render={({ field }) => (
<FormItem>
<FormLabel>Signal Number</FormLabel>
<FormControl>
<Input placeholder="+1234567890" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Turn notifications on or off
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button variant="outline" type="button" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,122 @@
import { AlertConfiguration } from "@/services/alertConfigService";
import { Bell, Edit, Trash2 } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { alertConfigService } from "@/services/alertConfigService";
interface NotificationChannelListProps {
channels: AlertConfiguration[];
onEdit: (config: AlertConfiguration) => void;
onDelete: (id: string) => void;
}
export const NotificationChannelList = ({
channels,
onEdit,
onDelete
}: NotificationChannelListProps) => {
const toggleEnabled = async (config: AlertConfiguration) => {
if (!config.id) return;
await alertConfigService.updateAlertConfiguration(config.id, {
enabled: !config.enabled
});
// The parent component will refresh the list
onEdit(config);
};
const getChannelTypeLabel = (type: string) => {
switch(type) {
case "telegram": return "Telegram";
case "discord": return "Discord";
case "slack": return "Slack";
case "signal": return "Signal";
case "email": return "Email";
default: return type;
}
};
if (channels.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-10 text-center">
<Bell className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No notification channels configured</h3>
<p className="text-muted-foreground mt-2">
Add a notification channel to get alerts when your services go down.
</p>
</div>
);
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channels.map((channel) => (
<TableRow key={channel.id}>
<TableCell className="font-medium">{channel.notify_name}</TableCell>
<TableCell>
<Badge variant="outline">{getChannelTypeLabel(channel.notification_type)}</Badge>
</TableCell>
<TableCell>
<Switch
checked={
typeof channel.enabled === 'string'
? channel.enabled === "true"
: !!channel.enabled
}
onCheckedChange={() => toggleEnabled(channel)}
/>
</TableCell>
<TableCell>
{channel.created ? new Date(channel.created).toLocaleDateString() : "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onEdit(channel)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
if (channel.id && confirm("Are you sure you want to delete this notification channel?")) {
onDelete(channel.id)
}
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
@@ -0,0 +1,117 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Plus, Loader2 } from "lucide-react";
import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService";
import { NotificationChannelDialog } from "./NotificationChannelDialog";
import { NotificationChannelList } from "./NotificationChannelList";
const NotificationSettings = () => {
const [isLoading, setIsLoading] = useState(true);
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [currentTab, setCurrentTab] = useState<string>("all");
const [editingConfig, setEditingConfig] = useState<AlertConfiguration | null>(null);
const fetchAlertConfigurations = async () => {
setIsLoading(true);
try {
const configs = await alertConfigService.getAlertConfigurations();
setAlertConfigs(configs);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchAlertConfigurations();
}, []);
const handleAddNew = () => {
setEditingConfig(null);
setDialogOpen(true);
};
const handleEdit = (config: AlertConfiguration) => {
setEditingConfig(config);
setDialogOpen(true);
};
const handleDelete = async (id: string) => {
const success = await alertConfigService.deleteAlertConfiguration(id);
if (success) {
fetchAlertConfigurations();
}
};
const handleDialogClose = (refreshList: boolean) => {
setDialogOpen(false);
if (refreshList) {
fetchAlertConfigurations();
}
};
const getFilteredConfigs = () => {
if (currentTab === "all") return alertConfigs;
return alertConfigs.filter(config => config.notification_type === currentTab);
};
return (
<Card className="w-full">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Notification Settings</CardTitle>
<CardDescription>
Configure notification channels for your services
</CardDescription>
</div>
<Button onClick={handleAddNew}>
<Plus className="mr-2 h-4 w-4" /> Add Channel
</Button>
</div>
</CardHeader>
<CardContent>
<Tabs
defaultValue="all"
value={currentTab}
onValueChange={setCurrentTab}
className="w-full"
>
<TabsList className="mb-4">
<TabsTrigger value="all">All Channels</TabsTrigger>
<TabsTrigger value="telegram">Telegram</TabsTrigger>
<TabsTrigger value="discord">Discord</TabsTrigger>
<TabsTrigger value="slack">Slack</TabsTrigger>
<TabsTrigger value="signal">Signal</TabsTrigger>
<TabsTrigger value="email">Email</TabsTrigger>
</TabsList>
<TabsContent value={currentTab} className="mt-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<NotificationChannelList
channels={getFilteredConfigs()}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
</TabsContent>
</Tabs>
</CardContent>
<NotificationChannelDialog
open={dialogOpen}
onClose={handleDialogClose}
editingConfig={editingConfig}
/>
</Card>
);
};
export default NotificationSettings;
@@ -0,0 +1,4 @@
import NotificationSettings from "./NotificationSettings";
export { NotificationSettings };
@@ -0,0 +1,46 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { UseFormReturn } from "react-hook-form";
import AddUserForm from "./form-fields/AddUserForm";
interface AddUserDialogProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
form: UseFormReturn<any>;
onSubmit: (data: any) => void;
isSubmitting: boolean;
}
const AddUserDialog = ({ isOpen, setIsOpen, form, onSubmit, isSubmitting }: AddUserDialogProps) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[700px] w-[95vw]">
<DialogHeader>
<DialogTitle>Add New User</DialogTitle>
<DialogDescription>
Create a new user account
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[60vh]">
<div className="p-4">
<AddUserForm
form={form}
onSubmit={onSubmit}
isSubmitting={isSubmitting}
/>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
export default AddUserDialog;
@@ -0,0 +1,81 @@
import React, { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { User } from "@/services/userService";
import { Loader2 } from "lucide-react";
interface DeleteUserDialogProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
user: User | null;
onDelete: () => void;
isDeleting?: boolean;
}
const DeleteUserDialog = ({
isOpen,
setIsOpen,
user,
onDelete,
isDeleting = false,
}: DeleteUserDialogProps) => {
if (!user) return null;
const handleCancel = () => {
if (!isDeleting) {
setIsOpen(false);
}
};
const handleConfirm = () => {
if (!isDeleting) {
onDelete();
}
};
return (
<AlertDialog open={isOpen} onOpenChange={(newOpen) => {
// Only allow closing if not currently deleting
if (!isDeleting || !newOpen) {
setIsOpen(newOpen);
}
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete user</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {user.full_name || user.username}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel} disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default DeleteUserDialog;
@@ -0,0 +1,124 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { UseFormReturn } from "react-hook-form";
import { User } from "@/services/userService";
import { Loader2, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import UserTextField from "./form-fields/UserTextField";
import UserToggleField from "./form-fields/UserToggleField";
import UserProfilePictureField from "./form-fields/UserProfilePictureField";
interface EditUserDialogProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
form: UseFormReturn<any>;
user: User | null;
onSubmit: (data: any) => void;
isSubmitting?: boolean;
error?: string | null;
}
const EditUserDialog = ({
isOpen,
setIsOpen,
form,
user,
onSubmit,
isSubmitting = false,
error = null
}: EditUserDialogProps) => {
if (!user) return null;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[700px] w-[95vw]">
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>
Update user information
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[60vh]">
<div className="p-4">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<UserProfilePictureField control={form.control} />
<div className="grid grid-cols-2 gap-4">
<UserTextField
control={form.control}
name="full_name"
label="Full Name"
placeholder="Enter full name"
/>
<UserTextField
control={form.control}
name="email"
label="Email"
placeholder="Enter email"
type="email"
/>
<UserTextField
control={form.control}
name="username"
label="Username"
placeholder="Enter username"
/>
<UserTextField
control={form.control}
name="role"
label="Role"
placeholder="Enter role (e.g. admin, user)"
/>
</div>
<UserToggleField
control={form.control}
name="isActive"
label="Active Status"
description="User will be able to access the system"
/>
<DialogFooter className="pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
</>
) : (
"Update User"
)}
</Button>
</DialogFooter>
</form>
</Form>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
export default EditUserDialog;
@@ -0,0 +1,117 @@
import React from "react";
import UserTable from "./UserTable";
import AddUserDialog from "./AddUserDialog";
import EditUserDialog from "./EditUserDialog";
import DeleteUserDialog from "./DeleteUserDialog";
import { useUserManagement } from "./hooks";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from "@/components/ui/accordion";
import { UserCog, Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
const UserManagement = () => {
const {
users,
loading,
error,
newUserForm,
isAddUserDialogOpen,
setIsAddUserDialogOpen,
isDialogOpen,
setIsDialogOpen,
isDeleting,
setIsDeleting,
isSubmitting,
updateError,
form,
currentUser,
userToDelete,
handleEditUser,
handleDeletePrompt,
handleDeleteUser,
onSubmit,
onAddUser,
fetchUsers
} = useUserManagement();
return (
<div className="space-y-4">
<Accordion type="single" collapsible className="w-full" defaultValue="user-management">
<AccordionItem value="user-management">
<AccordionTrigger className="py-4 px-5 bg-card hover:bg-card/90 hover:no-underline rounded-lg text-lg font-medium flex items-center w-full">
<div className="flex items-center">
<UserCog className="h-5 w-5 mr-2 text-green-500" />
<span>User Management</span>
</div>
</AccordionTrigger>
<AccordionContent className="p-4 pt-6 bg-background rounded-b-lg">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">User Management</h2>
<button
onClick={() => setIsAddUserDialogOpen(true)}
className="bg-green-500 hover:bg-green-600 text-white py-2 px-4 rounded-md flex items-center"
>
<span className="mr-1">+</span> Add User
</button>
</div>
{loading ? (
<div className="p-8 flex flex-col items-center justify-center text-center">
<Loader2 className="h-8 w-8 animate-spin mb-2 text-primary" />
<p className="text-muted-foreground">Loading users...</p>
</div>
) : error ? (
<div className="p-8 flex flex-col items-center justify-center text-center">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<p className="text-destructive font-medium mb-2">Failed to load users</p>
<p className="text-muted-foreground mb-4">{error}</p>
<Button onClick={fetchUsers} variant="outline">
Retry
</Button>
</div>
) : (
<UserTable
users={users}
onUserUpdate={handleEditUser}
onUserDelete={handleDeletePrompt}
/>
)}
<AddUserDialog
isOpen={isAddUserDialogOpen}
setIsOpen={setIsAddUserDialogOpen}
form={newUserForm}
onSubmit={onAddUser}
isSubmitting={isSubmitting}
/>
<EditUserDialog
isOpen={isDialogOpen}
setIsOpen={setIsDialogOpen}
form={form}
user={currentUser}
onSubmit={onSubmit}
isSubmitting={isSubmitting}
error={updateError}
/>
<DeleteUserDialog
isOpen={isDeleting}
setIsOpen={setIsDeleting}
user={userToDelete}
onDelete={handleDeleteUser}
isDeleting={isSubmitting}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};
export default UserManagement;
@@ -0,0 +1,112 @@
import React from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Edit, Trash2 } from "lucide-react";
import { User } from "@/services/userService";
export interface UserTableProps {
users: User[];
onUserUpdate: (user: User) => void;
onUserDelete: (user: User) => void;
}
const UserTable = ({ users, onUserUpdate, onUserDelete }: UserTableProps) => {
// Helper function to get the user's initials for the avatar fallback
const getUserInitials = (user: User): string => {
return (user.full_name || user.username || "").substring(0, 2).toUpperCase();
};
return (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
No users found
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
{user.avatar ? (
<AvatarImage
src={user.avatar}
alt={user.full_name || user.username}
/>
) : (
<AvatarFallback>
{getUserInitials(user)}
</AvatarFallback>
)}
</Avatar>
<span>{user.full_name || "-"}</span>
</div>
</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.role || "user"}</TableCell>
<TableCell>
{user.isActive !== false ? (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
Active
</Badge>
) : (
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
Inactive
</Badge>
)}
</TableCell>
<TableCell className="text-right space-x-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onUserUpdate(user)}
>
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => onUserDelete(user)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
};
export default UserTable;
@@ -0,0 +1,13 @@
export const avatarOptions = [
{ url: "https://api.dicebear.com/7.x/bottts/svg?seed=Felix", label: "Avatar 1" },
{ url: "https://api.dicebear.com/7.x/bottts/svg?seed=Aneka", label: "Avatar 2" },
{ url: "https://api.dicebear.com/7.x/bottts/svg?seed=Milo", label: "Avatar 3" },
{ url: "https://api.dicebear.com/7.x/fun-emoji/svg?seed=Cuddles", label: "Avatar 4" },
{ url: "https://api.dicebear.com/7.x/fun-emoji/svg?seed=Bella", label: "Avatar 5" },
{ url: "https://api.dicebear.com/7.x/fun-emoji/svg?seed=Shadow", label: "Avatar 6" },
{ url: "https://api.dicebear.com/7.x/lorelei/svg?seed=Jasper", label: "Avatar 7" },
{ url: "https://api.dicebear.com/7.x/lorelei/svg?seed=Willow", label: "Avatar 8" },
{ url: "https://api.dicebear.com/7.x/notionists/svg?seed=Mittens", label: "Avatar 9" },
{ url: "https://api.dicebear.com/7.x/notionists/svg?seed=Oliver", label: "Avatar 10" },
];
@@ -0,0 +1,95 @@
import React from "react";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { UseFormReturn } from "react-hook-form";
import UserProfilePictureField from "./UserProfilePictureField";
import UserTextField from "./UserTextField";
import UserToggleField from "./UserToggleField";
import { DialogFooter } from "@/components/ui/dialog";
interface AddUserFormProps {
form: UseFormReturn<any>;
onSubmit: (data: any) => void;
isSubmitting: boolean;
}
const AddUserForm = ({ form, onSubmit, isSubmitting }: AddUserFormProps) => {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<UserProfilePictureField control={form.control} />
<div className="grid grid-cols-2 gap-4">
<UserTextField
control={form.control}
name="full_name"
label="Full Name"
placeholder="Enter full name"
/>
<UserTextField
control={form.control}
name="email"
label="Email"
placeholder="Enter email"
type="email"
/>
<UserTextField
control={form.control}
name="username"
label="Username"
placeholder="Enter username"
/>
<UserTextField
control={form.control}
name="role"
label="Role"
placeholder="Enter role (e.g. admin, user)"
/>
<UserTextField
control={form.control}
name="password"
label="Password"
placeholder="Enter password"
type="password"
/>
<UserTextField
control={form.control}
name="passwordConfirm"
label="Confirm Password"
placeholder="Confirm password"
type="password"
/>
</div>
<UserToggleField
control={form.control}
name="isActive"
label="Active Status"
description="User will be able to access the system"
/>
<DialogFooter className="pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create User"
)}
</Button>
</DialogFooter>
</form>
</Form>
);
};
export default AddUserForm;
@@ -0,0 +1,76 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Control } from "react-hook-form";
// Custom interface for local profile images
interface ProfileImage {
url: string;
label: string;
}
// Define local profile image paths - include all uploaded SVG images
const localProfileImages: ProfileImage[] = [
{ url: "/upload/profile/profile1.svg", label: "Profile 1" },
{ url: "/upload/profile/profile2.svg", label: "Profile 2" },
{ url: "/upload/profile/profile3.svg", label: "Profile 3" },
{ url: "/upload/profile/profile4.svg", label: "Profile 4" },
{ url: "/upload/profile/profile5.svg", label: "Profile 5" },
{ url: "/upload/profile/profile6.svg", label: "Profile 6" },
{ url: "/upload/profile/profile7.svg", label: "Profile 7" },
{ url: "/upload/profile/profile8.svg", label: "Profile 8" }
];
interface UserProfilePictureFieldProps {
control: Control<any>;
}
const UserProfilePictureField = ({ control }: UserProfilePictureFieldProps) => {
return (
<FormField
control={control}
name="avatar"
render={({ field }) => (
<FormItem>
<FormLabel>Profile Picture</FormLabel>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid grid-cols-3 gap-4"
>
{localProfileImages.map((avatar) => (
<FormItem key={avatar.url} className="flex flex-col items-center justify-center space-y-1">
<FormControl>
<RadioGroupItem
value={avatar.url}
id={`new-${avatar.url}`}
className="sr-only"
/>
</FormControl>
<label
htmlFor={`new-${avatar.url}`}
className={`cursor-pointer rounded-md p-1 ${
field.value === avatar.url
? "ring-2 ring-primary ring-offset-2"
: ""
}`}
>
<Avatar className="h-16 w-16">
<AvatarImage src={avatar.url} alt={avatar.label} />
<AvatarFallback>?</AvatarFallback>
</Avatar>
</label>
<div className="text-xs text-center">{avatar.label}</div>
</FormItem>
))}
</RadioGroup>
<FormMessage />
</FormItem>
)}
/>
);
};
export default UserProfilePictureField;
@@ -0,0 +1,54 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Control } from "react-hook-form";
interface UserTextFieldProps {
control: Control<any>;
name: string;
label: string;
placeholder: string;
type?: string;
required?: boolean;
}
const UserTextField = ({
control,
name,
label,
placeholder,
type = "text",
required = false
}: UserTextFieldProps) => {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>
{label}
{required && <span className="text-destructive ml-1">*</span>}
</FormLabel>
<FormControl>
<Input
type={type}
placeholder={placeholder}
{...field}
value={field.value || ''}
onChange={e => {
// Handle the value change properly
const value = e.target.value;
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export default UserTextField;
@@ -0,0 +1,39 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Control } from "react-hook-form";
interface UserToggleFieldProps {
control: Control<any>;
name: string;
label: string;
description: string;
}
const UserToggleField = ({ control, name, label, description }: UserToggleFieldProps) => {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{label}</FormLabel>
<FormDescription>
{description}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
);
};
export default UserToggleField;
@@ -0,0 +1,5 @@
export { default as UserProfilePictureField } from './UserProfilePictureField';
export { default as UserTextField } from './UserTextField';
export { default as UserToggleField } from './UserToggleField';
export { default as AddUserForm } from './AddUserForm';
@@ -0,0 +1,6 @@
export * from './useUserManagement';
export * from './useUsersList';
export * from './useUserForm';
export * from './useUserDialogs';
export * from './useUserOperations';
@@ -0,0 +1,51 @@
import { useState } from "react";
import { User } from "@/services/userService";
export const useUserDialogs = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isAddUserDialogOpen, setIsAddUserDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleEditUser = (user: User, form: any) => {
setUpdateError(null);
setCurrentUser(user);
form.reset({
full_name: user.full_name || "",
email: user.email,
username: user.username,
isActive: user.isActive !== undefined ? user.isActive : true,
role: user.role || "user",
avatar: user.avatar || "",
});
setIsDialogOpen(true);
};
const handleDeletePrompt = (user: User) => {
setUserToDelete(user);
setIsDeleting(true);
};
return {
isDialogOpen,
setIsDialogOpen,
isAddUserDialogOpen,
setIsAddUserDialogOpen,
isDeleting,
setIsDeleting,
isSubmitting,
setIsSubmitting,
updateError,
setUpdateError,
currentUser,
setCurrentUser,
userToDelete,
setUserToDelete,
handleEditUser,
handleDeletePrompt,
};
};
@@ -0,0 +1,35 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { userFormSchema, newUserFormSchema, UserFormValues, NewUserFormValues } from "../userForms";
import { avatarOptions } from "../avatarOptions";
export const useUserForm = () => {
const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: {
full_name: "",
email: "",
username: "",
isActive: true,
role: "user",
avatar: "",
},
});
const newUserForm = useForm<NewUserFormValues>({
resolver: zodResolver(newUserFormSchema),
defaultValues: {
full_name: "",
email: "",
username: "",
password: "",
passwordConfirm: "",
isActive: true,
role: "user",
avatar: avatarOptions[0].url,
},
});
return { form, newUserForm };
};
@@ -0,0 +1,86 @@
import { useUsersList } from "./useUsersList";
import { useUserForm } from "./useUserForm";
import { useUserDialogs } from "./useUserDialogs";
import { useUserOperations } from "./useUserOperations";
import { User } from "@/services/userService";
import { UserFormValues, NewUserFormValues } from "../userForms";
export const useUserManagement = () => {
const { users, loading, error, fetchUsers } = useUsersList();
const { form, newUserForm } = useUserForm();
const {
isDialogOpen,
setIsDialogOpen,
isAddUserDialogOpen,
setIsAddUserDialogOpen,
isDeleting,
setIsDeleting,
isSubmitting,
setIsSubmitting,
updateError,
setUpdateError,
currentUser,
setCurrentUser,
userToDelete,
setUserToDelete,
handleEditUser: baseHandleEditUser,
handleDeletePrompt,
} = useUserDialogs();
const {
handleDeleteUser: baseHandleDeleteUser,
onSubmit: baseOnSubmit,
onAddUser: baseOnAddUser,
} = useUserOperations(
fetchUsers,
setIsDialogOpen,
setIsAddUserDialogOpen,
setIsSubmitting,
setIsDeleting,
setUpdateError,
newUserForm.reset
);
// Wrapper functions to provide the needed arguments
const handleEditUser = (user: User) => {
baseHandleEditUser(user, form);
};
const handleDeleteUser = async () => {
await baseHandleDeleteUser(userToDelete);
setUserToDelete(null);
};
const onSubmit = (data: UserFormValues) => {
baseOnSubmit(data, currentUser);
};
const onAddUser = (data: NewUserFormValues) => {
baseOnAddUser(data);
};
return {
users,
loading,
error,
isDialogOpen,
setIsDialogOpen,
isAddUserDialogOpen,
setIsAddUserDialogOpen,
isDeleting,
setIsDeleting,
isSubmitting,
updateError,
form,
newUserForm,
currentUser,
userToDelete,
fetchUsers,
handleEditUser,
handleDeletePrompt,
handleDeleteUser,
onSubmit,
onAddUser,
};
};
@@ -0,0 +1,158 @@
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { userService, User, UpdateUserData, CreateUserData } from "@/services/userService";
import { UserFormValues, NewUserFormValues } from "../userForms";
import { avatarOptions } from "../avatarOptions";
export const useUserOperations = (
fetchUsers: () => Promise<void>,
setIsDialogOpen: (isOpen: boolean) => void,
setIsAddUserDialogOpen: (isOpen: boolean) => void,
setIsSubmitting: (isSubmitting: boolean) => void,
setIsDeleting: (isDeleting: boolean) => void,
setUpdateError: (error: string | null) => void,
newUserFormReset: (values: any) => void
) => {
const { toast } = useToast();
const handleDeleteUser = async (userToDelete: User | null) => {
if (!userToDelete) return;
try {
const success = await userService.deleteUser(userToDelete.id);
if (success) {
toast({
title: "User deleted",
description: `${userToDelete.full_name || userToDelete.username} has been deleted.`,
});
fetchUsers();
} else {
throw new Error("Failed to delete user");
}
} catch (error) {
toast({
title: "Error",
description: "Could not delete user. Please try again later.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
const onSubmit = async (data: UserFormValues, currentUser: User | null) => {
if (!currentUser) return;
setIsSubmitting(true);
setUpdateError(null);
try {
// Create update object with only the fields we want to update
const updateData: UpdateUserData = {
full_name: data.full_name,
email: data.email,
username: data.username,
role: data.role,
isActive: data.isActive,
};
// For avatar, only include if it's different from current one
// Note: We're still sending the avatar path, but our updated userService will handle it properly
if (data.avatar && data.avatar !== currentUser.avatar) {
updateData.avatar = data.avatar;
}
console.log("Submitting user update with data:", updateData);
await userService.updateUser(currentUser.id, updateData);
// After successful update, refresh the auth user data if this is the current user
// In a real app, you'd check if the updated user is the current logged-in user
toast({
title: "User updated",
description: `${data.full_name || data.username}'s profile has been updated.`,
});
setIsDialogOpen(false);
await fetchUsers();
} catch (error: any) {
console.error("Error updating user:", error);
let errorMessage = "Failed to update user. Please check your inputs and try again.";
if (error.data?.message) {
errorMessage = error.data.message;
} else if (error.message) {
errorMessage = error.message;
}
setUpdateError(errorMessage);
toast({
title: "Error updating user",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
const onAddUser = async (data: NewUserFormValues) => {
setIsSubmitting(true);
try {
const newUserData: CreateUserData = {
username: data.username,
email: data.email,
password: data.password,
passwordConfirm: data.passwordConfirm,
full_name: data.full_name,
role: data.role,
isActive: data.isActive,
avatar: data.avatar,
emailVisibility: true,
};
await userService.createUser(newUserData);
toast({
title: "User created",
description: `${data.full_name || data.username} has been added successfully.`,
});
setIsAddUserDialogOpen(false);
newUserFormReset({
full_name: "",
email: "",
username: "",
password: "",
passwordConfirm: "",
isActive: true,
role: "user",
avatar: avatarOptions[0].url,
});
await fetchUsers();
} catch (error: any) {
let errorMessage = "Could not create user. Please try again later.";
if (error.data?.message) {
errorMessage = error.data.message;
} else if (error.message) {
errorMessage = error.message;
}
toast({
title: "Error creating user",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return {
handleDeleteUser,
onSubmit,
onAddUser,
};
};
@@ -0,0 +1,53 @@
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { userService, User } from "@/services/userService";
export const useUsersList = () => {
const { toast } = useToast();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchUsers = async () => {
setLoading(true);
setError(null);
try {
console.log("Fetching users list");
const data = await userService.getUsers();
console.log("Received users data:", data);
if (Array.isArray(data) && data.length >= 0) {
setUsers(data);
// Clear any previous errors
setError(null);
} else {
console.error("Invalid users data format:", data);
setUsers([]);
setError("Invalid data format received from server");
toast({
title: "Warning",
description: "No users found or could not load users.",
variant: "destructive",
});
}
} catch (error) {
console.error("Error fetching users:", error);
setError(error instanceof Error ? error.message : "Unknown error");
toast({
title: "Error fetching users",
description: "Could not load users. Please try again later.",
variant: "destructive",
});
setUsers([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
return { users, loading, error, fetchUsers };
};
@@ -0,0 +1,3 @@
import UserManagement from "./UserManagement";
export default UserManagement;
@@ -0,0 +1,32 @@
import * as z from "zod";
export const userFormSchema = z.object({
full_name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
email: z.string().email({
message: "Please enter a valid email address.",
}),
username: z.string().min(3, {
message: "Username must be at least 3 characters.",
}),
isActive: z.boolean().optional(),
role: z.string().optional(),
avatar: z.string().optional(),
});
export const newUserFormSchema = userFormSchema.extend({
password: z.string().min(8, {
message: "Password must be at least 8 characters.",
}),
passwordConfirm: z.string().min(8, {
message: "Password confirmation must be at least 8 characters.",
}),
}).refine((data) => data.password === data.passwordConfirm, {
message: "Passwords don't match",
path: ["passwordConfirm"],
});
export type UserFormValues = z.infer<typeof userFormSchema>;
export type NewUserFormValues = z.infer<typeof newUserFormSchema>;
@@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

Some files were not shown because too many files have changed in this diff Show More