feat: Implement dropdown actions in SSL table

Action column in the SSL & Domain table to use a dropdown menu for View, Check, Edit, and Delete actions. And updated SSL Dashboard UI Component
This commit is contained in:
Tola Leng
2025-05-16 22:20:57 +08:00
parent 8ee933563e
commit 1ea81a23b2
4 changed files with 483 additions and 44 deletions
@@ -1,9 +1,9 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { toast } from "sonner";
import { Bell } from "lucide-react";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
@@ -11,10 +11,10 @@ import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DialogFooter } from "@/components/ui/dialog";
import { AddSSLCertificateDto } from "@/types/ssl.types";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
const formSchema = z.object({
domain: z.string().min(1, "Domain is required"),
port: z.coerce.number().int().min(1).max(65535),
warning_threshold: z.coerce.number().int().min(1).max(365),
expiry_threshold: z.coerce.number().int().min(1).max(30),
notification_channel: z.string().min(1, "Notification channel is required")
@@ -31,23 +31,60 @@ export const AddSSLCertificateForm = ({
onCancel,
isPending = false
}: AddSSLCertificateFormProps) => {
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
domain: "",
port: 443,
warning_threshold: 30,
expiry_threshold: 7,
notification_channel: "default"
notification_channel: ""
}
});
// Fetch notification channels when form loads
useEffect(() => {
const fetchNotificationChannels = async () => {
setIsLoading(true);
try {
const configs = await alertConfigService.getAlertConfigurations();
console.log("Fetched notification channels:", configs);
// Only include enabled channels
const enabledConfigs = configs.filter(config => {
// Handle the possibility of enabled being a string
if (typeof config.enabled === 'string') {
return config.enabled === "true";
}
// Otherwise treat as boolean
return config.enabled === true;
});
setAlertConfigs(enabledConfigs);
// Set default if available, preferring Telegram channels
const telegramChannel = enabledConfigs.find(c => c.notification_type === "telegram");
const defaultChannel = telegramChannel || enabledConfigs[0];
if (defaultChannel?.id) {
form.setValue("notification_channel", defaultChannel.id);
}
} catch (error) {
console.error("Error fetching notification channels:", error);
toast.error("Failed to load notification channels");
} finally {
setIsLoading(false);
}
};
fetchNotificationChannels();
}, [form]);
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
try {
// Convert the form values to the required DTO format with required properties
const certData: AddSSLCertificateDto = {
domain: values.domain,
port: values.port,
warning_threshold: values.warning_threshold,
expiry_threshold: values.expiry_threshold,
notification_channel: values.notification_channel
@@ -78,20 +115,6 @@ export const AddSSLCertificateForm = ({
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="warning_threshold"
@@ -132,20 +155,28 @@ export const AddSSLCertificateForm = ({
render={({ field }) => (
<FormItem>
<FormLabel>Notification Channel</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select notification channel" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
{alertConfigs.length > 0 ? (
alertConfigs.map((config) => (
<SelectItem key={config.id} value={config.id || ""}>
{config.notify_name} ({config.notification_type})
</SelectItem>
))
) : isLoading ? (
<SelectItem value="loading" disabled>Loading channels...</SelectItem>
) : (
<SelectItem value="none" disabled>No notification channels found</SelectItem>
)}
</SelectContent>
</Select>
<FormDescription>
<FormDescription className="flex items-center gap-1">
<Bell className="h-4 w-4" />
Choose where to receive SSL certificate alerts
</FormDescription>
<FormMessage />
@@ -155,7 +186,7 @@ export const AddSSLCertificateForm = ({
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="submit" disabled={isPending}>
<Button type="submit" disabled={isPending || isLoading}>
Add Certificate
</Button>
</DialogFooter>
@@ -0,0 +1,232 @@
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { SSLCertificate } from "@/types/ssl.types";
import { Loader2, Bell } from "lucide-react";
import { toast } from "sonner";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
const formSchema = z.object({
domain: z.string().min(1, "Domain is required"),
warning_threshold: z.coerce.number().min(1, "Warning threshold must be at least 1 day"),
expiry_threshold: z.coerce.number().min(1, "Expiry threshold must be at least 1 day"),
notification_channel: z.string().min(1, "Notification channel is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface EditSSLCertificateFormProps {
certificate: SSLCertificate;
onSubmit: (data: SSLCertificate) => void;
onCancel: () => void;
isPending: boolean;
}
export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPending }: EditSSLCertificateFormProps) => {
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
domain: certificate.domain,
warning_threshold: certificate.warning_threshold,
expiry_threshold: certificate.expiry_threshold,
notification_channel: certificate.notification_channel,
},
});
// Fetch notification channels when form loads
useEffect(() => {
const fetchNotificationChannels = async () => {
setIsLoading(true);
try {
const configs = await alertConfigService.getAlertConfigurations();
console.log("Fetched notification channels:", configs);
// Only include enabled channels
const enabledConfigs = configs.filter(config => {
// Handle the possibility of enabled being a string
if (typeof config.enabled === 'string') {
return config.enabled === "true";
}
// Otherwise treat as boolean
return config.enabled === true;
});
setAlertConfigs(enabledConfigs);
} catch (error) {
console.error("Error fetching notification channels:", error);
toast.error("Failed to load notification channels");
} finally {
setIsLoading(false);
}
};
fetchNotificationChannels();
}, []);
const handleSubmit = (data: FormValues) => {
// Merge the updated values with the original certificate
const updatedCertificate: SSLCertificate = {
...certificate,
...data,
// Ensure values are correctly typed as numbers
warning_threshold: Number(data.warning_threshold),
expiry_threshold: Number(data.expiry_threshold)
};
console.log("Submitting updated certificate:", updatedCertificate);
onSubmit(updatedCertificate);
};
// For debugging
console.log("Certificate data:", certificate);
console.log("Form default values:", form.getValues());
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input
{...field}
placeholder="example.com"
disabled={true} // Domain shouldn't be editable
/>
</FormControl>
<FormDescription>
Domain name cannot be changed. To monitor a different domain, add a new certificate.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="warning_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Warning Threshold (Days)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min="1"
placeholder="30"
/>
</FormControl>
<FormDescription>
Days before expiration to send warning
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiry_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Expiry Threshold (Days)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min="1"
placeholder="7"
/>
</FormControl>
<FormDescription>
Days before expiration to send critical alert
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="notification_channel"
render={({ field }) => (
<FormItem>
<FormLabel>Notification Channel</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
disabled={isLoading}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select notification channel" />
</SelectTrigger>
</FormControl>
<SelectContent>
{alertConfigs.length > 0 ? (
alertConfigs.map((config) => (
<SelectItem key={config.id} value={config.id || ""}>
{config.notify_name} ({config.notification_type})
</SelectItem>
))
) : isLoading ? (
<SelectItem value="loading" disabled>Loading channels...</SelectItem>
) : (
<>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
<SelectItem value="none">None</SelectItem>
</>
)}
</SelectContent>
</Select>
<FormDescription className="flex items-center gap-1">
<Bell className="h-4 w-4" />
Where to send notifications about this certificate
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isPending}
>
Cancel
</Button>
<Button
type="submit"
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Updating...
</>
) : (
'Save Changes'
)}
</Button>
</div>
</form>
</Form>
);
};
@@ -1,4 +1,3 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@@ -9,20 +8,40 @@ import { toast } from "sonner";
import { SSLCertificateStatusCards } from "./SSLCertificateStatusCards";
import { SSLCertificatesTable } from "./SSLCertificatesTable";
import { LoadingState } from "@/components/services/LoadingState";
import { fetchSSLCertificates, addSSLCertificate, checkAndUpdateCertificate } from "@/services/sslCertificateService";
import { fetchSSLCertificates, addSSLCertificate, checkAndUpdateCertificate, refreshAllCertificates, deleteSSLCertificate } from "@/services/ssl";
import { AddSSLCertificateForm } from "./AddSSLCertificateForm";
import { AddSSLCertificateDto, SSLCertificate } from "@/types/ssl.types";
import { EditSSLCertificateForm } from "./EditSSLCertificateForm";
import type { AddSSLCertificateDto, SSLCertificate } from "@/types/ssl.types";
import { pb } from "@/lib/pocketbase";
export const SSLDomainContent = () => {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const [isRefreshingAll, setIsRefreshingAll] = useState(false);
const [selectedCertificate, setSelectedCertificate] = useState<SSLCertificate | null>(null);
const queryClient = useQueryClient();
// Fetch SSL certificates with explicit error handling
const { data: certificates = [], isLoading, error } = useQuery({
queryKey: ['ssl-certificates'],
queryFn: fetchSSLCertificates,
queryFn: async () => {
try {
console.log("Fetching SSL certificates from SSLDomainContent...");
const result = await fetchSSLCertificates();
console.log("Received SSL certificates:", result);
return result;
} catch (error) {
console.error("Error fetching certificates:", error);
toast.error("Failed to load SSL certificates");
throw error;
}
},
refetchOnWindowFocus: false,
refetchInterval: 300000, // Refresh every 5 minutes
});
// Add certificate mutation
const addMutation = useMutation({
mutationFn: addSSLCertificate,
onSuccess: () => {
@@ -32,21 +51,105 @@ export const SSLDomainContent = () => {
},
onError: (error) => {
console.error("Error adding SSL certificate:", error);
toast.error(error instanceof Error ? error.message : "Failed to add SSL certificate");
toast.error(error instanceof Error ? error.message : "Failed to add SSL certificate. Make sure the domain is valid and accessible.");
}
});
const refreshMutation = useMutation({
mutationFn: checkAndUpdateCertificate,
// Edit certificate mutation - Updated to ensure thresholds are properly updated
const editMutation = useMutation({
mutationFn: async (certificate: SSLCertificate) => {
console.log("Updating certificate with data:", certificate);
// Create the update data object
const updateData = {
warning_threshold: Number(certificate.warning_threshold),
expiry_threshold: Number(certificate.expiry_threshold),
notification_channel: certificate.notification_channel,
};
console.log("Update data to be sent:", updateData);
// Update certificate in the database using PocketBase directly
const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData);
console.log("PocketBase update response:", updated);
// After updating the settings, refresh the certificate to ensure it's up to date
// This will also check if notification needs to be sent based on updated thresholds
const refreshedCert = await checkAndUpdateCertificate(certificate.id);
return refreshedCert;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
setIsEditDialogOpen(false);
setSelectedCertificate(null);
toast.success("SSL certificate updated successfully");
},
onError: (error) => {
console.error("Error updating SSL certificate:", error);
toast.error(error instanceof Error ? error.message : "Failed to update SSL certificate");
}
});
// Delete certificate mutation
const deleteMutation = useMutation({
mutationFn: deleteSSLCertificate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
toast.success("SSL certificate deleted successfully");
},
onError: (error) => {
console.error("Error deleting SSL certificate:", error);
toast.error(error instanceof Error ? error.message : "Failed to delete SSL certificate");
}
});
// Refresh certificate mutation - Updated to ensure notifications are sent
const refreshMutation = useMutation({
mutationFn: checkAndUpdateCertificate,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
setRefreshingId(null);
toast.success("SSL certificate checked and updated successfully");
toast.success(`SSL certificate for ${data.domain} updated successfully`);
},
onError: (error) => {
console.error("Error refreshing SSL certificate:", error);
toast.error(error instanceof Error ? error.message : "Failed to refresh SSL certificate");
let errorMessage = "Failed to check SSL certificate.";
if (error instanceof Error) {
errorMessage = error.message;
}
toast.error(errorMessage);
setRefreshingId(null);
// Still refresh the data to show any partial information that was saved
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
}
});
// Refresh all certificates mutation
const refreshAllMutation = useMutation({
mutationFn: refreshAllCertificates,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
setIsRefreshingAll(false);
if (result.failed === 0) {
toast.success(`Successfully refreshed all ${result.success} certificates`);
} else {
toast.info(`Refreshed ${result.success} certificates, ${result.failed} failed`);
}
},
onError: (error) => {
console.error("Error refreshing all certificates:", error);
toast.error("Failed to refresh all certificates");
setIsRefreshingAll(false);
// Still refresh the data to show any partial information
queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] });
}
});
@@ -55,10 +158,36 @@ export const SSLDomainContent = () => {
};
const handleRefreshCertificate = (id: string) => {
if (refreshingId) return; // Prevent multiple refreshes
setRefreshingId(id);
refreshMutation.mutate(id);
};
const handleEditCertificate = (certificate: SSLCertificate) => {
setSelectedCertificate(certificate);
setIsEditDialogOpen(true);
};
const handleUpdateCertificate = (certificate: SSLCertificate) => {
console.log("Handling certificate update with data:", certificate);
editMutation.mutate(certificate);
};
const handleDeleteCertificate = (certificate: SSLCertificate) => {
deleteMutation.mutate(certificate.id);
};
const handleRefreshAll = async () => {
if (certificates.length === 0) {
toast.info("No certificates to refresh");
return;
}
setIsRefreshingAll(true);
toast.info(`Starting refresh of all ${certificates.length} certificates...`);
refreshAllMutation.mutate();
};
if (isLoading) {
return <LoadingState />;
}
@@ -67,7 +196,7 @@ export const SSLDomainContent = () => {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
<p>Error loading SSL certificate data.</p>
<Button onClick={() => window.location.reload()}>Retry</Button>
<Button onClick={() => queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] })}>Retry</Button>
</div>
);
}
@@ -76,13 +205,32 @@ export const SSLDomainContent = () => {
<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">SSL & Domain Management</h2>
<Button
className="text-primary-foreground"
onClick={() => setIsAddDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-2" /> Add Domain
</Button>
<div>
<h2 className="text-2xl font-bold text-foreground">SSL & Domain Management</h2>
<p className="text-sm text-muted-foreground mt-1">Monitor SSL certificates and their expiration dates</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleRefreshAll}
disabled={isRefreshingAll || refreshingId !== null}
className="relative"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshingAll ? 'animate-spin' : ''}`} />
Refresh All
{isRefreshingAll && (
<span className="absolute top-0 right-0 -mt-2 -mr-2 bg-primary text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
...
</span>
)}
</Button>
<Button
className="text-primary-foreground"
onClick={() => setIsAddDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-2" /> Add Domain
</Button>
</div>
</div>
<SSLCertificateStatusCards certificates={certificates} />
@@ -92,6 +240,8 @@ export const SSLDomainContent = () => {
certificates={certificates}
onRefresh={handleRefreshCertificate}
refreshingId={refreshingId}
onEdit={handleEditCertificate}
onDelete={handleDeleteCertificate}
/>
</div>
</div>
@@ -108,6 +258,28 @@ export const SSLDomainContent = () => {
/>
</DialogContent>
</Dialog>
{selectedCertificate && (
<Dialog open={isEditDialogOpen} onOpenChange={(open) => {
setIsEditDialogOpen(open);
if (!open) setSelectedCertificate(null);
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit SSL Certificate</DialogTitle>
</DialogHeader>
<EditSSLCertificateForm
certificate={selectedCertificate}
onSubmit={handleUpdateCertificate}
onCancel={() => {
setIsEditDialogOpen(false);
setSelectedCertificate(null);
}}
isPending={editMutation.isPending}
/>
</DialogContent>
</Dialog>
)}
</main>
);
};
@@ -23,6 +23,10 @@ export const SSLStatusBadge: React.FC<SSLStatusBadgeProps> = ({ status }) => {
variant = "bg-red-500 hover:bg-red-600";
label = "Expired";
break;
case "pending":
variant = "bg-blue-500 hover:bg-blue-600";
label = "Pending";
break;
default:
variant = "bg-gray-500 hover:bg-gray-600";
label = status.charAt(0).toUpperCase() + status.slice(1);