From 539cc224b441a16ee5f7bf44790238c4c557a290 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Fri, 31 Oct 2025 09:40:31 +0700 Subject: [PATCH 1/4] login page --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9994045..0976982 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ dist-ssr *.njsproj *.sln *.sw? -src-tauri/target/* \ No newline at end of file +src-tauri/target/* +src/components/pages/Login.tsx +src/routes/login.tsx From 8a4b7c005d442cdefb129921a73d153ca2beec11 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Fri, 31 Oct 2025 09:42:49 +0700 Subject: [PATCH 2/4] undo --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0976982..14e627b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,3 @@ dist-ssr *.sln *.sw? src-tauri/target/* -src/components/pages/Login.tsx -src/routes/login.tsx From cbc96cc261978010edcd235e10e39d894bc13408 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Fri, 31 Oct 2025 10:05:40 +0700 Subject: [PATCH 3/4] update to new version --- .../access-lists/AccessListFormDialog.tsx | 186 +++- src/components/acl/PreviewConfigDialog.tsx | 104 ++ src/components/domains/DomainDialog.tsx | 6 +- src/components/domains/DomainDialogV2.tsx | 3 +- src/components/forms/NLBFormDialog.tsx | 364 ++++++- src/components/logs/LogDetailsDialog.tsx | 106 +- src/components/modsec/CustomRuleDialog.tsx | 118 ++- src/components/pages/ACL.tsx | 155 ++- src/components/pages/DashboardNew.tsx | 697 +++++++++++++ src/components/pages/Domains.tsx | 18 +- src/components/pages/Logs.tsx | 969 +++++++++--------- src/components/pages/ModSecurity.tsx | 82 +- src/components/pages/index.ts | 1 + src/lib/i18n.ts | 96 ++ src/queries/acl.query-options.ts | 10 + .../dashboard-analytics.query-options.ts | 117 +++ src/queries/index.ts | 1 + src/routes/_auth/dashboard.tsx | 16 +- src/services/acl.service.ts | 8 + src/services/dashboard-analytics.service.ts | 175 ++++ src/utils/access-list-validators.ts | 131 +++ src/utils/acl-validators.ts | 301 ++++++ src/utils/nlb-validators.ts | 492 +++++++++ 23 files changed, 3467 insertions(+), 689 deletions(-) create mode 100644 src/components/acl/PreviewConfigDialog.tsx create mode 100644 src/components/pages/DashboardNew.tsx create mode 100644 src/queries/dashboard-analytics.query-options.ts create mode 100644 src/services/dashboard-analytics.service.ts create mode 100644 src/utils/access-list-validators.ts create mode 100644 src/utils/acl-validators.ts create mode 100644 src/utils/nlb-validators.ts diff --git a/src/components/access-lists/AccessListFormDialog.tsx b/src/components/access-lists/AccessListFormDialog.tsx index 576b059..02a1082 100644 --- a/src/components/access-lists/AccessListFormDialog.tsx +++ b/src/components/access-lists/AccessListFormDialog.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Plus, Trash2, Eye, EyeOff } from 'lucide-react'; +import { Plus, Trash2, Eye, EyeOff, AlertCircle, CheckCircle2, Info } from 'lucide-react'; import { Dialog, DialogContent, @@ -23,7 +23,16 @@ import { } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { useToast } from '@/hooks/use-toast'; +import { + validateAccessListName, + validateAccessListIp, + validateUsername, + validatePassword, + getAccessListHints, + getAccessListExample +} from '@/utils/access-list-validators'; import { useCreateAccessList, useUpdateAccessList, @@ -75,6 +84,20 @@ export function AccessListFormDialog({ const [selectedDomains, setSelectedDomains] = useState([]); const [originalDomainIds, setOriginalDomainIds] = useState([]); // Track original domains for edit mode + // Validation states + const [nameValidation, setNameValidation] = useState<{ valid: boolean; error?: string }>({ valid: true }); + const [ipValidations, setIpValidations] = useState>({}); + const [userValidations, setUserValidations] = useState>({}); + + // Validate name in real-time + useEffect(() => { + if (formData.name.trim().length > 0) { + setNameValidation(validateAccessListName(formData.name)); + } else { + setNameValidation({ valid: true }); + } + }, [formData.name]); + // Reset form when dialog opens or access list changes useEffect(() => { if (open) { @@ -120,6 +143,10 @@ export function AccessListFormDialog({ setSelectedDomains([]); setOriginalDomainIds([]); // Reset original domains } + // Reset validations + setNameValidation({ valid: true }); + setIpValidations({}); + setUserValidations({}); } }, [open, accessList]); @@ -280,6 +307,18 @@ export function AccessListFormDialog({ const newIps = [...allowedIps]; newIps[index] = value; setAllowedIps(newIps); + + // Validate IP in real-time + if (value.trim().length > 0) { + const validation = validateAccessListIp(value); + setIpValidations(prev => ({ ...prev, [index]: validation })); + } else { + setIpValidations(prev => { + const newValidations = { ...prev }; + delete newValidations[index]; + return newValidations; + }); + } }; const addAuthUser = () => { @@ -301,6 +340,31 @@ export function AccessListFormDialog({ const newUsers = [...authUsers]; (newUsers[index] as any)[field] = value; setAuthUsers(newUsers); + + // Validate username/password in real-time + if (field === 'username' && typeof value === 'string') { + if (value.trim().length > 0) { + const validation = validateUsername(value); + setUserValidations(prev => ({ + ...prev, + [index]: { + username: validation, + password: prev[index]?.password || { valid: true } + } + })); + } + } else if (field === 'password' && typeof value === 'string') { + if (value.trim().length > 0) { + const validation = validatePassword(value, !isEditMode); + setUserValidations(prev => ({ + ...prev, + [index]: { + username: prev[index]?.username || { valid: true }, + password: validation + } + })); + } + } }; const toggleDomainSelection = (domainId: string) => { @@ -338,16 +402,29 @@ export function AccessListFormDialog({
- - setFormData({ ...formData, name: e.target.value }) - } - placeholder="e.g., admin-panel-access" - disabled={isPending} - required - /> +
+ + setFormData({ ...formData, name: e.target.value }) + } + placeholder={getAccessListExample('name')} + disabled={isPending} + required + className={!nameValidation.valid && formData.name.trim().length > 0 ? 'border-red-500' : nameValidation.valid && formData.name.trim().length > 0 ? 'border-green-500' : ''} + /> + {nameValidation.valid && formData.name.trim().length > 0 && ( + + )} + {!nameValidation.valid && formData.name.trim().length > 0 && ( + + )} +
+ {!nameValidation.valid && nameValidation.error && ( +

{nameValidation.error}

+ )} +

{getAccessListHints('name')}

@@ -430,29 +507,46 @@ export function AccessListFormDialog({
{allowedIps.map((ip, index) => ( -
- updateIpField(index, e.target.value)} - placeholder="e.g., 192.168.1.1 or 10.0.0.0/24" - disabled={isPending} - /> - {allowedIps.length > 1 && ( - +
+
+
+ updateIpField(index, e.target.value)} + placeholder={getAccessListExample('ip')} + disabled={isPending} + className={ipValidations[index] && !ipValidations[index].valid ? 'border-red-500' : ipValidations[index]?.valid ? 'border-green-500' : ''} + /> + {ipValidations[index]?.valid && ip.trim().length > 0 && ( + + )} + {ipValidations[index] && !ipValidations[index].valid && ( + + )} +
+ {allowedIps.length > 1 && ( + + )} +
+ {ipValidations[index] && !ipValidations[index].valid && ipValidations[index].error && ( +

{ipValidations[index].error}

)}
))} -

- Enter IP addresses or CIDR notation (e.g., 192.168.1.0/24) -

+ + + + Hint: {getAccessListHints('ip')} + +
)} @@ -496,15 +590,27 @@ export function AccessListFormDialog({
- - updateAuthUser(index, 'username', e.target.value) - } - placeholder="username" - disabled={isPending} - minLength={3} - /> +
+ + updateAuthUser(index, 'username', e.target.value) + } + placeholder={getAccessListExample('username')} + disabled={isPending} + minLength={3} + className={userValidations[index]?.username && !userValidations[index].username.valid ? 'border-red-500' : userValidations[index]?.username?.valid ? 'border-green-500' : ''} + /> + {userValidations[index]?.username?.valid && user.username.trim().length > 0 && ( + + )} + {userValidations[index]?.username && !userValidations[index].username.valid && ( + + )} +
+ {userValidations[index]?.username && !userValidations[index].username.valid && userValidations[index].username.error && ( +

{userValidations[index].username.error}

+ )}
diff --git a/src/components/acl/PreviewConfigDialog.tsx b/src/components/acl/PreviewConfigDialog.tsx new file mode 100644 index 0000000..67a4b72 --- /dev/null +++ b/src/components/acl/PreviewConfigDialog.tsx @@ -0,0 +1,104 @@ +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, FileCode, Copy, CheckCircle } from "lucide-react"; +import { usePreviewAclConfig } from "@/queries"; +import { useState } from "react"; +import { useToast } from "@/hooks/use-toast"; + +interface PreviewConfigDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function PreviewConfigDialog({ open, onOpenChange }: PreviewConfigDialogProps) { + const { toast } = useToast(); + const { data, isLoading, error } = usePreviewAclConfig(); + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + if (data?.config) { + navigator.clipboard.writeText(data.config); + setCopied(true); + toast({ + title: "Copied!", + description: "Configuration copied to clipboard" + }); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + + + + + + Preview Nginx ACL Configuration + + + Review the generated nginx configuration before applying + + + +
+ {isLoading && ( +
+ +
+ )} + + {error && ( + + + Failed to load configuration preview. Please try again. + + + )} + + {data && ( + <> + + + {data.rulesCount} enabled rule{data.rulesCount !== 1 ? 's' : ''} will be applied to nginx configuration + + + +
+
+ +
+
+                  {data.config}
+                
+
+ + )} +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/domains/DomainDialog.tsx b/src/components/domains/DomainDialog.tsx index e6fa75c..682e301 100644 --- a/src/components/domains/DomainDialog.tsx +++ b/src/components/domains/DomainDialog.tsx @@ -41,7 +41,7 @@ interface DomainDialogProps { } export function DomainDialog({ open, onOpenChange, domain, onSave }: DomainDialogProps) { - // const { t } = useTranslation(); + const { t } = useTranslation(); const [formData, setFormData] = useState({ name: '', status: 'active' as 'active' | 'inactive' | 'error', @@ -181,7 +181,7 @@ export function DomainDialog({ open, onOpenChange, domain, onSave }: DomainDialo }; onSave(domainData); - onOpenChange(false); + // Do not close dialog here - let parent component handle it after successful save }; const addUpstream = () => { @@ -190,7 +190,7 @@ export function DomainDialog({ open, onOpenChange, domain, onSave }: DomainDialo { host: '', port: 80, - protocol: 'http' as 'http' | 'https', + protocol: 'http', sslVerify: true, weight: 1, maxFails: 3, diff --git a/src/components/domains/DomainDialogV2.tsx b/src/components/domains/DomainDialogV2.tsx index 2804b32..99d3e13 100644 --- a/src/components/domains/DomainDialogV2.tsx +++ b/src/components/domains/DomainDialogV2.tsx @@ -44,7 +44,6 @@ interface UpstreamFormData { interface CustomLocationFormData { path: string; - useUpstream?: boolean; upstreamType: 'proxy_pass' | 'grpc_pass' | 'grpcs_pass'; upstreams: UpstreamFormData[]; config?: string; @@ -223,7 +222,7 @@ export function DomainDialogV2({ open, onOpenChange, domain, onSave }: DomainDia }; onSave(domainData); - onOpenChange(false); + // Do not close dialog here - let parent component handle it after successful save }; return ( diff --git a/src/components/forms/NLBFormDialog.tsx b/src/components/forms/NLBFormDialog.tsx index 1e6bef8..f0a02ae 100644 --- a/src/components/forms/NLBFormDialog.tsx +++ b/src/components/forms/NLBFormDialog.tsx @@ -1,7 +1,14 @@ import { useEffect, useState } from 'react'; import { useForm, useFieldArray, Controller } from 'react-hook-form'; import { useCreateNLB, useUpdateNLB } from '@/queries/nlb.query-options'; -import { NetworkLoadBalancer, CreateNLBInput, NLBUpstream } from '@/types'; +import { NetworkLoadBalancer, CreateNLBInput } from '@/types'; +import { + validateNLBConfig, + isValidNLBName, + validateUpstreamHost, + getValidationHints, + checkConfigurationWarnings, +} from '@/utils/nlb-validators'; import { Dialog, DialogContent, @@ -30,7 +37,7 @@ import { import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Card, CardContent } from '@/components/ui/card'; -import { Plus, Trash2, HelpCircle } from 'lucide-react'; +import { Plus, Trash2, HelpCircle, AlertTriangle } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; interface NLBFormDialogProps { @@ -46,6 +53,8 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia const { toast } = useToast(); const createMutation = useCreateNLB(); const updateMutation = useUpdateNLB(); + const [configWarnings, setConfigWarnings] = useState([]); + const [validationErrors, setValidationErrors] = useState([]); const { register, @@ -82,6 +91,27 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia }); const protocol = watch('protocol'); + const upstreams = watch('upstreams'); + const proxyTimeout = watch('proxyTimeout'); + const proxyConnectTimeout = watch('proxyConnectTimeout'); + const healthCheckEnabled = watch('healthCheckEnabled'); + const healthCheckInterval = watch('healthCheckInterval'); + const healthCheckTimeout = watch('healthCheckTimeout'); + + // Check for configuration warnings whenever form values change + useEffect(() => { + if (upstreams && upstreams.length > 0) { + const warnings = checkConfigurationWarnings({ + upstreams: upstreams, + proxyTimeout: proxyTimeout || 3, + proxyConnectTimeout: proxyConnectTimeout || 1, + healthCheckEnabled: healthCheckEnabled || false, + healthCheckInterval: healthCheckInterval, + healthCheckTimeout: healthCheckTimeout, + }); + setConfigWarnings(warnings); + } + }, [upstreams, proxyTimeout, proxyConnectTimeout, healthCheckEnabled, healthCheckInterval, healthCheckTimeout]); useEffect(() => { if (isOpen && nlb && mode === 'edit') { @@ -136,6 +166,65 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia const onSubmit = async (data: FormData) => { try { + // Validate complete configuration before submission + const validation = validateNLBConfig({ + name: data.name, + port: Number(data.port), + upstreams: data.upstreams.map(u => ({ + host: u.host, + port: Number(u.port), + weight: Number(u.weight), + maxFails: Number(u.maxFails), + failTimeout: Number(u.failTimeout), + maxConns: Number(u.maxConns), + backup: Boolean(u.backup), + down: Boolean(u.down), + })), + proxyTimeout: Number(data.proxyTimeout), + proxyConnectTimeout: Number(data.proxyConnectTimeout), + proxyNextUpstreamTimeout: Number(data.proxyNextUpstreamTimeout), + proxyNextUpstreamTries: Number(data.proxyNextUpstreamTries), + healthCheckEnabled: Boolean(data.healthCheckEnabled), + healthCheckInterval: Number(data.healthCheckInterval), + healthCheckTimeout: Number(data.healthCheckTimeout), + healthCheckRises: Number(data.healthCheckRises), + healthCheckFalls: Number(data.healthCheckFalls), + }); + + if (!validation.valid) { + const errorMessages = Object.entries(validation.errors) + .map(([field, error]) => { + // Format field names to be more user-friendly + const fieldNames: Record = { + name: 'Name', + port: 'Port', + upstreams: 'Upstreams', + proxyTimeout: 'Proxy Timeout', + proxyConnectTimeout: 'Proxy Connect Timeout', + proxyNextUpstreamTimeout: 'Next Upstream Timeout', + proxyNextUpstreamTries: 'Next Upstream Tries', + healthCheckInterval: 'Health Check Interval', + healthCheckTimeout: 'Health Check Timeout', + healthCheckRises: 'Health Check Rises', + healthCheckFalls: 'Health Check Falls', + }; + const friendlyField = fieldNames[field] || field; + return `${friendlyField}: ${error}`; + }); + + setValidationErrors(errorMessages); + + toast({ + title: 'Configuration Error', + description: `Please fix ${errorMessages.length} validation error${errorMessages.length > 1 ? 's' : ''} before submitting.`, + variant: 'destructive', + }); + return; + } + + // Clear validation errors if everything is valid + setValidationErrors([]); + // Convert all string numbers to actual numbers const processedData = { ...data, @@ -177,18 +266,41 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia } onClose(); } catch (error: any) { - const message = error.response?.data?.message; - let description = `Failed to ${mode} NLB`; + console.error('NLB submission error:', error); + + const response = error.response?.data; + let errorMessages: string[] = []; + let title = 'Error'; - if (message?.includes('already exists')) { - description = 'An NLB with this name already exists'; - } else if (message) { - description = message; + // Handle validation errors from backend + if (response?.errors && Array.isArray(response.errors)) { + title = 'Validation Error'; + errorMessages = response.errors.map((err: any) => { + if (err.msg && err.path) { + return `${err.path}: ${err.msg}`; + } + return err.msg || err.message || 'Unknown error'; + }); + setValidationErrors(errorMessages); + } else if (response?.message) { + // Handle single error message + if (response.message.includes('already exists')) { + errorMessages = ['An NLB with this name already exists. Please choose a different name.']; + } else if (response.message.includes('host not found') || response.message.includes('Invalid host')) { + errorMessages = ['Invalid upstream host. Please check your IP address or hostname format.']; + } else if (response.message.includes('nginx')) { + errorMessages = ['Nginx configuration error: ' + response.message]; + } else { + errorMessages = [response.message]; + } + setValidationErrors(errorMessages); + } else { + errorMessages = [`Failed to ${mode} NLB. Please check your configuration and try again.`]; } toast({ - title: 'Error', - description, + title, + description: errorMessages[0] || `Failed to ${mode} NLB`, variant: 'destructive', }); } @@ -198,6 +310,14 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia append({ host: '', port: 80, weight: 1, maxFails: 3, failTimeout: 10, maxConns: 0, backup: false, down: false }); }; + // Clear validation errors when dialog closes + useEffect(() => { + if (!isOpen) { + setValidationErrors([]); + setConfigWarnings([]); + } + }, [isOpen]); + return ( !open && onClose()}> @@ -209,6 +329,31 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia
+ {/* Validation Errors Alert */} + {validationErrors.length > 0 && ( +
+
+ +
+

+ Configuration Errors ({validationErrors.length}) +

+
    + {validationErrors.map((error, idx) => ( +
  • + • + {error} +
  • + ))} +
+

+ Please fix these errors before submitting the form. +

+
+
+
+ )} + Basic @@ -221,12 +366,21 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia { + const validation = isValidNLBName(value); + return validation.valid || validation.error || 'Invalid name'; + }, + })} + placeholder="my-load-balancer" /> {errors.name && (

{errors.name.message}

)} +

+ {getValidationHints('name')} +

@@ -249,11 +403,15 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia required: 'Port is required', min: { value: 10000, message: 'Port must be ≥ 10000' }, max: { value: 65535, message: 'Port must be ≤ 65535' }, + valueAsNumber: true, })} /> {errors.port && (

{errors.port.message}

)} +

+ {getValidationHints('port')} +

@@ -326,14 +484,23 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia { + const validation = validateUpstreamHost(value); + return validation.valid || validation.error || 'Invalid host'; + }, })} - placeholder="192.168.1.100" + placeholder="192.168.1.100 or backend.example.com" /> {errors.upstreams?.[index]?.host && (

{errors.upstreams[index]?.host?.message}

)} + {!errors.upstreams?.[index]?.host && ( +

+ {getValidationHints('host')} +

+ )}
@@ -342,10 +509,16 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia type="number" {...register(`upstreams.${index}.port`, { required: 'Port is required', - min: 1, - max: 65535, + min: { value: 1, message: 'Port must be ≥ 1' }, + max: { value: 65535, message: 'Port must be ≤ 65535' }, + valueAsNumber: true, })} /> + {errors.upstreams?.[index]?.port && ( +

+ {errors.upstreams[index]?.port?.message} +

+ )}
@@ -355,26 +528,50 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia + {errors.upstreams?.[index]?.weight && ( +

+ {errors.upstreams[index]?.weight?.message} +

+ )}
+ {errors.upstreams?.[index]?.maxFails && ( +

+ {errors.upstreams[index]?.maxFails?.message} +

+ )}
+ {errors.upstreams?.[index]?.failTimeout && ( +

+ {errors.upstreams[index]?.failTimeout?.message} +

+ )}
@@ -383,9 +580,18 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia + {errors.upstreams?.[index]?.maxConns && ( +

+ {errors.upstreams[index]?.maxConns?.message} +

+ )} @@ -448,6 +654,25 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia At least one upstream is required

)} + + {/* Configuration Warnings */} + {configWarnings.length > 0 && ( +
+
+ +
+

+ Configuration Warnings +

+
    + {configWarnings.map((warning, idx) => ( +
  • • {warning}
  • + ))} +
+
+
+
+ )} @@ -456,12 +681,38 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia
- + + {errors.proxyTimeout && ( +

{errors.proxyTimeout.message}

+ )} +

+ {getValidationHints('proxyTimeout')} +

- + + {errors.proxyConnectTimeout && ( +

{errors.proxyConnectTimeout.message}

+ )} +

+ {getValidationHints('proxyConnectTimeout')} +

@@ -487,18 +738,32 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia + {errors.proxyNextUpstreamTimeout && ( +

{errors.proxyNextUpstreamTimeout.message}

+ )}
+ {errors.proxyNextUpstreamTries && ( +

{errors.proxyNextUpstreamTries.message}

+ )}
@@ -525,25 +790,68 @@ export default function NLBFormDialog({ isOpen, onClose, nlb, mode }: NLBFormDia + {errors.healthCheckInterval && ( +

{errors.healthCheckInterval.message}

+ )} +

+ {getValidationHints('healthCheckInterval')} +

- + + {errors.healthCheckTimeout && ( +

{errors.healthCheckTimeout.message}

+ )} +

+ {getValidationHints('healthCheckTimeout')} +

- + + {errors.healthCheckRises && ( +

{errors.healthCheckRises.message}

+ )}
- + + {errors.healthCheckFalls && ( +

{errors.healthCheckFalls.message}

+ )}
diff --git a/src/components/logs/LogDetailsDialog.tsx b/src/components/logs/LogDetailsDialog.tsx index e6a8eb5..c20eea3 100644 --- a/src/components/logs/LogDetailsDialog.tsx +++ b/src/components/logs/LogDetailsDialog.tsx @@ -48,7 +48,7 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr return ( - + Log Details @@ -65,7 +65,7 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr {/* Basic Information */}

Basic Information

-
+
Source: {log.source}
@@ -93,7 +93,7 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr {(log.method || log.path || log.uri || log.statusCode) && (

Request Information

-
+
{log.method && (
Method:{" "} @@ -101,43 +101,49 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr
)} {log.path && ( -
- Path:{" "} - - {log.path} - +
+ Path: +
+ + {log.path} + +
)} {log.uri && ( -
- URI:{" "} - - {log.uri} - -
- )} - {log.statusCode && ( -
- Status Code:{" "} - = 500 - ? "destructive" - : log.statusCode >= 400 - ? "outline" - : "default" - } - > - {log.statusCode} - -
- )} - {log.responseTime && ( -
- Response Time:{" "} - {log.responseTime}ms +
+ URI: +
+ + {log.uri} + +
)} +
+ {log.statusCode && ( +
+ Status Code:{" "} + = 500 + ? "destructive" + : log.statusCode >= 400 + ? "outline" + : "default" + } + > + {log.statusCode} + +
+ )} + {log.responseTime && ( +
+ Response Time:{" "} + {log.responseTime}ms +
+ )} +
)} @@ -186,11 +192,13 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr
)} {log.file && ( -
- Rule File: - - {log.file} - +
+ Rule File: +
+ + {log.file} + +
)} {log.line && ( @@ -208,11 +216,13 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr
)} {log.data && ( -
- Data: - - {log.data} - +
+ Data: +
+ + {log.data} + +
)}
@@ -223,7 +233,7 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr

Message

-

{log.message}

+

{log.message}

@@ -231,8 +241,8 @@ export function LogDetailsDialog({ log, open, onOpenChange }: LogDetailsDialogPr {log.fullMessage && (

Complete Log Entry

-
-
+                
+
                     {log.fullMessage}
                   
diff --git a/src/components/modsec/CustomRuleDialog.tsx b/src/components/modsec/CustomRuleDialog.tsx index 52d6e1b..fd5244f 100644 --- a/src/components/modsec/CustomRuleDialog.tsx +++ b/src/components/modsec/CustomRuleDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; @@ -11,21 +11,43 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import { ExternalLink } from 'lucide-react'; import { toast } from 'sonner'; -import { useAddModSecRule } from '@/queries/modsec.query-options'; +import { useAddModSecRule, useUpdateModSecRule } from '@/queries/modsec.query-options'; +import type { ModSecurityCustomRule } from '@/types'; interface CustomRuleDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + editRule?: ModSecurityCustomRule | null; } -export function CustomRuleDialog({ open, onOpenChange }: CustomRuleDialogProps) { +export function CustomRuleDialog({ open, onOpenChange, editRule }: CustomRuleDialogProps) { const addCustomRuleMutation = useAddModSecRule(); + const updateCustomRuleMutation = useUpdateModSecRule(); const [name, setName] = useState(''); const [category, setCategory] = useState(''); const [ruleContent, setRuleContent] = useState(''); const [description, setDescription] = useState(''); + const isEditMode = !!editRule; + + // Load rule data when editing + useEffect(() => { + if (editRule) { + setName(editRule.name); + setCategory(editRule.category); + setRuleContent(editRule.ruleContent || ''); + setDescription(editRule.description || ''); + } else { + // Reset form when not editing + setName(''); + setCategory(''); + setRuleContent(''); + setDescription(''); + } + }, [editRule, open]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -35,24 +57,39 @@ export function CustomRuleDialog({ open, onOpenChange }: CustomRuleDialogProps) } try { - await addCustomRuleMutation.mutateAsync({ - name: name.trim(), - category: category.trim(), - ruleContent: ruleContent.trim(), - description: description.trim() || undefined, - enabled: true, - }); - - toast.success('Custom rule added successfully'); - onOpenChange(false); - - // Reset form - setName(''); - setCategory(''); - setRuleContent(''); - setDescription(''); + if (isEditMode && editRule) { + // Update existing rule + await updateCustomRuleMutation.mutateAsync({ + id: editRule.id, + data: { + name: name.trim(), + category: category.trim(), + ruleContent: ruleContent.trim(), + description: description.trim() || undefined, + }, + }); + toast.success('Custom rule updated successfully'); + // Only close dialog on success + onOpenChange(false); + } else { + // Add new rule + await addCustomRuleMutation.mutateAsync({ + name: name.trim(), + category: category.trim(), + ruleContent: ruleContent.trim(), + description: description.trim() || undefined, + enabled: true, + }); + toast.success('Custom rule added successfully'); + // Only close dialog on success + onOpenChange(false); + } } catch (error: any) { - toast.error(error?.response?.data?.message || 'Failed to add custom rule'); + const errorMessage = error?.response?.data?.message || `Failed to ${isEditMode ? 'update' : 'add'} custom rule`; + toast.error(errorMessage, { + duration: 5000, + }); + // Do not close dialog on error - keep form open for user to fix issues } }; @@ -69,10 +106,32 @@ SecRule REQUEST_FILENAME "@contains /admin" \\ - Add Custom ModSecurity Rule + {isEditMode ? 'Edit' : 'Add'} Custom ModSecurity Rule Write custom ModSecurity rules using SecRule directives +
+
+ +
+

+ Need to create whitelist rules? +

+

+ Use the ModSecurity Whitelist Generator to parse raw logs and generate whitelist rules automatically. +

+ + Open Whitelist Generator + + +
+
+
@@ -153,11 +212,22 @@ SecRule ARGS "@detectSQLi" \\
- - diff --git a/src/components/pages/ACL.tsx b/src/components/pages/ACL.tsx index ffd37b5..9363db3 100644 --- a/src/components/pages/ACL.tsx +++ b/src/components/pages/ACL.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Suspense } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -9,11 +9,14 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { Plus, Download, Upload, Trash2, Edit, Loader2, UserCog } from "lucide-react"; +import { Plus, Download, Upload, Trash2, Edit, Loader2, UserCog, AlertCircle, CheckCircle2, Info, FileCode } from "lucide-react"; import { ACLRule } from "@/types"; import { useToast } from "@/hooks/use-toast"; import { SkeletonTable } from "@/components/ui/skeletons"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { validateAclValue, getValidationHints, getExampleValue } from "@/utils/acl-validators"; +import { PreviewConfigDialog } from "@/components/acl/PreviewConfigDialog"; import { useSuspenseAclRules, useCreateAclRule, @@ -30,6 +33,7 @@ function AclRulesTable() { const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingRule, setEditingRule] = useState(null); const [ruleToDelete, setRuleToDelete] = useState<{ id: string; name: string } | null>(null); + const [previewOpen, setPreviewOpen] = useState(false); const createAclRule = useCreateAclRule(); const updateAclRule = useUpdateAclRule(); @@ -47,7 +51,81 @@ function AclRulesTable() { enabled: true }); + const [validationError, setValidationError] = useState(null); + const [validationSuccess, setValidationSuccess] = useState(false); + + // Real-time validation when value changes + useEffect(() => { + if (formData.value.trim().length === 0) { + setValidationError(null); + setValidationSuccess(false); + return; + } + + const result = validateAclValue(formData.field, formData.operator, formData.value); + if (result.valid) { + setValidationError(null); + setValidationSuccess(true); + } else { + setValidationError(result.error || 'Invalid value'); + setValidationSuccess(false); + } + }, [formData.value, formData.field, formData.operator]); + + // Auto-adjust action based on type + useEffect(() => { + if (formData.type === 'whitelist' && formData.action === 'deny') { + setFormData(prev => ({ ...prev, action: 'allow' })); + } else if (formData.type === 'blacklist' && formData.action === 'allow') { + setFormData(prev => ({ ...prev, action: 'deny' })); + } + }, [formData.type]); + + // Reset validation when field or operator changes + useEffect(() => { + setValidationError(null); + setValidationSuccess(false); + if (formData.value.trim().length > 0) { + const result = validateAclValue(formData.field, formData.operator, formData.value); + if (result.valid) { + setValidationSuccess(true); + } else { + setValidationError(result.error || 'Invalid value'); + } + } + }, [formData.field, formData.operator]); + const handleAddRule = async () => { + // Validate before submission + if (!formData.name.trim()) { + toast({ + title: "Validation Error", + description: "Rule name is required", + variant: "destructive" + }); + return; + } + + if (!formData.value.trim()) { + toast({ + title: "Validation Error", + description: "Condition value is required", + variant: "destructive" + }); + return; + } + + // Validate value + const valueValidation = validateAclValue(formData.field, formData.operator, formData.value); + if (!valueValidation.valid) { + toast({ + title: "Validation Error", + description: valueValidation.error || "Invalid condition value", + variant: "destructive" + }); + return; + } + // Transform field format: user-agent -> user_agent for backend const conditionField = formData.field.replace('-', '_') as any; @@ -101,6 +179,8 @@ function AclRulesTable() { action: "deny", enabled: true }); + setValidationError(null); + setValidationSuccess(false); }; const handleEdit = (rule: ACLRule) => { @@ -190,6 +270,10 @@ function AclRulesTable() {
+
- setFormData({ ...formData, value: e.target.value })} - placeholder="e.g., 192.168.1.100" - /> +
+ setFormData({ ...formData, value: e.target.value })} + placeholder={getExampleValue(formData.field, formData.operator)} + className={validationError ? 'border-red-500' : validationSuccess ? 'border-green-500' : ''} + /> + {validationSuccess && formData.value.trim().length > 0 && ( + + )} + {validationError && ( + + )} +
+ + {/* Validation feedback */} + {validationError && ( + + + {validationError} + + )} + + {/* Hints */} + + + + Hint: {getValidationHints(formData.field, formData.operator)} + +
- @@ -387,20 +505,17 @@ function AclRulesTable() { !open && setRuleToDelete(null)} - title="Delete ACL Rule" - description={ - <> - Are you sure you want to delete the rule {ruleToDelete?.name}? -
- This action cannot be undone and may affect access control to your domains. - - } - confirmText="Delete Rule" - cancelText="Cancel" onConfirm={handleDelete} - isLoading={deleteAclRule.isPending} + title="Delete ACL Rule" + description={`Are you sure you want to delete the rule "${ruleToDelete?.name}"? This action cannot be undone.`} + confirmText="Delete" variant="destructive" /> + + ); } diff --git a/src/components/pages/DashboardNew.tsx b/src/components/pages/DashboardNew.tsx new file mode 100644 index 0000000..2c0fa48 --- /dev/null +++ b/src/components/pages/DashboardNew.tsx @@ -0,0 +1,697 @@ +import { useTranslation } from "react-i18next"; +import { Suspense, useState } from "react"; +import { + LayoutDashboard, + Globe, + AlertTriangle, + CheckCircle2, + Activity, + Shield, + TrendingUp, + Clock, + Users, + Eye, +} from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Legend, + ResponsiveContainer, +} from "recharts"; +import { + useSuspenseDashboardStats, + useSuspenseRequestTrend, + useSuspenseSlowRequests, + useSuspenseLatestAttackStats, + useSuspenseLatestNews, + useSuspenseRequestAnalytics, + useSuspenseAttackRatio, +} from "@/queries"; +import { SkeletonStatsCard, SkeletonChart, SkeletonTable } from "@/components/ui/skeletons"; + +// Constants for status codes and colors +const STATUS_CODES_CONFIG = [ + { key: "status200", color: "#22c55e", label: "dashboard.status200" }, + { key: "status301", color: "#3b82f6", label: "dashboard.status301" }, + { key: "status302", color: "#06b6d4", label: "dashboard.status302" }, + { key: "status400", color: "#f59e0b", label: "dashboard.status400" }, + { key: "status403", color: "#f97316", label: "dashboard.status403" }, + { key: "status404", color: "#eab308", label: "dashboard.status404" }, + { key: "status500", color: "#ef4444", label: "dashboard.status500" }, + { key: "status502", color: "#dc2626", label: "dashboard.status502" }, + { key: "status503", color: "#b91c1c", label: "dashboard.status503" }, +] as const; + +// Helper function to format time +const formatTime = (date: Date) => + `${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`; + +// Helper function to get attack percentage color +const getAttackPercentageColor = (percentage: number) => { + if (percentage > 10) return "text-destructive"; + if (percentage > 5) return "text-warning"; + return "text-success"; +}; + +// Helper function to get severity badge variant +const getSeverityVariant = (severity: string): "destructive" | "default" => { + return severity === "CRITICAL" || severity === "2" ? "destructive" : "default"; +}; + +// Reusable Empty State Component +const EmptyState = ({ message }: { message: string }) => ( +
+ {message} +
+); + +// Reusable Count Badge Component +const CountBadge = ({ count, variant = "destructive" }: { count: number; variant?: "destructive" | "secondary" }) => ( + count > 0 ? ( + {count} + ) : ( + 0 + ) +); + +// Reusable List Item Component +const ListItem = ({ + title, + subtitle, + badge +}: { + title: string; + subtitle: string; + badge: React.ReactNode; +}) => ( +
+
+

{title}

+

{subtitle}

+
+ {badge} +
+); + +// Reusable Card Header with Icon +const CardHeaderWithIcon = ({ + icon: Icon, + title, + description +}: { + icon: any; + title: string; + description?: string; +}) => ( + + + + {title} + + {description && {description}} + +); + +// Reusable Metric Row Component +const MetricRow = ({ + label, + value, + valueClassName = "" +}: { + label: string; + value: React.ReactNode; + valueClassName?: string; +}) => ( +
+ {label} + {value} +
+); + +// Reusable Data Card Component +const DataCard = ({ + icon, + title, + description, + data, + emptyMessage, + children +}: { + icon: any; + title: string; + description?: string; + data: any; + emptyMessage: string; + children: (data: any) => React.ReactNode; +}) => ( + + + + {data && (Array.isArray(data) ? data.length > 0 : true) ? ( + children(data) + ) : ( + + )} + + +); + +// Reusable Table Card Component +const TableCard = ({ + icon, + title, + description, + data, + emptyMessage, + headers, + renderRow, + maxHeight = "max-h-[400px]" +}: { + icon: any; + title: string; + description?: string; + data: any[]; + emptyMessage: string; + headers: TableHeader[]; + renderRow: (item: any) => React.ReactNode; + maxHeight?: string; +}) => ( + + {(items) => ( +
+ + + + {headers.map(({ key, label, width, align }) => ( + + {label} + + ))} + + + + {items.map(renderRow)} + +
+
+ )} +
+); + +// Component for stats overview +function DashboardStatsOverview() { + const { t } = useTranslation(); + const { data: stats } = useSuspenseDashboardStats(); + + const activeDomains = stats?.domains.active || 0; + const errorDomains = stats?.domains.errors || 0; + const unacknowledgedAlerts = stats?.alerts.unacknowledged || 0; + const criticalAlerts = stats?.alerts.critical || 0; + + const statsCards = [ + { + title: t("dashboard.domains"), + value: stats?.domains.total || 0, + description: `${activeDomains} active, ${errorDomains} errors`, + icon: Globe, + color: "text-primary", + }, + { + title: t("dashboard.traffic"), + value: stats?.traffic.requestsPerDay || "0", + description: "Requests/day", + icon: LayoutDashboard, + color: "text-success", + }, + { + title: t("dashboard.errors"), + value: errorDomains, + description: "Domains with issues", + icon: AlertTriangle, + color: "text-destructive", + }, + { + title: t("dashboard.uptime"), + value: `${stats?.uptime || "0"}%`, + description: "Last 30 days", + icon: CheckCircle2, + color: "text-success", + }, + ]; + + return ( + <> + {unacknowledgedAlerts > 0 && ( + + + + + Active Alerts + + + +

+ You have {unacknowledgedAlerts} unacknowledged alerts + {criticalAlerts > 0 && `, including ${criticalAlerts} critical`}. +

+
+
+ )} + +
+ {statsCards.map((stat) => ( + + + {stat.title} + + + +
{stat.value}
+

{stat.description}

+
+
+ ))} +
+ + ); +} + +// Component for Request Trend Chart +function RequestTrendChart() { + const { t } = useTranslation(); + const { data: trendData } = useSuspenseRequestTrend(5); + + // Generate chart config dynamically + const chartConfig = Object.fromEntries( + STATUS_CODES_CONFIG.map(({ key, color, label }) => [ + key, + { label: t(label), color } + ]) + ); + + return ( + + +
+
+ + + {t("dashboard.requestTrend")} + + {t("dashboard.requestTrendDesc")} +
+
+
+ + {trendData && trendData.length > 0 ? ( + + + + + formatTime(new Date(value))} + /> + + new Date(label).toLocaleString()} + /> + } + /> + + {STATUS_CODES_CONFIG.map(({ key, color, label }) => ( + + ))} + + + + ) : ( + + )} + +
+ ); +} + +// Component for Slow Requests +function SlowRequestsCard() { + const { t } = useTranslation(); + const { data: slowRequests } = useSuspenseSlowRequests(10); + + return ( + + {(data) => ( +
+ {data.slice(0, 3).map((req: any, idx: number) => ( +
+
+

{req.path}

+

+ {req.requestCount} requests +

+
+ + {req.avgResponseTime.toFixed(2)}ms + +
+ ))} +
+ )} +
+ ); +} + +// Component for Attack Ratio +function AttackRatioCard() { + const { t } = useTranslation(); + const { data: attackRatio } = useSuspenseAttackRatio(); + + const metrics = [ + { label: "dashboard.attackRequests", value: attackRatio?.attackRequests, variant: "destructive" as const }, + { label: "dashboard.normalRequests", value: attackRatio?.normalRequests, variant: "secondary" as const }, + ]; + + return ( + + {(data) => ( +
+ +
+ {metrics.map(({ label, value, variant }) => ( +
+ {t(label)} + {value?.toLocaleString()} +
+ ))} +
+
+ +
+
+ )} +
+ ); +} + +// Component for Latest Attacks +function LatestAttacksCard() { + const { t } = useTranslation(); + const { data: attacks } = useSuspenseLatestAttackStats(5); + + return ( + + {(data) => ( +
+ {data.map((attack: any, idx: number) => ( + {attack.count}} + /> + ))} +
+ )} +
+ ); +} + +// Table headers configuration +type TableHeader = { key: string; label: string; width?: string; align?: string }; + +const NEWS_TABLE_HEADERS: TableHeader[] = [ + { key: "timestamp", label: "dashboard.timestamp", width: "w-[140px]" }, + { key: "attackerIp", label: "dashboard.attackerIp", width: "w-[120px]" }, + { key: "domain", label: "dashboard.domain", width: "w-[140px]" }, + { key: "attackType", label: "dashboard.attackType" }, + { key: "action", label: "dashboard.action" }, + { key: "actions", label: "dashboard.actions", align: "text-right" }, +]; + +const IP_TABLE_HEADERS: TableHeader[] = [ + { key: "sourceIp", label: "dashboard.sourceIp" }, + { key: "requestCount", label: "dashboard.requestCount", align: "text-right" }, + { key: "errors", label: "Errors", align: "text-right" }, + { key: "attacks", label: "Attacks", align: "text-right" }, +]; + +// Component for Latest News Table +function LatestNewsTable() { + const { t } = useTranslation(); + const { data: news } = useSuspenseLatestNews(10); + + const handleViewDetails = (item: any) => { + const url = item.uniqueId + ? `/logs?uniqueId=${encodeURIComponent(item.uniqueId)}` + : `/logs?search=${encodeURIComponent(item.ruleId || item.attackType)}`; + window.location.href = url; + }; + + const headers = NEWS_TABLE_HEADERS.map(h => ({ ...h, label: t(h.label) })); + + return ( + ( + + + {new Date(item.timestamp).toLocaleString()} + + {item.attackerIp} + {item.domain || '-'} + {item.attackType} + {item.action} + + + + + )} + /> + ); +} + +// Component for Request Analytics (IP Analytics) +function RequestAnalyticsCard() { + const { t } = useTranslation(); + const [period, setPeriod] = useState<'day' | 'week' | 'month'>('day'); + const { data: analytics } = useSuspenseRequestAnalytics(period); + + const periods = ['day', 'week', 'month'] as const; + const headers = IP_TABLE_HEADERS.map(h => ({ + ...h, + label: h.key === 'sourceIp' || h.key === 'requestCount' ? t(h.label) : h.label + })); + + return ( + + +
+
+ + + {t("dashboard.requestAnalytics")} + + {t("dashboard.requestAnalyticsDesc")} +
+ +
+
+ + {analytics && analytics.topIps.length > 0 ? ( +
+ + + + {headers.map(({ key, label, align }) => ( + + {label} + + ))} + + + + {analytics.topIps.map((ip: any, idx: number) => ( + + {ip.ip} + + {ip.requestCount.toLocaleString()} + + + + + + + + + ))} + +
+
+ ) : ( + + )} +
+
+ ); +} + +// Main Dashboard component with Suspense boundaries +export default function DashboardNew() { + const { t } = useTranslation(); + + return ( +
+
+
+
+ +
+
+

+ {t("dashboard.title")} +

+

{t("dashboard.overview")}

+
+
+
+ + {/* Stats Overview */} + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ } + > + + + + {/* Dashboard Rows */} + {[ + { + cols: "lg:grid-cols-3", + items: [ + { component: , fallback: , className: "lg:col-span-2" }, + { component: , fallback: }, + ] + }, + { + cols: "lg:grid-cols-2", + items: [ + { component: , fallback: }, + { component: , fallback: }, + ] + }, + { + cols: "lg:grid-cols-2", + items: [ + { component: , fallback: }, + { component: , fallback: }, + ] + }, + ].map((row, rowIdx) => ( +
+ {row.items.map((item, itemIdx) => ( + +
+ {item.component} +
+
+ ))} +
+ ))} +
+ ); +} diff --git a/src/components/pages/Domains.tsx b/src/components/pages/Domains.tsx index e15148e..663c55c 100644 --- a/src/components/pages/Domains.tsx +++ b/src/components/pages/Domains.tsx @@ -527,15 +527,23 @@ export default function Domains() { try { if (editingDomain) { await updateDomain.mutateAsync({ id: editingDomain.id, data: domainData }); - toast.success(`Domain ${domainData.name} updated`); + toast.success(`Domain ${domainData.name} updated successfully`); + // Only close dialog on success + setDialogOpen(false); + setEditingDomain(null); } else { await createDomain.mutateAsync(domainData); - toast.success(`Domain ${domainData.name} created`); + toast.success(`Domain ${domainData.name} created successfully`); + // Only close dialog on success + setDialogOpen(false); + setEditingDomain(null); } - setDialogOpen(false); - setEditingDomain(null); } catch (error: any) { - toast.error(error.message || 'Failed to save domain'); + const errorMessage = error?.response?.data?.message || error.message || 'Failed to save domain'; + toast.error(errorMessage, { + duration: 5000, + }); + // Do not close dialog on error - keep form open for user to fix issues } }; diff --git a/src/components/pages/Logs.tsx b/src/components/pages/Logs.tsx index bb5d875..daf348d 100644 --- a/src/components/pages/Logs.tsx +++ b/src/components/pages/Logs.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, Suspense } from "react"; +import { useState, useEffect, useMemo, useCallback, Suspense } from "react"; import { useTranslation } from "react-i18next"; import { ColumnDef, @@ -62,9 +62,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { LogEntry } from "@/types"; -import { - downloadLogs, -} from "@/services/logs.service"; +import { downloadLogs } from "@/services/logs.service"; import { useToast } from "@/hooks/use-toast"; import { SkeletonStatsCard, SkeletonTable } from "@/components/ui/skeletons"; import { @@ -74,73 +72,308 @@ import { useLogs } from "@/queries/logs.query-options"; import { LogDetailsDialog } from "@/components/logs/LogDetailsDialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +// Constants +const LEVEL_OPTIONS = [ + { value: "all", label: "All Levels" }, + { value: "info", label: "Info" }, + { value: "warning", label: "Warning" }, + { value: "error", label: "Error" }, +] as const; + +const TYPE_OPTIONS = [ + { value: "all", label: "All Types" }, + { value: "access", label: "Access" }, + { value: "error", label: "Error" }, + { value: "system", label: "System" }, +] as const; + +const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50] as const; + +const STATS_CONFIG = [ + { key: "total", label: "Total Logs", color: "", description: "All log entries" }, + { key: "info", label: "Info Logs", color: "text-blue-500", description: "Information messages", path: "byLevel.info" }, + { key: "warning", label: "Warning Logs", color: "text-yellow-500", description: "Warning messages", path: "byLevel.warning" }, + { key: "error", label: "Error Logs", color: "text-red-500", description: "Error messages", path: "byLevel.error" }, +] as const; + +// Helper functions +const getLevelColor = (level: string): "destructive" | "default" | "secondary" | "outline" => { + const colorMap = { + error: "destructive" as const, + warning: "outline" as const, + info: "default" as const, + }; + return colorMap[level as keyof typeof colorMap] || "secondary"; +}; + +const getTypeColor = (type: string) => { + const colorMap = { + access: "default" as const, + error: "destructive" as const, + system: "secondary" as const, + }; + return colorMap[type as keyof typeof colorMap] || "outline"; +}; + +// Get nested value from object path +const getNestedValue = (obj: any, path: string) => { + return path.split('.').reduce((acc, part) => acc?.[part], obj); +}; -// Component for fast-loading statistics data +// Build filter parameters helper +const buildFilterParams = (filters: { + level: string; + type: string; + domain: string; + search: string; + ruleId: string; + uniqueId: string; + page?: number; + limit?: number; +}) => { + const params: any = {}; + + if (filters.page) params.page = filters.page; + if (filters.limit) params.limit = filters.limit; + + const filterConfigs = [ + { key: 'level', value: filters.level, exclude: 'all' }, + { key: 'type', value: filters.type, exclude: 'all' }, + { key: 'domain', value: filters.domain, exclude: 'all' }, + { key: 'search', value: filters.search }, + { key: 'ruleId', value: filters.ruleId }, + { key: 'uniqueId', value: filters.uniqueId }, + ]; + + filterConfigs.forEach(({ key, value, exclude }) => { + if (value && value !== exclude) params[key] = value; + }); + + return params; +}; + +// Truncatable text component +const TruncatableText = ({ text, maxLength = 40 }: { text: string; maxLength?: number }) => { + if (text.length <= maxLength) return
{text}
; + + return ( + + +
+ {text.substring(0, maxLength)}... +
+
+ + {text} + +
+ ); +}; + +// Log details renderer component +const LogDetailsCell = ({ log }: { log: LogEntry }) => { + const details = [ + { condition: log.ip, label: "IP", value: log.ip }, + { + condition: log.method && log.path, + value: `${log.method} ${log.path}`, + truncate: true + }, + { condition: log.statusCode, label: "Status", value: log.statusCode }, + { condition: log.responseTime, label: "RT", value: `${log.responseTime}ms` }, + { + condition: log.ruleId, + label: "Rule ID", + value: log.ruleId, + className: "font-semibold text-red-600" + }, + { condition: log.severity, label: "Severity", value: log.severity }, + { + condition: log.tags?.length, + label: "Tags", + value: log.tags?.join(', '), + truncate: true + }, + { condition: log.uri, label: "URI", value: log.uri, truncate: true }, + ].filter(detail => detail.condition); + + return ( +
+ {details.map((detail, idx) => { + const content = detail.label ? `${detail.label}: ${detail.value}` : detail.value; + const displayContent = detail.truncate ? ( + + ) : ( +
{content}
+ ); + + return
{displayContent}
; + })} +
+ ); +}; + +// Statistics card component +const StatCard = ({ stat, value }: { stat: typeof STATS_CONFIG[number]; value: number }) => ( + + + {stat.label} + + +
+ {value.toLocaleString()} +
+

+ {stat.description} +

+
+
+); + +// Statistics component const LogStatistics = () => { const { data: stats } = useSuspenseLogStatistics(); return (
- - - Total Logs - - -
- {stats.total.toLocaleString()} -
-

- All log entries -

-
-
- - - - Info Logs - - -
- {stats.byLevel.info.toLocaleString()} -
-

- Information messages -

-
-
- - - - Warning Logs - - -
- {stats.byLevel.warning.toLocaleString()} -
-

- Warning messages -

-
-
- - - - Error Logs - - -
- {stats.byLevel.error.toLocaleString()} -
-

- Error messages -

-
-
+ {STATS_CONFIG.map((stat) => { + const value = stat.path + ? getNestedValue(stats, stat.path) + : stats[stat.key as keyof typeof stats]; + return ; + })}
); }; -// Component for deferred log entries data +// Custom event dispatcher hook +const useCustomEvent = (eventName: string, handler: (value: any) => void) => { + useEffect(() => { + const handleEvent = (e: any) => handler(e.detail); + window.addEventListener(eventName, handleEvent); + return () => window.removeEventListener(eventName, handleEvent); + }, [eventName, handler]); +}; + +const dispatchCustomEvent = (eventName: string, value: any) => { + window.dispatchEvent(new CustomEvent(eventName, { detail: value })); +}; + +// Filter component +const FilterInput = ({ + placeholder, + value, + eventName, + className = "" +}: { + placeholder: string; + value: string; + eventName: string; + className?: string; +}) => ( + dispatchCustomEvent(eventName, e.target.value)} + onPaste={(e) => { + e.preventDefault(); + const text = e.clipboardData.getData('text/plain'); + dispatchCustomEvent(eventName, text); + }} + className={className} + /> +); + +// Filter select component +const FilterSelect = ({ + value, + options, + eventName, + className = "", + placeholder = "Select" +}: { + value: string; + options: readonly { value: string; label: string }[]; + eventName: string; + className?: string; + placeholder?: string; +}) => ( + +); + +// Skeleton row component +const SkeletonRow = ({ columnCount }: { columnCount: number }) => ( + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+); + +// Pagination button component +const PaginationButton = ({ + onClick, + disabled, + icon: Icon, + label, + className = "" +}: { + onClick: () => void; + disabled: boolean; + icon: any; + label: string; + className?: string; +}) => ( + +); + +// Log entries component const LogEntries = ({ page, limit, @@ -148,6 +381,8 @@ const LogEntries = ({ level, type, domain, + ruleId, + uniqueId, setPage, setLimit, sorting, @@ -159,7 +394,6 @@ const LogEntries = ({ rowSelection, setRowSelection, autoRefresh, - setAutoRefresh, toast, onRefetch, selectedLog, @@ -171,6 +405,8 @@ const LogEntries = ({ level: string; type: string; domain: string; + ruleId: string; + uniqueId: string; setPage: (page: number) => void; setLimit: (limit: number) => void; sorting: SortingState; @@ -182,107 +418,51 @@ const LogEntries = ({ rowSelection: Record; setRowSelection: (selection: Record) => void; autoRefresh: boolean; - setAutoRefresh: (refresh: boolean) => void; toast: any; onRefetch: (refetch: () => Promise) => void; selectedLog: LogEntry | null; setSelectedLog: (log: LogEntry | null) => void; }) => { const [isPageChanging, setIsPageChanging] = useState(false); - // Build query parameters - const params: any = { - page, - limit, - }; + + const stableUniqueId = useMemo(() => uniqueId ? String(uniqueId) : "", [uniqueId]); + + const params = useMemo(() => + buildFilterParams({ + level, type, domain, search, ruleId, + uniqueId: stableUniqueId, + page, limit + }), + [page, limit, level, type, domain, search, ruleId, stableUniqueId] + ); - if (level !== "all") { - params.level = level; - } - if (type !== "all") { - params.type = type; - } - if (domain !== "all") { - params.domain = domain; - } - if (search) { - params.search = search; - } - - // Use regular query instead of suspense query for better control const { data: logsResponse, refetch, isFetching, isLoading } = useLogs(params); const logs = logsResponse?.data || []; const pagination = logsResponse?.pagination || { total: 0, totalPages: 1 }; - // Get domains for filter const { data: domains } = useSuspenseAvailableDomains(); - // Pass refetch function to parent component useEffect(() => { onRefetch(refetch); }, [refetch, onRefetch]); - // Auto refresh effect useEffect(() => { if (!autoRefresh) return; - - const interval = setInterval(() => { - refetch(); - }, 5000); // Refresh every 5 seconds - + const interval = setInterval(refetch, 5000); return () => clearInterval(interval); }, [autoRefresh, refetch]); - // Update page changing state based on isFetching useEffect(() => { - setIsPageChanging(isFetching && !isLoading); // Only show skeleton when refetching, not initial load + setIsPageChanging(isFetching && !isLoading); }, [isFetching, isLoading]); - const getLevelColor = ( - level: string - ): "destructive" | "default" | "secondary" | "outline" => { - switch (level) { - case "error": - return "destructive"; - case "warning": - return "outline"; - case "info": - return "default"; - default: - return "secondary"; - } - }; - - const getTypeColor = (type: string) => { - switch (type) { - case "access": - return "default"; - case "error": - return "destructive"; - case "system": - return "secondary"; - default: - return "outline"; - } - }; - - const handleDownloadLogs = async () => { + const handleDownloadLogs = useCallback(async () => { try { - const params: any = { limit: 1000 }; - - if (level !== "all") { - params.level = level; - } - if (type !== "all") { - params.type = type; - } - if (domain !== "all") { - params.domain = domain; - } - if (search) { - params.search = search; - } - - await downloadLogs(params); + const downloadParams = buildFilterParams({ + level, type, domain, search, ruleId, uniqueId, + limit: 1000 + }); + await downloadLogs(downloadParams); toast({ title: "Success", description: "Logs downloaded successfully", @@ -295,9 +475,9 @@ const LogEntries = ({ variant: "destructive", }); } - }; + }, [level, type, domain, search, ruleId, uniqueId, toast]); - // Define columns for the table + // Define columns const columns: ColumnDef[] = [ { accessorKey: "timestamp", @@ -325,9 +505,6 @@ const LogEntries = ({ {row.getValue("level")} ), - filterFn: (row, id, value) => { - return value === "all" || row.getValue(id) === value; - }, }, { accessorKey: "type", @@ -337,16 +514,11 @@ const LogEntries = ({ {row.getValue("type")} ), - filterFn: (row, id, value) => { - return value === "all" || row.getValue(id) === value; - }, }, { accessorKey: "source", header: "Source", - cell: ({ row }) => ( -
{row.getValue("source")}
- ), + cell: ({ row }) =>
{row.getValue("source")}
, }, { accessorKey: "domain", @@ -354,16 +526,11 @@ const LogEntries = ({ cell: ({ row }) => { const domain = row.getValue("domain") as string; return domain ? ( - - {domain} - + {domain} ) : ( - ); }, - filterFn: (row, id, value) => { - return value === "all" || row.getValue(id) === value; - }, }, { accessorKey: "message", @@ -371,11 +538,12 @@ const LogEntries = ({ cell: ({ row }) => { const log = row.original; const displayMessage = log.fullMessage || log.message; + const hasMore = log.fullMessage && log.fullMessage.length > log.message.length; + return (
- {/* Show truncated version in table, full message in title tooltip */}
{log.message}
- {log.fullMessage && log.fullMessage.length > log.message.length && ( + {hasMore && (
Click for full details
@@ -387,38 +555,10 @@ const LogEntries = ({ { accessorKey: "details", header: "Details", - cell: ({ row }) => { - const log = row.original; - return ( -
- {log.ip &&
IP: {log.ip}
} - {log.method && log.path && ( -
- {log.method} {log.path} -
- )} - {log.statusCode &&
Status: {log.statusCode}
} - {log.responseTime &&
RT: {log.responseTime}ms
} - {/* ModSecurity specific details */} - {log.ruleId && ( -
Rule ID: {log.ruleId}
- )} - {log.severity && ( -
Severity: {log.severity}
- )} - {log.tags && log.tags.length > 0 && ( -
- Tags: {log.tags.join(', ')} -
- )} - {log.uri &&
URI: {log.uri}
} -
- ); - }, + cell: ({ row }) => , }, ]; - // Create table instance const table = useReactTable({ data: logs, columns, @@ -438,10 +578,7 @@ const LogEntries = ({ columnFilters, columnVisibility, rowSelection, - pagination: { - pageIndex: page - 1, - pageSize: limit, - }, + pagination: { pageIndex: page - 1, pageSize: limit }, }, }); @@ -455,105 +592,79 @@ const LogEntries = ({ {/* Filters */} -
- {/* Search */} -
+
+
- { - // This will be handled by the parent component - const event = new CustomEvent('searchChange', { detail: e.target.value }); - window.dispatchEvent(event); - }} + eventName="searchChange" className="pl-10" />
- {/* Filters */} -
- - - - - - - - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {column.id} - - ); - })} - - -
+ + + + + + + + + + + + + + + + {table.getAllColumns().filter((col) => col.getCanHide()).map((col) => ( + col.toggleVisibility(!!value)} + > + {col.id} + + ))} + +
+ + {/* Table */}
@@ -561,12 +672,7 @@ const LogEntries = ({ {headerGroup.headers.map((header) => ( - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ))} @@ -574,117 +680,25 @@ const LogEntries = ({ {(isLoading || isPageChanging) ? ( - // Show skeleton rows for initial load or page changes - Array.from({ length: limit }).map((_, index) => ( - - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
- )) + Array.from({ length: limit }).map((_, i) => ) ) : logs.length > 0 ? ( logs.map((log, index) => ( setSelectedLog(log)} > - - {new Date(log.timestamp).toLocaleString()} - - - - {log.level} - - - - - {log.type} - - - - {log.source} - - - {log.domain ? ( - - {log.domain} - - ) : ( - - - - - )} - - -
- {log.message} -
- {log.fullMessage && log.fullMessage.length > log.message.length && ( -
- Click for full details -
- )} -
- - {log.ip &&
IP: {log.ip}
} - {log.method && log.path && ( -
- {log.method} {log.path} -
- )} - {log.statusCode &&
Status: {log.statusCode}
} - {log.responseTime && ( -
RT: {log.responseTime}ms
- )} - {/* ModSecurity specific details */} - {log.ruleId && ( -
- Rule ID: {log.ruleId} -
- )} - {log.severity && ( -
Severity: {log.severity}
- )} - {log.tags && log.tags.length > 0 && ( -
- Tags: {log.tags.join(', ')} -
- )} - {log.uri &&
URI: {log.uri}
} -
+ {table.getRowModel().rows[index]?.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))}
)) ) : ( - + No logs found. @@ -703,19 +717,15 @@ const LogEntries = ({ value={`${limit}`} onValueChange={(value) => { setLimit(Number(value)); - setPage(1); // Reset to first page when changing page size + setPage(1); }} > - + - {[10, 20, 30, 40, 50].map((pageSize) => ( - - {pageSize} - + {PAGE_SIZE_OPTIONS.map((size) => ( + {size} ))} @@ -724,42 +734,14 @@ const LogEntries = ({ Page {page} of {pagination.totalPages || 1}
- - - - + {[ + { icon: ChevronsLeft, onClick: () => setPage(1), disabled: page === 1, label: "Go to first page", className: "hidden lg:flex" }, + { icon: ChevronLeft, onClick: () => setPage(page - 1), disabled: page === 1, label: "Go to previous page" }, + { icon: ChevronRight, onClick: () => setPage(page + 1), disabled: page === pagination.totalPages, label: "Go to next page" }, + { icon: ChevronsRight, onClick: () => setPage(pagination.totalPages || 1), disabled: page === pagination.totalPages, label: "Go to last page", className: "hidden lg:flex" }, + ].map((btn, idx) => ( + + ))}
@@ -768,7 +750,7 @@ const LogEntries = ({ ); }; -// Main Logs component +// Main component const Logs = () => { const { t } = useTranslation(); const { toast } = useToast(); @@ -777,28 +759,30 @@ const Logs = () => { const [isReloading, setIsReloading] = useState(false); const [selectedLog, setSelectedLog] = useState(null); - // URL state management with nuqs + // URL state const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1)); - const [limit, setLimit] = useQueryState( - "limit", - parseAsInteger.withDefault(10) - ); - const [search, setSearch] = useQueryState( - "search", - parseAsString.withDefault("") - ); - const [level, setLevel] = useQueryState( - "level", - parseAsString.withDefault("all") - ); - const [type, setType] = useQueryState( - "type", - parseAsString.withDefault("all") - ); - const [domain, setDomain] = useQueryState( - "domain", - parseAsString.withDefault("all") - ); + const [limit, setLimit] = useQueryState("limit", parseAsInteger.withDefault(10)); + const [search, setSearch] = useQueryState("search", parseAsString.withDefault("")); + const [level, setLevel] = useQueryState("level", parseAsString.withDefault("all")); + const [type, setType] = useQueryState("type", parseAsString.withDefault("all")); + const [domain, setDomain] = useQueryState("domain", parseAsString.withDefault("all")); + const [ruleId, setRuleId] = useQueryState("ruleId", parseAsString.withDefault("")); + + const [uniqueId, setUniqueIdState] = useState(() => { + if (typeof window !== 'undefined') { + return new URLSearchParams(window.location.search).get('uniqueId') || ""; + } + return ""; + }); + + const setUniqueId = useCallback((value: string) => { + setUniqueIdState(value); + if (typeof window !== 'undefined') { + const url = new URL(window.location.href); + value ? url.searchParams.set('uniqueId', value) : url.searchParams.delete('uniqueId'); + window.history.replaceState({}, '', url.toString()); + } + }, []); // Table state const [sorting, setSorting] = useState([]); @@ -806,49 +790,22 @@ const Logs = () => { const [columnVisibility, setColumnVisibility] = useState({}); const [rowSelection, setRowSelection] = useState>({}); - // Handle custom events for filter changes from LogEntries component - useEffect(() => { - const handleSearchChange = (e: any) => setSearch(e.detail); - const handleDomainChange = (e: any) => setDomain(e.detail); - const handleLevelChange = (e: any) => setLevel(e.detail); - const handleTypeChange = (e: any) => setType(e.detail); - - window.addEventListener('searchChange', handleSearchChange); - window.addEventListener('domainChange', handleDomainChange); - window.addEventListener('levelChange', handleLevelChange); - window.addEventListener('typeChange', handleTypeChange); - - return () => { - window.removeEventListener('searchChange', handleSearchChange); - window.removeEventListener('domainChange', handleDomainChange); - window.removeEventListener('levelChange', handleLevelChange); - window.removeEventListener('typeChange', handleTypeChange); - }; - }, [setSearch, setDomain, setLevel, setType]); - - - const handleDownloadLogs = async () => { + // Custom event handlers + useCustomEvent('searchChange', setSearch); + useCustomEvent('domainChange', setDomain); + useCustomEvent('levelChange', setLevel); + useCustomEvent('typeChange', setType); + useCustomEvent('ruleIdChange', setRuleId); + useCustomEvent('uniqueIdChange', setUniqueId); + + const handleDownloadLogs = useCallback(async () => { try { - const params: any = { limit: 1000 }; - - if (level !== "all") { - params.level = level; - } - if (type !== "all") { - params.type = type; - } - if (domain !== "all") { - params.domain = domain; - } - if (search) { - params.search = search; - } - - await downloadLogs(params); - toast({ - title: "Success", - description: "Logs downloaded successfully", + const downloadParams = buildFilterParams({ + level, type, domain, search, ruleId, uniqueId, + limit: 1000 }); + await downloadLogs(downloadParams); + toast({ title: "Success", description: "Logs downloaded successfully" }); } catch (error: any) { console.error("Failed to download logs:", error); toast({ @@ -857,11 +814,10 @@ const Logs = () => { variant: "destructive", }); } - }; + }, [level, type, domain, search, ruleId, uniqueId, toast]); return (
- {/* Header with action buttons */}
@@ -880,9 +836,7 @@ const Logs = () => { size="sm" onClick={() => setAutoRefresh(!autoRefresh)} > - + Auto Refresh -
- {/* Fast-loading statistics data - loaded immediately via route loader */} - - - - + {Array.from({ length: 4 }).map((_, i) => )}
}> - {/* Deferred log entries data - loaded after initial render */} { level={level} type={type} domain={domain} + ruleId={ruleId} + uniqueId={uniqueId || ""} setPage={setPage} setLimit={setLimit} sorting={sorting} @@ -944,14 +891,12 @@ const Logs = () => { rowSelection={rowSelection} setRowSelection={setRowSelection} autoRefresh={autoRefresh} - setAutoRefresh={setAutoRefresh} toast={toast} onRefetch={(refetch) => setLogsRefetch(() => refetch)} selectedLog={selectedLog} setSelectedLog={setSelectedLog} /> - {/* Log Details Dialog */} { ); }; -export default Logs; +export default Logs; \ No newline at end of file diff --git a/src/components/pages/ModSecurity.tsx b/src/components/pages/ModSecurity.tsx index d17c9bb..1efa72f 100644 --- a/src/components/pages/ModSecurity.tsx +++ b/src/components/pages/ModSecurity.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Shield, Plus } from 'lucide-react'; +import { Shield, Plus, Pencil, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Switch } from '@/components/ui/switch'; @@ -15,6 +15,16 @@ import { TableRow, } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { CustomRuleDialog } from '@/components/modsec/CustomRuleDialog'; import { toast } from 'sonner'; import { @@ -24,11 +34,15 @@ import { useToggleCrsRule, useToggleModSecRule, useSetGlobalModSec, + useDeleteModSecRule, } from '@/queries/modsec.query-options'; +import type { ModSecurityCustomRule } from '@/types'; export default function ModSecurity() { const { t } = useTranslation(); const [customRuleDialogOpen, setCustomRuleDialogOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [deletingRuleId, setDeletingRuleId] = useState(null); // Queries const { data: crsRules = [] } = useCrsRules(); @@ -39,6 +53,7 @@ export default function ModSecurity() { const toggleCrsRuleMutation = useToggleCrsRule(); const toggleCustomRuleMutation = useToggleModSecRule(); const setGlobalModSecMutation = useSetGlobalModSec(); + const deleteCustomRuleMutation = useDeleteModSecRule(); const globalModSecEnabled = globalSettings?.enabled ?? true; @@ -69,6 +84,30 @@ export default function ModSecurity() { } }; + const handleEditRule = (rule: ModSecurityCustomRule) => { + setEditingRule(rule); + setCustomRuleDialogOpen(true); + }; + + const handleDeleteRule = async () => { + if (!deletingRuleId) return; + + try { + await deleteCustomRuleMutation.mutateAsync(deletingRuleId); + toast.success('Custom rule deleted successfully'); + setDeletingRuleId(null); + } catch (error) { + toast.error('Failed to delete custom rule'); + } + }; + + const handleDialogClose = (open: boolean) => { + setCustomRuleDialogOpen(open); + if (!open) { + setEditingRule(null); + } + }; + return (
@@ -180,12 +219,13 @@ export default function ModSecurity() { Description Status Enable + Actions {customRules.length === 0 ? ( - + No custom rules. Click "Add Custom Rule" to create one. @@ -210,6 +250,24 @@ export default function ModSecurity() { onCheckedChange={() => handleCustomRuleToggle(rule.id, rule.name, rule.enabled)} /> + +
+ + +
+
)) )} @@ -223,8 +281,26 @@ export default function ModSecurity() { + + !open && setDeletingRuleId(null)}> + + + Delete Custom Rule + + Are you sure you want to delete this custom rule? This action cannot be undone. + + + + Cancel + + Delete + + + +
); } diff --git a/src/components/pages/index.ts b/src/components/pages/index.ts index 84e7ada..6d94bc5 100644 --- a/src/components/pages/index.ts +++ b/src/components/pages/index.ts @@ -4,6 +4,7 @@ export { default as Account } from './Account'; export { default as Alerts } from './Alerts'; export { default as Backup } from './Backup'; export { default as Dashboard } from './Dashboard'; +export { default as DashboardNew } from './DashboardNew'; export { default as Domains } from './Domains'; export { default as Login } from './Login'; export { default as Logs } from './Logs'; diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 0ef340c..7cb3b8f 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -18,6 +18,7 @@ const resources = { 'nav.users': 'User Management', 'nav.nodes': 'Slave Nodes', 'nav.network': 'Network Manager', + 'nav.plugins': 'Plugins', // Login 'login.title': 'Admin Portal', @@ -34,6 +35,53 @@ const resources = { 'dashboard.errors': 'Errors', 'dashboard.uptime': 'Uptime', + // Dashboard Analytics + 'dashboard.requestTrend': 'Request Trend', + 'dashboard.requestTrendDesc': 'Real-time request statistics by HTTP status', + 'dashboard.slowRequests': 'Slow Requests', + 'dashboard.slowRequestsDesc': 'Top 10 slowest URL paths', + 'dashboard.latestAttacks': 'Latest Attacks', + 'dashboard.latestAttacksDesc': 'Top 5 attack types in 24 hours', + 'dashboard.latestNews': 'Latest Security Events', + 'dashboard.latestNewsDesc': 'Recent security incidents', + 'dashboard.requestAnalytics': 'Request Analytics', + 'dashboard.requestAnalyticsDesc': 'Top 10 IP addresses by period', + 'dashboard.attackRatio': 'Attack vs Normal Requests', + 'dashboard.attackRatioDesc': 'Security threat ratio', + 'dashboard.path': 'Path', + 'dashboard.avgResponseTime': 'Avg Response Time', + 'dashboard.requestCount': 'Request Count', + 'dashboard.attackType': 'Attack Type', + 'dashboard.count': 'Count', + 'dashboard.severity': 'Severity', + 'dashboard.lastOccurred': 'Last Occurred', + 'dashboard.timestamp': 'Timestamp', + 'dashboard.attackerIp': 'Attacker IP', + 'dashboard.domain': 'Target Domain', + 'dashboard.urlPath': 'URL Path', + 'dashboard.action': 'Action', + 'dashboard.viewDetails': 'View Details', + 'dashboard.actions': 'Actions', + 'dashboard.sourceIp': 'Source IP', + 'dashboard.totalRequests': 'Total Requests', + 'dashboard.attackRequests': 'Attack Requests', + 'dashboard.normalRequests': 'Normal Requests', + 'dashboard.attackPercentage': 'Attack Percentage', + 'dashboard.period.day': 'Today', + 'dashboard.period.week': 'This Week', + 'dashboard.period.month': 'This Month', + 'dashboard.status200': '200 OK', + 'dashboard.status301': '301 Redirect', + 'dashboard.status302': '302 Redirect', + 'dashboard.status400': '400 Bad Request', + 'dashboard.status403': '403 Forbidden', + 'dashboard.status404': '404 Not Found', + 'dashboard.status500': '500 Server Error', + 'dashboard.status502': '502 Bad Gateway', + 'dashboard.status503': '503 Service Unavailable', + 'dashboard.statusOther': 'Other', + 'dashboard.noData': 'No data available', + // Domains 'domains.title': 'Domain Management', 'domains.add': 'Add Domain', @@ -77,6 +125,7 @@ const resources = { 'nav.users': 'Quản lý người dùng', 'nav.nodes': 'Nút phụ', 'nav.network': 'Quản lý mạng', + 'nav.plugins': 'Plugin', // Login 'login.title': 'Cổng Quản Trị', @@ -93,6 +142,53 @@ const resources = { 'dashboard.errors': 'Lỗi', 'dashboard.uptime': 'Thời gian hoạt động', + // Dashboard Analytics + 'dashboard.requestTrend': 'Xu hướng Request', + 'dashboard.requestTrendDesc': 'Thống kê request theo trạng thái HTTP', + 'dashboard.slowRequests': 'Request Chậm', + 'dashboard.slowRequestsDesc': 'Top 10 URL path chậm nhất', + 'dashboard.latestAttacks': 'Tấn công mới nhất', + 'dashboard.latestAttacksDesc': 'Top 5 loại tấn công trong 24h', + 'dashboard.latestNews': 'Sự kiện bảo mật', + 'dashboard.latestNewsDesc': 'Sự cố bảo mật gần đây', + 'dashboard.requestAnalytics': 'Phân tích Request', + 'dashboard.requestAnalyticsDesc': 'Top 10 địa chỉ IP theo thời gian', + 'dashboard.attackRatio': 'Tỷ lệ Tấn công', + 'dashboard.attackRatioDesc': 'Tỷ lệ mối đe dọa bảo mật', + 'dashboard.path': 'Đường dẫn', + 'dashboard.avgResponseTime': 'Thời gian phản hồi TB', + 'dashboard.requestCount': 'Số lượng request', + 'dashboard.attackType': 'Loại tấn công', + 'dashboard.count': 'Số lượng', + 'dashboard.severity': 'Mức độ nghiêm trọng', + 'dashboard.lastOccurred': 'Lần cuối xảy ra', + 'dashboard.timestamp': 'Thời gian', + 'dashboard.attackerIp': 'IP tấn công', + 'dashboard.domain': 'Domain đích', + 'dashboard.urlPath': 'Đường dẫn URL', + 'dashboard.action': 'Hành động', + 'dashboard.viewDetails': 'Xem chi tiết', + 'dashboard.actions': 'Thao tác', + 'dashboard.sourceIp': 'IP nguồn', + 'dashboard.totalRequests': 'Tổng số request', + 'dashboard.attackRequests': 'Request tấn công', + 'dashboard.normalRequests': 'Request bình thường', + 'dashboard.attackPercentage': 'Tỷ lệ tấn công', + 'dashboard.period.day': 'Hôm nay', + 'dashboard.period.week': 'Tuần này', + 'dashboard.period.month': 'Tháng này', + 'dashboard.status200': '200 OK', + 'dashboard.status301': '301 Chuyển hướng', + 'dashboard.status302': '302 Chuyển hướng', + 'dashboard.status400': '400 Yêu cầu không hợp lệ', + 'dashboard.status403': '403 Bị cấm', + 'dashboard.status404': '404 Không tìm thấy', + 'dashboard.status500': '500 Lỗi máy chủ', + 'dashboard.status502': '502 Bad Gateway', + 'dashboard.status503': '503 Dịch vụ không khả dụng', + 'dashboard.statusOther': 'Khác', + 'dashboard.noData': 'Không có dữ liệu', + // Domains 'domains.title': 'Quản lý tên miền', 'domains.add': 'Thêm tên miền', diff --git a/src/queries/acl.query-options.ts b/src/queries/acl.query-options.ts index d106f3a..e8968f3 100644 --- a/src/queries/acl.query-options.ts +++ b/src/queries/acl.query-options.ts @@ -19,6 +19,12 @@ export const aclQueryOptions = { queryKey: aclQueryKeys.detail(id), queryFn: () => aclService.getById(id), }), + + // Preview ACL configuration + preview: { + queryKey: [...aclQueryKeys.lists(), 'preview'], + queryFn: aclService.preview, + }, }; // Suspense query options for ACL rules @@ -174,6 +180,10 @@ export const useApplyAclRules = () => { return useMutation(aclMutationOptions.apply); }; +export const usePreviewAclConfig = () => { + return useQuery(aclQueryOptions.preview); +}; + // Hook to preload ACL rules data export const usePreloadAclRules = () => { const queryClient = useQueryClient(); diff --git a/src/queries/dashboard-analytics.query-options.ts b/src/queries/dashboard-analytics.query-options.ts new file mode 100644 index 0000000..1e41b71 --- /dev/null +++ b/src/queries/dashboard-analytics.query-options.ts @@ -0,0 +1,117 @@ +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { dashboardAnalyticsService } from '@/services/dashboard-analytics.service'; +import { createQueryKeys } from '@/lib/query-client'; + +// Create query keys for dashboard analytics +export const dashboardAnalyticsQueryKeys = createQueryKeys('dashboardAnalytics'); + +// Query options for dashboard analytics +export const dashboardAnalyticsQueryOptions = { + // Get request trend (auto-refresh every 5 seconds) + requestTrend: (intervalSeconds: number = 5) => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ interval: intervalSeconds }), + queryFn: () => dashboardAnalyticsService.getRequestTrend(intervalSeconds), + refetchInterval: 5 * 1000, // Auto-refresh every 5 seconds + staleTime: 3 * 1000, // Consider stale after 3 seconds + }), + + // Get slow requests + slowRequests: (limit: number = 10) => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ type: 'slow', limit }), + queryFn: () => dashboardAnalyticsService.getSlowRequests(limit), + refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds + }), + + // Get latest attack statistics + latestAttacks: (limit: number = 5) => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ type: 'attacks', limit }), + queryFn: () => dashboardAnalyticsService.getLatestAttackStats(limit), + refetchInterval: 10 * 1000, // Auto-refresh every 10 seconds + }), + + // Get latest security news/events + latestNews: (limit: number = 20) => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ type: 'news', limit }), + queryFn: () => dashboardAnalyticsService.getLatestNews(limit), + refetchInterval: 10 * 1000, // Auto-refresh every 10 seconds + }), + + // Get request analytics (top IPs by period) + requestAnalytics: (period: 'day' | 'week' | 'month' = 'day') => ({ + queryKey: dashboardAnalyticsQueryKeys.list({ type: 'ipAnalytics', period }), + queryFn: () => dashboardAnalyticsService.getRequestAnalytics(period), + refetchInterval: 60 * 1000, // Auto-refresh every minute + }), + + // Get attack vs normal request ratio + attackRatio: () => ({ + queryKey: dashboardAnalyticsQueryKeys.detail('attackRatio'), + queryFn: dashboardAnalyticsService.getAttackRatio, + refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds + }), + + // Get complete dashboard analytics + complete: () => ({ + queryKey: dashboardAnalyticsQueryKeys.detail('complete'), + queryFn: dashboardAnalyticsService.getDashboardAnalytics, + refetchInterval: 10 * 1000, // Auto-refresh every 10 seconds + }), +}; + +// Custom hooks for dashboard analytics operations +export const useRequestTrend = (intervalSeconds: number = 5) => { + return useQuery(dashboardAnalyticsQueryOptions.requestTrend(intervalSeconds)); +}; + +export const useSlowRequests = (limit: number = 10) => { + return useQuery(dashboardAnalyticsQueryOptions.slowRequests(limit)); +}; + +export const useLatestAttackStats = (limit: number = 5) => { + return useQuery(dashboardAnalyticsQueryOptions.latestAttacks(limit)); +}; + +export const useLatestNews = (limit: number = 20) => { + return useQuery(dashboardAnalyticsQueryOptions.latestNews(limit)); +}; + +export const useRequestAnalytics = (period: 'day' | 'week' | 'month' = 'day') => { + return useQuery(dashboardAnalyticsQueryOptions.requestAnalytics(period)); +}; + +export const useAttackRatio = () => { + return useQuery(dashboardAnalyticsQueryOptions.attackRatio()); +}; + +export const useDashboardAnalytics = () => { + return useQuery(dashboardAnalyticsQueryOptions.complete()); +}; + +// Suspense hooks for deferred loading pattern +export const useSuspenseRequestTrend = (intervalSeconds: number = 5) => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.requestTrend(intervalSeconds)); +}; + +export const useSuspenseSlowRequests = (limit: number = 10) => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.slowRequests(limit)); +}; + +export const useSuspenseLatestAttackStats = (limit: number = 5) => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.latestAttacks(limit)); +}; + +export const useSuspenseLatestNews = (limit: number = 20) => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.latestNews(limit)); +}; + +export const useSuspenseRequestAnalytics = (period: 'day' | 'week' | 'month' = 'day') => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.requestAnalytics(period)); +}; + +export const useSuspenseAttackRatio = () => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.attackRatio()); +}; + +export const useSuspenseDashboardAnalytics = () => { + return useSuspenseQuery(dashboardAnalyticsQueryOptions.complete()); +}; diff --git a/src/queries/index.ts b/src/queries/index.ts index 3b22567..36669ac 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -1,6 +1,7 @@ // Export all query options and hooks export * from './auth.query-options'; export * from './dashboard.query-options'; +export * from './dashboard-analytics.query-options'; export * from './domain.query-options'; export * from './acl.query-options'; export * from './alerts.query-options'; diff --git a/src/routes/_auth/dashboard.tsx b/src/routes/_auth/dashboard.tsx index bce9d13..9929a22 100644 --- a/src/routes/_auth/dashboard.tsx +++ b/src/routes/_auth/dashboard.tsx @@ -1,21 +1,29 @@ -import Dashboard from '@/components/pages/Dashboard' +import DashboardNew from '@/components/pages/DashboardNew' import { createFileRoute } from '@tanstack/react-router' -import { dashboardQueryOptions } from '@/queries/dashboard.query-options' +import { dashboardQueryOptions, dashboardAnalyticsQueryOptions } from '@/queries' export const Route = createFileRoute('/_auth/dashboard')({ component: RouteComponent, loader: async ({ context }) => { const { queryClient } = context - // Prefetch all dashboard data but don't await it (allow it to load in background) + // Prefetch dashboard stats queryClient.prefetchQuery(dashboardQueryOptions.stats) queryClient.prefetchQuery(dashboardQueryOptions.systemMetrics('24h')) queryClient.prefetchQuery(dashboardQueryOptions.recentAlerts(5)) + // Prefetch dashboard analytics data + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.requestTrend(5)) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.slowRequests(10)) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.latestAttacks(5)) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.latestNews(20)) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.requestAnalytics('day')) + queryClient.prefetchQuery(dashboardAnalyticsQueryOptions.attackRatio()) + return {} }, }) function RouteComponent() { - return + return } diff --git a/src/services/acl.service.ts b/src/services/acl.service.ts index ab1c7db..937a4b5 100644 --- a/src/services/acl.service.ts +++ b/src/services/acl.service.ts @@ -126,6 +126,14 @@ export const aclService = { }; }, + /** + * Preview ACL configuration + */ + async preview(): Promise<{ config: string; rulesCount: number }> { + const response = await api.get<{ success: boolean; data: { config: string; rulesCount: number } }>('/acl/preview'); + return response.data.data; + }, + /** * Apply ACL rules to Nginx */ diff --git a/src/services/dashboard-analytics.service.ts b/src/services/dashboard-analytics.service.ts new file mode 100644 index 0000000..9c502ca --- /dev/null +++ b/src/services/dashboard-analytics.service.ts @@ -0,0 +1,175 @@ +import api from './api'; + +/** + * Dashboard Analytics Types + */ + +// HTTP Status codes mapping type +type HttpStatusCodes = 200 | 301 | 302 | 400 | 403 | 404 | 500 | 502 | 503; + +// Base statistics interface for count-based metrics +interface BaseCountStats { + count: number; +} + +// Base timestamp interface +interface TimestampedEntry { + timestamp: string; +} + +// Request metrics with min/max/avg pattern +interface RequestMetrics { + requestCount: number; + avgResponseTime: number; + maxResponseTime: number; + minResponseTime: number; +} + +// Request trend data point with dynamic status codes +export interface RequestTrendDataPoint extends TimestampedEntry { + total: number; + status200: number; + status301: number; + status302: number; + status400: number; + status403: number; + status404: number; + status500: number; + status502: number; + status503: number; + statusOther: number; +} + +// Slow request entry +export interface SlowRequestEntry extends RequestMetrics { + path: string; +} + +// Attack type statistics +export interface AttackTypeStats extends BaseCountStats { + attackType: string; + severity: string; + lastOccurred: string; + ruleIds: string[]; +} + +// Latest attack/security event +export interface LatestAttackEntry extends TimestampedEntry { + id: string; + attackerIp: string; + domain?: string; + urlPath: string; + attackType: string; + ruleId?: string; + severity?: string; + action: string; + logId: string; +} + +// IP analytics entry with count-based metrics +export interface IpAnalyticsEntry { + ip: string; + requestCount: number; + errorCount: number; + attackCount: number; + lastSeen: string; + userAgent?: string; +} + +// Attack vs Normal request ratio +export interface AttackRatioStats { + totalRequests: number; + attackRequests: number; + normalRequests: number; + attackPercentage: number; +} + +// Period type for analytics +export type AnalyticsPeriod = 'day' | 'week' | 'month'; + +// Request analytics response +export interface RequestAnalyticsResponse { + period: AnalyticsPeriod; + topIps: IpAnalyticsEntry[]; + totalRequests: number; + uniqueIps: number; +} + +// Complete dashboard analytics response +export interface DashboardAnalyticsResponse { + requestTrend: RequestTrendDataPoint[]; + slowRequests: SlowRequestEntry[]; + latestAttacks: AttackTypeStats[]; + latestNews: LatestAttackEntry[]; + requestAnalytics: RequestAnalyticsResponse; + attackRatio: AttackRatioStats; +} + +/** + * Generic API caller to reduce duplication + */ +const fetchAnalytics = async ( + endpoint: string, + params?: Record +): Promise => { + const response = await api.get(`/dashboard/analytics/${endpoint}`, { params }); + return response.data.data; +}; + +/** + * Dashboard Analytics Service + */ +export const dashboardAnalyticsService = { + /** + * Get request trend analytics (auto-refresh every 5s) + */ + getRequestTrend: (intervalSeconds: number = 5) => + fetchAnalytics('request-trend', { interval: intervalSeconds }), + + /** + * Get slow requests from performance monitoring + */ + getSlowRequests: (limit: number = 10) => + fetchAnalytics('slow-requests', { limit }), + + /** + * Get latest attack statistics (top 5 in 24h) + */ + getLatestAttackStats: (limit: number = 5) => + fetchAnalytics('latest-attacks', { limit }), + + /** + * Get latest security news/events + */ + getLatestNews: (limit: number = 20) => + fetchAnalytics('latest-news', { limit }), + + /** + * Get request analytics (top IPs by period) + */ + getRequestAnalytics: (period: AnalyticsPeriod = 'day') => + fetchAnalytics('request-analytics', { period }), + + /** + * Get attack vs normal request ratio + */ + getAttackRatio: () => + fetchAnalytics('attack-ratio'), + + /** + * Get complete dashboard analytics (all in one) + */ + getDashboardAnalytics: () => + fetchAnalytics(''), +}; + +// Export individual functions for backward compatibility +export const getRequestTrend = dashboardAnalyticsService.getRequestTrend; +export const getSlowRequests = dashboardAnalyticsService.getSlowRequests; +export const getLatestAttackStats = dashboardAnalyticsService.getLatestAttackStats; +export const getLatestNews = dashboardAnalyticsService.getLatestNews; +export const getRequestAnalytics = dashboardAnalyticsService.getRequestAnalytics; +export const getAttackRatio = dashboardAnalyticsService.getAttackRatio; +export const getDashboardAnalytics = dashboardAnalyticsService.getDashboardAnalytics; + +export default dashboardAnalyticsService; diff --git a/src/utils/access-list-validators.ts b/src/utils/access-list-validators.ts new file mode 100644 index 0000000..e466a3d --- /dev/null +++ b/src/utils/access-list-validators.ts @@ -0,0 +1,131 @@ +/** + * Access List Validation Utilities + * Provides validation for Access List form fields + */ + +import { isValidIpOrCidr } from './acl-validators'; + +/** + * Validate access list name + */ +export function validateAccessListName(name: string): { valid: boolean; error?: string } { + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Name is required' }; + } + + if (name.length < 3) { + return { valid: false, error: 'Name must be at least 3 characters' }; + } + + if (name.length > 100) { + return { valid: false, error: 'Name must not exceed 100 characters' }; + } + + // Only allow letters, numbers, underscores, and hyphens + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + return { valid: false, error: 'Name can only contain letters, numbers, underscores, and hyphens' }; + } + + return { valid: true }; +} + +/** + * Validate IP address for access list + */ +export function validateAccessListIp(ip: string): { valid: boolean; error?: string } { + if (!ip || ip.trim().length === 0) { + return { valid: false, error: 'IP address is required' }; + } + + if (!isValidIpOrCidr(ip)) { + return { valid: false, error: 'Invalid IP address or CIDR notation. Examples: 192.168.1.1 or 192.168.1.0/24' }; + } + + return { valid: true }; +} + +/** + * Validate username for HTTP Basic Auth + */ +export function validateUsername(username: string): { valid: boolean; error?: string } { + if (!username || username.trim().length === 0) { + return { valid: false, error: 'Username is required' }; + } + + if (username.length < 3) { + return { valid: false, error: 'Username must be at least 3 characters' }; + } + + if (username.length > 50) { + return { valid: false, error: 'Username must not exceed 50 characters' }; + } + + // Only allow letters, numbers, underscores, and hyphens + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + return { valid: false, error: 'Username can only contain letters, numbers, underscores, and hyphens' }; + } + + return { valid: true }; +} + +/** + * Validate password for HTTP Basic Auth + */ +export function validatePassword(password: string, isRequired: boolean = true): { valid: boolean; error?: string } { + // In edit mode, password might be optional (empty = keep existing) + if (!isRequired && (!password || password.length === 0)) { + return { valid: true }; + } + + if (!password || password.trim().length === 0) { + return { valid: false, error: 'Password is required' }; + } + + if (password.length < 4) { + return { valid: false, error: 'Password must be at least 4 characters' }; + } + + return { valid: true }; +} + +/** + * Validate description + */ +export function validateDescription(description: string, maxLength: number = 500): { valid: boolean; error?: string } { + if (description && description.length > maxLength) { + return { valid: false, error: `Description must not exceed ${maxLength} characters` }; + } + + return { valid: true }; +} + +/** + * Get validation hints for access list fields + */ +export function getAccessListHints(field: string): string { + const hints: Record = { + name: 'Use only letters, numbers, underscores, and hyphens (3-100 characters)', + ip: 'Enter a valid IP address (e.g., 192.168.1.1) or CIDR notation (e.g., 192.168.1.0/24)', + username: 'Use only letters, numbers, underscores, and hyphens (3-50 characters)', + password: 'Minimum 4 characters. In edit mode, leave empty to keep existing password', + description: 'Optional description for this item (max 500 characters)' + }; + + return hints[field] || ''; +} + +/** + * Get example values for access list fields + */ +export function getAccessListExample(field: string): string { + const examples: Record = { + name: 'admin-panel-access', + ip: '192.168.1.1', + ipCidr: '192.168.1.0/24', + username: 'admin_user', + password: '••••••••', + description: 'Access for admin panel' + }; + + return examples[field] || ''; +} diff --git a/src/utils/acl-validators.ts b/src/utils/acl-validators.ts new file mode 100644 index 0000000..c104e02 --- /dev/null +++ b/src/utils/acl-validators.ts @@ -0,0 +1,301 @@ +/** + * Frontend ACL Validation Utilities + * Mirrors backend validation for real-time feedback + */ + +/** + * Validate IP address (IPv4 or IPv6) + */ +export function isValidIpAddress(ip: string): boolean { + // IPv4 validation + const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + + // IPv6 validation (simplified) + const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/; + + return ipv4Regex.test(ip) || ipv6Regex.test(ip); +} + +/** + * Validate CIDR notation (e.g., 192.168.1.0/24) + */ +export function isValidCidr(cidr: string): boolean { + const cidrRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/(?:[0-9]|[1-2][0-9]|3[0-2])$/; + + // IPv6 CIDR + const cidrV6Regex = /^(?:[0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{0,4}\/(?:[0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$/; + + return cidrRegex.test(cidr) || cidrV6Regex.test(cidr); +} + +/** + * Validate IP or CIDR + */ +export function isValidIpOrCidr(value: string): boolean { + return isValidIpAddress(value) || isValidCidr(value); +} + +/** + * Validate regex pattern + */ +export function isValidRegex(pattern: string): { valid: boolean; error?: string } { + try { + new RegExp(pattern); + return { valid: true }; + } catch (error: any) { + return { valid: false, error: error.message }; + } +} + +/** + * Validate URL pattern + */ +export function isValidUrlPattern(pattern: string): boolean { + if (!pattern || pattern.trim().length === 0) { + return false; + } + + const dangerousChars = /[;<>{}|\\]/; + if (dangerousChars.test(pattern)) { + return false; + } + + return true; +} + +/** + * Validate HTTP method + */ +export function isValidHttpMethod(method: string): boolean { + const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE']; + return validMethods.includes(method.toUpperCase()); +} + +/** + * Validate GeoIP country code + */ +export function isValidCountryCode(code: string): boolean { + return /^[A-Z]{2}$/.test(code); +} + +/** + * Validate User-Agent pattern + */ +export function isValidUserAgentPattern(pattern: string): boolean { + if (!pattern || pattern.trim().length === 0) { + return false; + } + + const dangerousChars = /[;<>{}|\\]/; + if (dangerousChars.test(pattern)) { + return false; + } + + return true; +} + +/** + * Validate header name + */ +export function isValidHeaderName(name: string): boolean { + return /^[a-zA-Z0-9\-_]+$/.test(name); +} + +/** + * Validate ACL rule value based on field and operator + */ +export function validateAclValue( + field: string, + operator: string, + value: string +): { valid: boolean; error?: string } { + if (!value || value.trim().length === 0) { + return { valid: false, error: 'Value cannot be empty' }; + } + + switch (field) { + case 'ip': + if (operator === 'equals' || operator === 'contains') { + if (!isValidIpOrCidr(value)) { + return { + valid: false, + error: 'Invalid IP address or CIDR notation. Examples: 192.168.1.1 or 192.168.1.0/24' + }; + } + } else if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } + break; + + case 'geoip': + if (operator === 'equals') { + if (!isValidCountryCode(value)) { + return { + valid: false, + error: 'Invalid country code. Use ISO 3166-1 alpha-2 format (e.g., US, CN, VN)' + }; + } + } else if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } + break; + + case 'user-agent': + if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } else if (!isValidUserAgentPattern(value)) { + return { + valid: false, + error: 'Invalid user-agent pattern. Avoid special characters like ; < > { } | \\' + }; + } + break; + + case 'url': + if (operator === 'regex') { + const regexCheck = isValidRegex(value); + if (!regexCheck.valid) { + return { + valid: false, + error: `Invalid regex pattern: ${regexCheck.error}` + }; + } + } else if (!isValidUrlPattern(value)) { + return { + valid: false, + error: 'Invalid URL pattern. Avoid special characters like ; < > { } | \\' + }; + } + break; + + case 'method': + if (operator === 'equals' && !isValidHttpMethod(value)) { + return { + valid: false, + error: 'Invalid HTTP method. Valid methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS' + }; + } + break; + + case 'header': + const headerParts = value.split(':'); + if (headerParts.length < 2) { + return { + valid: false, + error: 'Header value must be in format "Header-Name: value"' + }; + } + + const headerName = headerParts[0]?.trim() || ''; + if (!isValidHeaderName(headerName)) { + return { + valid: false, + error: 'Invalid header name. Use only alphanumeric, dash, and underscore characters' + }; + } + break; + + default: + return { valid: false, error: `Unknown field type: ${field}` }; + } + + return { valid: true }; +} + +/** + * Get validation hints for a specific field type + */ +export function getValidationHints(field: string, operator: string): string { + const hints: Record> = { + ip: { + equals: 'Enter a valid IP address (e.g., 192.168.1.1)', + contains: 'Enter a valid CIDR notation (e.g., 192.168.1.0/24)', + regex: 'Enter a valid regex pattern for IP matching' + }, + geoip: { + equals: 'Enter a 2-letter country code (e.g., US, CN, VN)', + contains: 'Enter country codes separated by comma', + regex: 'Enter a regex pattern for country codes' + }, + 'user-agent': { + equals: 'Enter exact user-agent string', + contains: 'Enter a substring to match in user-agent', + regex: 'Enter a regex pattern (e.g., (bot|crawler|spider))' + }, + url: { + equals: 'Enter exact URL path (e.g., /admin)', + contains: 'Enter a substring to match in URL', + regex: 'Enter a regex pattern (e.g., \\.(php|asp)$)' + }, + method: { + equals: 'Enter HTTP method (GET, POST, PUT, DELETE, etc.)', + contains: 'Enter HTTP method substring', + regex: 'Enter regex pattern for HTTP methods' + }, + header: { + equals: 'Enter in format "Header-Name: value"', + contains: 'Enter in format "Header-Name: value"', + regex: 'Enter in format "Header-Name: regex-pattern"' + } + }; + + return (hints[field] && hints[field][operator]) || 'Enter a valid value'; +} + +/** + * Get example values for field and operator combination + */ +export function getExampleValue(field: string, operator: string): string { + const examples: Record> = { + ip: { + equals: '192.168.1.1', + contains: '192.168.1.0/24', + regex: '^192\\.168\\.' + }, + geoip: { + equals: 'US', + contains: 'US,CN,VN', + regex: '(US|CN|VN)' + }, + 'user-agent': { + equals: 'Mozilla/5.0', + contains: 'bot', + regex: '(bot|crawler|spider)' + }, + url: { + equals: '/admin', + contains: '/api/', + regex: '\\.(php|asp)$' + }, + method: { + equals: 'POST', + contains: 'POST', + regex: '(POST|PUT|DELETE)' + }, + header: { + equals: 'X-Custom-Header: value', + contains: 'X-Custom-Header: value', + regex: 'X-Custom-Header: .*' + } + }; + + return examples[field]?.[operator] || ''; +} diff --git a/src/utils/nlb-validators.ts b/src/utils/nlb-validators.ts new file mode 100644 index 0000000..26948c9 --- /dev/null +++ b/src/utils/nlb-validators.ts @@ -0,0 +1,492 @@ +/** + * Network Load Balancer Validation Utilities + * Validates NLB configuration to prevent nginx errors + */ + +/** + * Validate IP address (IPv4 or IPv6) or hostname + */ +export function isValidHost(host: string): boolean { + if (!host || host.trim().length === 0) { + return false; + } + + // Remove whitespace + host = host.trim(); + + // IPv4 validation - strict format + const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if (ipv4Regex.test(host)) { + return true; + } + + // IPv6 validation (simplified) + const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/; + if (ipv6Regex.test(host)) { + return true; + } + + // If it looks like an IP but failed validation, reject it + // This catches malformed IPs like "888880.8832884" + if (/^[\d.]+$/.test(host)) { + return false; // Only digits and dots but not valid IP + } + + // Hostname validation (RFC 1123) + // Must start with letter or digit, can contain letters, digits, hyphens, dots + // Each label must be 1-63 chars, total max 253 chars + // Cannot start or end with hyphen or dot + const hostnameRegex = /^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(? /^\d+$/.test(label)); + if (allNumeric) { + return false; + } + return true; + } + + return false; +} + +/** + * Validate port number + */ +export function isValidPort(port: number): boolean { + return Number.isInteger(port) && port >= 1 && port <= 65535; +} + +/** + * Validate NLB listening port (must be >= 10000 to avoid conflicts) + */ +export function isValidNLBPort(port: number): boolean { + return Number.isInteger(port) && port >= 10000 && port <= 65535; +} + +/** + * Validate weight value + */ +export function isValidWeight(weight: number): boolean { + return Number.isInteger(weight) && weight >= 1 && weight <= 100; +} + +/** + * Validate max fails + */ +export function isValidMaxFails(maxFails: number): boolean { + return Number.isInteger(maxFails) && maxFails >= 0 && maxFails <= 100; +} + +/** + * Validate fail timeout (in seconds) + */ +export function isValidFailTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 1 && timeout <= 3600; +} + +/** + * Validate max connections + */ +export function isValidMaxConns(maxConns: number): boolean { + return Number.isInteger(maxConns) && maxConns >= 0 && maxConns <= 100000; +} + +/** + * Validate proxy timeout (in seconds) + */ +export function isValidProxyTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 1 && timeout <= 3600; +} + +/** + * Validate proxy connect timeout (in seconds) + */ +export function isValidProxyConnectTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 1 && timeout <= 300; +} + +/** + * Validate proxy next upstream timeout (in seconds, 0 = disabled) + */ +export function isValidProxyNextUpstreamTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 0 && timeout <= 3600; +} + +/** + * Validate proxy next upstream tries (0 = unlimited) + */ +export function isValidProxyNextUpstreamTries(tries: number): boolean { + return Number.isInteger(tries) && tries >= 0 && tries <= 100; +} + +/** + * Validate health check interval (in seconds) + */ +export function isValidHealthCheckInterval(interval: number): boolean { + return Number.isInteger(interval) && interval >= 5 && interval <= 3600; +} + +/** + * Validate health check timeout (in seconds) + */ +export function isValidHealthCheckTimeout(timeout: number): boolean { + return Number.isInteger(timeout) && timeout >= 1 && timeout <= 300; +} + +/** + * Validate health check rises + */ +export function isValidHealthCheckRises(rises: number): boolean { + return Number.isInteger(rises) && rises >= 1 && rises <= 10; +} + +/** + * Validate health check falls + */ +export function isValidHealthCheckFalls(falls: number): boolean { + return Number.isInteger(falls) && falls >= 1 && falls <= 10; +} + +/** + * Validate NLB name + */ +export function isValidNLBName(name: string): { valid: boolean; error?: string } { + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Name cannot be empty' }; + } + + if (name.length < 3) { + return { valid: false, error: 'Name must be at least 3 characters' }; + } + + if (name.length > 50) { + return { valid: false, error: 'Name must not exceed 50 characters' }; + } + + // Only allow alphanumeric, dash, underscore + const nameRegex = /^[a-zA-Z0-9\-_]+$/; + if (!nameRegex.test(name)) { + return { + valid: false, + error: 'Name can only contain letters, numbers, dashes, and underscores' + }; + } + + // Cannot start or end with dash/underscore + if (/^[-_]|[-_]$/.test(name)) { + return { + valid: false, + error: 'Name cannot start or end with dash or underscore' + }; + } + + return { valid: true }; +} + +/** + * Validate upstream host + */ +export function validateUpstreamHost(host: string): { valid: boolean; error?: string } { + if (!host || host.trim().length === 0) { + return { valid: false, error: 'Host is required' }; + } + + if (!isValidHost(host)) { + return { + valid: false, + error: 'Invalid host. Enter a valid IP address (IPv4/IPv6) or hostname' + }; + } + + return { valid: true }; +} + +/** + * Validate upstream port + */ +export function validateUpstreamPort(port: number): { valid: boolean; error?: string } { + if (!port) { + return { valid: false, error: 'Port is required' }; + } + + if (!isValidPort(port)) { + return { + valid: false, + error: 'Port must be between 1 and 65535' + }; + } + + return { valid: true }; +} + +/** + * Validate upstream configuration + */ +export function validateUpstream(upstream: { + host: string; + port: number; + weight: number; + maxFails: number; + failTimeout: number; + maxConns: number; +}): { valid: boolean; errors: Record } { + const errors: Record = {}; + + // Validate host + const hostValidation = validateUpstreamHost(upstream.host); + if (!hostValidation.valid) { + errors.host = hostValidation.error || 'Invalid host'; + } + + // Validate port + const portValidation = validateUpstreamPort(upstream.port); + if (!portValidation.valid) { + errors.port = portValidation.error || 'Invalid port'; + } + + // Validate weight + if (!isValidWeight(upstream.weight)) { + errors.weight = 'Weight must be between 1 and 100'; + } + + // Validate maxFails + if (!isValidMaxFails(upstream.maxFails)) { + errors.maxFails = 'Max fails must be between 0 and 100'; + } + + // Validate failTimeout + if (!isValidFailTimeout(upstream.failTimeout)) { + errors.failTimeout = 'Fail timeout must be between 1 and 3600 seconds'; + } + + // Validate maxConns + if (!isValidMaxConns(upstream.maxConns)) { + errors.maxConns = 'Max connections must be between 0 and 100000'; + } + + return { + valid: Object.keys(errors).length === 0, + errors, + }; +} + +/** + * Validate complete NLB configuration + */ +export function validateNLBConfig(config: { + name: string; + port: number; + upstreams: Array<{ + host: string; + port: number; + weight: number; + maxFails: number; + failTimeout: number; + maxConns: number; + backup: boolean; + down: boolean; + }>; + proxyTimeout: number; + proxyConnectTimeout: number; + proxyNextUpstreamTimeout: number; + proxyNextUpstreamTries: number; + healthCheckEnabled: boolean; + healthCheckInterval?: number; + healthCheckTimeout?: number; + healthCheckRises?: number; + healthCheckFalls?: number; +}): { valid: boolean; errors: Record } { + const errors: Record = {}; + + // Validate name + const nameValidation = isValidNLBName(config.name); + if (!nameValidation.valid) { + errors.name = nameValidation.error || 'Invalid name'; + } + + // Validate port + if (!isValidNLBPort(config.port)) { + errors.port = 'Port must be between 10000 and 65535'; + } + + // Validate upstreams + if (!config.upstreams || config.upstreams.length === 0) { + errors.upstreams = 'At least one upstream is required'; + } else { + // Check for duplicate upstreams + const upstreamKeys = new Set(); + const duplicates: string[] = []; + + config.upstreams.forEach((upstream) => { + const key = `${upstream.host}:${upstream.port}`; + if (upstreamKeys.has(key)) { + duplicates.push(key); + } + upstreamKeys.add(key); + }); + + if (duplicates.length > 0) { + errors.upstreams = `Duplicate upstreams detected: ${duplicates.join(', ')}`; + } + + // Check if all upstreams are marked as down or backup + const activeUpstreams = config.upstreams.filter(u => !u.down && !u.backup); + if (activeUpstreams.length === 0) { + errors.upstreams = 'At least one upstream must be active (not marked as down or backup)'; + } + } + + // Validate proxy settings + if (!isValidProxyTimeout(config.proxyTimeout)) { + errors.proxyTimeout = 'Proxy timeout must be between 1 and 3600 seconds'; + } + + if (!isValidProxyConnectTimeout(config.proxyConnectTimeout)) { + errors.proxyConnectTimeout = 'Proxy connect timeout must be between 1 and 300 seconds'; + } + + if (!isValidProxyNextUpstreamTimeout(config.proxyNextUpstreamTimeout)) { + errors.proxyNextUpstreamTimeout = 'Proxy next upstream timeout must be between 0 and 3600 seconds'; + } + + if (!isValidProxyNextUpstreamTries(config.proxyNextUpstreamTries)) { + errors.proxyNextUpstreamTries = 'Proxy next upstream tries must be between 0 and 100'; + } + + // Validate health check settings if enabled + if (config.healthCheckEnabled) { + if (config.healthCheckInterval !== undefined && !isValidHealthCheckInterval(config.healthCheckInterval)) { + errors.healthCheckInterval = 'Health check interval must be between 5 and 3600 seconds'; + } + + if (config.healthCheckTimeout !== undefined && !isValidHealthCheckTimeout(config.healthCheckTimeout)) { + errors.healthCheckTimeout = 'Health check timeout must be between 1 and 300 seconds'; + } + + if (config.healthCheckRises !== undefined && !isValidHealthCheckRises(config.healthCheckRises)) { + errors.healthCheckRises = 'Health check rises must be between 1 and 10'; + } + + if (config.healthCheckFalls !== undefined && !isValidHealthCheckFalls(config.healthCheckFalls)) { + errors.healthCheckFalls = 'Health check falls must be between 1 and 10'; + } + + // Health check timeout must be less than interval + if ( + config.healthCheckTimeout !== undefined && + config.healthCheckInterval !== undefined && + config.healthCheckTimeout >= config.healthCheckInterval + ) { + errors.healthCheckTimeout = 'Health check timeout must be less than interval'; + } + } + + // Proxy connect timeout should be less than proxy timeout + if (config.proxyConnectTimeout >= config.proxyTimeout) { + errors.proxyConnectTimeout = 'Proxy connect timeout should be less than proxy timeout'; + } + + return { + valid: Object.keys(errors).length === 0, + errors, + }; +} + +/** + * Get validation hints for specific fields + */ +export function getValidationHints(field: string): string { + const hints: Record = { + name: 'Use 3-50 characters: letters, numbers, dashes, underscores', + port: 'Port must be between 10000-65535 to avoid conflicts', + host: 'Enter IP address (IPv4/IPv6) or hostname', + upstreamPort: 'Port must be between 1-65535', + weight: 'Weight determines traffic distribution (1-100)', + maxFails: 'Number of failed attempts before marking server down (0-100)', + failTimeout: 'Time to wait before retrying failed server (1-3600s)', + maxConns: 'Maximum concurrent connections (0 = unlimited)', + proxyTimeout: 'Maximum time to wait for upstream response (1-3600s)', + proxyConnectTimeout: 'Maximum time to establish connection (1-300s)', + healthCheckInterval: 'Time between health checks (5-3600s)', + healthCheckTimeout: 'Maximum time to wait for health check response (1-300s)', + }; + + return hints[field] || ''; +} + +/** + * Get example values for fields + */ +export function getExampleValue(field: string): string { + const examples: Record = { + name: 'my-load-balancer', + host: '192.168.1.100 or backend.example.com', + port: '10000', + upstreamPort: '80 or 443', + weight: '1', + maxFails: '3', + failTimeout: '10', + maxConns: '0', + proxyTimeout: '3', + proxyConnectTimeout: '1', + healthCheckInterval: '10', + healthCheckTimeout: '5', + }; + + return examples[field] || ''; +} + +/** + * Check for common configuration issues + */ +export function checkConfigurationWarnings(config: { + upstreams: Array<{ + host: string; + port: number; + weight: number; + maxFails: number; + failTimeout: number; + backup: boolean; + down: boolean; + }>; + proxyTimeout: number; + proxyConnectTimeout: number; + healthCheckEnabled: boolean; + healthCheckInterval?: number; + healthCheckTimeout?: number; +}): string[] { + const warnings: string[] = []; + + // Check if all upstreams have the same weight + const weights = config.upstreams.map(u => u.weight); + if (new Set(weights).size === 1 && weights[0] !== 1) { + warnings.push('All upstreams have the same weight. Consider using weight=1 for simplicity.'); + } + + // Check if proxy timeout is very high + if (config.proxyTimeout > 300) { + warnings.push('Proxy timeout is very high (>5 minutes). This may cause long waits for clients.'); + } + + // Check if health check is disabled + if (!config.healthCheckEnabled) { + warnings.push('Health checks are disabled. Failed upstreams will not be automatically detected.'); + } + + // Check if health check interval is too frequent + if (config.healthCheckEnabled && config.healthCheckInterval && config.healthCheckInterval < 10) { + warnings.push('Health check interval is very frequent (<10s). This may increase server load.'); + } + + // Check if backup servers exist without active servers + const hasBackup = config.upstreams.some(u => u.backup); + const hasActive = config.upstreams.some(u => !u.backup && !u.down); + if (hasBackup && !hasActive) { + warnings.push('Only backup servers configured. They will only be used when all primary servers are down.'); + } + + return warnings; +} From 0665977e4fce0830e5757a91cba955c1e425dce4 Mon Sep 17 00:00:00 2001 From: vncloudsco Date: Fri, 31 Oct 2025 10:21:16 +0700 Subject: [PATCH 4/4] new version --- .github/workflows/build.yml | 8 ++++---- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4937ce..5346404 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -127,13 +127,13 @@ jobs: with: path: ./artifacts - - name: Create Draft Release 0.1.2 + - name: Create Draft Release 0.1.3 uses: softprops/action-gh-release@v1 with: - tag_name: 0.1.2 - name: Release 0.1.2 + tag_name: 0.1.3 + name: Release 0.1.3 body: | - Automated release for Nginx WAF Desktop Client v0.1.2. + Automated release for Nginx WAF Desktop Client v0.1.3. ## Changes - Cross-platform builds for Linux x86_64, Windows x86_64, Mac Intel, Mac ARM. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 96b1622..c44ad5e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Nginx_WAF" -version = "0.1.2" +version = "0.1.3" description = "Nginx WAF - Advanced Nginx Management Platform" authors = ["TinyActive"] edition = "2024" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 587fcca..2be182a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Nginx WAF", - "version": "0.1.2", + "version": "0.1.3", "identifier": "com.tinyactive.nginx-waf", "build": { "beforeDevCommand": "npm run dev",