chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center p-6">
|
||||
<h2 className="text-2xl font-bold text-destructive mb-4">Something went wrong</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { Mail, ArrowLeft } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { getCurrentEndpoint } from '@/lib/pocketbase';
|
||||
|
||||
interface ForgotPasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) {
|
||||
const [step, setStep] = useState<'request' | 'confirm'>('request');
|
||||
const [email, setEmail] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleRequestReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const apiUrl = getCurrentEndpoint();
|
||||
const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to send reset email');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Reset Email Sent",
|
||||
description: "Please check your email for password reset instructions.",
|
||||
});
|
||||
setStep('confirm');
|
||||
} catch (error) {
|
||||
console.error('Password reset request error:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Reset Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Password Mismatch",
|
||||
description: "Passwords do not match. Please try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Password Too Short",
|
||||
description: "Password must be at least 6 characters long.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const apiUrl = getCurrentEndpoint();
|
||||
const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
password,
|
||||
passwordConfirm
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to reset password');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Password Reset Successful",
|
||||
description: "Your password has been reset successfully. You can now log in with your new password.",
|
||||
});
|
||||
onOpenChange(false);
|
||||
// Reset form state
|
||||
setStep('request');
|
||||
setEmail('');
|
||||
setToken('');
|
||||
setPassword('');
|
||||
setPasswordConfirm('');
|
||||
} catch (error) {
|
||||
console.error('Password reset confirmation error:', error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Reset Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to reset password. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
// Reset form state when closing
|
||||
setStep('request');
|
||||
setEmail('');
|
||||
setToken('');
|
||||
setPassword('');
|
||||
setPasswordConfirm('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'request'
|
||||
? 'Enter your email address and we\'ll send you a reset link.'
|
||||
: 'Enter the reset token from your email and your new password.'
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'request' ? (
|
||||
<form onSubmit={handleRequestReset} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
id="reset-email"
|
||||
placeholder="your.email@provider.com"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !email}
|
||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Reset Email'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleConfirmReset} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
|
||||
Reset Token
|
||||
</label>
|
||||
<Input
|
||||
id="reset-token"
|
||||
placeholder="Enter token from email"
|
||||
type="text"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
id="new-password"
|
||||
placeholder="••••••••••••"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
placeholder="••••••••••••"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setStep('request')}
|
||||
className="flex-1"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !token || !password || !passwordConfirm}
|
||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { authService } from '@/services/authService';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
|
||||
if (!authService.isAuthenticated()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
|
||||
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";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DashboardContentProps {
|
||||
services: Service[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
|
||||
const { t } = useLanguage();
|
||||
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()}>{t('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">{t('overview')}</h2>
|
||||
<Button
|
||||
className="text-primary-foreground"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> {t('newService')}
|
||||
</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,207 @@
|
||||
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, Menu, X
|
||||
} 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";
|
||||
import { useSidebar } from "@/contexts/SidebarContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HeaderProps {
|
||||
currentUser: AuthUser | null;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({
|
||||
currentUser,
|
||||
onLogout,
|
||||
}: HeaderProps) => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
const { sidebarCollapsed, toggleSidebar, isMobileOpen, toggleMobileMenu } = useSidebar();
|
||||
const [greeting, setGreeting] = useState<string>("");
|
||||
const { systemName } = useSystemSettings();
|
||||
const navigate = useNavigate();
|
||||
|
||||
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();
|
||||
}, [language, t]);
|
||||
|
||||
const avatarUrl = currentUser?.avatar || '';
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="relative bg-background border-b border-border px-4 lg:px-6 flex justify-between items-center h-16 shrink-0 z-30 overflow-hidden">
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 pointer-events-none opacity-20">
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-2 lg:gap-4 z-10">
|
||||
{/* Mobile Toggle */}
|
||||
<Button variant="ghost" size="icon" onClick={toggleMobileMenu} className="lg:hidden h-9 w-9">
|
||||
{isMobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Desktop Toggle */}
|
||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="hidden lg:flex h-9 w-9">
|
||||
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-sm lg:text-lg font-medium truncate max-w-[150px] lg:max-w-none">
|
||||
{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 lg:space-x-3 z-10">
|
||||
{/* External Links - Hidden on small screens */}
|
||||
<div className="hidden md:flex items-center space-x-1 mr-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => openExternalLink('https://docs.checkcle.io')}
|
||||
title={t("documentation")}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => openExternalLink('https://github.com/operacle/checkcle')}
|
||||
title="GitHub"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => openExternalLink('https://x.com/checkcle_oss')}
|
||||
title="X (Twitter)"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
onClick={() => openExternalLink('https://discord.gg/xs9gbubGwX')}
|
||||
title="Discord"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full w-8 h-8 border-border"
|
||||
title={t("notifications")}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border hidden sm:flex" 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" className="w-40">
|
||||
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
|
||||
{t("english")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
|
||||
{t("khmer")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
|
||||
{t("german")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("ko")} className={language === "ko" ? "bg-accent" : ""}>
|
||||
{t("korean")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}>
|
||||
{t("japanese")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLanguage("zhcn")} className={language === "zhcn" ? "bg-accent" : ""}>
|
||||
{t("simplifiedChinese")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="h-8 w-px bg-border mx-1 hidden sm:block" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Avatar className="h-8 w-8 cursor-pointer border hover:ring-2 hover:ring-primary/20 transition-all">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[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="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
|
||||
</Avatar>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="text-sm font-medium truncate">{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,56 @@
|
||||
|
||||
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";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
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) => {
|
||||
const { t } = useLanguage();
|
||||
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">{t('currentlyMonitoring')}</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={t('allTypes')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('allTypes')}</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={t('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,44 @@
|
||||
import React from "react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { SidebarHeader } from "./sidebar/SidebarHeader";
|
||||
import { MainNavigation } from "./sidebar/MainNavigation";
|
||||
import { SettingsPanel } from "./sidebar/SettingsPanel";
|
||||
import { useSidebar } from "@/contexts/SidebarContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Sidebar = () => {
|
||||
const { theme } = useTheme();
|
||||
const { sidebarCollapsed, isMobileOpen, toggleMobileMenu } = useSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Overlay */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 lg:hidden backdrop-blur-sm transition-opacity animate-in fade-in"
|
||||
onClick={toggleMobileMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar Container */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 lg:static lg:block transition-all duration-300 ease-in-out border-r flex flex-col h-full",
|
||||
theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border',
|
||||
// Mobile state
|
||||
isMobileOpen ? "translate-x-0 w-64" : "-translate-x-full lg:translate-x-0",
|
||||
// Desktop state (collapsed/expanded)
|
||||
!isMobileOpen && sidebarCollapsed ? "lg:w-16" : "lg:w-64"
|
||||
)}
|
||||
>
|
||||
<SidebarHeader collapsed={!isMobileOpen && sidebarCollapsed} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar">
|
||||
<MainNavigation collapsed={!isMobileOpen && sidebarCollapsed} />
|
||||
</div>
|
||||
|
||||
<SettingsPanel collapsed={!isMobileOpen && sidebarCollapsed} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
|
||||
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";
|
||||
import {useLanguage} from "@/contexts/LanguageContext.tsx";
|
||||
|
||||
interface StatusCardsProps {
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export const StatusCards = ({ services }: StatusCardsProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
// 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(65, 59, 55, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 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(#000 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">{t("upServices")}</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(65, 59, 55, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 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(#000 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">{t("downServices")}</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(65, 59, 55, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 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(#000 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">{t("pausedServices")}</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(65, 59, 55, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 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(#000 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">{t("warningServices")}</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,27 @@
|
||||
|
||||
import React from "react";
|
||||
import { MenuItem } from "./MenuItem";
|
||||
import { mainMenuItems } from "./navigationData";
|
||||
|
||||
interface MainNavigationProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) => {
|
||||
return (
|
||||
<nav className="my-2 mx-1 py-1 px-1">
|
||||
{mainMenuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
path={item.path}
|
||||
icon={item.icon}
|
||||
translationKey={item.translationKey}
|
||||
color={item.color}
|
||||
hasNavigation={item.hasNavigation}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MenuItemProps {
|
||||
id: string;
|
||||
path: string | null;
|
||||
icon: LucideIcon;
|
||||
translationKey: string;
|
||||
color: string;
|
||||
hasNavigation: boolean;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const MenuItem: React.FC<MenuItemProps> = ({
|
||||
path,
|
||||
icon: Icon,
|
||||
translationKey,
|
||||
color,
|
||||
hasNavigation,
|
||||
collapsed
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isActive = path && location.pathname === path;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasNavigation && path) {
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center mb-1 rounded-lg cursor-pointer transition-all duration-200",
|
||||
collapsed ? "p-3 justify-center" : "p-2.5 px-4",
|
||||
isActive
|
||||
? (theme === 'dark' ? 'bg-zinc-800 text-white' : 'bg-primary/10 text-primary')
|
||||
: (theme === 'dark' ? 'text-zinc-400 hover:bg-zinc-800/50 hover:text-white' : 'text-zinc-600 hover:bg-zinc-100')
|
||||
)}
|
||||
onClick={handleClick}
|
||||
title={collapsed ? t(translationKey) : ""}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5 shrink-0 transition-colors", color, isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100")} />
|
||||
|
||||
{!collapsed && (
|
||||
<span className="ml-3 font-medium tracking-wide text-sm truncate">
|
||||
{t(translationKey)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Small Indicator for active state */}
|
||||
{isActive && !collapsed && (
|
||||
<div className="absolute left-0 w-1 h-6 bg-primary rounded-r-full" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { Settings, ChevronDown } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { settingsMenuItems } from "./navigationData";
|
||||
|
||||
interface SettingsPanelProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
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 handleMenuItemClick = (path: string, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Use navigate instead of window.location to prevent full page reload
|
||||
navigate(path, { replace: false });
|
||||
};
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
const mainIconSize = "h-6 w-6";
|
||||
return (
|
||||
<div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
|
||||
<div
|
||||
onClick={(e) => handleMenuItemClick('/settings', e)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Settings className={`${mainIconSize} text-purple-400`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
{settingsMenuItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={getMenuItemClasses(activeSettingsItem === item.id)}
|
||||
onClick={(e) => {
|
||||
handleMenuItemClick(`/settings?panel=${item.id}`, e);
|
||||
handleSettingsItemClick(item.id);
|
||||
}}
|
||||
>
|
||||
<item.icon className="h-4 w-4 mr-2" />
|
||||
<span className="text-sm">{t(item.translationKey)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
import React from "react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
|
||||
<div className="h-8 w-8 bg-gray-600 rounded flex items-center justify-center mr-2">
|
||||
<img
|
||||
src="/favicon_sidebar.ico"
|
||||
alt="CheckCle"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export { SidebarHeader } from './SidebarHeader';
|
||||
export { MainNavigation } from './MainNavigation';
|
||||
export { MenuItem } from './MenuItem';
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
export { mainMenuItems, settingsMenuItems } from './navigationData';
|
||||
@@ -0,0 +1,94 @@
|
||||
|
||||
import { Globe, Boxes, Layers, Calendar, BarChart2, LineChart, MapPin, Settings, User, Bell, Database, Info, BookOpen } from "lucide-react";
|
||||
|
||||
export const mainMenuItems = [
|
||||
{
|
||||
id: 'uptime-monitoring',
|
||||
path: '/dashboard',
|
||||
icon: Globe,
|
||||
translationKey: 'uptimeMonitoring',
|
||||
color: 'text-purple-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'instance-monitoring',
|
||||
path: '/instance-monitoring',
|
||||
icon: Boxes,
|
||||
translationKey: 'instanceMonitoring',
|
||||
color: 'text-blue-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'ssl-domain',
|
||||
path: '/ssl-domain',
|
||||
icon: Layers,
|
||||
translationKey: 'sslDomain',
|
||||
color: 'text-cyan-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'schedule-incident',
|
||||
path: '/schedule-incident',
|
||||
icon: Calendar,
|
||||
translationKey: 'scheduleIncident',
|
||||
color: 'text-emerald-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'operational-page',
|
||||
path: '/operational-page',
|
||||
icon: BarChart2,
|
||||
translationKey: 'operationalPage',
|
||||
color: 'text-amber-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'regional-monitoring',
|
||||
path: '/regional-monitoring',
|
||||
icon: MapPin,
|
||||
translationKey: 'regionalMonitoring',
|
||||
color: 'text-indigo-400',
|
||||
hasNavigation: true
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
path: null,
|
||||
icon: LineChart,
|
||||
translationKey: 'reports',
|
||||
color: 'text-rose-400',
|
||||
hasNavigation: false
|
||||
}
|
||||
];
|
||||
|
||||
export const settingsMenuItems = [
|
||||
{
|
||||
id: 'general',
|
||||
icon: Settings,
|
||||
translationKey: 'generalSettings'
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
icon: User,
|
||||
translationKey: 'userManagement'
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
icon: Bell,
|
||||
translationKey: 'notificationSettings'
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
icon: BookOpen,
|
||||
translationKey: 'alertsTemplates'
|
||||
},
|
||||
{
|
||||
id: 'data-retention',
|
||||
icon: Database,
|
||||
translationKey: 'dataRetention'
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
icon: Info,
|
||||
translationKey: 'aboutSystem'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,99 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody } from "@/components/ui/table";
|
||||
import { DockerContainer } from "@/types/docker.types";
|
||||
import { DockerMetricsDialog } from "./DockerMetricsDialog";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import {
|
||||
DockerTableSearch,
|
||||
DockerTableHeader,
|
||||
DockerTableRow,
|
||||
DockerEmptyState
|
||||
} from "./table";
|
||||
|
||||
interface DockerContainersTableProps {
|
||||
containers: DockerContainer[];
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => {
|
||||
const { t } = useLanguage();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null);
|
||||
const [metricsDialogOpen, setMetricsDialogOpen] = useState(false);
|
||||
|
||||
const filteredContainers = containers.filter(container =>
|
||||
container.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
container.docker_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
container.hostname.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleContainerAction = (action: string, containerId: string, containerName: string) => {
|
||||
console.log(`${action} action for container ${containerName} (${containerId})`);
|
||||
// TODO: Implement container actions
|
||||
};
|
||||
|
||||
const handleRowClick = (container: DockerContainer) => {
|
||||
setSelectedContainer(container);
|
||||
setMetricsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewMetrics = (container: DockerContainer) => {
|
||||
setSelectedContainer(container);
|
||||
setMetricsDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full bg-transparent border-0 shadow-none">
|
||||
<CardHeader className="pb-4 px-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<CardTitle className="text-lg sm:text-xl font-semibold">{t('dockerContainers', 'docker')}</CardTitle>
|
||||
<DockerTableSearch
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
onRefresh={onRefresh}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-full inline-block align-middle">
|
||||
<div className="overflow-hidden border border-border rounded-lg shadow-sm">
|
||||
<Table>
|
||||
<DockerTableHeader />
|
||||
<TableBody>
|
||||
{filteredContainers.length === 0 ? (
|
||||
<DockerEmptyState searchTerm={searchTerm} />
|
||||
) : (
|
||||
filteredContainers.map((container) => (
|
||||
<DockerTableRow
|
||||
key={container.id}
|
||||
container={container}
|
||||
onRowClick={handleRowClick}
|
||||
onContainerAction={handleContainerAction}
|
||||
onViewMetrics={handleViewMetrics}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DockerMetricsDialog
|
||||
container={selectedContainer}
|
||||
open={metricsDialogOpen}
|
||||
onOpenChange={setMetricsDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,697 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, AreaChart, Area } from "recharts";
|
||||
import { DockerContainer, DockerMetrics } from "@/types/docker.types";
|
||||
import { dockerService } from "@/services/dockerService";
|
||||
import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerMetricsDialogProps {
|
||||
container: DockerContainer | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: '60m' as TimeRange, label: 'minutes60', hours: 1 },
|
||||
{ value: '1d' as TimeRange, label: 'day1', hours: 24 },
|
||||
{ value: '7d' as TimeRange, label: 'days7', hours: 24 * 7 },
|
||||
{ value: '1m' as TimeRange, label: 'month1', hours: 24 * 30 },
|
||||
{ value: '3m' as TimeRange, label: 'months3', hours: 24 * 90 },
|
||||
];
|
||||
|
||||
export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const {
|
||||
data: metrics = [],
|
||||
isLoading,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['docker-metrics', container?.docker_id, timeRange],
|
||||
queryFn: () => container ? dockerService.getContainerMetrics(container.docker_id) : Promise.resolve([]),
|
||||
enabled: !!container && open,
|
||||
refetchInterval: 30000
|
||||
});
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const parseValueWithUnit = (value: string | number): { numeric: number; unit: string; original: string } => {
|
||||
if (typeof value === 'number') {
|
||||
return { numeric: value, unit: 'B', original: value.toString() };
|
||||
}
|
||||
|
||||
const str = value.toString();
|
||||
const match = str.match(/^([\d.]+)\s*([A-Za-z%]*)/);
|
||||
if (match) {
|
||||
const numeric = parseFloat(match[1]);
|
||||
const unit = match[2] || '';
|
||||
return { numeric, unit, original: str };
|
||||
}
|
||||
return { numeric: 0, unit: '', original: str };
|
||||
};
|
||||
|
||||
const convertToBytes = (value: string | number): number => {
|
||||
if (typeof value === 'number') return value;
|
||||
|
||||
const parsed = parseValueWithUnit(value);
|
||||
const multipliers: { [key: string]: number } = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
const multiplier = multipliers[parsed.unit.toUpperCase()] || 1;
|
||||
return parsed.numeric * multiplier;
|
||||
};
|
||||
|
||||
const filterMetricsByTimeRange = (metrics: DockerMetrics[], timeRange: TimeRange): DockerMetrics[] => {
|
||||
const now = new Date();
|
||||
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
|
||||
if (!selectedRange) return metrics;
|
||||
|
||||
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
|
||||
|
||||
return metrics.filter(metric => {
|
||||
const metricTime = new Date(metric.timestamp);
|
||||
return metricTime >= cutoffTime;
|
||||
});
|
||||
};
|
||||
|
||||
const formatChartData = (metrics: DockerMetrics[]) => {
|
||||
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
|
||||
|
||||
return filteredMetrics.slice(0, 100).reverse().map((metric, index) => {
|
||||
// Parse CPU usage
|
||||
const cpuUsage = typeof metric.cpu_usage === 'string' ?
|
||||
parseFloat(metric.cpu_usage.replace('%', '')) :
|
||||
parseFloat(metric.cpu_usage) || 0;
|
||||
|
||||
// Parse memory values
|
||||
const ramUsedBytes = convertToBytes(metric.ram_used);
|
||||
const ramTotalBytes = convertToBytes(metric.ram_total);
|
||||
const ramFreeBytes = convertToBytes(metric.ram_free);
|
||||
const ramUsagePercent = ramTotalBytes > 0 ? (ramUsedBytes / ramTotalBytes) * 100 : 0;
|
||||
|
||||
// Parse disk values
|
||||
const diskUsedBytes = convertToBytes(metric.disk_used);
|
||||
const diskTotalBytes = convertToBytes(metric.disk_total);
|
||||
const diskFreeBytes = convertToBytes(metric.disk_free);
|
||||
const diskUsagePercent = diskTotalBytes > 0 ? (diskUsedBytes / diskTotalBytes) * 100 : 0;
|
||||
|
||||
// Network values
|
||||
const networkRxBytes = metric.network_rx_bytes || 0;
|
||||
const networkTxBytes = metric.network_tx_bytes || 0;
|
||||
const networkRxSpeed = metric.network_rx_speed || 0;
|
||||
const networkTxSpeed = metric.network_tx_speed || 0;
|
||||
|
||||
return {
|
||||
timestamp: new Date(metric.timestamp).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}),
|
||||
// CPU
|
||||
cpuUsage: Math.round(cpuUsage * 100) / 100,
|
||||
cpuCores: parseInt(metric.cpu_cores) || 0,
|
||||
cpuFree: 100 - cpuUsage,
|
||||
|
||||
// Memory
|
||||
ramUsedBytes,
|
||||
ramTotalBytes,
|
||||
ramFreeBytes,
|
||||
ramUsed: formatBytes(ramUsedBytes),
|
||||
ramTotal: formatBytes(ramTotalBytes),
|
||||
ramFree: formatBytes(ramFreeBytes),
|
||||
ramUsagePercent: Math.round(ramUsagePercent * 100) / 100,
|
||||
|
||||
// Disk
|
||||
diskUsedBytes,
|
||||
diskTotalBytes,
|
||||
diskFreeBytes,
|
||||
diskUsed: formatBytes(diskUsedBytes),
|
||||
diskTotal: formatBytes(diskTotalBytes),
|
||||
diskFree: formatBytes(diskFreeBytes),
|
||||
diskUsagePercent: Math.round(diskUsagePercent * 100) / 100,
|
||||
|
||||
// Network
|
||||
networkRxBytes,
|
||||
networkTxBytes,
|
||||
networkRx: formatBytes(networkRxBytes),
|
||||
networkTx: formatBytes(networkTxBytes),
|
||||
networkRxSpeed: Math.round(networkRxSpeed * 100) / 100,
|
||||
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const chartData = formatChartData(metrics);
|
||||
const latestMetric = chartData[chartData.length - 1];
|
||||
|
||||
const chartConfig = {
|
||||
cpuUsage: {
|
||||
label: "CPU Usage (%)",
|
||||
color: theme === 'dark' ? "#3b82f6" : "#2563eb",
|
||||
},
|
||||
ramUsagePercent: {
|
||||
label: "RAM Usage (%)",
|
||||
color: theme === 'dark' ? "#10b981" : "#059669",
|
||||
},
|
||||
diskUsagePercent: {
|
||||
label: "Disk Usage (%)",
|
||||
color: theme === 'dark' ? "#f59e0b" : "#d97706",
|
||||
},
|
||||
networkRx: {
|
||||
label: "Network RX",
|
||||
color: theme === 'dark' ? "#8b5cf6" : "#7c3aed",
|
||||
},
|
||||
networkTx: {
|
||||
label: "Network TX",
|
||||
color: theme === 'dark' ? "#ef4444" : "#dc2626",
|
||||
},
|
||||
};
|
||||
|
||||
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
|
||||
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
|
||||
|
||||
const MetricCard = ({ title, used, total, free, percentage, icon: Icon, color }: {
|
||||
title: string;
|
||||
used: string;
|
||||
total: string;
|
||||
free: string;
|
||||
percentage: number;
|
||||
icon: any;
|
||||
color: string;
|
||||
}) => (
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" style={{ color }} />
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Used:</span>
|
||||
<span className="font-mono">{used}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Free:</span>
|
||||
<span className="font-mono">{free}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total:</span>
|
||||
<span className="font-mono">{total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Usage:</span>
|
||||
<span className="font-mono">{percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-7xl max-h-[95vh] overflow-y-auto bg-background text-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Cpu className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
{t('containerMetricsTitle', 'docker', { name: container.name })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{timeRangeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.label, 'docker')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dockerId', 'docker')}: {container.docker_id} • {container.hostname}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<span className="ml-2">{t('loadingMetrics', 'docker')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||
<p>{t('errorLoadingMetrics', 'docker')}: {String((error as any)?.message ?? '')}</p>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-96 text-muted-foreground">
|
||||
<p>{t('noMetricsAvailable', 'docker')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Current Metrics Summary */}
|
||||
{latestMetric && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<MetricCard
|
||||
title={t('cpu', 'docker')}
|
||||
used={`${latestMetric.cpuUsage}%`}
|
||||
total={`${latestMetric.cpuCores} cores`}
|
||||
free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`}
|
||||
percentage={latestMetric.cpuUsage}
|
||||
icon={Cpu}
|
||||
color={chartConfig.cpuUsage.color}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('memoryTitle', 'docker')}
|
||||
used={latestMetric.ramUsed}
|
||||
total={latestMetric.ramTotal}
|
||||
free={latestMetric.ramFree}
|
||||
percentage={latestMetric.ramUsagePercent}
|
||||
icon={MemoryStick}
|
||||
color={chartConfig.ramUsagePercent.color}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('diskTitle', 'docker')}
|
||||
used={latestMetric.diskUsed}
|
||||
total={latestMetric.diskTotal}
|
||||
free={latestMetric.diskFree}
|
||||
percentage={latestMetric.diskUsagePercent}
|
||||
icon={HardDrive}
|
||||
color={chartConfig.diskUsagePercent.color}
|
||||
/>
|
||||
<MetricCard
|
||||
title={t('network', 'docker')}
|
||||
used={`RX: ${latestMetric.networkRx}`}
|
||||
total={`TX: ${latestMetric.networkTx}`}
|
||||
free={`${t('networkSpeedKbs', 'docker').replace('(KB/s)', '')}: ${latestMetric.networkRxSpeed} KB/s`}
|
||||
percentage={0}
|
||||
icon={Network}
|
||||
color={chartConfig.networkRx.color}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="cpu" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 bg-muted">
|
||||
<TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t('cpu', 'docker')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<MemoryStick className="h-4 w-4" />
|
||||
{t('memoryTitle', 'docker')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
{t('diskTitle', 'docker')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background">
|
||||
<Network className="h-4 w-4" />
|
||||
{t('network', 'docker')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="cpu" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('cpuUsagePct', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cpuUsage"
|
||||
stroke={chartConfig.cpuUsage.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: chartConfig.cpuUsage.color }}
|
||||
name={t('cpuUsagePct', 'docker')}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('cpuUsageVsAvailable', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpuFree"
|
||||
stackId="1"
|
||||
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fillOpacity={0.3}
|
||||
name={t('cpuAvailablePct', 'docker')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpuUsage"
|
||||
stackId="1"
|
||||
stroke={chartConfig.cpuUsage.color}
|
||||
fill={chartConfig.cpuUsage.color}
|
||||
fillOpacity={0.6}
|
||||
name={t('cpuUsagePct', 'docker')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="memory" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('ramUsagePct', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ramUsagePercent"
|
||||
stackId="1"
|
||||
stroke={chartConfig.ramUsagePercent.color}
|
||||
fill={chartConfig.ramUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name="RAM Usage (%)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('memoryUsageBytes', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
formatter={(value, name) => [
|
||||
name === t('usedMemory', 'docker') ? formatBytes(Number(value)) :
|
||||
name === t('totalMemory', 'docker') ? formatBytes(Number(value)) : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ramTotalBytes"
|
||||
stackId="1"
|
||||
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fillOpacity={0.3}
|
||||
name={t('totalMemory', 'docker')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ramUsedBytes"
|
||||
stackId="1"
|
||||
stroke={chartConfig.ramUsagePercent.color}
|
||||
fill={chartConfig.ramUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name={t('usedMemory', 'docker')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disk" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('diskUsagePct', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskUsagePercent"
|
||||
stackId="1"
|
||||
stroke={chartConfig.diskUsagePercent.color}
|
||||
fill={chartConfig.diskUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name={t('diskUsagePct', 'docker')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('diskUsageBytes', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-80">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
formatter={(value, name) => [
|
||||
name === t('usedDisk', 'docker') ? formatBytes(Number(value)) :
|
||||
name === t('totalDisk', 'docker') ? formatBytes(Number(value)) : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskTotalBytes"
|
||||
stackId="1"
|
||||
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
|
||||
fillOpacity={0.3}
|
||||
name={t('totalDisk', 'docker')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskUsedBytes"
|
||||
stackId="1"
|
||||
stroke={chartConfig.diskUsagePercent.color}
|
||||
fill={chartConfig.diskUsagePercent.color}
|
||||
fillOpacity={0.6}
|
||||
name={t('usedDisk', 'docker')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" className="space-y-4 mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('networkTraffic', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-64">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkRxBytes"
|
||||
stroke={chartConfig.networkRx.color}
|
||||
strokeWidth={2}
|
||||
name={t('rxBytes', 'docker')}
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkTxBytes"
|
||||
stroke={chartConfig.networkTx.color}
|
||||
strokeWidth={2}
|
||||
name={t('txBytes', 'docker')}
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground">{t('networkSpeedKbs', 'docker')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-64">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: getAxisColor() }}
|
||||
axisLine={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent className="bg-popover border-border" />}
|
||||
cursor={{ stroke: getGridColor() }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkRxSpeed"
|
||||
stroke={chartConfig.networkRx.color}
|
||||
strokeWidth={2}
|
||||
name={t('rxSpeedKbs', 'docker')}
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="networkTxSpeed"
|
||||
stroke={chartConfig.networkTx.color}
|
||||
strokeWidth={2}
|
||||
name={t('txSpeedKbs', 'docker')}
|
||||
dot={{ r: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Container, Play, Square, AlertTriangle } from "lucide-react";
|
||||
import { DockerStats } from "@/types/docker.types";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerStatsCardsProps {
|
||||
stats: DockerStats;
|
||||
}
|
||||
|
||||
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
|
||||
const { theme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('totalContainers', 'docker'),
|
||||
value: stats.total,
|
||||
icon: Container,
|
||||
color: "text-blue-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(59, 130, 246, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
|
||||
},
|
||||
{
|
||||
title: t('running', 'docker'),
|
||||
value: stats.running,
|
||||
icon: Play,
|
||||
color: "text-green-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(16, 185, 129, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
|
||||
},
|
||||
{
|
||||
title: t('stopped', 'docker'),
|
||||
value: stats.stopped,
|
||||
icon: Square,
|
||||
color: "text-gray-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(107, 114, 128, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
|
||||
},
|
||||
{
|
||||
title: t('warning', 'docker'),
|
||||
value: stats.warning,
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600",
|
||||
gradient: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(245, 158, 11, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #f59e0b 100%)"
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||
{cards.map((card) => {
|
||||
const IconComponent = card.icon;
|
||||
return (
|
||||
<Card
|
||||
key={card.title}
|
||||
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
|
||||
style={{ background: card.gradient }}
|
||||
>
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white/70">
|
||||
{card.title}
|
||||
</CardTitle>
|
||||
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
|
||||
<IconComponent className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{card.value}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30"
|
||||
>
|
||||
{t('containersLabel', 'docker')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerStatusBadgeProps {
|
||||
status: 'running' | 'stopped' | 'warning';
|
||||
}
|
||||
|
||||
export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => {
|
||||
const { t } = useLanguage();
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
variant: 'default' as const,
|
||||
className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200',
|
||||
label: t('running', 'docker')
|
||||
};
|
||||
case 'stopped':
|
||||
return {
|
||||
variant: 'secondary' as const,
|
||||
className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
|
||||
label: t('stopped', 'docker')
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
variant: 'destructive' as const,
|
||||
className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200',
|
||||
label: t('warning', 'docker')
|
||||
};
|
||||
default:
|
||||
return {
|
||||
variant: 'outline' as const,
|
||||
className: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
label: t('unknown', 'docker')
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={config.variant}
|
||||
className={`${config.className} font-medium text-xs px-2 py-1`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerEmptyStateProps {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-lg font-medium">
|
||||
{searchTerm ? t('noContainersFound', 'docker') : t('noContainersRunning', 'docker')}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{searchTerm ? t('tryAdjustSearch', 'docker') : t('startSomeContainers', 'docker')}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react";
|
||||
import { DockerContainer } from "@/types/docker.types";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerRowActionsProps {
|
||||
container: DockerContainer;
|
||||
containerStatus: 'running' | 'stopped' | 'warning';
|
||||
onContainerAction: (action: string, containerId: string, containerName: string) => void;
|
||||
onViewMetrics: (container: DockerContainer) => void;
|
||||
}
|
||||
|
||||
export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted">
|
||||
<span className="sr-only">{t('openMenu', 'docker')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-popover border-border shadow-md">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onViewMetrics(container)}
|
||||
className="cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
{t('viewMetrics', 'docker')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onContainerAction('view-detail', container.id, container.name)}
|
||||
className="cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t('viewDetails', 'docker')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
export const DockerTableHeader = () => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<TableHeader>
|
||||
<TableRow className="border-border bg-muted/30">
|
||||
<TableHead className="min-w-[200px] font-semibold">{t('container', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[100px] font-semibold">{t('status', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[140px] font-semibold">{t('cpuUsage', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[160px] font-semibold">{t('memory', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[160px] font-semibold">{t('disk', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[100px] font-semibold">{t('uptime', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[160px] font-semibold">{t('lastChecked', 'docker')}</TableHead>
|
||||
<TableHead className="min-w-[80px] text-center font-semibold">{t('actions', 'docker')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { DockerContainer } from "@/types/docker.types";
|
||||
import { DockerStatusBadge } from "../DockerStatusBadge";
|
||||
import { DockerRowActions } from "./DockerRowActions";
|
||||
import { dockerService } from "@/services/dockerService";
|
||||
|
||||
interface DockerTableRowProps {
|
||||
container: DockerContainer;
|
||||
onRowClick: (container: DockerContainer) => void;
|
||||
onContainerAction: (action: string, containerId: string, containerName: string) => void;
|
||||
onViewMetrics: (container: DockerContainer) => void;
|
||||
}
|
||||
|
||||
export const DockerTableRow = ({ container, onRowClick, onContainerAction, onViewMetrics }: DockerTableRowProps) => {
|
||||
const cpuPercentage = container.cpu_usage;
|
||||
const memoryPercentage = Math.round((container.ram_used / container.ram_total) * 100);
|
||||
const diskPercentage = Math.round((container.disk_used / container.disk_total) * 100);
|
||||
const containerStatus = dockerService.getStatusFromDockerStatus(container.status);
|
||||
|
||||
const formatPercentage = (used: number, total: number) => {
|
||||
if (total === 0) return "0%";
|
||||
return `${Math.round((used / total) * 100)}%`;
|
||||
};
|
||||
|
||||
const getUsageColor = (percentage: number) => {
|
||||
if (percentage >= 90) return "text-red-500";
|
||||
if (percentage >= 70) return "text-amber-500";
|
||||
return "text-emerald-500";
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage >= 90) return "bg-red-500";
|
||||
if (percentage >= 70) return "bg-amber-500";
|
||||
return "bg-emerald-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className="hover:bg-muted/50 transition-colors border-border cursor-pointer"
|
||||
onClick={() => onRowClick(container)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-sm sm:text-base text-foreground">{container.name}</div>
|
||||
<div className="text-xs sm:text-sm text-muted-foreground">
|
||||
<div className="font-mono">{container.docker_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DockerStatusBadge status={containerStatus} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Progress
|
||||
value={cpuPercentage}
|
||||
className="flex-1 h-2 bg-muted/50"
|
||||
indicatorClassName={getProgressColor(cpuPercentage)}
|
||||
/>
|
||||
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(cpuPercentage)}`}>
|
||||
{cpuPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Progress
|
||||
value={memoryPercentage}
|
||||
className="flex-1 h-2 bg-muted/50"
|
||||
indicatorClassName={getProgressColor(memoryPercentage)}
|
||||
/>
|
||||
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(memoryPercentage)}`}>
|
||||
{formatPercentage(container.ram_used, container.ram_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{dockerService.formatBytes(container.ram_used)} / {dockerService.formatBytes(container.ram_total)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Progress
|
||||
value={diskPercentage}
|
||||
className="flex-1 h-2 bg-muted/50"
|
||||
indicatorClassName={getProgressColor(diskPercentage)}
|
||||
/>
|
||||
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(diskPercentage)}`}>
|
||||
{formatPercentage(container.disk_used, container.disk_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{dockerService.formatBytes(container.disk_used)} / {dockerService.formatBytes(container.disk_total)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs sm:text-sm font-medium font-mono">
|
||||
{dockerService.formatUptime(container.uptime)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground">
|
||||
{new Date(container.last_checked).toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<DockerRowActions
|
||||
container={container}
|
||||
containerStatus={containerStatus}
|
||||
onContainerAction={onContainerAction}
|
||||
onViewMetrics={onViewMetrics}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw } from "lucide-react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface DockerTableSearchProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 sm:flex-initial">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder={t('searchContainersPlaceholder', 'docker')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10 sm:w-64 bg-background border-border"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto bg-background border-border hover:bg-muted"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<span className="sm:inline">{t('refresh', 'docker')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export { DockerTableSearch } from './DockerTableSearch';
|
||||
export { DockerTableHeader } from './DockerTableHeader';
|
||||
export { DockerTableRow } from './DockerTableRow';
|
||||
export { DockerRowActions } from './DockerRowActions';
|
||||
export { DockerEmptyState } from './DockerEmptyState';
|
||||
@@ -0,0 +1,209 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, X, Server } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { serviceService } from '@/services/serviceService';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface ComponentsSelectorProps {
|
||||
selectedComponents: Partial<StatusPageComponentRecord>[];
|
||||
onComponentsChange: (components: Partial<StatusPageComponentRecord>[]) => void;
|
||||
onComponentDelete?: (componentId: string) => void;
|
||||
}
|
||||
|
||||
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
|
||||
const { t } = useLanguage();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newComponent, setNewComponent] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
service_id: '',
|
||||
server_id: '',
|
||||
display_order: selectedComponents.length + 1,
|
||||
});
|
||||
|
||||
// Fetch uptime services for the dropdown
|
||||
const { data: services = [] } = useQuery({
|
||||
queryKey: ['services'],
|
||||
queryFn: serviceService.getServices,
|
||||
});
|
||||
|
||||
const addComponent = () => {
|
||||
if (!newComponent.name.trim()) return;
|
||||
|
||||
const component: Partial<StatusPageComponentRecord> = {
|
||||
...newComponent,
|
||||
operational_status_id: '', // Will be set when page is created
|
||||
};
|
||||
|
||||
onComponentsChange([...selectedComponents, component]);
|
||||
setNewComponent({
|
||||
name: '',
|
||||
description: '',
|
||||
service_id: '',
|
||||
server_id: '',
|
||||
display_order: selectedComponents.length + 2,
|
||||
});
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const removeComponent = async (index: number) => {
|
||||
const component = selectedComponents[index];
|
||||
|
||||
// If component has an ID, it exists in database and needs to be deleted
|
||||
if (component.id && onComponentDelete) {
|
||||
await onComponentDelete(component.id);
|
||||
} else {
|
||||
// For new components not yet saved, just remove from local state
|
||||
const updated = selectedComponents.filter((_, i) => i !== index);
|
||||
onComponentsChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
{t('statusPageComponents')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('addMonitoringComponentsDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedComponents.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('selectedComponents')}</Label>
|
||||
<div className="space-y-2">
|
||||
{selectedComponents.map((component, index) => (
|
||||
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{component.name}</div>
|
||||
{component.description && (
|
||||
<div className="text-sm text-muted-foreground">{component.description}</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-1">
|
||||
{component.service_id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t('service')}: {services.find(s => s.id === component.service_id)?.name || component.service_id}
|
||||
</Badge>
|
||||
)}
|
||||
{component.server_id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t('server')}: {component.server_id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeComponent(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAddForm ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('addComponent')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="component-name">{t('componentName')}</Label>
|
||||
<Input
|
||||
id="component-name"
|
||||
placeholder={t('componentNamePlaceholder')}
|
||||
value={newComponent.name}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="display-order">{t('displayOrder')}</Label>
|
||||
<Input
|
||||
id="display-order"
|
||||
type="number"
|
||||
value={newComponent.display_order}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, display_order: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="component-description">{t('descriptionOptional')}</Label>
|
||||
<Textarea
|
||||
id="component-description"
|
||||
placeholder={t('descriptionPlaceholder')}
|
||||
value={newComponent.description}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="service-id">{t('uptimeServiceOptional')}</Label>
|
||||
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectUptimeService')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{services.map((service) => (
|
||||
<SelectItem key={service.id} value={service.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
service.status === 'up' ? 'bg-green-500' :
|
||||
service.status === 'down' ? 'bg-red-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
{service.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="server-id">{t('serverIdOptional')}</Label>
|
||||
<Input
|
||||
id="server-id"
|
||||
placeholder={t('serverIdPlaceholder')}
|
||||
value={newComponent.server_id}
|
||||
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
|
||||
{t('addComponent')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowAddForm(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCreateOperationalPage } from '@/hooks/useOperationalPage';
|
||||
import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||
import { ComponentsSelector } from './ComponentsSelector';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
theme: z.string().min(1, 'Theme is required'),
|
||||
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||
is_public: z.boolean(),
|
||||
logo_url: z.string().optional(),
|
||||
custom_domain: z.string().optional(),
|
||||
custom_css: z.string().optional(),
|
||||
page_style: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const CreateOperationalPageDialog = () => {
|
||||
const { t } = useLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||
const createMutation = useCreateOperationalPage();
|
||||
const createComponentMutation = useCreateStatusPageComponent();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
slug: '',
|
||||
theme: 'default',
|
||||
status: 'operational',
|
||||
is_public: true,
|
||||
logo_url: '',
|
||||
custom_domain: '',
|
||||
custom_css: '',
|
||||
page_style: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const payload = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
slug: data.slug,
|
||||
theme: data.theme,
|
||||
status: data.status,
|
||||
is_public: data.is_public ? 'true' : 'false',
|
||||
logo_url: data.logo_url || '',
|
||||
custom_domain: data.custom_domain || '',
|
||||
custom_css: data.custom_css || '',
|
||||
page_style: data.page_style || '',
|
||||
};
|
||||
|
||||
// console.log('Creating operational page with payload:', payload);
|
||||
const createdPage = await createMutation.mutateAsync(payload);
|
||||
// console.log('Created page:', createdPage);
|
||||
|
||||
// Create components after page is created
|
||||
if (selectedComponents.length > 0) {
|
||||
// console.log('Creating components for page:', createdPage.id);
|
||||
for (const component of selectedComponents) {
|
||||
const componentPayload = {
|
||||
operational_status_id: createdPage.id,
|
||||
name: component.name || '',
|
||||
description: component.description || '',
|
||||
service_id: component.service_id || '',
|
||||
server_id: component.server_id || '',
|
||||
display_order: component.display_order || 1,
|
||||
};
|
||||
|
||||
// console.log('Creating component with payload:', componentPayload);
|
||||
await createComponentMutation.mutateAsync(componentPayload);
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
setSelectedComponents([]);
|
||||
} catch (error) {
|
||||
// console.error('Error creating operational page:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('createPage')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('createOperationalPage')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('createOperationalPageDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('title')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('operationalPageDescriptionPlaceholder')}
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('theme')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectTheme')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t('themeDefault')}</SelectItem>
|
||||
<SelectItem value="dark">{t('themeDark')}</SelectItem>
|
||||
<SelectItem value="light">{t('themeLight')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('initialStatus')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectStatus')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="operational">{t('statusOperational')}</SelectItem>
|
||||
<SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
|
||||
<SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
|
||||
<SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_public"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t('publicPage')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('makePagePublic')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('customDomainOptional')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('customDomainPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('customDomainDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ComponentsSelector
|
||||
selectedComponents={selectedComponents}
|
||||
onComponentsChange={setSelectedComponents}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || createComponentMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending || createComponentMutation.isPending ? t('creating') : t('createPage')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useUpdateOperationalPage } from '@/hooks/useOperationalPage';
|
||||
import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, useDeleteStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||
import { ComponentsSelector } from './ComponentsSelector';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
slug: z.string().min(1, 'Slug is required'),
|
||||
theme: z.string().min(1, 'Theme is required'),
|
||||
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||
is_public: z.boolean(),
|
||||
logo_url: z.string().optional(),
|
||||
custom_domain: z.string().optional(),
|
||||
custom_css: z.string().optional(),
|
||||
page_style: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
interface EditOperationalPageDialogProps {
|
||||
page: OperationalPageRecord | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
|
||||
const { t } = useLanguage();
|
||||
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
|
||||
const updateMutation = useUpdateOperationalPage();
|
||||
const createComponentMutation = useCreateStatusPageComponent();
|
||||
const deleteComponentMutation = useDeleteStatusPageComponent();
|
||||
|
||||
// Fetch existing components for this operational page
|
||||
const { data: components = [] } = useStatusPageComponentsByOperationalId(page?.id || '');
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
slug: '',
|
||||
theme: 'default',
|
||||
status: 'operational',
|
||||
is_public: true,
|
||||
logo_url: '',
|
||||
custom_domain: '',
|
||||
custom_css: '',
|
||||
page_style: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize the form reset values to prevent unnecessary re-renders
|
||||
const formValues = useMemo(() => {
|
||||
if (!page) return null;
|
||||
return {
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
slug: page.slug,
|
||||
theme: page.theme,
|
||||
status: page.status,
|
||||
is_public: page.is_public === 'true',
|
||||
logo_url: page.logo_url || '',
|
||||
custom_domain: page.custom_domain || '',
|
||||
custom_css: page.custom_css || '',
|
||||
page_style: page.page_style || '',
|
||||
};
|
||||
}, [page?.id, page?.title, page?.description, page?.slug, page?.theme, page?.status, page?.is_public, page?.logo_url, page?.custom_domain, page?.custom_css, page?.page_style]);
|
||||
|
||||
// Reset form when page data changes
|
||||
useEffect(() => {
|
||||
if (formValues) {
|
||||
form.reset(formValues);
|
||||
}
|
||||
}, [formValues, form]);
|
||||
|
||||
// Convert components to selector format and initialize state - only when dialog opens and components change
|
||||
useEffect(() => {
|
||||
if (!open || !page?.id || !components) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if components actually changed or haven't been loaded yet
|
||||
const componentIds = components.map(c => c.id).sort().join(',');
|
||||
const currentSelectedIds = selectedComponents.map(c => c.id).filter(Boolean).sort().join(',');
|
||||
|
||||
if (componentIds !== currentSelectedIds || !componentsLoaded) {
|
||||
// console.log('Loading existing components:', components);
|
||||
const existingComponentsForSelector = components.map(comp => ({
|
||||
id: comp.id,
|
||||
name: comp.name,
|
||||
description: comp.description,
|
||||
service_id: comp.service_id,
|
||||
server_id: comp.server_id,
|
||||
display_order: comp.display_order,
|
||||
operational_status_id: comp.operational_status_id,
|
||||
}));
|
||||
|
||||
setSelectedComponents(existingComponentsForSelector);
|
||||
setComponentsLoaded(true);
|
||||
}
|
||||
}, [open, page?.id, components, componentsLoaded, selectedComponents]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setComponentsLoaded(false);
|
||||
setSelectedComponents([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleComponentDelete = useCallback(async (componentId: string) => {
|
||||
try {
|
||||
// console.log('Deleting component:', componentId);
|
||||
await deleteComponentMutation.mutateAsync(componentId);
|
||||
|
||||
// Update local state to remove the deleted component
|
||||
setSelectedComponents(prev => prev.filter(comp => comp.id !== componentId));
|
||||
} catch (error) {
|
||||
// console.error('Error deleting component:', error);
|
||||
}
|
||||
}, [deleteComponentMutation]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
if (!page) return;
|
||||
|
||||
try {
|
||||
setIsFormSubmitting(true);
|
||||
|
||||
const payload = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
slug: data.slug,
|
||||
theme: data.theme,
|
||||
status: data.status,
|
||||
is_public: data.is_public ? 'true' : 'false',
|
||||
logo_url: data.logo_url || '',
|
||||
custom_domain: data.custom_domain || '',
|
||||
custom_css: data.custom_css || '',
|
||||
page_style: data.page_style || '',
|
||||
};
|
||||
|
||||
// console.log('Updating operational page with payload:', payload);
|
||||
await updateMutation.mutateAsync({ id: page.id, data: payload });
|
||||
|
||||
// Handle component changes
|
||||
const currentComponentIds = components.map(c => c.id);
|
||||
const newComponentsToCreate = selectedComponents.filter(comp => !comp.id);
|
||||
const componentsToDelete = components.filter(comp => !selectedComponents.some(selected => selected.id === comp.id));
|
||||
|
||||
// Delete removed components
|
||||
for (const component of componentsToDelete) {
|
||||
// console.log('Deleting component during save:', component.id);
|
||||
await deleteComponentMutation.mutateAsync(component.id);
|
||||
}
|
||||
|
||||
// Create new components
|
||||
for (const component of newComponentsToCreate) {
|
||||
const componentPayload = {
|
||||
operational_status_id: page.id,
|
||||
name: component.name || '',
|
||||
description: component.description || '',
|
||||
service_id: component.service_id || '',
|
||||
server_id: component.server_id || '',
|
||||
display_order: component.display_order || 1,
|
||||
};
|
||||
|
||||
// console.log('Creating component with payload:', componentPayload);
|
||||
await createComponentMutation.mutateAsync(componentPayload);
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
// console.error('Error updating operational page:', error);
|
||||
} finally {
|
||||
setIsFormSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('editOperationalPage')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('updateYourOperationalPage')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('title')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('slug')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('operationalPageDescriptionPlaceholder')}
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('theme')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectTheme')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t('themeDefault')}</SelectItem>
|
||||
<SelectItem value="dark">{t('themeDark')}</SelectItem>
|
||||
<SelectItem value="light">{t('themeLight')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('status')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectStatus')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="operational">{t('statusOperational')}</SelectItem>
|
||||
<SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
|
||||
<SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
|
||||
<SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_public"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t('publicPage')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('makePagePublic')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('customDomainOptional')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('customDomainPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('customDomainDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ComponentsSelector
|
||||
selectedComponents={selectedComponents}
|
||||
onComponentsChange={setSelectedComponents}
|
||||
onComponentDelete={handleComponentDelete}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
|
||||
>
|
||||
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? t('updating') : t('updatePage')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface OperationalPageCardProps {
|
||||
page: OperationalPageRecord;
|
||||
onEdit?: (page: OperationalPageRecord) => void;
|
||||
onView?: (page: OperationalPageRecord) => void;
|
||||
onDelete?: (page: OperationalPageRecord) => void;
|
||||
}
|
||||
|
||||
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-lg transition-shadow duration-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg font-semibold mb-1">{page.title}</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{page.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<StatusBadge status={page.status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">{t('slug')}:</span>
|
||||
<p className="mt-1">{page.slug}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">{t('theme')}:</span>
|
||||
<p className="mt-1 capitalize">{page.theme}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">{t('public')}:</span>
|
||||
<div className="mt-1">
|
||||
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
|
||||
{page.is_public === 'true' ? t('yes') : t('no')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">{t('updated')}:</span>
|
||||
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{page.custom_domain && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{page.custom_domain}</span>
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{onView && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onView(page)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
{t('view')}
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(page)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('edit')}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(page)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOperationalPages, useDeleteOperationalPage } from '@/hooks/useOperationalPage';
|
||||
import { CreateOperationalPageDialog } from './CreateOperationalPageDialog';
|
||||
import { EditOperationalPageDialog } from './EditOperationalPageDialog';
|
||||
import { OperationalPageCard } from './OperationalPageCard';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { Activity, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export const OperationalPageContent = () => {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
|
||||
const deleteMutation = useDeleteOperationalPage();
|
||||
|
||||
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [pageToDelete, setPageToDelete] = useState<OperationalPageRecord | null>(null);
|
||||
|
||||
const handleEdit = (page: OperationalPageRecord) => {
|
||||
setEditingPage(page);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleView = (page: OperationalPageRecord) => {
|
||||
if (page.custom_domain) {
|
||||
window.open(`https://${page.custom_domain}`, '_blank');
|
||||
} else {
|
||||
// Navigate to the public status page route using the correct format
|
||||
window.open(`/public/${page.slug}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (page: OperationalPageRecord) => {
|
||||
setPageToDelete(page);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (pageToDelete) {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(pageToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setPageToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting page:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('failedToLoadOperationalPages')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('loadingoperationalPages')}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('tryagain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2"> {t('operationalPages')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('describeOperation')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 sm:mt-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<CreateOperationalPageDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-6 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Skeleton className="h-8 flex-1" />
|
||||
<Skeleton className="h-8 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && (!pages || pages.length === 0) && (
|
||||
<Card className="p-12">
|
||||
<CardContent className="text-center">
|
||||
<div className="mb-4">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('noOperationalPagesFound')}</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t('createYourFirstOperationalPage')}
|
||||
</p>
|
||||
<CreateOperationalPageDialog />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pages Grid */}
|
||||
{!isLoading && pages && pages.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pages.map((page) => (
|
||||
<OperationalPageCard
|
||||
key={page.id}
|
||||
page={page}
|
||||
onEdit={handleEdit}
|
||||
onView={handleView}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Footer */}
|
||||
{!isLoading && pages && pages.length > 0 && (
|
||||
<div className="mt-8 pt-6 border-t">
|
||||
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">{t('totalPages')}:</span> {pages.length}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{t('totalPages')}:</span>{' '}
|
||||
{pages.filter(p => p.is_public === 'true').length}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{t('operational')}:</span>{' '}
|
||||
{pages.filter(p => p.status === 'operational').length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<EditOperationalPageDialog
|
||||
page={editingPage}
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteOperationalPage')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteOperationalPageConfirm').replace('{title}', pageToDelete?.title ?? '')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? t('deleting') : t('delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: OperationalPageRecord['status'];
|
||||
}
|
||||
|
||||
export const StatusBadge = ({ status }: StatusBadgeProps) => {
|
||||
const getStatusConfig = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return {
|
||||
label: 'Operational',
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-200',
|
||||
};
|
||||
case 'degraded':
|
||||
return {
|
||||
label: 'Degraded Performance',
|
||||
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
|
||||
};
|
||||
case 'maintenance':
|
||||
return {
|
||||
label: 'Under Maintenance',
|
||||
className: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
|
||||
};
|
||||
case 'major_outage':
|
||||
return {
|
||||
label: 'Major Outage',
|
||||
className: 'bg-red-100 text-red-800 hover:bg-red-200',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Unknown',
|
||||
className: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status);
|
||||
|
||||
return (
|
||||
<Badge className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
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";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// 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 navigate = useNavigate();
|
||||
|
||||
const form = useForm<PasswordFormValues>({
|
||||
resolver: zodResolver(passwordFormSchema),
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Function to determine which collection the user belongs to
|
||||
const getUserCollection = async (userId: string): Promise<string> => {
|
||||
try {
|
||||
// First try to find the user in the regular users collection
|
||||
await pb.collection('users').getOne(userId);
|
||||
return 'users';
|
||||
} catch (error) {
|
||||
try {
|
||||
// If not found, try the superadmin collection
|
||||
await pb.collection('_superusers').getOne(userId);
|
||||
return '_superusers';
|
||||
} catch (error) {
|
||||
throw new Error('User not found in any collection');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function onSubmit(data: PasswordFormValues) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
console.log("Starting password change for user:", userId);
|
||||
|
||||
// Determine which collection the user belongs to
|
||||
const collection = await getUserCollection(userId);
|
||||
console.log("User found in collection:", collection);
|
||||
|
||||
// PocketBase requires the old password along with the new one
|
||||
await pb.collection(collection).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. You will be logged out in 3 seconds for security.",
|
||||
});
|
||||
|
||||
// Reset the form
|
||||
form.reset();
|
||||
|
||||
// Auto logout after successful password change
|
||||
setTimeout(() => {
|
||||
console.log("Auto logout after password change");
|
||||
authService.logout();
|
||||
navigate("/login");
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Password change error:", error);
|
||||
|
||||
let errorMessage = "Failed to update password. Please try again.";
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Failed to authenticate")) {
|
||||
errorMessage = "Current password is incorrect. Please try again.";
|
||||
} else if (error.message.includes("User not found")) {
|
||||
errorMessage = "User account not found. Please contact your administrator.";
|
||||
} else {
|
||||
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,82 @@
|
||||
|
||||
import { useState, useEffect } 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;
|
||||
onUserUpdated?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ProfileContent({ currentUser, onUserUpdated }: ProfileContentProps) {
|
||||
const [activeTab, setActiveTab] = useState("details");
|
||||
|
||||
// When active tab changes, refresh user data if needed
|
||||
useEffect(() => {
|
||||
if (activeTab === "details" && onUserUpdated) {
|
||||
onUserUpdated();
|
||||
}
|
||||
}, [activeTab, onUserUpdated]);
|
||||
|
||||
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,195 @@
|
||||
|
||||
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";
|
||||
import { AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// 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 [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [updateSuccess, setUpdateSuccess] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 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);
|
||||
setUpdateError(null);
|
||||
setUpdateSuccess(null);
|
||||
|
||||
try {
|
||||
console.log("Submitting profile update with data:", data);
|
||||
|
||||
// Detect if email is being changed
|
||||
const isEmailChanged = data.email !== user.email;
|
||||
|
||||
// Create update payload with all fields
|
||||
const updateData = {
|
||||
full_name: data.full_name,
|
||||
username: data.username,
|
||||
// Only include email if it's changed
|
||||
email: isEmailChanged ? data.email : undefined,
|
||||
// Always set emailVisibility to true if email is changing
|
||||
emailVisibility: isEmailChanged ? true : undefined
|
||||
};
|
||||
|
||||
console.log("Sending update payload:", updateData);
|
||||
|
||||
// Update user data using the userService
|
||||
await userService.updateUser(user.id, updateData);
|
||||
|
||||
// If email was changed, show success message and auto-logout
|
||||
if (isEmailChanged) {
|
||||
setUpdateSuccess("Email changed successfully! You will be logged out for security reasons. Please log in again with your new email.");
|
||||
|
||||
toast({
|
||||
title: "Email changed successfully",
|
||||
description: "You will be logged out for security reasons. Please log in again with your new email.",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// Auto-logout after 3 seconds
|
||||
setTimeout(() => {
|
||||
authService.logout();
|
||||
navigate("/login");
|
||||
}, 3000);
|
||||
} else {
|
||||
// Refresh user data in auth context for other field changes
|
||||
await authService.refreshUserData();
|
||||
|
||||
setUpdateSuccess("Your profile information has been updated successfully.");
|
||||
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;
|
||||
}
|
||||
|
||||
setUpdateError(errorMessage);
|
||||
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{updateError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{updateError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{updateSuccess && (
|
||||
<Alert className="bg-green-50 border-green-200 text-green-800">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription>
|
||||
{updateSuccess}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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 />
|
||||
{field.value !== user.email && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Changing your email will log you out for security reasons. You will need to log in again with your new email.
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
// Determine if the user is active based on the status field
|
||||
const isActive = user.status === "Active";
|
||||
|
||||
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={isActive ? "default" : "outline"} className="px-2 py-1">
|
||||
{user.status || (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,210 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Server, CheckCircle, XCircle, AlertTriangle, Pause, Clock } from 'lucide-react';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { Service, UptimeData } from '@/types/service.types';
|
||||
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface ComponentsStatusSectionProps {
|
||||
components: StatusPageComponentRecord[];
|
||||
services: Service[];
|
||||
uptimeData: Record<string, UptimeData[]>;
|
||||
}
|
||||
|
||||
export const ComponentsStatusSection = ({ components, services, uptimeData }: ComponentsStatusSectionProps) => {
|
||||
const getServiceForComponent = (component: StatusPageComponentRecord) => {
|
||||
return services.find(service => service.id === component.service_id);
|
||||
};
|
||||
|
||||
const getComponentStatus = (component: StatusPageComponentRecord) => {
|
||||
const service = getServiceForComponent(component);
|
||||
return service?.status || 'unknown';
|
||||
};
|
||||
|
||||
const getUptimePercentage = (serviceId: string) => {
|
||||
const history = uptimeData[serviceId] || [];
|
||||
if (history.length === 0) return 100;
|
||||
|
||||
const upCount = history.filter(record => record.status === 'up').length;
|
||||
return Math.round((upCount / history.length) * 100 * 100) / 100;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'down':
|
||||
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
case 'paused':
|
||||
return <Pause className="h-5 w-5 text-gray-500" />;
|
||||
default:
|
||||
return <Server className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-200 dark:border-green-800">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Operational
|
||||
</Badge>
|
||||
);
|
||||
case 'down':
|
||||
return (
|
||||
<Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-red-200 dark:border-red-800">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Down
|
||||
</Badge>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<Badge className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Degraded
|
||||
</Badge>
|
||||
);
|
||||
case 'paused':
|
||||
return (
|
||||
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200 border-gray-200 dark:border-gray-800">
|
||||
<Pause className="h-3 w-3 mr-1" />
|
||||
Maintenance
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusDotColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return 'bg-green-500';
|
||||
case 'down':
|
||||
return 'bg-red-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (components.length === 0) {
|
||||
return (
|
||||
<Card className="mb-8 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||
<Server className="h-5 w-5" />
|
||||
System Components
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: 'API Services', description: 'Core API endpoints and services', status: 'up' },
|
||||
{ name: 'Database', description: 'Primary database systems', status: 'up' },
|
||||
{ name: 'Authentication', description: 'User authentication services', status: 'up' },
|
||||
{ name: 'File Storage', description: 'Media and file hosting', status: 'up' }
|
||||
].map((component, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(component.status)}
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">{component.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{component.description}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">99.9% uptime</span>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">100ms response</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(component.status)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-8 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||
<Server className="h-5 w-5" />
|
||||
System Components
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real-time status of all monitored components
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{components
|
||||
.sort((a, b) => a.display_order - b.display_order)
|
||||
.map((component) => {
|
||||
const service = getServiceForComponent(component);
|
||||
const status = getComponentStatus(component);
|
||||
const uptime = service?.id ? getUptimePercentage(component.service_id) : 100;
|
||||
|
||||
return (
|
||||
<div key={component.id} className="space-y-4">
|
||||
<div className="flex items-center justify-between p-5 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-all duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon(status)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-foreground text-lg">{component.name}</h3>
|
||||
{service?.responseTime && service.responseTime > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
<Clock className="h-3 w-3" />
|
||||
{service.responseTime}ms
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{component.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">{component.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full ${getStatusDotColor(status)}`}></div>
|
||||
<span className="font-medium">{uptime}% uptime (90 days)</span>
|
||||
</div>
|
||||
{service?.lastChecked && (
|
||||
<span>Last checked: {format(new Date(service.lastChecked), 'HH:mm:ss')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{getStatusBadge(status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{component.service_id && (
|
||||
<div className="ml-9">
|
||||
<div className="text-xs text-muted-foreground mb-2">90-day uptime history</div>
|
||||
<UptimeHistoryRenderer
|
||||
serviceId={component.service_id}
|
||||
uptimeData={uptimeData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Shield, Clock, CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { Service } from '@/types/service.types';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
interface CurrentStatusSectionProps {
|
||||
page: OperationalPageRecord;
|
||||
components: StatusPageComponentRecord[];
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
const getActualStatus = (components: StatusPageComponentRecord[], services: Service[]) => {
|
||||
if (components.length === 0) {
|
||||
return 'operational'; // Default if no components
|
||||
}
|
||||
|
||||
let hasDown = false;
|
||||
let hasDegraded = false;
|
||||
let hasMaintenance = false;
|
||||
|
||||
components.forEach(component => {
|
||||
const service = services.find(s => s.id === component.service_id);
|
||||
if (service) {
|
||||
switch (service.status) {
|
||||
case 'down':
|
||||
hasDown = true;
|
||||
break;
|
||||
case 'warning':
|
||||
hasDegraded = true;
|
||||
break;
|
||||
case 'paused':
|
||||
hasMaintenance = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Priority: down > degraded > maintenance > operational
|
||||
if (hasDown) return 'major_outage';
|
||||
if (hasDegraded) return 'degraded';
|
||||
if (hasMaintenance) return 'maintenance';
|
||||
return 'operational';
|
||||
};
|
||||
|
||||
const getStatusMessage = (status: OperationalPageRecord['status'], t: (k: string, m?: string) => string) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return t('allOperational', 'public');
|
||||
case 'degraded':
|
||||
return t('degradedPerformance', 'public');
|
||||
case 'maintenance':
|
||||
return t('underMaintenance', 'public');
|
||||
case 'major_outage':
|
||||
return t('majorOutage', 'public');
|
||||
default:
|
||||
return t('statusUnknown', 'public');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'degraded':
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
case 'maintenance':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'major_outage':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return <CheckCircle className="h-6 w-6 text-green-500" />;
|
||||
case 'degraded':
|
||||
return <AlertTriangle className="h-6 w-6 text-yellow-500" />;
|
||||
case 'maintenance':
|
||||
return <Wrench className="h-6 w-6 text-blue-500" />;
|
||||
case 'major_outage':
|
||||
return <XCircle className="h-6 w-6 text-red-500" />;
|
||||
default:
|
||||
return <Shield className="h-6 w-6 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBackground = (status: OperationalPageRecord['status']) => {
|
||||
switch (status) {
|
||||
case 'operational':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
|
||||
case 'degraded':
|
||||
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
|
||||
case 'maintenance':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
|
||||
case 'major_outage':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
|
||||
default:
|
||||
return 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
|
||||
const { t } = useLanguage();
|
||||
const actualStatus = getActualStatus(components, services);
|
||||
const displayStatus = actualStatus; // Use actual status for real-time accuracy
|
||||
|
||||
return (
|
||||
<Card className={`mb-8 border-2 ${getStatusBackground(displayStatus)}`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
|
||||
<Shield className="h-6 w-6" />
|
||||
{t('systemStatus', 'public')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className={`flex items-center justify-between p-6 rounded-lg border-2 ${getStatusBackground(displayStatus)}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
{getStatusIcon(displayStatus)}
|
||||
<div>
|
||||
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
|
||||
{getStatusMessage(displayStatus, t)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('autoUpdatedByHealth', 'public')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-4 py-2 rounded-full text-sm font-medium ${
|
||||
displayStatus === 'operational' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
|
||||
displayStatus === 'degraded' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
|
||||
displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{displayStatus === 'operational' ? t('allOperational', 'public') :
|
||||
displayStatus === 'degraded' ? t('degradedPerformance', 'public') :
|
||||
displayStatus === 'maintenance' ? t('underMaintenance', 'public') : t('majorOutage', 'public')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{t('lastUpdatedAt', 'public', { time: format(new Date(), 'MMM dd, yyyy HH:mm') })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>{t('liveStatusMonitoring', 'public')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, Calendar, BarChart3 } from 'lucide-react';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||
|
||||
interface OverallUptimeSectionProps {
|
||||
uptimeData: Record<string, UptimeData[]>;
|
||||
}
|
||||
|
||||
export const OverallUptimeSection = ({ uptimeData }: OverallUptimeSectionProps) => {
|
||||
const getOverallUptime = () => {
|
||||
const allHistories = Object.values(uptimeData);
|
||||
if (allHistories.length === 0) return 99.9;
|
||||
|
||||
let totalRecords = 0;
|
||||
let upRecords = 0;
|
||||
|
||||
allHistories.forEach(history => {
|
||||
totalRecords += history.length;
|
||||
upRecords += history.filter(record => record.status === 'up').length;
|
||||
});
|
||||
|
||||
if (totalRecords === 0) return 99.9;
|
||||
return Math.round((upRecords / totalRecords) * 100 * 100) / 100;
|
||||
};
|
||||
|
||||
const getUptimeTrend = () => {
|
||||
const uptime = getOverallUptime();
|
||||
if (uptime >= 99.9) return 'excellent';
|
||||
if (uptime >= 99.5) return 'good';
|
||||
if (uptime >= 95) return 'fair';
|
||||
return 'poor';
|
||||
};
|
||||
|
||||
const getIncidentCount = () => {
|
||||
const allHistories = Object.values(uptimeData);
|
||||
let incidents = 0;
|
||||
|
||||
allHistories.forEach(history => {
|
||||
let wasDown = false;
|
||||
history.forEach(record => {
|
||||
if (record.status === 'down' && !wasDown) {
|
||||
incidents++;
|
||||
wasDown = true;
|
||||
} else if (record.status === 'up') {
|
||||
wasDown = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return incidents;
|
||||
};
|
||||
|
||||
const getBadgeClassName = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'excellent':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
case 'good':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
case 'fair':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
default:
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendText = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'excellent':
|
||||
return 'Excellent';
|
||||
case 'good':
|
||||
return 'Good';
|
||||
case 'fair':
|
||||
return 'Fair';
|
||||
default:
|
||||
return 'Needs Improvement';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusMessage = (uptime: number) => {
|
||||
if (uptime >= 99.9) {
|
||||
return "All systems are performing excellently with minimal downtime.";
|
||||
} else if (uptime >= 99.5) {
|
||||
return "Systems are performing well with occasional minor issues.";
|
||||
} else if (uptime >= 95) {
|
||||
return "We're working to improve system reliability and reduce incidents.";
|
||||
} else {
|
||||
return "We apologize for recent service disruptions and are actively working on improvements.";
|
||||
}
|
||||
};
|
||||
|
||||
const overallUptime = getOverallUptime();
|
||||
const trend = getUptimeTrend();
|
||||
const incidentCount = getIncidentCount();
|
||||
|
||||
return (
|
||||
<Card className="mb-8 bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Performance Metrics (Last 90 Days)
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Historical performance and reliability statistics
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-300">Overall Uptime</span>
|
||||
</div>
|
||||
<Badge className={getBadgeClassName(trend)}>
|
||||
{getTrendText(trend)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-green-600 dark:text-green-400">{overallUptime}%</div>
|
||||
<div className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
Target: 99.9%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Incidents</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">{incidentCount}</div>
|
||||
<div className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||
Last 90 days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Avg Response</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">100ms</div>
|
||||
<div className="text-xs text-purple-700 dark:text-purple-300 mt-1">
|
||||
Response time
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-foreground">Uptime History</h4>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 bg-green-500 rounded"></div>
|
||||
<span>Operational</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 bg-yellow-500 rounded"></div>
|
||||
<span>Degraded</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 bg-red-500 rounded"></div>
|
||||
<span>Down</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-background/50 rounded-lg border">
|
||||
{Object.keys(uptimeData).length > 0 ? (
|
||||
<UptimeHistoryRenderer serviceId={Object.keys(uptimeData)[0]} uptimeData={uptimeData} />
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-12 text-muted-foreground">
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 90 }, (_, i) => (
|
||||
<div key={i} className="h-8 w-1 bg-green-500 rounded-sm"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>90 days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg border">
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
{getStatusMessage(overallUptime)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { usePublicStatusPageData } from './hooks/usePublicStatusPageData';
|
||||
import { StatusPageHeader } from './StatusPageHeader';
|
||||
import { CurrentStatusSection } from './CurrentStatusSection';
|
||||
import { ComponentsStatusSection } from './ComponentsStatusSection';
|
||||
import { OverallUptimeSection } from './OverallUptimeSection';
|
||||
import { PublicStatusPageFooter } from './PublicStatusPageFooter';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
export const PublicStatusPage = () => {
|
||||
const { t } = useLanguage();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
// console.log('PublicStatusPage - slug from params:', slug);
|
||||
|
||||
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
|
||||
const [lastUpdated, setLastUpdated] = useState(new Date());
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setLastUpdated(new Date());
|
||||
// The usePublicStatusPageData hook handles data refetching
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
if (page) {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove any existing theme classes
|
||||
root.classList.remove('dark', 'light');
|
||||
|
||||
// Apply the selected theme
|
||||
if (page.theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else if (page.theme === 'light') {
|
||||
root.classList.add('light');
|
||||
}
|
||||
// For 'default' theme, don't add any class (uses system preference)
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark', 'light');
|
||||
};
|
||||
}, [page?.theme]);
|
||||
|
||||
// console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium text-foreground">{t('loadingStatusPage', 'public')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('fetchingRealtimeStatus', 'public')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !page) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-6 max-w-md">
|
||||
<div className="mx-auto h-16 w-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('statusPageNotFound', 'public')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{error || t('notFoundDescription', 'public')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button onClick={() => window.history.back()} variant="outline">
|
||||
{t('goBack', 'public')}
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('retry', 'public')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* Header */}
|
||||
<StatusPageHeader page={page} />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Current Status */}
|
||||
<CurrentStatusSection page={page} components={components} services={services} />
|
||||
|
||||
{/* Components Status */}
|
||||
<ComponentsStatusSection
|
||||
components={components}
|
||||
services={services}
|
||||
uptimeData={uptimeData}
|
||||
/>
|
||||
|
||||
{/* Overall Uptime History */}
|
||||
<OverallUptimeSection uptimeData={uptimeData} />
|
||||
|
||||
{/* Footer */}
|
||||
<PublicStatusPageFooter page={page} />
|
||||
</main>
|
||||
|
||||
{/* Custom CSS */}
|
||||
{page.custom_css && (
|
||||
<style dangerouslySetInnerHTML={{ __html: page.custom_css }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { format } from 'date-fns';
|
||||
import { Clock, Shield, Zap, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface PublicStatusPageFooterProps {
|
||||
page: OperationalPageRecord;
|
||||
}
|
||||
|
||||
export const PublicStatusPageFooter = ({ page }: PublicStatusPageFooterProps) => {
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="mt-12 pt-8 border-t border-border">
|
||||
<div className="space-y-6">
|
||||
{/* Status Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6 bg-muted/30 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Real-time Monitoring</div>
|
||||
<div className="text-sm text-muted-foreground">24/7 automated checks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
|
||||
<Zap className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Instant Updates</div>
|
||||
<div className="text-sm text-muted-foreground">Status changes in real-time</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Historical Data</div>
|
||||
<div className="text-sm text-muted-foreground">90-day performance history</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm:ss')} UTC</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Monitoring active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh Status
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="text-center text-xs text-muted-foreground p-4 bg-muted/20 rounded-lg">
|
||||
<p>
|
||||
This status page provides real-time information about our systems and services.
|
||||
Historical data reflects the last 90 days of monitoring. For support inquiries, please contact our team.
|
||||
</p>
|
||||
{page.custom_domain && (
|
||||
<p className="mt-2">
|
||||
Powered by automated monitoring • Status page for {page.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { Shield, Globe, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface StatusPageHeaderProps {
|
||||
page: OperationalPageRecord;
|
||||
}
|
||||
|
||||
export const StatusPageHeader = ({ page }: StatusPageHeaderProps) => {
|
||||
return (
|
||||
<header className="bg-background border-b border-border">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{page.logo_url ? (
|
||||
<img
|
||||
src={page.logo_url}
|
||||
alt={`${page.title} logo`}
|
||||
className="h-12 w-12 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">{page.title}</h1>
|
||||
<p className="text-muted-foreground mt-1">{page.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{page.custom_domain && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={`https://${page.custom_domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Visit Site
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-medium">Live Status</span>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
Auto-updated every 30s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Status Page</span>
|
||||
<span>•</span>
|
||||
<span className="text-foreground font-medium">{page.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { format } from 'date-fns';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
|
||||
interface UptimeHistoryRendererProps {
|
||||
serviceId: string;
|
||||
uptimeData: Record<string, UptimeData[]>;
|
||||
}
|
||||
|
||||
export const UptimeHistoryRenderer = ({ serviceId, uptimeData }: UptimeHistoryRendererProps) => {
|
||||
const renderUptimeHistory = (serviceId: string) => {
|
||||
const history = uptimeData[serviceId] || [];
|
||||
|
||||
// Generate array for the last 90 days
|
||||
const days = Array.from({ length: 90 }, (_, i) => {
|
||||
const daysSinceToday = 90 - i - 1;
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysSinceToday);
|
||||
date.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
||||
return date;
|
||||
});
|
||||
|
||||
if (history.length === 0) {
|
||||
// Generate mock data if no real data - showing mostly operational with some realistic incidents
|
||||
return days.map((date, i) => {
|
||||
// Simulate some realistic patterns - mostly up with occasional incidents
|
||||
const isUp = Math.random() > 0.02; // 98% uptime simulation
|
||||
const responseTime = isUp ? Math.floor(Math.random() * 200) + 50 : 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider key={i}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-8 w-1 rounded-sm cursor-pointer ${
|
||||
isUp ? 'bg-green-500 hover:bg-green-600' : 'bg-red-500 hover:bg-red-600'
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{format(date, 'MMM dd, yyyy')}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Status: {isUp ? 'Operational' : 'Incident - Down'}
|
||||
</div>
|
||||
{isUp && (
|
||||
<div className="text-muted-foreground">
|
||||
Response: {responseTime}ms
|
||||
</div>
|
||||
)}
|
||||
{!isUp && (
|
||||
<div className="text-muted-foreground text-red-400">
|
||||
Service outage detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Create a map of dates to status records for efficient lookup and incident tracking
|
||||
const dateToRecordMap = new Map();
|
||||
const incidentsByDate = new Map();
|
||||
|
||||
history.forEach(record => {
|
||||
const recordDate = new Date(record.timestamp);
|
||||
recordDate.setHours(0, 0, 0, 0); // Normalize to start of day
|
||||
const dateKey = recordDate.toDateString();
|
||||
|
||||
// Track incidents for this date
|
||||
if (!incidentsByDate.has(dateKey)) {
|
||||
incidentsByDate.set(dateKey, []);
|
||||
}
|
||||
incidentsByDate.get(dateKey).push(record);
|
||||
|
||||
// Keep the latest record for each day (or aggregate if needed)
|
||||
if (!dateToRecordMap.has(dateKey) ||
|
||||
new Date(record.timestamp) > new Date(dateToRecordMap.get(dateKey).timestamp)) {
|
||||
dateToRecordMap.set(dateKey, record);
|
||||
}
|
||||
});
|
||||
|
||||
// Use real uptime data mapped to the correct days with incident details
|
||||
return days.map((date, i) => {
|
||||
const dateKey = date.toDateString();
|
||||
const record = dateToRecordMap.get(dateKey);
|
||||
const incidents = incidentsByDate.get(dateKey) || [];
|
||||
|
||||
// Calculate uptime percentage for the day
|
||||
const uptimePercentage = incidents.length > 0 ?
|
||||
Math.round((incidents.filter(inc => inc.status === 'up').length / incidents.length) * 100) : 100;
|
||||
|
||||
// Determine color based on actual status and incident history
|
||||
const getStatusColor = (status: string, incidents: UptimeData[]) => {
|
||||
const downIncidents = incidents.filter(inc => inc.status === 'down').length;
|
||||
const warningIncidents = incidents.filter(inc => inc.status === 'warning').length;
|
||||
|
||||
if (downIncidents > 0) return 'bg-red-500 hover:bg-red-600';
|
||||
if (warningIncidents > 0) return 'bg-yellow-500 hover:bg-yellow-600';
|
||||
|
||||
switch (status) {
|
||||
case 'up':
|
||||
return 'bg-green-500 hover:bg-green-600';
|
||||
case 'down':
|
||||
return 'bg-red-500 hover:bg-red-600';
|
||||
case 'paused':
|
||||
return 'bg-gray-400 hover:bg-gray-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500 hover:bg-yellow-600';
|
||||
default:
|
||||
return 'bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = record ? getStatusColor(record.status, incidents) : 'bg-gray-300 dark:bg-gray-600';
|
||||
const statusText = record ?
|
||||
record.status === 'up' ? (incidents.some(inc => inc.status === 'down') ? 'Recovered' : 'Operational') :
|
||||
record.status === 'down' ? 'Incident - Down' :
|
||||
record.status === 'paused' ? 'Paused' :
|
||||
record.status === 'warning' ? 'Incident - Degraded' : 'Unknown'
|
||||
: 'No Data';
|
||||
|
||||
return (
|
||||
<TooltipProvider key={i}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`h-8 w-1 rounded-sm cursor-pointer ${statusColor}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{format(date, 'MMM dd, yyyy')}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Status: {statusText}
|
||||
</div>
|
||||
{incidents.length > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
Uptime: {uptimePercentage}% ({incidents.length} checks)
|
||||
</div>
|
||||
)}
|
||||
{incidents.filter(inc => inc.status === 'down').length > 0 && (
|
||||
<div className="text-red-400 text-xs">
|
||||
{incidents.filter(inc => inc.status === 'down').length} incident(s) detected
|
||||
</div>
|
||||
)}
|
||||
{record && record.status === 'up' && record.responseTime > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
Response: {record.responseTime}ms
|
||||
</div>
|
||||
)}
|
||||
{record && (
|
||||
<div className="text-muted-foreground">
|
||||
Last Check: {format(new Date(record.timestamp), 'HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-8 p-3 bg-background/30 rounded-lg border border-border/50">
|
||||
<div className="text-sm font-medium text-foreground mb-2">90-day uptime history</div>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
{renderUptimeHistory(serviceId)}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>90 days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { OperationalPageRecord } from '@/types/operational.types';
|
||||
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||
import { Service, UptimeData } from '@/types/service.types';
|
||||
import { operationalPageService } from '@/services/operationalPageService';
|
||||
import { statusPageComponentsService } from '@/services/statusPageComponentsService';
|
||||
import { serviceService } from '@/services/serviceService';
|
||||
import { uptimeService } from '@/services/uptimeService';
|
||||
|
||||
export const usePublicStatusPageData = (slug: string | undefined) => {
|
||||
const [page, setPage] = useState<OperationalPageRecord | null>(null);
|
||||
const [components, setComponents] = useState<StatusPageComponentRecord[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [uptimeData, setUptimeData] = useState<Record<string, UptimeData[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPublicPage = async () => {
|
||||
if (!slug) {
|
||||
// console.log('No slug provided');
|
||||
setError('No status page slug provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// console.log('Fetching public status page for slug:', slug);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch operational page
|
||||
// console.log('Fetching operational pages...');
|
||||
const pages = await operationalPageService.getOperationalPages();
|
||||
// console.log('All pages:', pages);
|
||||
|
||||
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
|
||||
// console.log('Found page:', foundPage);
|
||||
|
||||
if (!foundPage) {
|
||||
// console.log('Page not found or not public');
|
||||
setError('Status page not found or not public');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPage(foundPage);
|
||||
// console.log('Page set successfully');
|
||||
|
||||
// Fetch components for this page
|
||||
// console.log('Fetching components for page:', foundPage.id);
|
||||
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
|
||||
// console.log('Components found:', pageComponents);
|
||||
setComponents(pageComponents);
|
||||
|
||||
// Fetch all services
|
||||
// console.log('Fetching all services...');
|
||||
const allServices = await serviceService.getServices();
|
||||
// console.log('Services found:', allServices);
|
||||
setServices(allServices);
|
||||
|
||||
// Fetch uptime data for each component that has a service
|
||||
// console.log('Fetching uptime data...');
|
||||
const uptimePromises = pageComponents
|
||||
.filter(component => component.service_id)
|
||||
.map(async (component) => {
|
||||
try {
|
||||
// console.log('Fetching uptime for service:', component.service_id);
|
||||
const endDate = new Date();
|
||||
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
|
||||
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
|
||||
// console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
||||
return { serviceId: component.service_id, history };
|
||||
} catch (error) {
|
||||
// console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
||||
return { serviceId: component.service_id, history: [] };
|
||||
}
|
||||
});
|
||||
|
||||
const uptimeResults = await Promise.all(uptimePromises);
|
||||
const uptimeMap: Record<string, UptimeData[]> = {};
|
||||
uptimeResults.forEach(result => {
|
||||
uptimeMap[result.serviceId] = result.history;
|
||||
});
|
||||
setUptimeData(uptimeMap);
|
||||
// console.log('Uptime data set successfully');
|
||||
|
||||
// console.log('All data fetched successfully');
|
||||
|
||||
} catch (err) {
|
||||
// console.error('Error fetching public page:', err);
|
||||
setError(`Failed to load status page: ${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPublicPage();
|
||||
}, [slug]);
|
||||
|
||||
return {
|
||||
page,
|
||||
components,
|
||||
services,
|
||||
uptimeData,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { getCurrentEndpoint } from "@/lib/pocketbase";
|
||||
import { RegionalAgentConfigForm } from "./RegionalAgentConfigForm";
|
||||
import { RegionalOneClickTab } from "./RegionalOneClickTab";
|
||||
import { RegionalManualTab } from "./RegionalManualTab";
|
||||
|
||||
interface AddRegionalAgentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAgentAdded: () => void;
|
||||
}
|
||||
|
||||
export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAgentAdded
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState("configure");
|
||||
const [formData, setFormData] = useState({
|
||||
regionName: "",
|
||||
agentIp: "",
|
||||
});
|
||||
const [agentToken, setAgentToken] = useState("");
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [currentPocketBaseUrl, setCurrentPocketBaseUrl] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Generate new credentials when dialog opens or after successful creation
|
||||
const generateNewCredentials = () => {
|
||||
const newToken = `rgn_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
|
||||
const newAgentId = `regional_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
setAgentToken(newToken);
|
||||
setAgentId(newAgentId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const endpoint = getCurrentEndpoint();
|
||||
setCurrentPocketBaseUrl(endpoint);
|
||||
generateNewCredentials();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.regionName.trim() || !formData.agentIp.trim()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('agentCreatedSuccessfully'),
|
||||
});
|
||||
setActiveTab("one-click");
|
||||
generateNewCredentials();
|
||||
|
||||
onAgentAdded();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('failedToCreateAgent'),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setFormData({
|
||||
regionName: "",
|
||||
agentIp: "",
|
||||
});
|
||||
setActiveTab("configure");
|
||||
generateNewCredentials();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('addRegionalMonitoringAgent')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('deployRegionalMonitoringAgent')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="configure">{t('configureAgent')}</TabsTrigger>
|
||||
<TabsTrigger value="one-click">{t('oneClickInstallTab')}</TabsTrigger>
|
||||
<TabsTrigger value="manual">{t('manualInstallTab')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="configure" className="space-y-6">
|
||||
<RegionalAgentConfigForm
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
agentId={agentId}
|
||||
agentToken={agentToken}
|
||||
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="one-click" className="space-y-6">
|
||||
<RegionalOneClickTab
|
||||
agentToken={agentToken}
|
||||
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||
formData={formData}
|
||||
agentId={agentId}
|
||||
onDialogClose={handleDialogClose}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="space-y-6">
|
||||
<RegionalManualTab
|
||||
agentToken={agentToken}
|
||||
currentPocketBaseUrl={currentPocketBaseUrl}
|
||||
formData={formData}
|
||||
agentId={agentId}
|
||||
onDialogClose={handleDialogClose}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { MapPin, Wifi, WifiOff, MoreVertical, Trash2, Terminal, Copy } from "lucide-react";
|
||||
import { RegionalService } from "@/types/regional.types";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface RegionalAgentCardProps {
|
||||
agent: RegionalService;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Check if this is the default agent that cannot be removed
|
||||
const isDefaultAgent = agent.agent_id === "1" || agent.region_name === "Default";
|
||||
|
||||
const copyAgentId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(agent.agent_id);
|
||||
toast({
|
||||
title: t('copied'),
|
||||
description: t('copiedDescription'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('copyFailed'),
|
||||
description: t('copyFailedDescription'),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionStatus = () => {
|
||||
if (agent.connection === 'online') {
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4" />,
|
||||
label: t('online'),
|
||||
variant: 'default' as const,
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-100'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
label: t('offline'),
|
||||
variant: 'secondary' as const,
|
||||
className: 'bg-red-100 text-red-800 hover:bg-red-100'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const connectionStatus = getConnectionStatus();
|
||||
|
||||
return (
|
||||
<Card className="relative">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-5 w-5 text-blue-600" />
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{agent.region_name}
|
||||
{isDefaultAgent && (
|
||||
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
|
||||
{t('defaultBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1 text-sm">
|
||||
{agent.agent_ip_address}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={copyAgentId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{t('copyAgentId')}
|
||||
</DropdownMenuItem>
|
||||
{!isDefaultAgent && (
|
||||
<DropdownMenuItem onClick={onDelete} className="text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('removeAgent')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={connectionStatus.variant}
|
||||
className={connectionStatus.className}
|
||||
>
|
||||
{connectionStatus.icon}
|
||||
<span className="ml-1">{connectionStatus.label}</span>
|
||||
</Badge>
|
||||
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{agent.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('agentId')}</span>
|
||||
<span className="font-mono text-xs">{agent.agent_id.substring(0, 12)}...</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t('lastUpdated')}</span>
|
||||
<span className="text-xs">
|
||||
{formatDistanceToNow(new Date(agent.updated), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agent.connection === 'online' && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center text-xs text-green-600">
|
||||
<div className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></div>
|
||||
{t('activeMonitoring')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent.connection === 'offline' && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex items-center text-xs text-red-600">
|
||||
<div className="w-2 h-2 bg-red-600 rounded-full mr-2"></div>
|
||||
{t('connectionLost')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy } from "lucide-react";
|
||||
import { copyToClipboard } from "@/utils/copyUtils";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface RegionalAgentConfigFormProps {
|
||||
formData: {
|
||||
regionName: string;
|
||||
agentIp: string;
|
||||
};
|
||||
setFormData: (data: { regionName: string; agentIp: string }) => void;
|
||||
agentId: string;
|
||||
agentToken: string;
|
||||
currentPocketBaseUrl: string;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
export const RegionalAgentConfigForm: React.FC<RegionalAgentConfigFormProps> = ({
|
||||
formData,
|
||||
setFormData,
|
||||
agentId,
|
||||
agentToken,
|
||||
currentPocketBaseUrl,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="regionName">{t('regionName')}</Label>
|
||||
<Input
|
||||
id="regionName"
|
||||
placeholder={t('regionNamePlaceholder')}
|
||||
value={formData.regionName}
|
||||
onChange={(e) => setFormData({ ...formData, regionName: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="agentIp">{t('agentServerIpAddress')}</Label>
|
||||
<Input
|
||||
id="agentIp"
|
||||
placeholder={t('agentIpPlaceholder')}
|
||||
value={formData.agentIp}
|
||||
onChange={(e) => setFormData({ ...formData, agentIp: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('agentId')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={agentId}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
|
||||
onClick={() => copyToClipboard(agentId)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('apiEndpoint')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={currentPocketBaseUrl}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
|
||||
onClick={() => copyToClipboard(currentPocketBaseUrl)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{t('authenticationToken')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={agentToken}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
|
||||
onClick={() => copyToClipboard(agentToken)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('creating') : t('createAgent')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Terminal } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { copyToClipboard } from "@/utils/copyUtils";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
interface RegionalManualTabProps {
|
||||
agentToken: string;
|
||||
currentPocketBaseUrl: string;
|
||||
formData: {
|
||||
regionName: string;
|
||||
agentIp: string;
|
||||
};
|
||||
agentId: string;
|
||||
onDialogClose: () => void;
|
||||
}
|
||||
|
||||
export const RegionalManualTab: React.FC<RegionalManualTabProps> = ({
|
||||
agentToken,
|
||||
currentPocketBaseUrl,
|
||||
formData,
|
||||
agentId,
|
||||
onDialogClose,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const getManualInstallSteps = () => {
|
||||
return [
|
||||
{
|
||||
title: t('step1DownloadPackage'),
|
||||
commands: [
|
||||
{
|
||||
label: t('downloadAmd64Notice'),
|
||||
command: 'wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_amd64.deb'
|
||||
},
|
||||
{
|
||||
label: t('downloadArm64Notice'),
|
||||
command: 'wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_arm64.deb'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('step2InstallPackage'),
|
||||
commands: [
|
||||
{
|
||||
label: '',
|
||||
command: 'sudo dpkg -i distributed-regional-check-agent_1.0.0_amd64.deb\nsudo apt-get install -f'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('step3ConfigureAgent'),
|
||||
commands: [
|
||||
{
|
||||
label: '',
|
||||
command: 'sudo nano /etc/regional-check-agent/regional-check-agent.env'
|
||||
},
|
||||
{
|
||||
label: t('addConfigurationValues'),
|
||||
command: `POCKETBASE_URL=${currentPocketBaseUrl}
|
||||
AGENT_TOKEN=${agentToken}
|
||||
AGENT_ID=${agentId}
|
||||
REGION_NAME=${formData.regionName}
|
||||
AGENT_IP=${formData.agentIp}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('step4StartService'),
|
||||
commands: [
|
||||
{
|
||||
label: '',
|
||||
command: 'sudo systemctl enable regional-check-agent\nsudo systemctl start regional-check-agent'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('step5VerifyInstallation'),
|
||||
commands: [
|
||||
{
|
||||
label: '',
|
||||
command: 'sudo systemctl status regional-check-agent\ncurl http://localhost:8091/health'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
{t('manualInstallationSteps')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('stepByStepManualInstallation')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{getManualInstallSteps().map((step, stepIndex) => (
|
||||
<div key={stepIndex} className="border-l-4 border-blue-500 pl-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm font-medium">
|
||||
{stepIndex + 1}
|
||||
</span>
|
||||
<p className="font-medium">{step.title}</p>
|
||||
</div>
|
||||
{step.commands.map((cmd, cmdIndex) => (
|
||||
<div key={cmdIndex} className="space-y-1">
|
||||
{cmd.label && (
|
||||
<p className="text-xs text-muted-foreground ml-8">{cmd.label}</p>
|
||||
)}
|
||||
<div className="ml-8 relative">
|
||||
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all">
|
||||
<code>{cmd.command}</code>
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => copyToClipboard(cmd.command)}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">Prerequisites:</h4>
|
||||
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1 list-disc list-inside mb-3">
|
||||
<li>Ensure you have root/sudo access on the target server</li>
|
||||
<li>Make sure wget or curl is installed for downloading files</li>
|
||||
<li>Internet connection required for downloading packages</li>
|
||||
</ul>
|
||||
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">After Installation:</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
The agent will start automatically and appear in your dashboard within a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onDialogClose}>
|
||||
{t('done')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, MapPin, Activity, Wifi, WifiOff } from "lucide-react";
|
||||
import { regionalService } from "@/services/regionalService";
|
||||
import { RegionalService } from "@/types/regional.types";
|
||||
import { AddRegionalAgentDialog } from "./AddRegionalAgentDialog";
|
||||
import { RegionalAgentCard } from "./RegionalAgentCard";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
export const RegionalMonitoringContent = () => {
|
||||
const { t } = useLanguage();
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: regionalServices = [], isLoading, error } = useQuery({
|
||||
queryKey: ['regional-services'],
|
||||
queryFn: regionalService.getRegionalServices,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
});
|
||||
|
||||
const handleAgentAdded = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
|
||||
toast({
|
||||
title: t('regionalAgentAdded'),
|
||||
description: t('regionalAgentAddedDesc'),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAgent = async (id: string) => {
|
||||
try {
|
||||
await regionalService.deleteRegionalService(id);
|
||||
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
|
||||
toast({
|
||||
title: t('agentRemoved'),
|
||||
description: t('agentRemovedDesc'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('failedToRemoveAgent'),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onlineAgents = regionalServices.filter(agent => agent.connection === 'online').length;
|
||||
const totalAgents = regionalServices.length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('regionalmonitoring')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('descriptRegionPage')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('addRegionalAgent')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('totalAgents')}</CardTitle>
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalAgents}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('regionalMonitoringAgents')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('onlineAgents')}</CardTitle>
|
||||
<Wifi className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{onlineAgents}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('currentlyConnected')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('offlineAgents')}</CardTitle>
|
||||
<WifiOff className="h-4 w-4 text-red-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{totalAgents - onlineAgents}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('disconnectedAgents')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Agents List */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t('regionalAgents')}</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-gray-200 rounded"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-5/6"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : regionalServices.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<MapPin className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">{t('noRegionalAgents')}</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
{t('getStartedAddAgent')}
|
||||
</p>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('addFirstAgent')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{regionalServices.map((agent) => (
|
||||
<RegionalAgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onDelete={() => handleDeleteAgent(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddRegionalAgentDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
onAgentAdded={handleAgentAdded}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Copy, Download, Zap, Play } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { copyToClipboard } from "@/utils/copyUtils";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface RegionalOneClickTabProps {
|
||||
agentToken: string;
|
||||
currentPocketBaseUrl: string;
|
||||
formData: {
|
||||
regionName: string;
|
||||
agentIp: string;
|
||||
};
|
||||
agentId: string;
|
||||
onDialogClose: () => void;
|
||||
}
|
||||
|
||||
export const RegionalOneClickTab: React.FC<RegionalOneClickTabProps> = ({
|
||||
agentToken,
|
||||
currentPocketBaseUrl,
|
||||
formData,
|
||||
agentId,
|
||||
onDialogClose,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
|
||||
const getOneClickCommand = () => {
|
||||
return `curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- \\
|
||||
--region-name="${formData.regionName}" \\
|
||||
--agent-id="${agentId}" \\
|
||||
--agent-ip="${formData.agentIp}" \\
|
||||
--token="${agentToken}" \\
|
||||
--pocketbase-url="${currentPocketBaseUrl}"`;
|
||||
};
|
||||
|
||||
const downloadScript = () => {
|
||||
const scriptContent = getOneClickCommand();
|
||||
const blob = new Blob([scriptContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `install-regional-agent-${agentId}.sh`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: t('downloaded'),
|
||||
description: t('downloadedDescription'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-green-500/20 bg-green-50/50 dark:bg-green-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400">
|
||||
<Zap className="h-5 w-5" />
|
||||
{t('oneClickAutomaticInstallation')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-green-600 dark:text-green-300">
|
||||
{t('completeInstallationDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 dark:bg-green-950/50 dark:border-green-800">
|
||||
<div className="flex items-center gap-2 text-green-800 dark:text-green-200 font-medium mb-2">
|
||||
<Play className="h-4 w-4" />
|
||||
{t('whatThisScriptDoes')}
|
||||
</div>
|
||||
<ul className="text-sm text-green-700 dark:text-green-300 space-y-1 ml-6">
|
||||
<li>• {t('scriptActionDownload')}</li>
|
||||
<li>• {t('scriptActionInstall')}</li>
|
||||
<li>• {t('scriptActionConfigure')}</li>
|
||||
<li>• {t('scriptActionStart')}</li>
|
||||
<li>• {t('scriptActionHealthChecks')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-green-700 dark:text-green-400">{t('runCommandOnServer')}</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
readOnly
|
||||
value={getOneClickCommand()}
|
||||
className="font-mono text-sm min-h-[120px] pr-12 bg-muted/50 border-green-200 dark:border-green-800"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="absolute top-2 right-2 bg-green-50 dark:bg-green-950/50 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900"
|
||||
onClick={() => copyToClipboard(getOneClickCommand())}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={downloadScript} variant="outline" className="flex-1">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('downloadCompleteScript')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 dark:bg-yellow-950/20 dark:border-yellow-800">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200" dangerouslySetInnerHTML={{ __html: t('localDevelopmentNotice') }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onDialogClose}>
|
||||
{t('done')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
export { RegionalMonitoringContent } from './RegionalMonitoringContent';
|
||||
export { AddRegionalAgentDialog } from './AddRegionalAgentDialog';
|
||||
export { RegionalAgentCard } from './RegionalAgentCard';
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import { IncidentManagementContainer } from './incident-management';
|
||||
|
||||
interface IncidentManagementTabProps {
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export const IncidentManagementTab = React.memo(({ refreshTrigger = 0 }: IncidentManagementTabProps) => {
|
||||
return <IncidentManagementContainer refreshTrigger={refreshTrigger} />;
|
||||
});
|
||||
|
||||
IncidentManagementTab.displayName = 'IncidentManagementTab';
|
||||
@@ -0,0 +1,126 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, CalendarClock, AlertCircle } from "lucide-react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { ScheduledMaintenanceTab } from "./ScheduledMaintenanceTab";
|
||||
import { IncidentManagementTab } from "./IncidentManagementTab";
|
||||
import { CreateMaintenanceDialog } from './maintenance/CreateMaintenanceDialog';
|
||||
import { CreateIncidentDialog } from './incident/CreateIncidentDialog';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { initMaintenanceNotifications, stopMaintenanceNotifications } from '@/services/maintenance/maintenanceNotificationService';
|
||||
|
||||
export const ScheduleIncidentContent = () => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState("maintenance");
|
||||
const [createMaintenanceDialogOpen, setCreateMaintenanceDialogOpen] = useState(false);
|
||||
const [createIncidentDialogOpen, setCreateIncidentDialogOpen] = useState(false);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [incidentRefreshTrigger, setIncidentRefreshTrigger] = useState(0);
|
||||
|
||||
// Initialize maintenance notifications when the component mounts
|
||||
useEffect(() => {
|
||||
// console.log("Initializing maintenance notifications");
|
||||
initMaintenanceNotifications();
|
||||
|
||||
// Clean up when the component unmounts
|
||||
return () => {
|
||||
// console.log("Cleaning up maintenance notifications");
|
||||
stopMaintenanceNotifications();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCreateButtonClick = () => {
|
||||
if (activeTab === "maintenance") {
|
||||
setCreateMaintenanceDialogOpen(true);
|
||||
} else {
|
||||
setCreateIncidentDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaintenanceCreated = () => {
|
||||
// Refresh data by incrementing the refresh trigger
|
||||
const newTriggerValue = refreshTrigger + 1;
|
||||
// console.log("Maintenance created, refreshing data with new trigger value:", newTriggerValue);
|
||||
setRefreshTrigger(newTriggerValue);
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('maintenanceCreatedSuccess'),
|
||||
});
|
||||
};
|
||||
|
||||
const handleIncidentCreated = () => {
|
||||
// Refresh data by incrementing the refresh trigger
|
||||
const newTriggerValue = incidentRefreshTrigger + 1;
|
||||
// console.log("Incident created, refreshing data with new trigger value:", newTriggerValue);
|
||||
setIncidentRefreshTrigger(newTriggerValue);
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('incidentCreatedSuccess'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 flex flex-col overflow-auto bg-background p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
{t('scheduleIncidentManagement')}
|
||||
</h2>
|
||||
<Button
|
||||
className="text-primary-foreground"
|
||||
onClick={handleCreateButtonClick}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{activeTab === "maintenance" ? t('createMaintenanceWindow') : t('createIncident')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue="maintenance"
|
||||
className="w-full"
|
||||
onValueChange={(value) => setActiveTab(value)}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||
<TabsTrigger value="maintenance">
|
||||
<CalendarClock className="w-4 h-4 mr-2" />
|
||||
{t('scheduledMaintenance')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="incidents">
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
{t('incidentManagement')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="maintenance" className="space-y-4">
|
||||
<ScheduledMaintenanceTab refreshTrigger={refreshTrigger} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="incidents" className="space-y-4">
|
||||
<IncidentManagementTab refreshTrigger={incidentRefreshTrigger} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Maintenance creation dialog */}
|
||||
<CreateMaintenanceDialog
|
||||
open={createMaintenanceDialogOpen}
|
||||
onOpenChange={setCreateMaintenanceDialogOpen}
|
||||
onMaintenanceCreated={handleMaintenanceCreated}
|
||||
/>
|
||||
|
||||
{/* Incident creation dialog */}
|
||||
<CreateIncidentDialog
|
||||
open={createIncidentDialogOpen}
|
||||
onOpenChange={setCreateIncidentDialogOpen}
|
||||
onIncidentCreated={handleIncidentCreated}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { MaintenanceTable, MaintenanceStatusChecker } from './maintenance';
|
||||
import { LoadingState } from '@/components/services/LoadingState';
|
||||
import { Calendar, Clock, CheckCircle } from 'lucide-react';
|
||||
import { useMaintenanceData } from './hooks/useMaintenanceData';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
interface ScheduledMaintenanceTabProps {
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMaintenanceTabProps) => {
|
||||
const { t } = useLanguage();
|
||||
const { theme } = useTheme();
|
||||
const { toast } = useToast();
|
||||
const [manualRefresh, setManualRefresh] = React.useState(0);
|
||||
|
||||
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
|
||||
|
||||
const {
|
||||
loading,
|
||||
filter,
|
||||
setFilter,
|
||||
maintenanceData,
|
||||
overviewStats,
|
||||
fetchMaintenanceData,
|
||||
isEmpty,
|
||||
error,
|
||||
initialized,
|
||||
} = useMaintenanceData({ refreshTrigger: combinedRefreshTrigger });
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: error,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [error, toast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaintenanceData(true);
|
||||
}, [fetchMaintenanceData]);
|
||||
|
||||
const handleMaintenanceUpdated = () => {
|
||||
setManualRefresh((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setFilter(value);
|
||||
};
|
||||
|
||||
if (loading && !initialized) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const gradientCard = (
|
||||
title: string,
|
||||
value: string | number,
|
||||
icon: JSX.Element,
|
||||
gradient: string
|
||||
) => (
|
||||
<Card
|
||||
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative z-10"
|
||||
style={{
|
||||
background: theme === 'dark'
|
||||
? `linear-gradient(135deg, rgba(65,59,55,0.8) 0%, ${gradient})`
|
||||
: `linear-gradient(135deg, rgba(65,59,55,0.8) 0%, ${gradient})`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 z-0 opacity-10">
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
|
||||
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-white">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between relative z-10">
|
||||
<span className="text-4xl font-bold text-white">{value}</span>
|
||||
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||
{icon}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MaintenanceStatusChecker
|
||||
maintenanceData={maintenanceData}
|
||||
onStatusUpdated={handleMaintenanceUpdated}
|
||||
/>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 w-full">
|
||||
{gradientCard(
|
||||
t('upcomingMaintenance'),
|
||||
loading ? '...' : overviewStats.upcoming,
|
||||
<Calendar className="h-6 w-6 text-white" />,
|
||||
'rgba(59,130,246,0.6)'
|
||||
)}
|
||||
{gradientCard(
|
||||
t('ongoingMaintenance'),
|
||||
loading ? '...' : overviewStats.ongoing,
|
||||
<Clock className="h-6 w-6 text-white" />,
|
||||
'rgba(251,191,36,0.6)'
|
||||
)}
|
||||
{gradientCard(
|
||||
t('completedMaintenance'),
|
||||
loading ? '...' : overviewStats.completed,
|
||||
<CheckCircle className="h-6 w-6 text-white" />,
|
||||
'rgba(34,197,94,0.6)'
|
||||
)}
|
||||
{gradientCard(
|
||||
t('totalScheduledHours'),
|
||||
loading ? '...' : `${overviewStats.totalDuration}h`,
|
||||
<Clock className="h-6 w-6 text-white" />,
|
||||
'rgba(139,92,246,0.6)'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('scheduledMaintenance')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('scheduledMaintenanceDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
defaultValue="upcoming"
|
||||
value={filter}
|
||||
className="w-full"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="upcoming">{t('upcomingMaintenance')}</TabsTrigger>
|
||||
<TabsTrigger value="ongoing">{t('ongoingMaintenance')}</TabsTrigger>
|
||||
<TabsTrigger value="completed">{t('completedMaintenance')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upcoming">
|
||||
<MaintenanceTable
|
||||
data={maintenanceData}
|
||||
isLoading={loading && initialized}
|
||||
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ongoing">
|
||||
<MaintenanceTable
|
||||
data={maintenanceData}
|
||||
isLoading={loading && initialized}
|
||||
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="completed">
|
||||
<MaintenanceTable
|
||||
data={maintenanceData}
|
||||
isLoading={loading && initialized}
|
||||
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
interface OverviewCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
icon: ReactNode;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
className?: string;
|
||||
valueClassName?: string;
|
||||
isLoading?: boolean;
|
||||
color?: string;
|
||||
gradient?: string;
|
||||
}
|
||||
|
||||
export const OverviewCard = ({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon,
|
||||
trend,
|
||||
className,
|
||||
valueClassName,
|
||||
isLoading = false,
|
||||
color = "blue",
|
||||
gradient,
|
||||
}: OverviewCardProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Map color prop to gradient colors
|
||||
const getGradientBackground = () => {
|
||||
if (gradient) {
|
||||
return gradient;
|
||||
}
|
||||
const colors = {
|
||||
blue: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)",
|
||||
green: 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%)",
|
||||
amber: 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%)",
|
||||
red: 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%)",
|
||||
purple: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(123, 31, 162, 0.8) 0%, rgba(156, 39, 176, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #7b1fa2 0%, #9c27b0 100%)",
|
||||
orange: theme === 'dark'
|
||||
? "linear-gradient(135deg, rgba(230, 81, 0, 0.8) 0%, rgba(255, 109, 0, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #e65100 0%, #ff6d00 100%)",
|
||||
};
|
||||
|
||||
return colors[color as keyof typeof colors] || colors.blue;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative z-10",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
background: getGradientBackground()
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
<CardContent className="p-6 relative z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90 mb-1">{title}</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-8 w-16 bg-white/20" />
|
||||
) : (
|
||||
<h3 className={cn("text-3xl font-bold text-white", valueClassName)}>{value}</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-white/80 mt-1">{description}</p>
|
||||
)}
|
||||
{trend && (
|
||||
<div className={`flex items-center mt-2 text-sm ${trend.isPositive ? 'text-green-100' : 'text-red-100'}`}>
|
||||
<span className="mr-1">
|
||||
{trend.isPositive ? '↑' : '↓'}
|
||||
</span>
|
||||
<span>{Math.abs(trend.value)}%</span>
|
||||
<span className="ml-1 text-white/70">vs last month</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
export * from './useMaintenanceData';
|
||||
export * from './useIncidentData';
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { incidentService, IncidentItem } from '@/services/incident';
|
||||
|
||||
interface UseIncidentDataProps {
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) => {
|
||||
const [incidents, setIncidents] = useState<IncidentItem[]>([]);
|
||||
const [filter, setFilter] = useState("unresolved");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Use a ref to prevent multiple simultaneous fetch requests
|
||||
const isFetchingRef = useRef(false);
|
||||
// Use a ref to track the refresh trigger
|
||||
const lastRefreshTriggerRef = useRef(refreshTrigger);
|
||||
|
||||
// Simplified fetch function with improved controls to prevent duplicate calls
|
||||
const fetchIncidentData = useCallback(async (force = false) => {
|
||||
// Skip if already fetching
|
||||
if (isFetchingRef.current) {
|
||||
// console.log('Already fetching data, skipping additional request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not forced and already initialized
|
||||
if (initialized && !force) {
|
||||
// console.log('Data already initialized and no force refresh, skipping fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set fetching flags
|
||||
isFetchingRef.current = true;
|
||||
|
||||
if (!initialized || force) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
if (force) {
|
||||
setIsRefreshing(true);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// console.log(`Fetching incident data (force=${force})`);
|
||||
const allIncidents = await incidentService.getAllIncidents(force);
|
||||
|
||||
if (Array.isArray(allIncidents)) {
|
||||
setIncidents(allIncidents);
|
||||
// console.log(`Successfully set ${allIncidents.length} incidents to state`);
|
||||
} else {
|
||||
setIncidents([]);
|
||||
// console.warn('No incidents returned from service');
|
||||
}
|
||||
|
||||
setInitialized(true);
|
||||
setLoading(false);
|
||||
setIsRefreshing(false);
|
||||
} catch (error) {
|
||||
// console.error('Error fetching incident data:', error);
|
||||
setError('Failed to load incident data. Please try again later.');
|
||||
setIncidents([]);
|
||||
setInitialized(true);
|
||||
setLoading(false);
|
||||
setIsRefreshing(false);
|
||||
} finally {
|
||||
// Reset fetching flag after a slight delay to prevent rapid consecutive calls
|
||||
setTimeout(() => {
|
||||
isFetchingRef.current = false;
|
||||
}, 500);
|
||||
}
|
||||
}, [initialized]);
|
||||
|
||||
// Only fetch when component mounts or refreshTrigger changes
|
||||
useEffect(() => {
|
||||
// Skip if the refresh trigger hasn't changed (prevents duplicate effect calls)
|
||||
if (refreshTrigger === lastRefreshTriggerRef.current && initialized) {
|
||||
// console.log('Refresh trigger unchanged, skipping fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update last refresh trigger ref
|
||||
lastRefreshTriggerRef.current = refreshTrigger;
|
||||
|
||||
// Create an abort controller for cleanup
|
||||
const abortController = new AbortController();
|
||||
let isMounted = true;
|
||||
|
||||
// console.log(`useIncidentData effect running, refreshTrigger: ${refreshTrigger}`);
|
||||
|
||||
// Use a longer delay to ensure we don't trigger too many API calls
|
||||
const fetchTimer = setTimeout(() => {
|
||||
if (isMounted) {
|
||||
fetchIncidentData(refreshTrigger > 0);
|
||||
}
|
||||
}, 500); // Use a longer delay
|
||||
|
||||
// Cleanup function to abort any in-flight requests and clear timers
|
||||
return () => {
|
||||
// console.log('Cleaning up incident data fetch effect');
|
||||
isMounted = false;
|
||||
clearTimeout(fetchTimer);
|
||||
abortController.abort();
|
||||
};
|
||||
}, [fetchIncidentData, refreshTrigger, initialized]);
|
||||
|
||||
// Filter the data based on the current filter
|
||||
const incidentData = useMemo(() => {
|
||||
if (!initialized || incidents.length === 0) return [];
|
||||
|
||||
// console.log(`Filtering incidents by: ${filter}`);
|
||||
|
||||
if (filter === "unresolved") {
|
||||
return incidents.filter(item => {
|
||||
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||
return status !== 'resolved';
|
||||
});
|
||||
} else if (filter === "resolved") {
|
||||
return incidents.filter(item => {
|
||||
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||
return status === 'resolved';
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [filter, incidents, initialized]);
|
||||
|
||||
// Calculate stats for overview cards
|
||||
const overviewStats = useMemo(() => {
|
||||
if (!initialized || incidents.length === 0) {
|
||||
return {
|
||||
unresolved: 0,
|
||||
resolved: 0,
|
||||
critical: 0,
|
||||
highPriority: 0,
|
||||
avgResolutionTime: "0h"
|
||||
};
|
||||
}
|
||||
|
||||
const unresolvedIncidents = incidents.filter(item => {
|
||||
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||
return status !== 'resolved';
|
||||
});
|
||||
|
||||
const resolvedIncidents = incidents.filter(item => {
|
||||
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||
return status === 'resolved';
|
||||
});
|
||||
|
||||
const criticalCount = unresolvedIncidents.filter(item =>
|
||||
(item.priority?.toLowerCase() === 'critical') ||
|
||||
(item.impact?.toLowerCase() === 'critical')
|
||||
).length;
|
||||
|
||||
const highPriorityCount = unresolvedIncidents.filter(item =>
|
||||
(item.priority?.toLowerCase() === 'high')
|
||||
).length;
|
||||
|
||||
let avgResolutionTime = "0h";
|
||||
if (resolvedIncidents.length > 0) {
|
||||
const totalHours = resolvedIncidents.reduce((total, item) => {
|
||||
if (!item.created || !item.resolution_time) return total;
|
||||
|
||||
const createdAt = new Date(item.created).getTime();
|
||||
const resolvedAt = new Date(item.resolution_time).getTime();
|
||||
const durationHours = (resolvedAt - createdAt) / (1000 * 60 * 60);
|
||||
return isNaN(durationHours) ? total : total + durationHours;
|
||||
}, 0);
|
||||
|
||||
avgResolutionTime = `${(totalHours / resolvedIncidents.length).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
return {
|
||||
unresolved: unresolvedIncidents.length,
|
||||
resolved: resolvedIncidents.length,
|
||||
critical: criticalCount,
|
||||
highPriority: highPriorityCount,
|
||||
avgResolutionTime
|
||||
};
|
||||
}, [incidents, initialized]);
|
||||
|
||||
const isEmpty = !incidentData.length && initialized && !loading;
|
||||
|
||||
return {
|
||||
filter,
|
||||
setFilter,
|
||||
incidentData,
|
||||
overviewStats,
|
||||
fetchIncidentData,
|
||||
isEmpty,
|
||||
error,
|
||||
loading,
|
||||
initialized,
|
||||
isRefreshing
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { maintenanceService, MaintenanceItem } from '@/services/maintenance';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
interface UseMaintenanceDataProps {
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export const useMaintenanceData = ({ refreshTrigger = 0 }: UseMaintenanceDataProps) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [allMaintenanceData, setAllMaintenanceData] = useState<MaintenanceItem[]>([]);
|
||||
const [filter, setFilter] = useState("upcoming");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Refs for cleanup and request management
|
||||
const mountedRef = useRef(true);
|
||||
const currentRequestRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
// Memoize categorized data to prevent unnecessary recalculations
|
||||
const categorizedData = useMemo(() => {
|
||||
if (!allMaintenanceData.length) return { upcoming: [], ongoing: [], completed: [] };
|
||||
|
||||
const currentDate = new Date();
|
||||
const upcoming: MaintenanceItem[] = [];
|
||||
const ongoing: MaintenanceItem[] = [];
|
||||
const completed: MaintenanceItem[] = [];
|
||||
|
||||
allMaintenanceData.forEach(item => {
|
||||
const status = item.status?.toLowerCase() || '';
|
||||
const startTime = new Date(item.start_time);
|
||||
const endTime = new Date(item.end_time);
|
||||
|
||||
if (status === 'completed' || status === 'cancelled') {
|
||||
completed.push(item);
|
||||
} else if (status === 'in_progress' ||
|
||||
(status === 'scheduled' && startTime <= currentDate && endTime >= currentDate)) {
|
||||
ongoing.push(item);
|
||||
} else if (status === 'scheduled' && startTime > currentDate) {
|
||||
upcoming.push(item);
|
||||
} else {
|
||||
// Default case: treat as upcoming if we can't determine
|
||||
upcoming.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return { upcoming, ongoing, completed };
|
||||
}, [allMaintenanceData]);
|
||||
|
||||
// Simple fetch function - only called when explicitly requested
|
||||
const fetchMaintenanceData = useCallback(async (force = false) => {
|
||||
// Check if component is still mounted
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
// Prevent duplicate requests
|
||||
if (currentRequestRef.current) {
|
||||
await currentRequestRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show loading state for initial load or forced refresh
|
||||
if (!initialized || force) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const data = await maintenanceService.getMaintenanceRecords();
|
||||
|
||||
// Check if component is still mounted before updating state
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
// Update state with fetched data
|
||||
setAllMaintenanceData(data);
|
||||
setInitialized(true);
|
||||
|
||||
// Clear any previous error
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching maintenance data:', err);
|
||||
|
||||
// Only update error state if component is still mounted
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const errorMessage = 'Failed to load maintenance data. Please try again.';
|
||||
setError(errorMessage);
|
||||
|
||||
// Show toast for errors
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorFetchingMaintenanceData'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
// Only update loading state if component is still mounted
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
})();
|
||||
|
||||
currentRequestRef.current = requestPromise;
|
||||
await requestPromise;
|
||||
}, [t, toast, error, initialized]);
|
||||
|
||||
// Initial fetch on mount - NO AUTOMATIC POLLING
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
// Only fetch initial data, no polling
|
||||
fetchMaintenanceData(true);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
currentRequestRef.current = null;
|
||||
};
|
||||
}, []); // Remove fetchMaintenanceData from dependencies to prevent re-runs
|
||||
|
||||
// Handle refresh trigger changes - ONLY when explicitly triggered
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
fetchMaintenanceData(true); // Force refresh to bypass cache
|
||||
}
|
||||
}, [refreshTrigger, fetchMaintenanceData]);
|
||||
|
||||
// Get filtered data based on current tab
|
||||
const maintenanceData = useMemo(() => {
|
||||
if (!initialized) return [];
|
||||
return categorizedData[filter as keyof typeof categorizedData] || [];
|
||||
}, [filter, categorizedData, initialized]);
|
||||
|
||||
// Calculate overview stats with memoization
|
||||
const overviewStats = useMemo(() => {
|
||||
const { upcoming, ongoing, completed } = categorizedData;
|
||||
|
||||
// Calculate total hours more efficiently
|
||||
const calculateTotalHours = (items: MaintenanceItem[]) => {
|
||||
return items.reduce((total, item) => {
|
||||
try {
|
||||
const start = new Date(item.start_time).getTime();
|
||||
const end = new Date(item.end_time).getTime();
|
||||
const durationHours = (end - start) / (1000 * 60 * 60);
|
||||
return total + (isNaN(durationHours) ? 0 : durationHours);
|
||||
} catch (e) {
|
||||
return total;
|
||||
}
|
||||
}, 0).toFixed(1);
|
||||
};
|
||||
|
||||
return {
|
||||
upcoming: upcoming.length,
|
||||
ongoing: ongoing.length,
|
||||
completed: completed.length,
|
||||
totalDuration: calculateTotalHours([...upcoming, ...ongoing]),
|
||||
};
|
||||
}, [categorizedData]);
|
||||
|
||||
const isEmpty = !loading && maintenanceData.length === 0 && initialized;
|
||||
|
||||
return {
|
||||
loading,
|
||||
filter,
|
||||
setFilter,
|
||||
maintenanceData,
|
||||
overviewStats,
|
||||
fetchMaintenanceData,
|
||||
isEmpty,
|
||||
error,
|
||||
initialized,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
interface ErrorStateProps {
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
export const ErrorState: React.FC<ErrorStateProps> = ({
|
||||
error,
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{t('tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
import React from 'react';
|
||||
import { CardDescription, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
interface HeaderActionsProps {
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
export const HeaderActions: React.FC<HeaderActionsProps> = ({ onRefresh, isRefreshing }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('incidentManagement')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('incidentsManagementDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
className="ml-auto"
|
||||
title={t('refreshData')}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
<span className="sr-only">{t('refresh')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { useIncidentData } from '../hooks/useIncidentData';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { OverviewCards } from './OverviewCards';
|
||||
import { HeaderActions } from './HeaderActions';
|
||||
import { TabContent } from './TabContent';
|
||||
import { LoadingState } from '@/components/services/LoadingState';
|
||||
import { IncidentDetailDialog } from '../incident/detail-dialog/IncidentDetailDialog';
|
||||
|
||||
interface IncidentManagementContainerProps {
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export const IncidentManagementContainer: React.FC<IncidentManagementContainerProps> = ({
|
||||
refreshTrigger = 0
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [manualRefresh, setManualRefresh] = useState(0);
|
||||
|
||||
// Use a ref to debounce multiple refresh requests
|
||||
const refreshTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Combine the external refresh trigger with our internal one
|
||||
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
|
||||
|
||||
const {
|
||||
filter,
|
||||
setFilter,
|
||||
incidentData,
|
||||
overviewStats,
|
||||
isEmpty,
|
||||
loading,
|
||||
error,
|
||||
initialized,
|
||||
isRefreshing
|
||||
} = useIncidentData({ refreshTrigger: combinedRefreshTrigger });
|
||||
|
||||
// Handle incident updates with debouncing
|
||||
const handleIncidentUpdated = useCallback(() => {
|
||||
console.log('Incident updated, triggering refresh');
|
||||
|
||||
// Clear any existing timer
|
||||
if (refreshTimerRef.current !== null) {
|
||||
window.clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
|
||||
// Set a new timer to debounce multiple quick updates
|
||||
refreshTimerRef.current = window.setTimeout(() => {
|
||||
setManualRefresh(prev => prev + 1);
|
||||
refreshTimerRef.current = null;
|
||||
|
||||
toast({
|
||||
title: t('incidentUpdated'),
|
||||
description: t('incidentUpdateSuccess'),
|
||||
});
|
||||
}, 300);
|
||||
|
||||
}, [t, toast]);
|
||||
|
||||
// Handle tab changes
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
console.log(`Tab changed to: ${value}`);
|
||||
setFilter(value);
|
||||
}, [setFilter]);
|
||||
|
||||
// Handle view incident details
|
||||
const handleViewIncidentDetails = useCallback((incident: IncidentItem) => {
|
||||
setSelectedIncident(incident);
|
||||
setDetailDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle manual refresh
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log('Manual refresh triggered by user');
|
||||
setManualRefresh(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Handle detail dialog close with refresh
|
||||
const handleDetailDialogClose = useCallback((open: boolean) => {
|
||||
setDetailDialogOpen(open);
|
||||
if (!open) {
|
||||
// When dialog closes, refresh data
|
||||
handleManualRefresh();
|
||||
}
|
||||
}, [handleManualRefresh]);
|
||||
|
||||
// Clean up timer on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (refreshTimerRef.current !== null) {
|
||||
window.clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Show full-page loading state during initial load
|
||||
if (loading && !initialized) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overview Cards */}
|
||||
<OverviewCards
|
||||
overviewStats={overviewStats}
|
||||
loading={loading}
|
||||
initialized={initialized}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HeaderActions
|
||||
onRefresh={handleManualRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={filter} className="w-full" onValueChange={handleTabChange}>
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="unresolved">{t('unresolvedIncidents')}</TabsTrigger>
|
||||
<TabsTrigger value="resolved">{t('resolvedIncidents')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="unresolved" className="space-y-4">
|
||||
<TabContent
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
data={incidentData}
|
||||
loading={loading}
|
||||
initialized={initialized}
|
||||
isRefreshing={isRefreshing}
|
||||
onIncidentUpdated={handleIncidentUpdated}
|
||||
onViewDetails={handleViewIncidentDetails}
|
||||
onRefresh={handleManualRefresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="resolved" className="space-y-4">
|
||||
<TabContent
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
data={incidentData}
|
||||
loading={loading}
|
||||
initialized={initialized}
|
||||
isRefreshing={isRefreshing}
|
||||
onIncidentUpdated={handleIncidentUpdated}
|
||||
onViewDetails={handleViewIncidentDetails}
|
||||
onRefresh={handleManualRefresh}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Incident Detail Dialog */}
|
||||
<IncidentDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={handleDetailDialogClose}
|
||||
incident={selectedIncident}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AlertCircle, CheckCircle, Clock, AlertTriangle, Flag } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { OverviewCard } from '../common/OverviewCard';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
interface OverviewStatsProps {
|
||||
unresolved: number;
|
||||
resolved: number;
|
||||
critical: number;
|
||||
highPriority: number;
|
||||
avgResolutionTime: string;
|
||||
}
|
||||
|
||||
interface OverviewCardsProps {
|
||||
overviewStats: OverviewStatsProps;
|
||||
loading: boolean;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export const OverviewCards: React.FC<OverviewCardsProps> = ({
|
||||
overviewStats,
|
||||
loading,
|
||||
initialized
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||
<OverviewCard
|
||||
title={t('activeIncidents')}
|
||||
value={overviewStats.unresolved.toString()}
|
||||
icon={<AlertCircle className="h-5 w-5 text-white" />}
|
||||
isLoading={loading && initialized}
|
||||
gradient={
|
||||
theme === "dark"
|
||||
? "linear-gradient(135deg, #4b3b37 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #4b3b37 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||
}
|
||||
/>
|
||||
<OverviewCard
|
||||
title={t('criticalIssues')}
|
||||
value={overviewStats.critical.toString()}
|
||||
icon={<AlertTriangle className="h-5 w-5 text-white" />}
|
||||
isLoading={loading && initialized}
|
||||
gradient={
|
||||
theme === "dark"
|
||||
? "linear-gradient(135deg, #4b3b37 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #4b3b37 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||
}
|
||||
/>
|
||||
<OverviewCard
|
||||
title={t('highPriority')}
|
||||
value={overviewStats.highPriority.toString()}
|
||||
icon={<Flag className="h-5 w-5 text-white" />}
|
||||
isLoading={loading && initialized}
|
||||
gradient={
|
||||
theme === "dark"
|
||||
? "linear-gradient(135deg, #4b3b37 0%, rgba(255, 109, 0, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #4b3b37 0%, rgba(255, 109, 0, 0.6) 100%)"
|
||||
}
|
||||
/>
|
||||
<OverviewCard
|
||||
title={t('resolvedIncidents')}
|
||||
value={overviewStats.resolved.toString()}
|
||||
icon={<CheckCircle className="h-5 w-5 text-white" />}
|
||||
isLoading={loading && initialized}
|
||||
gradient={
|
||||
theme === "dark"
|
||||
? "linear-gradient(135deg, #4b3b37 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #4b3b37 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||
}
|
||||
/>
|
||||
<OverviewCard
|
||||
title={t('avgResolutionTime')}
|
||||
value={overviewStats.avgResolutionTime}
|
||||
icon={<Clock className="h-5 w-5 text-white" />}
|
||||
isLoading={loading && initialized}
|
||||
gradient={
|
||||
theme === "dark"
|
||||
? "linear-gradient(135deg, #4b3b37 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, #4b3b37 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
import React from 'react';
|
||||
import { IncidentTable } from '../incident/table/IncidentTable';
|
||||
import { EmptyIncidentState } from '../incident/EmptyIncidentState';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
|
||||
interface TabContentProps {
|
||||
error: string | null;
|
||||
isEmpty: boolean;
|
||||
data: IncidentItem[];
|
||||
loading: boolean;
|
||||
initialized: boolean;
|
||||
isRefreshing: boolean;
|
||||
onIncidentUpdated: () => void;
|
||||
onViewDetails: (incident: IncidentItem) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const TabContent: React.FC<TabContentProps> = ({
|
||||
error,
|
||||
isEmpty,
|
||||
data,
|
||||
loading,
|
||||
initialized,
|
||||
isRefreshing,
|
||||
onIncidentUpdated,
|
||||
onViewDetails,
|
||||
onRefresh
|
||||
}) => {
|
||||
if (error) {
|
||||
return <ErrorState error={error} onRefresh={onRefresh} isRefreshing={isRefreshing} />;
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return <EmptyIncidentState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IncidentTable
|
||||
data={data}
|
||||
onIncidentUpdated={onIncidentUpdated}
|
||||
onViewDetails={onViewDetails}
|
||||
isLoading={loading && initialized}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export * from './IncidentManagementContainer';
|
||||
export * from './OverviewCards';
|
||||
export * from './TabContent';
|
||||
export * from './ErrorState';
|
||||
export * from './HeaderActions';
|
||||
@@ -0,0 +1,96 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { useIncidentForm } from './hooks/useIncidentForm';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
IncidentBasicFields,
|
||||
IncidentAffectedFields,
|
||||
IncidentConfigFields,
|
||||
IncidentDetailsFields,
|
||||
} from './form';
|
||||
|
||||
interface CreateIncidentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onIncidentCreated: () => void;
|
||||
}
|
||||
|
||||
export const CreateIncidentDialog: React.FC<CreateIncidentDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onIncidentCreated,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { form, onSubmit } = useIncidentForm(
|
||||
onIncidentCreated,
|
||||
() => onOpenChange(false)
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
|
||||
<ScrollArea className="h-[80vh]">
|
||||
<div className="px-1 py-2">
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle className="text-xl">{t('createIncident')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('createIncidentDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="space-y-8 pb-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
|
||||
<IncidentBasicFields />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
|
||||
<IncidentAffectedFields />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
|
||||
<IncidentConfigFields />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
|
||||
<IncidentDetailsFields />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4 mt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{form.formState.isSubmitting ? t('creating') : t('create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useIncidentEditForm } from './hooks/useIncidentEditForm';
|
||||
import {
|
||||
IncidentBasicFields,
|
||||
IncidentAffectedFields,
|
||||
IncidentConfigFields,
|
||||
IncidentDetailsFields,
|
||||
} from './form';
|
||||
import { IncidentItem } from '@/services/incident/types';
|
||||
|
||||
interface EditIncidentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
incident: IncidentItem;
|
||||
onIncidentUpdated: () => void;
|
||||
}
|
||||
|
||||
export const EditIncidentDialog: React.FC<EditIncidentDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
incident,
|
||||
onIncidentUpdated,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const { form, onSubmit } = useIncidentEditForm(
|
||||
incident,
|
||||
onIncidentUpdated,
|
||||
handleClose
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
|
||||
<ScrollArea className="h-[80vh]">
|
||||
<div className="px-1 py-2">
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle className="text-xl">{t('editIncident')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('editIncidentDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="space-y-8 pb-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
|
||||
<IncidentBasicFields />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
|
||||
<IncidentAffectedFields />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
|
||||
<IncidentConfigFields />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
|
||||
<IncidentDetailsFields />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4 mt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{form.formState.isSubmitting ? t('updating') : t('update')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
export const EmptyIncidentState = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<AlertCircle className="w-12 h-12 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">{t('noIncidents')}</h3>
|
||||
<p className="text-sm text-center max-w-md">
|
||||
{t('noServices')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MoreHorizontal, Eye, Edit, Trash, Check } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { updateIncidentStatus, deleteIncident } from '@/services/incident/incidentOperations';
|
||||
import { IncidentItem } from '@/services/incident/types';
|
||||
|
||||
interface IncidentActionsMenuProps {
|
||||
item: IncidentItem;
|
||||
onIncidentUpdated: () => void;
|
||||
onViewDetails?: (incident: IncidentItem) => void;
|
||||
onEditIncident?: (incident: IncidentItem) => void;
|
||||
}
|
||||
|
||||
export const IncidentActionsMenu = ({
|
||||
item,
|
||||
onIncidentUpdated,
|
||||
onViewDetails,
|
||||
onEditIncident
|
||||
}: IncidentActionsMenuProps) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleResolveIncident = async () => {
|
||||
try {
|
||||
await updateIncidentStatus(item.id, 'resolved');
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('incidentResolved'),
|
||||
});
|
||||
onIncidentUpdated();
|
||||
} catch (error) {
|
||||
console.error('Error resolving incident:', error);
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorResolvingIncident'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteIncident = async () => {
|
||||
try {
|
||||
await deleteIncident(item.id);
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('incidentDeleted'),
|
||||
});
|
||||
onIncidentUpdated();
|
||||
} catch (error) {
|
||||
console.error('Error deleting incident:', error);
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorDeletingIncident'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
if (onEditIncident) {
|
||||
onEditIncident(item);
|
||||
} else {
|
||||
console.log(`Edit incident ${item.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{t('actions')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-background">
|
||||
<DropdownMenuLabel>{t('actions')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{onViewDetails && (
|
||||
<DropdownMenuItem onClick={() => onViewDetails(item)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t('view')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleEditClick}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{t('edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleResolveIncident}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{t('resolve')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDeleteIncident}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
{t('delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Gauge,
|
||||
Search,
|
||||
Wrench,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
interface IncidentStatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
type StatusConfig = {
|
||||
label: string;
|
||||
variant: 'outline' | 'default' | 'secondary' | 'destructive';
|
||||
icon: LucideIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const IncidentStatusBadge = ({ status }: IncidentStatusBadgeProps) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Normalize the status string
|
||||
const normalizedStatus = (status || '').toLowerCase();
|
||||
|
||||
// Status configuration map
|
||||
const statusConfigs: Record<string, StatusConfig> = {
|
||||
'investigating': {
|
||||
label: t('investigating'),
|
||||
variant: 'destructive',
|
||||
icon: Search,
|
||||
className: 'bg-red-100 border-red-200 text-red-700 hover:bg-red-100',
|
||||
},
|
||||
'identified': {
|
||||
label: t('identified'),
|
||||
variant: 'secondary',
|
||||
icon: AlertCircle,
|
||||
className: 'bg-amber-100 border-amber-200 text-amber-700 hover:bg-amber-100',
|
||||
},
|
||||
'found_root_cause': {
|
||||
label: t('rootCauseFound'),
|
||||
variant: 'secondary',
|
||||
icon: AlertCircle,
|
||||
className: 'bg-amber-100 border-amber-200 text-amber-700 hover:bg-amber-100',
|
||||
},
|
||||
'completed': {
|
||||
label: t('completed'),
|
||||
variant: 'default',
|
||||
icon: CheckCircle,
|
||||
className: 'bg-green-100 border-green-200 text-green-700 hover:bg-green-100',
|
||||
},
|
||||
'in_progress': {
|
||||
label: t('inProgress'),
|
||||
variant: 'default',
|
||||
icon: Wrench,
|
||||
className: 'bg-blue-100 border-blue-200 text-blue-700 hover:bg-blue-100',
|
||||
},
|
||||
'inprogress': {
|
||||
label: t('inProgress'),
|
||||
variant: 'default',
|
||||
icon: Wrench,
|
||||
className: 'bg-blue-100 border-blue-200 text-blue-700 hover:bg-blue-100',
|
||||
},
|
||||
'monitoring': {
|
||||
label: t('monitoring'),
|
||||
variant: 'outline',
|
||||
icon: Gauge,
|
||||
className: 'bg-purple-100 border-purple-200 text-purple-700 hover:bg-purple-100',
|
||||
},
|
||||
'resolved': {
|
||||
label: t('resolved'),
|
||||
variant: 'default',
|
||||
icon: CheckCircle,
|
||||
className: 'bg-green-100 border-green-200 text-green-700 hover:bg-green-100',
|
||||
}
|
||||
};
|
||||
|
||||
// Find the appropriate config, defaulting to investigating if not found
|
||||
const getStatusConfig = (): StatusConfig => {
|
||||
for (const [key, config] of Object.entries(statusConfigs)) {
|
||||
if (normalizedStatus.includes(key)) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
return statusConfigs['investigating'];
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={config.variant}
|
||||
className={`flex items-center gap-1 ${config.className}`}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
<span>{config.label}</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { AlertCircle, CheckCircle, Gauge, Search, Wrench } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { updateIncidentStatus } from '@/services/incident/incidentOperations';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { IncidentStatusBadge } from './IncidentStatusBadge';
|
||||
|
||||
interface IncidentStatusDropdownProps {
|
||||
status: string;
|
||||
id: string;
|
||||
onStatusUpdated: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const IncidentStatusDropdown = ({
|
||||
status,
|
||||
id,
|
||||
onStatusUpdated,
|
||||
disabled = false
|
||||
}: IncidentStatusDropdownProps) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
const [localStatus, setLocalStatus] = React.useState(status);
|
||||
|
||||
// Update local status when prop changes
|
||||
React.useEffect(() => {
|
||||
setLocalStatus(status);
|
||||
}, [status]);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'investigating', label: t('investigating'), icon: <Search className="h-4 w-4 mr-2" /> },
|
||||
{ value: 'identified', label: t('identified'), icon: <AlertCircle className="h-4 w-4 mr-2" /> },
|
||||
{ value: 'found_root_cause', label: t('foundRootCause'), icon: <AlertCircle className="h-4 w-4 mr-2" /> },
|
||||
{ value: 'in_progress', label: t('inProgress'), icon: <Wrench className="h-4 w-4 mr-2" /> },
|
||||
{ value: 'monitoring', label: t('monitoring'), icon: <Gauge className="h-4 w-4 mr-2" /> },
|
||||
{ value: 'resolved', label: t('resolved'), icon: <CheckCircle className="h-4 w-4 mr-2" /> },
|
||||
];
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
try {
|
||||
// Don't update if the status is the same
|
||||
if (localStatus === newStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Changing incident status from ${localStatus} to ${newStatus}`);
|
||||
|
||||
// Optimistically update the UI immediately
|
||||
setLocalStatus(newStatus);
|
||||
|
||||
// Make the API call in the background
|
||||
await updateIncidentStatus(id, newStatus);
|
||||
|
||||
toast({
|
||||
title: t('statusUpdated'),
|
||||
description: t('incidentStatusUpdated'),
|
||||
});
|
||||
|
||||
// Notify parent components about the status change
|
||||
onStatusUpdated();
|
||||
console.log('Status update complete, UI refresh triggered');
|
||||
} catch (error) {
|
||||
console.error('Error updating incident status:', error);
|
||||
|
||||
// Revert to the original status on error
|
||||
setLocalStatus(status);
|
||||
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('failedToUpdateStatus'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger disabled={disabled} className="w-full cursor-pointer">
|
||||
<IncidentStatusBadge status={localStatus} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-background border border-border shadow-md z-50">
|
||||
{statusOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
className="flex items-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent event from bubbling to table row click
|
||||
handleStatusChange(option.value);
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
// Re-export the refactored IncidentTable component
|
||||
export { IncidentTable } from './table/IncidentTable';
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
|
||||
import React from 'react';
|
||||
import { IncidentItem } from '@/services/incident/types';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { IncidentDetailHeader } from './IncidentDetailHeader';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
BasicInfoSection,
|
||||
TimelineSection,
|
||||
AffectedSystemsSection,
|
||||
ResolutionSection
|
||||
} from './sections';
|
||||
import { IncidentDetailFooter } from './IncidentDetailFooter';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { userService } from '@/services/userService';
|
||||
|
||||
interface IncidentDetailContentProps {
|
||||
incident: IncidentItem;
|
||||
onClose: () => void;
|
||||
assignedUser: any | null;
|
||||
}
|
||||
|
||||
export const IncidentDetailContent = ({
|
||||
incident,
|
||||
onClose,
|
||||
assignedUser
|
||||
}: IncidentDetailContentProps) => {
|
||||
// Fetch assigned user details if none was provided; prefer server field
|
||||
const assigneeId = incident?.assigned_users || incident?.assigned_to;
|
||||
const { data: fetchedUser } = useQuery({
|
||||
queryKey: ['user', assigneeId],
|
||||
queryFn: async () => {
|
||||
if (!assigneeId) return null;
|
||||
try {
|
||||
return await userService.getUser(assigneeId);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch assigned user:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!assigneeId && !assignedUser,
|
||||
staleTime: 300000 // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Use the provided assignedUser or the one we fetched
|
||||
const userToDisplay = assignedUser || fetchedUser;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[80vh] print:h-auto print:overflow-visible">
|
||||
<div className="px-6 py-6">
|
||||
<div className="print-section header-print">
|
||||
<IncidentDetailHeader incident={incident} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 print-compact-spacing">
|
||||
<div className="print-section">
|
||||
<BasicInfoSection incident={incident} assignedUser={userToDisplay} />
|
||||
</div>
|
||||
<Separator className="print:border-blue-200" />
|
||||
<div className="print-section">
|
||||
<TimelineSection incident={incident} assignedUser={userToDisplay} />
|
||||
</div>
|
||||
<Separator className="print:border-blue-200" />
|
||||
<div className="print-section">
|
||||
<AffectedSystemsSection incident={incident} assignedUser={userToDisplay} />
|
||||
</div>
|
||||
<Separator className="print:border-blue-200" />
|
||||
<div className="print-section">
|
||||
<ResolutionSection incident={incident} assignedUser={userToDisplay} />
|
||||
</div>
|
||||
|
||||
<IncidentDetailFooter onClose={onClose} incident={incident} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { IncidentItem } from '@/services/incident/types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { userService } from '@/services/userService';
|
||||
import { IncidentDetailContent } from './IncidentDetailContent';
|
||||
|
||||
interface IncidentDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
incident: IncidentItem | null;
|
||||
}
|
||||
|
||||
export const IncidentDetailDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
incident
|
||||
}: IncidentDetailDialogProps) => {
|
||||
// Fetch user data for assigned field (prefer assigned_users, fallback to assigned_to)
|
||||
const { data: users = [] } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: async () => {
|
||||
const usersList = await userService.getUsers();
|
||||
return Array.isArray(usersList) ? usersList : [];
|
||||
},
|
||||
staleTime: 300000, // Cache for 5 minutes
|
||||
enabled: !!(incident?.assigned_users || incident?.assigned_to) && open // Only run query if there's an assigned value and dialog is open
|
||||
});
|
||||
|
||||
// Find the assigned user (prefer assigned_users, fallback to assigned_to)
|
||||
const assignedUser = (incident?.assigned_users || incident?.assigned_to)
|
||||
? users.find(user => user.id === (incident?.assigned_users || incident?.assigned_to))
|
||||
: null;
|
||||
|
||||
if (!incident) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="dialog-content sm:max-w-[700px] max-h-[90vh] p-0
|
||||
print:max-w-none print:max-h-none print:overflow-visible
|
||||
print:shadow-none print:m-0 print:p-0 print:border-none
|
||||
print:absolute print:left-0 print:top-0 print:w-full print:h-auto"
|
||||
>
|
||||
<IncidentDetailContent
|
||||
incident={incident}
|
||||
onClose={() => onOpenChange(false)}
|
||||
assignedUser={assignedUser}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
|
||||
import React from 'react';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CloseButton, DownloadPdfButton, PrintButton } from './components';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
interface IncidentDetailFooterProps {
|
||||
onClose: () => void;
|
||||
incident: IncidentItem;
|
||||
}
|
||||
|
||||
export const IncidentDetailFooter: React.FC<IncidentDetailFooterProps> = ({
|
||||
onClose,
|
||||
incident,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="print:hidden">
|
||||
<Separator className="my-6" />
|
||||
<div className="flex justify-between items-center">
|
||||
<CloseButton onClose={onClose} />
|
||||
<div className="flex gap-2">
|
||||
<DownloadPdfButton incident={incident} />
|
||||
<PrintButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
|
||||
import React from 'react';
|
||||
import { DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { IncidentItem } from '@/services/incident/types';
|
||||
|
||||
interface IncidentDetailHeaderProps {
|
||||
incident: IncidentItem;
|
||||
}
|
||||
|
||||
export const IncidentDetailHeader = ({ incident }: IncidentDetailHeaderProps) => {
|
||||
return (
|
||||
<DialogHeader className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle className="text-xl">{incident.title || 'Incident Details'}</DialogTitle>
|
||||
<span className="text-sm text-muted-foreground">#{incident.id}</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { userService } from '@/services/userService';
|
||||
|
||||
// Import all section components from the new location
|
||||
import {
|
||||
BasicInfoSection,
|
||||
TimelineSection,
|
||||
AffectedSystemsSection,
|
||||
ResolutionSection
|
||||
} from './sections';
|
||||
|
||||
// Re-export all section components for compatibility with existing imports
|
||||
export {
|
||||
BasicInfoSection,
|
||||
TimelineSection,
|
||||
AffectedSystemsSection,
|
||||
ResolutionSection
|
||||
};
|
||||
|
||||
// Legacy component - keeping this for backward compatibility with other imports
|
||||
export const IncidentDetailSections = ({ incident }: { incident: IncidentItem | null }) => {
|
||||
// Fetch assigned user details if there's an assigned field (prefer assigned_users, fallback to assigned_to)
|
||||
const { data: assignedUser } = useQuery({
|
||||
queryKey: ['user', incident?.assigned_users || incident?.assigned_to],
|
||||
queryFn: async () => {
|
||||
if (!(incident?.assigned_users || incident?.assigned_to)) return null;
|
||||
try {
|
||||
return await userService.getUser(incident?.assigned_users || incident?.assigned_to);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch assigned user:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!(incident?.assigned_users || incident?.assigned_to),
|
||||
staleTime: 300000 // Cache for 5 minutes
|
||||
});
|
||||
|
||||
if (!incident) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BasicInfoSection incident={incident} assignedUser={assignedUser} />
|
||||
<Separator />
|
||||
<TimelineSection incident={incident} assignedUser={assignedUser} />
|
||||
<Separator />
|
||||
<AffectedSystemsSection incident={incident} assignedUser={assignedUser} />
|
||||
<Separator />
|
||||
<ResolutionSection incident={incident} assignedUser={assignedUser} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
interface CloseButtonProps {
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CloseButton: React.FC<CloseButtonProps> = ({
|
||||
onClose,
|
||||
className
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
className={className}
|
||||
>
|
||||
{t('close', 'common')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { useDownloadIncidentPdf } from '../hooks';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
|
||||
interface DownloadPdfButtonProps {
|
||||
incident: IncidentItem;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DownloadPdfButton: React.FC<DownloadPdfButtonProps> = ({
|
||||
incident,
|
||||
className
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { handleDownloadPDF } = useDownloadIncidentPdf();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`flex items-center gap-2 ${className || ''}`}
|
||||
onClick={() => handleDownloadPDF(incident)}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t('downloadPdf', 'incident')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { usePrintIncident } from '../hooks';
|
||||
|
||||
interface PrintButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PrintButton: React.FC<PrintButtonProps> = ({ className }) => {
|
||||
const { t } = useLanguage();
|
||||
const { handlePrint } = usePrintIncident();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`flex items-center gap-2 ${className || ''}`}
|
||||
onClick={handlePrint}
|
||||
variant="default"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
{t('print', 'incident')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
|
||||
export * from './PrintButton';
|
||||
export * from './DownloadPdfButton';
|
||||
export * from './CloseButton';
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
export * from './usePrintIncident';
|
||||
export * from './useDownloadIncidentPdf';
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentItem, incidentService } from '@/services/incident';
|
||||
|
||||
export const useDownloadIncidentPdf = () => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleDownloadPDF = async (incident: IncidentItem) => {
|
||||
try {
|
||||
await incidentService.generateIncidentPDF(incident);
|
||||
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('pdfDownloaded'),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating PDF:", error);
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('pdfGenerationFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { handleDownloadPDF };
|
||||
};
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
export const usePrintIncident = () => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handlePrint = React.useCallback(() => {
|
||||
try {
|
||||
// Add print-specific stylesheet temporarily
|
||||
const style = document.createElement('style');
|
||||
style.id = 'print-style';
|
||||
style.textContent = `
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 10mm;
|
||||
}
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dialog-content, .dialog-content * {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
position: absolute !important;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 15mm !important;
|
||||
margin: 0 !important;
|
||||
background-color: white !important;
|
||||
box-shadow: none;
|
||||
overflow: visible !important;
|
||||
display: block !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Remove any black backgrounds */
|
||||
html, body {
|
||||
background-color: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* Optimize spacing for single page */
|
||||
.print-section {
|
||||
margin-bottom: 2mm !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* Reduce vertical spacing */
|
||||
h4 {
|
||||
margin-bottom: 1mm !important;
|
||||
margin-top: 1mm !important;
|
||||
color: #1e40af !important; /* blue-800 */
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
.print-compact-text {
|
||||
font-size: 9pt !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
|
||||
.print-compact-spacing > * {
|
||||
margin-top: 1mm !important;
|
||||
margin-bottom: 1mm !important;
|
||||
}
|
||||
|
||||
.print-grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr 1fr !important;
|
||||
gap: 1mm !important;
|
||||
}
|
||||
|
||||
.badge-print {
|
||||
background-color: #dbeafe !important; /* blue-100 */
|
||||
color: #1e40af !important; /* blue-800 */
|
||||
border: 1px solid #93c5fd !important; /* blue-300 */
|
||||
padding: 0.5mm 1mm !important;
|
||||
margin: 0.5mm !important;
|
||||
display: inline-block !important;
|
||||
font-size: 8pt !important;
|
||||
}
|
||||
|
||||
/* More compact spacing */
|
||||
.print-compact-margin {
|
||||
margin: 1mm 0 !important;
|
||||
}
|
||||
|
||||
.print-smaller-font {
|
||||
font-size: 8pt !important;
|
||||
}
|
||||
|
||||
.header-print {
|
||||
background-color: #1e40af !important; /* blue-800 */
|
||||
color: #ffffff !important;
|
||||
padding: 2mm 3mm !important;
|
||||
margin-bottom: 2mm !important;
|
||||
}
|
||||
|
||||
/* More compact header */
|
||||
.header-print h1 {
|
||||
font-size: 12pt !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.header-print p {
|
||||
font-size: 9pt !important;
|
||||
margin-top: 1mm !important;
|
||||
}
|
||||
|
||||
/* Footer stays at bottom */
|
||||
.footer-print {
|
||||
font-size: 7pt !important;
|
||||
color: #6b7280 !important; /* gray-500 */
|
||||
border-top: 1px solid #e5e7eb !important; /* gray-200 */
|
||||
position: fixed !important;
|
||||
bottom: 10mm !important;
|
||||
left: 15mm !important;
|
||||
right: 15mm !important;
|
||||
padding-top: 2mm !important;
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
/* Hide any unnecessary elements */
|
||||
.print-hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Optimize description text */
|
||||
.print-description {
|
||||
max-height: 100px !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Wait a bit to ensure styles are applied
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
|
||||
// Remove the style after printing dialog closes
|
||||
setTimeout(() => {
|
||||
const printStyle = document.getElementById('print-style');
|
||||
if (printStyle) {
|
||||
printStyle.remove();
|
||||
}
|
||||
}, 1000);
|
||||
}, 100);
|
||||
|
||||
toast({
|
||||
title: t('success'),
|
||||
description: t('printJobStarted'),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error printing:", error);
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('printingFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [t, toast]);
|
||||
|
||||
return { handlePrint };
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
export * from './IncidentDetailDialog';
|
||||
export * from './IncidentDetailHeader';
|
||||
export * from './IncidentDetailContent';
|
||||
export * from './IncidentDetailSections';
|
||||
export * from './IncidentDetailFooter';
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getAffectedSystemsArray } from './utils';
|
||||
|
||||
interface AffectedSystemsSectionProps {
|
||||
incident: IncidentItem | null;
|
||||
assignedUser?: any | null;
|
||||
}
|
||||
|
||||
export const AffectedSystemsSection: React.FC<AffectedSystemsSectionProps> = ({ incident }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
if (!incident) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">{t('affectedSystems')}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('systems')}</h4>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{getAffectedSystemsArray(incident.affected_systems).map((system, idx) => (
|
||||
<Badge key={idx} variant="outline">{system}</Badge>
|
||||
))}
|
||||
{getAffectedSystemsArray(incident.affected_systems).length === 0 &&
|
||||
<span className="text-muted-foreground italic">{t('noSystems')}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
|
||||
<div className="mt-1">
|
||||
<Badge variant={
|
||||
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
|
||||
incident.impact?.toLowerCase() === 'high' ? 'default' :
|
||||
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||
}>
|
||||
{t(incident.impact?.toLowerCase() || 'low')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
|
||||
<div className="mt-1">
|
||||
<Badge variant={
|
||||
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
|
||||
incident.priority?.toLowerCase() === 'high' ? 'default' :
|
||||
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||
}>
|
||||
{t(incident.priority?.toLowerCase() || 'low')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { User } from '@/services/userService';
|
||||
import { getUserInitials } from './utils';
|
||||
|
||||
interface AssignmentSectionProps {
|
||||
incident: IncidentItem | null;
|
||||
assignedUser?: User | null;
|
||||
}
|
||||
|
||||
export const AssignmentSection: React.FC<AssignmentSectionProps> = ({ incident, assignedUser }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
if (!incident) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">{t('assignment')}</h3>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
|
||||
<div className="mt-1">
|
||||
{assignedUser ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
|
||||
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{assignedUser.full_name || assignedUser.username}</span>
|
||||
</div>
|
||||
) : (incident.assigned_users || incident.assigned_to) ? (
|
||||
<span>{incident.assigned_users || incident.assigned_to}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
import { User } from '@/services/userService';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { IncidentStatusBadge } from '../../IncidentStatusBadge';
|
||||
import { getUserInitials } from './utils';
|
||||
|
||||
interface BasicInfoSectionProps {
|
||||
incident: IncidentItem | null;
|
||||
assignedUser?: User | null;
|
||||
}
|
||||
|
||||
export const BasicInfoSection: React.FC<BasicInfoSectionProps> = ({ incident, assignedUser }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
if (!incident) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 print-compact-text">
|
||||
<h3 className="font-semibold text-lg">{t('basicInfo')}</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 print-grid">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('title')}</h4>
|
||||
<p className="mt-1">{incident.title || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('status')}</h4>
|
||||
<div className="mt-1">
|
||||
<IncidentStatusBadge status={incident.status || incident.impact_status || 'investigating'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('serviceId')}</h4>
|
||||
<p className="mt-1">{incident.service_id || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
|
||||
<div className="mt-1">
|
||||
{assignedUser ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
|
||||
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{assignedUser.full_name || assignedUser.username}</span>
|
||||
</div>
|
||||
) : (incident.assigned_users || incident.assigned_to) ? (
|
||||
<span>{incident.assigned_users || incident.assigned_to}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('description')}</h4>
|
||||
<p className="mt-1 whitespace-pre-line print-description">{incident.description || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface ImpactAnalysisSectionProps {
|
||||
incident: IncidentItem | null;
|
||||
assignedUser?: any | null;
|
||||
}
|
||||
|
||||
export const ImpactAnalysisSection: React.FC<ImpactAnalysisSectionProps> = ({ incident }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
if (!incident) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">{t('impactAnalysis')}</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
|
||||
<div className="mt-1">
|
||||
<Badge variant={
|
||||
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
|
||||
incident.impact?.toLowerCase() === 'high' ? 'default' :
|
||||
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||
}>
|
||||
{t(incident.impact?.toLowerCase() || 'low')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
|
||||
<div className="mt-1">
|
||||
<Badge variant={
|
||||
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
|
||||
incident.priority?.toLowerCase() === 'high' ? 'default' :
|
||||
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||
}>
|
||||
{t(incident.priority?.toLowerCase() || 'low')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
|
||||
interface ResolutionSectionProps {
|
||||
incident: IncidentItem | null;
|
||||
assignedUser?: any | null;
|
||||
}
|
||||
|
||||
export const ResolutionSection: React.FC<ResolutionSectionProps> = ({ incident }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
if (!incident) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">{t('resolutionDetails')}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('rootCause')}</h4>
|
||||
<p className="mt-1 whitespace-pre-line">{incident.root_cause || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionSteps')}</h4>
|
||||
<p className="mt-1 whitespace-pre-line">{incident.resolution_steps || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('lessonsLearned')}</h4>
|
||||
<p className="mt-1 whitespace-pre-line">{incident.lessons_learned || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentItem } from '@/services/incident';
|
||||
import { formatDate } from './utils';
|
||||
|
||||
interface TimelineSectionProps {
|
||||
incident: IncidentItem | null;
|
||||
assignedUser?: any | null;
|
||||
}
|
||||
|
||||
export const TimelineSection: React.FC<TimelineSectionProps> = ({ incident }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
if (!incident) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">{t('timeline')}</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('created')}</h4>
|
||||
<p className="mt-1">{formatDate(incident.created)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('lastUpdated')}</h4>
|
||||
<p className="mt-1">{formatDate(incident.updated)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('incidentTime')}</h4>
|
||||
<p className="mt-1">{formatDate(incident.timestamp)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionTime')}</h4>
|
||||
<p className="mt-1">{formatDate(incident.resolution_time)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
// Export all section components
|
||||
export * from './BasicInfoSection';
|
||||
export * from './TimelineSection';
|
||||
export * from './AffectedSystemsSection';
|
||||
export * from './ResolutionSection';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,32 @@
|
||||
|
||||
import { User } from '@/services/userService';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// Helper function to get user initials
|
||||
export const getUserInitials = (user: User): string => {
|
||||
if (user.full_name) {
|
||||
const nameParts = user.full_name.split(' ');
|
||||
if (nameParts.length > 1) {
|
||||
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
|
||||
}
|
||||
return user.full_name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
return user.username.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
// Format date helper
|
||||
export const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
return format(new Date(dateStr), 'PPp');
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', dateStr, error);
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Get affected systems helper
|
||||
export const getAffectedSystemsArray = (systems?: string) => {
|
||||
if (!systems) return [];
|
||||
return systems.split(',').map(system => system.trim()).filter(Boolean);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export const IncidentAffectedFields: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { control } = useFormContext<IncidentFormValues>();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="affected_systems"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('affectedSystems')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('enterAffectedSystems')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-sm text-muted-foreground">{t('separateSystemsWithComma')}</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="root_cause"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('rootCause')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('enterRootCause')}
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { userService } from '@/services/userService';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
export const IncidentBasicFields: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const form = useFormContext<IncidentFormValues>();
|
||||
const currentAssignedUserId = form.watch('assigned_to');
|
||||
|
||||
// For assigned users functionality
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
|
||||
// Update selectedUserIds when form value changes (for edit mode)
|
||||
useEffect(() => {
|
||||
if (currentAssignedUserId) {
|
||||
setSelectedUserIds([currentAssignedUserId]);
|
||||
}
|
||||
}, [currentAssignedUserId]);
|
||||
|
||||
// Fetch users for assignment
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const usersList = await userService.getUsers();
|
||||
// console.log("Fetched users for incident assignment:", usersList);
|
||||
return Array.isArray(usersList) ? usersList : [];
|
||||
} catch (error) {
|
||||
// console.error("Failed to fetch users:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: 300000 // Cache for 5 minutes
|
||||
});
|
||||
|
||||
// Add user to assigned_to
|
||||
const addUser = (userId: string) => {
|
||||
// For now, we're using a single user assignment
|
||||
// console.log("Setting user ID in form:", userId);
|
||||
form.setValue('assigned_to', userId, { shouldValidate: true, shouldDirty: true });
|
||||
setSelectedUserIds([userId]);
|
||||
};
|
||||
|
||||
// Remove assigned user
|
||||
const removeUser = () => {
|
||||
form.setValue('assigned_to', '', { shouldValidate: true, shouldDirty: true });
|
||||
setSelectedUserIds([]);
|
||||
};
|
||||
|
||||
// Get selected user data
|
||||
const selectedUser = users.find(user => user.id === form.getValues('assigned_to'));
|
||||
|
||||
// Function to get user initials from name
|
||||
const getUserInitials = (user: { full_name?: string; username: string }): string => {
|
||||
if (user.full_name) {
|
||||
const nameParts = user.full_name.split(' ');
|
||||
if (nameParts.length > 1) {
|
||||
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
|
||||
}
|
||||
return user.full_name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
return user.username.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('title')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('enterIncidentTitle')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('enterIncidentDescription')}
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="service_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('serviceId')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('enterServiceId')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="assigned_to"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" /> {t('assignedTo')}
|
||||
</FormLabel>
|
||||
<div className="space-y-3">
|
||||
<FormControl>
|
||||
<select
|
||||
id="assigned-user-select"
|
||||
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={field.value || ""}
|
||||
onChange={(e) => {
|
||||
const selectedValue = e.target.value;
|
||||
console.log("Selected user ID:", selectedValue);
|
||||
if (selectedValue) {
|
||||
addUser(selectedValue);
|
||||
}
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
disabled={field.disabled}
|
||||
name={field.name}
|
||||
>
|
||||
<option value="">{t('selectAssignedUser')}</option>
|
||||
{users.map(user => (
|
||||
<option
|
||||
key={user.id}
|
||||
value={user.id}
|
||||
>
|
||||
{user.full_name || user.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormControl>
|
||||
|
||||
{selectedUser ? (
|
||||
<div className="flex flex-wrap gap-2 mt-2 border p-2 rounded-md bg-muted/50">
|
||||
<Badge key={selectedUser.id} variant="secondary" className="flex items-center gap-1 py-1 px-2">
|
||||
<Avatar className="h-5 w-5 mr-1">
|
||||
<AvatarImage src={selectedUser.avatar} alt={selectedUser.full_name || selectedUser.username} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getUserInitials(selectedUser)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{selectedUser.full_name || selectedUser.username}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 p-0 ml-1 hover:bg-transparent hover:opacity-70"
|
||||
onClick={removeUser}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">{t('remove')}</span>
|
||||
</Button>
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic p-2">
|
||||
{t('noAssignedUser')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export const IncidentConfigFields: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { control } = useFormContext<IncidentFormValues>();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('status')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectIncidentStatus')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="investigating">{t('investigating')}</SelectItem>
|
||||
<SelectItem value="identified">{t('identified')}</SelectItem>
|
||||
<SelectItem value="found_root_cause">{t('foundRootCause')}</SelectItem>
|
||||
<SelectItem value="in_progress">{t('inProgress')}</SelectItem>
|
||||
<SelectItem value="monitoring">{t('monitoring')}</SelectItem>
|
||||
<SelectItem value="resolved">{t('resolved')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="impact"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-2">
|
||||
<FormLabel>{t('impact')}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex space-x-1"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="minor" id="minor" />
|
||||
<Label htmlFor="minor">{t('minor')}</Label>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="major" id="major" />
|
||||
<Label htmlFor="major">{t('major')}</Label>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="critical" id="critical" />
|
||||
<Label htmlFor="critical">{t('critical')}</Label>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="none" id="none" />
|
||||
<Label htmlFor="none">{t('none')}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('priority')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectPriorityLevel')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{t('low')}</SelectItem>
|
||||
<SelectItem value="medium">{t('medium')}</SelectItem>
|
||||
<SelectItem value="high">{t('high')}</SelectItem>
|
||||
<SelectItem value="critical">{t('critical')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export const IncidentDetailsFields: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { control } = useFormContext<IncidentFormValues>();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={control}
|
||||
name="resolution_steps"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('resolutionSteps')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('enterResolutionSteps')}
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="lessons_learned"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('lessonsLearned')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('enterLessonsLearned')}
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
export * from './IncidentBasicFields';
|
||||
export * from './IncidentAffectedFields';
|
||||
export * from './IncidentConfigFields';
|
||||
export * from './IncidentDetailsFields';
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { incidentService, IncidentItem } from '@/services/incident';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { incidentFormSchema, IncidentFormValues } from './useIncidentForm';
|
||||
|
||||
export const useIncidentEditForm = (
|
||||
incident: IncidentItem,
|
||||
onSuccess: () => void,
|
||||
onClose: () => void
|
||||
) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Initialize form with existing incident data
|
||||
const form = useForm<IncidentFormValues>({
|
||||
resolver: zodResolver(incidentFormSchema),
|
||||
defaultValues: {
|
||||
title: incident.title || '',
|
||||
description: incident.description || '',
|
||||
affected_systems: incident.affected_systems || '',
|
||||
status: (incident.status?.toLowerCase() || incident.impact_status?.toLowerCase() || 'investigating') as any,
|
||||
impact: (incident.impact?.toLowerCase() || 'minor') as any,
|
||||
priority: (incident.priority?.toLowerCase() || 'medium') as any,
|
||||
service_id: incident.service_id || '',
|
||||
assigned_to: incident.assigned_users || incident.assigned_to || '',
|
||||
root_cause: incident.root_cause || '',
|
||||
resolution_steps: incident.resolution_steps || '',
|
||||
lessons_learned: incident.lessons_learned || '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: IncidentFormValues) => {
|
||||
try {
|
||||
console.log("Form data for update:", data);
|
||||
console.log("Assigned user ID for update:", data.assigned_to);
|
||||
|
||||
await incidentService.updateIncident(incident.id, {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
status: data.status,
|
||||
affected_systems: data.affected_systems,
|
||||
impact: data.impact,
|
||||
priority: data.priority,
|
||||
service_id: data.service_id,
|
||||
...(data.assigned_to
|
||||
? { assigned_to: data.assigned_to, assigned_users: data.assigned_to }
|
||||
: {}),
|
||||
root_cause: data.root_cause,
|
||||
resolution_steps: data.resolution_steps,
|
||||
lessons_learned: data.lessons_learned,
|
||||
});
|
||||
|
||||
|
||||
toast({
|
||||
title: t('incidentUpdated'),
|
||||
description: t('incidentUpdatedDesc'),
|
||||
});
|
||||
|
||||
onClose();
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Error updating incident:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: `${t('errorUpdatingIncident')}: ${error.message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorUpdatingIncident'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit: form.handleSubmit(onSubmit),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { incidentService, CreateIncidentInput } from '@/services/incident';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
// Define the schema for our incident form
|
||||
export const incidentFormSchema = z.object({
|
||||
title: z.string().min(1, { message: 'Title is required' }),
|
||||
description: z.string().min(1, { message: 'Description is required' }),
|
||||
affected_systems: z.string().min(1, { message: 'Affected systems are required' }),
|
||||
status: z.enum(['investigating', 'found_root_cause', 'in_progress', 'monitoring', 'resolved']),
|
||||
impact: z.enum(['none', 'minor', 'major', 'critical']),
|
||||
priority: z.enum(['low', 'medium', 'high', 'critical']),
|
||||
service_id: z.string().optional(),
|
||||
assigned_to: z.string().optional(),
|
||||
root_cause: z.string().optional(),
|
||||
resolution_steps: z.string().optional(),
|
||||
lessons_learned: z.string().optional(),
|
||||
});
|
||||
|
||||
export type IncidentFormValues = z.infer<typeof incidentFormSchema>;
|
||||
|
||||
export const useIncidentForm = (onSuccess: () => void, onClose: () => void) => {
|
||||
const { t } = useLanguage();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<IncidentFormValues>({
|
||||
resolver: zodResolver(incidentFormSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
affected_systems: '',
|
||||
status: 'investigating',
|
||||
impact: 'minor',
|
||||
priority: 'medium',
|
||||
service_id: '',
|
||||
assigned_to: '',
|
||||
root_cause: '',
|
||||
resolution_steps: '',
|
||||
lessons_learned: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: IncidentFormValues) => {
|
||||
try {
|
||||
console.log("Form data before submission:", data);
|
||||
|
||||
const formattedData: CreateIncidentInput = {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
status: data.status,
|
||||
affected_systems: data.affected_systems,
|
||||
impact: data.impact,
|
||||
priority: data.priority,
|
||||
service_id: data.service_id,
|
||||
assigned_to: data.assigned_to,
|
||||
assigned_users: data.assigned_to, // map to server field
|
||||
root_cause: data.root_cause,
|
||||
resolution_steps: data.resolution_steps,
|
||||
lessons_learned: data.lessons_learned,
|
||||
timestamp: new Date().toISOString(),
|
||||
created_by: pb.authStore.model?.id || '',
|
||||
};
|
||||
|
||||
console.log("Formatted data for API:", formattedData);
|
||||
|
||||
await incidentService.createIncident(formattedData);
|
||||
|
||||
toast({
|
||||
title: t('incidentCreated'),
|
||||
description: t('incidentCreatedDesc'),
|
||||
});
|
||||
|
||||
console.log("Incident created successfully, about to call onSuccess and onClose");
|
||||
|
||||
form.reset();
|
||||
onClose();
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Error creating incident:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error('Error details:', error.message);
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: `${t('errorCreatingIncident')}: ${error.message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('error'),
|
||||
description: t('errorCreatingIncident'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit: form.handleSubmit(onSubmit),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
export * from './IncidentTable';
|
||||
export * from './IncidentActionsMenu';
|
||||
export * from './IncidentStatusBadge';
|
||||
export * from './IncidentStatusDropdown';
|
||||
export * from './CreateIncidentDialog';
|
||||
export * from './EditIncidentDialog';
|
||||
export * from './EmptyIncidentState';
|
||||
export * from './detail-dialog';
|
||||
@@ -0,0 +1,150 @@
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { format } from 'date-fns';
|
||||
import { IncidentItem } from '@/services/incident/types';
|
||||
import { IncidentTableRow } from './IncidentTableRow';
|
||||
import { IncidentDetailDialog } from '../detail-dialog/IncidentDetailDialog';
|
||||
import { EditIncidentDialog } from '../EditIncidentDialog';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { IncidentTableSkeleton } from './IncidentTableSkeleton';
|
||||
|
||||
interface IncidentTableProps {
|
||||
data: IncidentItem[];
|
||||
isLoading: boolean;
|
||||
onIncidentUpdated: () => void;
|
||||
onViewDetails?: (incident: IncidentItem) => void;
|
||||
onEditIncident?: (incident: IncidentItem) => void;
|
||||
}
|
||||
|
||||
export const IncidentTable = ({
|
||||
data,
|
||||
isLoading,
|
||||
onIncidentUpdated,
|
||||
onViewDetails,
|
||||
onEditIncident
|
||||
}: IncidentTableProps) => {
|
||||
const { t } = useLanguage();
|
||||
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
|
||||
const formatDate = useCallback((dateString: string | undefined) => {
|
||||
if (!dateString) return '-';
|
||||
try {
|
||||
return format(new Date(dateString), 'PPp');
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', dateString, error);
|
||||
return dateString;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getAffectedSystemsArray = useCallback((affectedSystems: string | undefined): string[] => {
|
||||
if (!affectedSystems) return [];
|
||||
return affectedSystems.split(',').map(system => system.trim()).filter(Boolean);
|
||||
}, []);
|
||||
|
||||
const handleViewDetails = useCallback((incident: IncidentItem) => {
|
||||
setSelectedIncident(incident);
|
||||
setIsDetailOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditIncident = useCallback((incident: IncidentItem) => {
|
||||
setSelectedIncident(incident);
|
||||
setIsEditOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle status updates efficiently
|
||||
const handleIncidentUpdated = useCallback(() => {
|
||||
console.log("Incident updated in IncidentTable, propagating event");
|
||||
onIncidentUpdated();
|
||||
}, [onIncidentUpdated]);
|
||||
|
||||
// Handle dialog closing
|
||||
const handleDetailDialogClose = useCallback((open: boolean) => {
|
||||
setIsDetailOpen(open);
|
||||
if (!open) {
|
||||
onIncidentUpdated();
|
||||
}
|
||||
}, [onIncidentUpdated]);
|
||||
|
||||
// Handle edit dialog closing
|
||||
const handleEditDialogClose = useCallback((open: boolean) => {
|
||||
setIsEditOpen(open);
|
||||
if (!open) {
|
||||
onIncidentUpdated();
|
||||
}
|
||||
}, [onIncidentUpdated]);
|
||||
|
||||
if (isLoading) {
|
||||
return <IncidentTableSkeleton />;
|
||||
}
|
||||
|
||||
// Add a safety check to prevent map of undefined error
|
||||
if (!data || !Array.isArray(data)) {
|
||||
console.error('Data is not an array:', data);
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<p>No incident data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('title')}</TableHead>
|
||||
<TableHead>{t('status')}</TableHead>
|
||||
<TableHead>{t('priority')}</TableHead>
|
||||
<TableHead>{t('time')}</TableHead>
|
||||
<TableHead>{t('affected')}</TableHead>
|
||||
<TableHead>{t('impact')}</TableHead>
|
||||
<TableHead>{t('assignedTo')}</TableHead>
|
||||
<TableHead className="text-right">{t('actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item) => (
|
||||
<IncidentTableRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
formatDate={formatDate}
|
||||
getAffectedSystemsArray={getAffectedSystemsArray}
|
||||
onViewDetails={onViewDetails || handleViewDetails}
|
||||
onEditIncident={onEditIncident || handleEditIncident}
|
||||
onIncidentUpdated={handleIncidentUpdated}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Incident detail dialog */}
|
||||
<IncidentDetailDialog
|
||||
open={isDetailOpen}
|
||||
onOpenChange={handleDetailDialogClose}
|
||||
incident={selectedIncident}
|
||||
/>
|
||||
|
||||
{/* Edit incident dialog */}
|
||||
{selectedIncident && (
|
||||
<EditIncidentDialog
|
||||
open={isEditOpen}
|
||||
onOpenChange={handleEditDialogClose}
|
||||
incident={selectedIncident}
|
||||
onIncidentUpdated={onIncidentUpdated}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user