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 (
+
+ );
+}
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 (
@@ -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')}
+
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 (