From 614c10188d0be603c917943994797fbc523b3300 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Fri, 20 Jun 2025 17:17:34 +0700 Subject: [PATCH] feat: Add check interval and check_at update - Added a "Check Interval" field to the SSL certificate create/edit forms. - Implemented functionality to update the `check_at` field in Pocketbase with the current time when the "Check" button is clicked. --- .../ssl-domain/AddSSLCertificateForm.tsx | 71 ++- .../ssl-domain/EditSSLCertificateForm.tsx | 33 +- .../ssl-domain/SSLCertificateActions.tsx | 64 +++ .../ssl-domain/SSLCertificatesTable.tsx | 522 +++++++----------- .../ssl-domain/SSLDomainContent.tsx | 9 +- application/src/services/ssl/index.ts | 3 +- .../services/ssl/sslCertificateOperations.ts | 22 + application/src/services/ssl/types.ts | 10 +- .../src/services/sslCertificateService.ts | 6 +- application/src/types/ssl.types.ts | 7 +- 10 files changed, 390 insertions(+), 357 deletions(-) create mode 100644 application/src/components/ssl-domain/SSLCertificateActions.tsx diff --git a/application/src/components/ssl-domain/AddSSLCertificateForm.tsx b/application/src/components/ssl-domain/AddSSLCertificateForm.tsx index 8314d36..3cb0a5e 100644 --- a/application/src/components/ssl-domain/AddSSLCertificateForm.tsx +++ b/application/src/components/ssl-domain/AddSSLCertificateForm.tsx @@ -1,3 +1,4 @@ + import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -18,7 +19,8 @@ const formSchema = z.object({ domain: z.string().min(1, "Domain is required"), 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") + notification_channel: z.string().min(1, "Notification channel is required"), + check_interval: z.coerce.number().int().min(1).max(30).optional() }); interface AddSSLCertificateFormProps { @@ -42,7 +44,8 @@ export const AddSSLCertificateForm = ({ domain: "", warning_threshold: 30, expiry_threshold: 7, - notification_channel: "" + notification_channel: "", + check_interval: 1 } }); @@ -89,7 +92,8 @@ export const AddSSLCertificateForm = ({ domain: values.domain, warning_threshold: values.warning_threshold, expiry_threshold: values.expiry_threshold, - notification_channel: values.notification_channel + notification_channel: values.notification_channel, + check_interval: values.check_interval }; await onSubmit(certData); @@ -117,34 +121,53 @@ export const AddSSLCertificateForm = ({ )} /> +
+ ( + + {t('warningThreshold')} + + + + + {t('getNotifiedExpiration')} + + + + )} + /> + + ( + + {t('expiryThreshold')} + + + + + {t('getNotifiedCritical')} + + + + )} + /> +
+ ( - {t('warningThreshold')} + Check Interval (Days) - + - {t('getNotifiedExpiration')} - - - - )} - /> - - ( - - {t('expiryThreshold')} - - - - - {t('getNotifiedCritical')} + How often to check the SSL certificate (in days) diff --git a/application/src/components/ssl-domain/EditSSLCertificateForm.tsx b/application/src/components/ssl-domain/EditSSLCertificateForm.tsx index 1512ce4..0e1cad1 100644 --- a/application/src/components/ssl-domain/EditSSLCertificateForm.tsx +++ b/application/src/components/ssl-domain/EditSSLCertificateForm.tsx @@ -1,3 +1,4 @@ + import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -17,6 +18,7 @@ const formSchema = z.object({ 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"), + check_interval: z.coerce.number().int().min(1).max(30).optional(), }); type FormValues = z.infer; @@ -40,6 +42,7 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend warning_threshold: certificate.warning_threshold, expiry_threshold: certificate.expiry_threshold, notification_channel: certificate.notification_channel, + check_interval: certificate.check_interval || 1, }, }); @@ -78,7 +81,8 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend ...data, // Ensure values are correctly typed as numbers warning_threshold: Number(data.warning_threshold), - expiry_threshold: Number(data.expiry_threshold) + expiry_threshold: Number(data.expiry_threshold), + check_interval: data.check_interval ? Number(data.check_interval) : undefined }; console.log("Submitting updated certificate:", updatedCertificate); @@ -113,7 +117,7 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend )} /> -
+
)} /> + + ( + + Check Interval (Days) + + + + + How often to check + + + + )} + />
); -}; +}; \ No newline at end of file diff --git a/application/src/components/ssl-domain/SSLCertificateActions.tsx b/application/src/components/ssl-domain/SSLCertificateActions.tsx new file mode 100644 index 0000000..9a86843 --- /dev/null +++ b/application/src/components/ssl-domain/SSLCertificateActions.tsx @@ -0,0 +1,64 @@ + +import React from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { MoreHorizontal, RefreshCw, Edit, Trash2 } from "lucide-react"; +import { SSLCertificate } from "@/types/ssl.types"; +import { triggerImmediateCheck } from "@/services/sslCertificateService"; +import { toast } from "sonner"; +import { useLanguage } from "@/contexts/LanguageContext"; + +interface SSLCertificateActionsProps { + certificate: SSLCertificate; + onEdit: (certificate: SSLCertificate) => void; + onDelete: (certificate: SSLCertificate) => void; +} + +export const SSLCertificateActions = ({ + certificate, + onEdit, + onDelete +}: SSLCertificateActionsProps) => { + const { t } = useLanguage(); + + const handleCheck = async () => { + try { + await triggerImmediateCheck(certificate.id); + } catch (error) { + console.error("Error triggering SSL check:", error); + } + }; + + return ( + + + + + + + + Check + + onEdit(certificate)}> + + {t('edit')} + + onDelete(certificate)} + className="text-destructive" + > + + {t('delete')} + + + + ); +}; \ No newline at end of file diff --git a/application/src/components/ssl-domain/SSLCertificatesTable.tsx b/application/src/components/ssl-domain/SSLCertificatesTable.tsx index 43ec130..64825d8 100644 --- a/application/src/components/ssl-domain/SSLCertificatesTable.tsx +++ b/application/src/components/ssl-domain/SSLCertificatesTable.tsx @@ -1,362 +1,248 @@ + import React, { useState } from "react"; -import { format } from "date-fns"; -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell -} from "@/components/ui/table"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { RefreshCw, Eye, Edit, Trash2, MoreHorizontal } from "lucide-react"; -import { SSLCertificate } from "@/types/ssl.types"; -import { SSLStatusBadge } from "./SSLStatusBadge"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { toast } from "sonner"; + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { SSLStatusBadge } from "./SSLStatusBadge"; +import { AddSSLCertificateForm } from "./AddSSLCertificateForm"; +import { EditSSLCertificateForm } from "./EditSSLCertificateForm"; +import { SSLCertificateActions } from "./SSLCertificateActions"; +import { fetchSSLCertificates, addSSLCertificate, deleteSSLCertificate } from "@/services/sslCertificateService"; +import { pb } from "@/lib/pocketbase"; +import { SSLCertificate } from "@/types/ssl.types"; import { useLanguage } from "@/contexts/LanguageContext"; +import { toast } from "sonner"; -interface SSLCertificatesTableProps { - certificates: SSLCertificate[]; - onRefresh: (id: string) => void; - refreshingId: string | null; - onEdit?: (certificate: SSLCertificate) => void; - onDelete?: (certificate: SSLCertificate) => void; -} - -export const SSLCertificatesTable = ({ - certificates, - onRefresh, - refreshingId, - onEdit, - onDelete -}: SSLCertificatesTableProps) => { +export const SSLCertificatesTable = () => { const { t } = useLanguage(); - const [selectedCert, setSelectedCert] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [certToDelete, setCertToDelete] = useState(null); - - const formatDate = (dateString: string | undefined) => { - if (!dateString) return t('unknown'); - + const queryClient = useQueryClient(); + const [showAddDialog, setShowAddDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [selectedCertificate, setSelectedCertificate] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data: certificates = [], isLoading, isError } = useQuery({ + queryKey: ['ssl-certificates'], + queryFn: fetchSSLCertificates, + }); + + if (isLoading) return
Loading...
; + if (isError) return
Error loading certificates
; + + const handleAddCertificate = async (data: any) => { + setIsSubmitting(true); try { - const date = new Date(dateString); - if (isNaN(date.getTime())) { - console.warn("Invalid date for formatting:", dateString); - return t('unknown'); - } - return format(date, "MMM dd, yyyy"); + await addSSLCertificate(data); + await queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] }); + setShowAddDialog(false); + toast.success(t('certificateAdded')); } catch (error) { - console.error("Error formatting date:", error); - return t('unknown'); + console.error("Error adding certificate:", error); + toast.error(t('failedToAddCertificate')); + } finally { + setIsSubmitting(false); } }; - const handleViewCertificate = (certificate: SSLCertificate) => { - setSelectedCert(certificate); - }; - - const handleEditCertificate = (certificate: SSLCertificate) => { - if (onEdit) { - onEdit(certificate); - } else { - toast.error("Edit functionality not implemented yet"); + const handleEditCertificate = async (updatedCertificate: SSLCertificate) => { + setIsSubmitting(true); + try { + await pb.collection('ssl_certificates').update(updatedCertificate.id, { + warning_threshold: updatedCertificate.warning_threshold, + expiry_threshold: updatedCertificate.expiry_threshold, + notification_channel: updatedCertificate.notification_channel, + check_interval: updatedCertificate.check_interval, + }); + + await queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] }); + setShowEditDialog(false); + setSelectedCertificate(null); + toast.success(t('certificateUpdated')); + } catch (error) { + console.error("Error updating certificate:", error); + toast.error(t('failedToUpdateCertificate')); + } finally { + setIsSubmitting(false); } }; - const handleDeleteCertificate = (certificate: SSLCertificate) => { - setCertToDelete(certificate); - setDeleteConfirmOpen(true); + const handleDeleteCertificate = async () => { + if (!selectedCertificate) return; + + setIsSubmitting(true); + try { + await deleteSSLCertificate(selectedCertificate.id); + await queryClient.invalidateQueries({ queryKey: ['ssl-certificates'] }); + setShowDeleteDialog(false); + setSelectedCertificate(null); + toast.success(t('certificateDeleted')); + } catch (error) { + console.error("Error deleting certificate:", error); + toast.error(t('failedToDeleteCertificate')); + } finally { + setIsSubmitting(false); + } }; - const confirmDelete = () => { - if (certToDelete && onDelete) { - onDelete(certToDelete); - setDeleteConfirmOpen(false); - setCertToDelete(null); - } + const openEditDialog = (certificate: SSLCertificate) => { + setSelectedCertificate(certificate); + setShowEditDialog(true); + }; + + const openDeleteDialog = (certificate: SSLCertificate) => { + setSelectedCertificate(certificate); + setShowDeleteDialog(true); }; return ( <> -
- {refreshingId && ( -
-
- - {t('checkingSSLCertificate')} -
-
- )} - - - - {t('domain')} - {t('issuer')} - {t('expirationDate')} - {t('daysLeft')} - {t('status')} - {t('lastNotified')} - {t('actions')} - - - - {certificates.length === 0 ? ( + + + {t('sslCertificates')} + + + +
+ - - {t('noSSLCertificates')} - + {t('domain')} + {t('status')} + {t('issuer')} + {t('validUntil')} + {t('daysLeft')} + Check Interval + - ) : ( - certificates.map((certificate) => ( + + + {certificates.map((certificate) => ( - {certificate.domain} - {certificate.issuer_o || t('unknown')} - - {formatDate(certificate.valid_till)} - - - {typeof certificate.days_left === 'number' ? certificate.days_left : t('unknown')} + + {certificate.domain} + {certificate.issuer_o || certificate.issuer_cn || 'Unknown'} - {certificate.last_notified - ? formatDate(certificate.last_notified) - : t('never')} + {certificate.valid_till ? new Date(certificate.valid_till).toLocaleDateString() : 'N/A'} - - - - - - - handleViewCertificate(certificate)} - className="cursor-pointer" - > - {t('view')} - - { - if (refreshingId === null) { - onRefresh(certificate.id); - } - }} - disabled={refreshingId !== null} - className="cursor-pointer" - > - - {t('check')} - - handleEditCertificate(certificate)} - className="cursor-pointer" - > - {t('edit')} - - handleDeleteCertificate(certificate)} - className="cursor-pointer text-destructive focus:text-destructive" - > - {t('delete')} - - - + + + {certificate.days_left} {t('days')} + + + + {certificate.check_interval || 1} {t('days')} + + + - )) - )} - -
-
+ ))} + + - {/* SSL Certificate Details Dialog */} - !open && setSelectedCert(null)}> - - - - {t('sslCertificateDetails')} - -

- {t('detailedInfo')} {selectedCert?.domain} -

-
- - {selectedCert && ( -
-
- {/* Basic Information */} -
-

{t('basicInformation')}

-
-
- {t('domain')}: - {selectedCert.domain} -
-
- {t('status')}: - -
-
- {t('issuer')}: - {selectedCert.issued_to || t('unknown')} -
-
- IP: - {selectedCert.resolved_ip || t('unknown')} -
-
-
- - {/* Validity */} -
-

{t('validity')}

-
-
- {t('validFrom')}: - {selectedCert.valid_from ? formatDate(selectedCert.valid_from) : t('unknown')} -
-
- {t('validUntil')}: - {formatDate(selectedCert.valid_till)} -
-
- {t('daysLeft')}: - {selectedCert.days_left} -
-
- {t('validityDays')}: - {selectedCert.validity_days || t('unknown')} -
-
-
- - {/* Issuer */} -
-

{t('issuerInfo')}

-
-
- {t('organization')}: - {selectedCert.issuer_o || t('unknown')} -
-
- {t('commonName')}: - {selectedCert.issuer_cn || t('unknown')} -
-
-
- - {/* Technical Details */} -
-

{t('technicalDetails')}

-
-
- {t('serialNumber')}: - {selectedCert.serial_number || t('unknown')} -
-
- {t('algorithm')}: - {selectedCert.cert_alg || t('unknown')} -
-
-
-
- - {/* Subject Alternative Names */} -
-

{t('subjectAltNames')}

-
- {selectedCert.cert_sans ? ( -

{selectedCert.cert_sans}

- ) : ( -

{t('none')}

- )} -
-
- - {/* Monitoring Configuration */} -
-

{t('monitoringConfig')}

-
-
-
{t('warningThreshold')}:
-
{selectedCert.warning_threshold} {t('daysLeft').toLowerCase()}
-
-
-
{t('expiryThreshold')}:
-
{selectedCert.expiry_threshold} {t('daysLeft').toLowerCase()}
-
-
-
{t('notificationChannel')}:
-
{selectedCert.notification_channel}
-
-
-
- - {/* Timestamps */} -
-

{t('recordInfo')}

-
-
-
{t('created')}:
-
{selectedCert.created ? formatDate(selectedCert.created) : t('unknown')}
-
-
-
{t('lastUpdated')}:
-
{selectedCert.updated ? formatDate(selectedCert.updated) : t('unknown')}
-
-
-
{t('lastNotification')}:
-
{selectedCert.last_notified ? formatDate(selectedCert.last_notified) : t('never')}
-
-
-
{t('collectionId')}:
-
{selectedCert.collectionId || t('unknown')}
-
-
-
+ {certificates.length === 0 && ( +
+ {t('noCertificatesFound')}
)} - - - - + + + + {/* Add Certificate Dialog */} + + + + {t('addSSLCertificate')} + + {t('addCertificateDescription')} + + + setShowAddDialog(false)} + isPending={isSubmitting} + /> - {/* Delete Confirmation Dialog */} - - + {/* Edit Certificate Dialog */} + + - {t('deleteSSLCertificate')} + {t('editSSLCertificate')} + + {t('editCertificateDescription')} + -
-

{t('deleteConfirmation')} {certToDelete?.domain}?

-

{t('deleteWarning')}

-
- - - - + {selectedCertificate && ( + setShowEditDialog(false)} + isPending={isSubmitting} + /> + )}
+ + {/* Delete Certificate Dialog */} + + + + {t('deleteCertificate')} + + {t('deleteCertificateConfirmation')} {selectedCertificate?.domain}? + + + + {t('cancel')} + + {isSubmitting ? t('deleting') : t('delete')} + + + + ); }; \ No newline at end of file diff --git a/application/src/components/ssl-domain/SSLDomainContent.tsx b/application/src/components/ssl-domain/SSLDomainContent.tsx index f4061fe..bc7af16 100644 --- a/application/src/components/ssl-domain/SSLDomainContent.tsx +++ b/application/src/components/ssl-domain/SSLDomainContent.tsx @@ -1,3 +1,4 @@ + import React, { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; @@ -242,13 +243,7 @@ export const SSLDomainContent = () => {
- +
diff --git a/application/src/services/ssl/index.ts b/application/src/services/ssl/index.ts index 95fee33..c12a334 100644 --- a/application/src/services/ssl/index.ts +++ b/application/src/services/ssl/index.ts @@ -13,7 +13,8 @@ export { addSSLCertificate, checkAndUpdateCertificate, deleteSSLCertificate, - refreshAllCertificates + refreshAllCertificates, + triggerImmediateCheck } from './sslCertificateOperations'; // SSL-specific notification service diff --git a/application/src/services/ssl/sslCertificateOperations.ts b/application/src/services/ssl/sslCertificateOperations.ts index 82f434d..dc37b67 100644 --- a/application/src/services/ssl/sslCertificateOperations.ts +++ b/application/src/services/ssl/sslCertificateOperations.ts @@ -30,6 +30,7 @@ export const addSSLCertificate = async ( warning_threshold: Number(certificateData.warning_threshold) || 30, expiry_threshold: Number(certificateData.expiry_threshold) || 7, notification_channel: certificateData.notification_channel || "", + check_interval: Number(certificateData.check_interval) || 1, // New field }; // Save to database @@ -71,6 +72,27 @@ export const checkAndUpdateCertificate = async ( } }; +/** + * Trigger immediate SSL check by setting check_at to current time + */ +export const triggerImmediateCheck = async (certificateId: string): Promise => { + try { + const currentTime = new Date().toISOString(); + + // Update the check_at field to current time to trigger immediate check by Go service + await pb.collection("ssl_certificates").update(certificateId, { + check_at: currentTime + }); + + console.log(`Triggered immediate check for certificate ${certificateId} at ${currentTime}`); + toast.success("SSL check scheduled - certificate will be checked shortly"); + } catch (error) { + console.error("Error triggering immediate SSL check:", error); + toast.error("Failed to schedule SSL check"); + throw error; + } +}; + /** * Delete an SSL certificate from monitoring */ diff --git a/application/src/services/ssl/types.ts b/application/src/services/ssl/types.ts index 4e4697c..6d73b74 100644 --- a/application/src/services/ssl/types.ts +++ b/application/src/services/ssl/types.ts @@ -1,10 +1,10 @@ - // SSL Certificate DTO for adding new certificates export interface AddSSLCertificateDto { domain: string; warning_threshold: number; expiry_threshold: number; notification_channel: string; + check_interval?: number; // New field for check interval in days } // SSL Certificate model @@ -28,6 +28,14 @@ export interface SSLCertificate { last_notified?: string; created?: string; updated?: string; + // New fields + check_interval?: number; // Check interval in days + check_at?: string; // Next check time + // Existing fields based on the provided structure + collectionId?: string; + collectionName?: string; + resolved_ip?: string; + issuer_cn?: string; } // SSL specific notification types diff --git a/application/src/services/sslCertificateService.ts b/application/src/services/sslCertificateService.ts index c946274..2cdb65f 100644 --- a/application/src/services/sslCertificateService.ts +++ b/application/src/services/sslCertificateService.ts @@ -3,7 +3,9 @@ import { fetchSSLCertificates, addSSLCertificate, - checkAndUpdateCertificate + checkAndUpdateCertificate, + triggerImmediateCheck, + deleteSSLCertificate } from './ssl'; import { determineSSLStatus } from './ssl/sslStatusUtils'; @@ -20,6 +22,8 @@ export { fetchSSLCertificates, addSSLCertificate, checkAndUpdateCertificate, + triggerImmediateCheck, + deleteSSLCertificate, checkAllCertificatesAndNotify, checkCertificateAndNotify, shouldRunDailyCheck diff --git a/application/src/types/ssl.types.ts b/application/src/types/ssl.types.ts index 75fc488..68d7427 100644 --- a/application/src/types/ssl.types.ts +++ b/application/src/types/ssl.types.ts @@ -1,4 +1,3 @@ - export interface SSLCertificate { id: string; domain: string; @@ -19,7 +18,10 @@ export interface SSLCertificate { last_notified?: string; created?: string; updated?: string; - // New fields based on the provided structure + // New fields + check_interval?: number; // Check interval in days + check_at?: string; // Next check time + // Existing fields based on the provided structure collectionId?: string; collectionName?: string; resolved_ip?: string; @@ -31,4 +33,5 @@ export interface AddSSLCertificateDto { warning_threshold: number; expiry_threshold: number; notification_channel: string; + check_interval?: number; // New field for check interval in days } \ No newline at end of file