Initial Release
This commit is contained in:
@@ -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';
|
||||
+87
@@ -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";
|
||||
+349
@@ -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>
|
||||
);
|
||||
};
|
||||
+122
@@ -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;
|
||||
+76
@@ -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,
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user