import { useEffect, useMemo, useRef, useState, Fragment } from 'react'; import { Monitor, Pencil, Plus, RefreshCw, Save, Search, Server, ShieldCheck, Trash2, X, Router, LockKeyhole, Network, Eye, EyeOff, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Select } from '@/components/ui/Select'; import { vpsApi } from '@/services/vpsApi'; import { Button } from '@/components/ui/Button'; import type { ControllerClient } from '@/services/vpsApi'; import type { RouterFirewallRule } from '@/services/vpsApi'; const scrollClass = '[scrollbar-width:thin] [scrollbar-color:rgba(59,130,246,0.55)_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-blue-500/45 hover:[&::-webkit-scrollbar-thumb]:bg-blue-500/65'; const controllerFilterOptions: { value: 'all' | 'with' | 'without'; label: string; }[] = [ { value: 'all', label: 'Todos' }, { value: 'with', label: 'Com controladores' }, { value: 'without', label: 'Sem controladores' }, ]; const pageSizeOptions: { value: '10' | '25' | '50'; label: string; }[] = [ { value: '10', label: '10 por página' }, { value: '25', label: '25 por página' }, { value: '50', label: '50 por página' }, ]; export function ControllersRoute() { const [search, setSearch] = useState(''); const [clients, setClients] = useState([]); const [selectedId, setSelectedId] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState<'10' | '25' | '50'>('10'); const [controllerFilter, setControllerFilter] = useState<'all' | 'with' | 'without'>('all'); const [editingClient, setEditingClient] = useState(null); const [creatingClient, setCreatingClient] = useState(false); const [deletingClientId, setDeletingClientId] = useState(''); const [clientToDelete, setClientToDelete] = useState(null); const [selectedRouterIp, setSelectedRouterIp] = useState(''); const [selectedClientFileContent, setSelectedClientFileContent] = useState(''); const [loadingRouterData, setLoadingRouterData] = useState(false); const [routerDataError, setRouterDataError] = useState(''); const [routerRules, setRouterRules] = useState([]); const [routerPassword, setRouterPassword] = useState('litoralr'); const [showRouterPassword, setShowRouterPassword] = useState(false); const [creatingRule, setCreatingRule] = useState(false); const [ruleToUpdate, setRuleToUpdate] = useState(null); const [ruleToDelete, setRuleToDelete] = useState(null); const [savingRule, setSavingRule] = useState(false); async function loadClients(selectClientId?: string) { try { setLoading(true); setError(''); const response = await vpsApi.listControllerClients(); setClients(response.clients); setSelectedId((current) => selectClientId || current || response.clients[0]?.id || '', ); } catch (err) { setError(String(err)); setClients([]); setSelectedId(''); } finally { setLoading(false); } } useEffect(() => { loadClients(); }, []); const filteredClients = useMemo(() => { return clients.filter((client) => { const matchesSearch = client.name .toLowerCase() .includes(search.toLowerCase()); const matchesControllerFilter = controllerFilter === 'all' || (controllerFilter === 'with' && client.controller_count > 0) || (controllerFilter === 'without' && client.controller_count === 0); return matchesSearch && matchesControllerFilter; }); }, [clients, search, controllerFilter]); useEffect(() => { setPage(1); }, [search, controllerFilter, pageSize]); const pageSizeNumber = Number(pageSize); const totalPages = Math.max( 1, Math.ceil(filteredClients.length / pageSizeNumber), ); const pagedClients = useMemo(() => { const start = (page - 1) * pageSizeNumber; return filteredClients.slice(start, start + pageSizeNumber); }, [filteredClients, page, pageSizeNumber]); const selectedClient = filteredClients.find((client) => client.id === selectedId) ?? filteredClients[0]; useEffect(() => { if (!selectedClient) { setSelectedClientFileContent(''); setSelectedRouterIp(''); setRouterDataError(''); setRouterRules([]); return; } let cancelled = false; async function loadSelectedClientFile() { try { setLoadingRouterData(true); setRouterDataError(''); const response = await vpsApi.readControllerClient(selectedClient.id); if (cancelled) return; setSelectedClientFileContent(response.content); } catch (err) { if (cancelled) return; setSelectedClientFileContent(''); setSelectedRouterIp(''); setRouterDataError(String(err)); } finally { if (!cancelled) { setLoadingRouterData(false); } } } loadSelectedClientFile(); return () => { cancelled = true; }; }, [selectedClient?.id]); const routerGroups = useMemo(() => { if (!selectedClientFileContent) return []; const parsed = parseClientTxt(selectedClientFileContent); const groups = new Map(); parsed.controllers.forEach((controller) => { const router = controller.routerVpnIp.trim(); if (!router) return; groups.set(router, [...(groups.get(router) ?? []), controller]); }); return Array.from(groups.entries()).map(([routerVpnIp, controllers]) => ({ routerVpnIp, controllers, })); }, [selectedClientFileContent]); useEffect(() => { if (routerGroups.length === 0) { setSelectedRouterIp(''); return; } setSelectedRouterIp((current) => { const currentStillExists = routerGroups.some( (group) => group.routerVpnIp === current, ); return currentStillExists ? current : routerGroups[0].routerVpnIp; }); }, [routerGroups]); const selectedRouterGroup = routerGroups.find( (group) => group.routerVpnIp === selectedRouterIp, ); const routerRulesLoaded = routerRules.length > 0; useEffect(() => { setRouterRules([]); setRouterDataError(''); }, [selectedRouterIp]); async function deleteClient(client: ControllerClient) { try { setDeletingClientId(client.id); setError(''); await vpsApi.deleteControllerClient(client.id); await loadClients(); setSelectedId((current) => (current === client.id ? '' : current)); setClientToDelete(null); } catch (err) { setError(String(err)); } finally { setDeletingClientId(''); } } async function loadRouterRules() { if (!selectedRouterIp || !routerPassword.trim()) return; try { setLoadingRouterData(true); setRouterDataError(''); const response = await vpsApi.getRouterFirewallRules( selectedRouterIp, routerPassword.trim(), ); setRouterRules(response.rules); } catch (err) { setRouterDataError(String(err)); setRouterRules([]); } finally { setLoadingRouterData(false); } } async function refreshRouterPanel() { if (!selectedClient) return; try { setLoadingRouterData(true); setRouterDataError(''); const clientResponse = await vpsApi.readControllerClient(selectedClient.id); setSelectedClientFileContent(clientResponse.content); if (selectedRouterIp && routerPassword.trim()) { const rulesResponse = await vpsApi.getRouterFirewallRules( selectedRouterIp, routerPassword.trim(), ); setRouterRules(rulesResponse.rules); } } catch (err) { setRouterDataError(String(err)); setRouterRules([]); } finally { setLoadingRouterData(false); } } return (

Controladores

Gestão de clientes, controladores, ficheiros VPS e regras do router.

{error && (
Erro ao carregar clientes: {error}
)}

Clientes

{clients.length} ficheiros em /var/litoral_regas_app/maquinas

setSearch(event.target.value) } placeholder="Pesquisar cliente..." className="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-600" />
{loading && clients.length === 0 && (
A carregar clientes da VPS...
)} {!loading && filteredClients.length === 0 && (
Nenhum cliente encontrado.
)} {pagedClients.map((client) => { const active = selectedClient?.id === client.id; return (
); })}
Página {page} de {totalPages} ·{' '} {filteredClients.length} resultados
{selectedClient ? (

Cliente selecionado

{selectedClient.name}

{selectedClient.controller_count} controlador(es) {selectedClient.file}

{selectedClient.path}

) : (
Nenhum cliente selecionado.
)}
{selectedClient ? ( <>

Router / Firewall

Gestão de DNAT

Seleciona o router, confirma a password e carrega as regras UCI.

setRouterPassword(event.target.value)} placeholder="Password do router" className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-3 pr-12 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/40" />

Estado

{routerDataError ? 'Falha ao comunicar com o router' : loadingRouterData ? 'A analisar router...' : routerRules.length > 0 ? `${routerRules.length} regra(s) carregada(s)` : selectedRouterIp ? 'Pronto para carregar regras' : 'Nenhum router selecionado'}

0 ? 'border border-emerald-500/20 bg-emerald-500/10 text-emerald-200' : 'border border-white/10 bg-white/[0.03] text-slate-400' }`} > {routerDataError ? 'Router inacessível' : loadingRouterData ? 'A ligar...' : routerRules.length > 0 ? 'Ligado' : 'Não carregado'}
{routerDataError && (

Não foi possível carregar as regras do router.

{routerDataError}

)}

Controladores

Associados ao router selecionado.

{!selectedRouterGroup ? (
Seleciona um router.
) : ( selectedRouterGroup.controllers.map((controller) => (

{controller.name}

Externa {controller.externalPort}

{controller.password}
)) )}

Regras DNAT

Portas externas e destinos internos.

{routerRules.length === 0 ? (

Nenhuma regra carregada ainda.

Carrega o router para listar as regras DNAT.

) : ( routerRules.map((rule) => (

{rule.name || rule.id}

{rule.externalPort} → {rule.internalIp}:{rule.internalPort}

{rule.proto}
)) )}
) : (
Seleciona um cliente para gerir o router.
)} {editingClient && ( setEditingClient(null)} onSaved={async () => { await loadClients(); await refreshRouterPanel(); }} /> )} {creatingClient && ( setCreatingClient(false)} onSaved={(clientId) => { setSearch(clientId); setControllerFilter('all'); setPage(1); return loadClients(clientId) }} /> )} {clientToDelete && (

Apagar Cliente

Tem a certeza?

Vai apagar o ficheiro do cliente{' '} {clientToDelete.name} .

)} {creatingRule && ( setCreatingRule(false)} onSaved={async () => { setCreatingRule(false); await loadRouterRules(); }} /> )} {ruleToUpdate && ( setRuleToUpdate(null)} onSaved={async () => { setRuleToUpdate(null); await loadRouterRules(); }} /> )} {ruleToDelete && ( setRuleToDelete(null)} onConfirm={async () => { try { setSavingRule(true); await vpsApi.deleteRouterFirewallRule( selectedRouterIp, routerPassword, ruleToDelete.id, ); setRuleToDelete(null); await loadRouterRules(); } finally { setSavingRule(false); } }} /> )} ); } function MiniStat({ icon, label, value, }: { icon: React.ReactNode; label: string; value: string; }) { return (
{icon}

{label}

{value}

); } type EditableController = { id: string; name: string; routerVpnIp: string; externalPort: string; password: string; }; function CreateClientModal({ clients, onClose, onSaved, }: { clients: ControllerClient[]; onClose: () => void; onSaved: (clientId: string) => Promise | void; }) { const [name, setName] = useState(''); const [password, setPassword] = useState(generateClientPassword()); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const cleanName = name.trim(); const nameError = useMemo(() => { if (!cleanName) return 'O nome do cliente é obrigatório.'; if (!/^[A-Za-z0-9 _-]+$/.test(cleanName)) { return 'Usa apenas letras, números, espaços, "_" ou "-".'; } if (hasTxtSeparator(cleanName)) { return 'O nome não pode conter "xxx" ou quebras de linha.'; } const clientId = normalizeClientId(cleanName); const duplicateClient = clients.some( (client) => normalizeClientId(client.id) === clientId, ); if (duplicateClient) { return 'Já existe um cliente com esse nome.'; } return ''; }, [cleanName, clients]); const passwordError = useMemo(() => { if (!password.trim()) return 'A palavra-passe é obrigatória.'; if (hasTxtSeparator(password)) { return 'A palavra-passe não pode conter "xxx" ou quebras de linha.'; } return ''; }, [password]); const formIsValid = !nameError && !passwordError; async function createClient() { try { setSaving(true); setError(''); await vpsApi.createControllerClient(cleanName, password); await onSaved(cleanName); onClose(); } catch (err) { setError(String(err)); } finally { setSaving(false); } } function normalizeClientId(value: string) { return value.trim().toLowerCase().replace(/\s+/g, '_'); } return (

Novo Cliente

Criar ficheiro VPS

Cria um novo cliente com apenas a primeira linha do ficheiro.

{error && (
Erro ao criar cliente: {error}
)} setName(value.replace(/[^\w\- ]/g, '')) } autoFocus />
); } function EditClientModal({ client, onClose, onSaved, }: { client: ControllerClient; onClose: () => void; onSaved: () => Promise | void; }) { const [clientPassword, setClientPassword] = useState(''); const [controllers, setControllers] = useState([]); const [loadingClientFile, setLoadingClientFile] = useState(true); const [clientFileError, setClientFileError] = useState(''); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); useEffect(() => { let cancelled = false; async function loadClientFile() { try { setLoadingClientFile(true); setClientFileError(''); const response = await vpsApi.readControllerClient(client.id); if (cancelled) return; const parsed = parseClientTxt(response.content); setClientPassword(parsed.password); setControllers(parsed.controllers); } catch (err) { if (cancelled) return; setClientFileError(String(err)); setClientPassword(''); setControllers([]); } finally { if (!cancelled) { setLoadingClientFile(false); } } } loadClientFile(); return () => { cancelled = true; }; }, [client.id]); const controllerErrors = useMemo( () => controllers.map(validateController), [controllers], ); const clientPasswordError = useMemo(() => { if (loadingClientFile) return ''; if (!clientPassword.trim()) return 'A palavra-passe é obrigatória.'; if (hasTxtSeparator(clientPassword)) { return 'A palavra-passe não pode conter "xxx" ou quebras de linha.'; } return ''; }, [clientPassword, loadingClientFile]); const formIsValid = !loadingClientFile && !clientFileError && !clientPasswordError && controllers.length > 0 && controllerErrors.every((errors) => !hasErrors(errors)); const txtPreview = useMemo(() => { const lines = [ clientPassword, ...controllers.map( (controller) => `${controller.name}xxx${controller.routerVpnIp}xxx${controller.externalPort}xxx${controller.password}`, ), ]; return `${lines.join('\n')}\n`; }, [clientPassword, controllers]); function updateController( id: string, patch: Partial, ) { setControllers((current) => current.map((controller) => controller.id === id ? { ...controller, ...patch } : controller, ), ); } function addController() { setControllers((current) => [ ...current, { id: crypto.randomUUID(), name: `Controlador${current.length + 1}`, routerVpnIp: '', externalPort: getNextAvailablePort(current), password: '111111', }, ]); } function removeController(id: string) { setControllers((current) => current.filter((controller) => controller.id !== id), ); } async function saveClientFile() { try { setSaving(true); setSaveError(''); await vpsApi.updateControllerClient(client.id, txtPreview); await onSaved(); onClose(); } catch (err) { setSaveError(String(err)); } finally { setSaving(false); } } return (

Editar Cliente

{client.name}

{client.path}

{saveError && (
Erro ao guardar: {saveError}
)} {loadingClientFile ? (
A carregar ficheiro do cliente...
) : clientFileError ? (
Erro ao carregar ficheiro do cliente: {clientFileError}
) : (

Dados do Cliente

Controladores

Entradas carregadas do ficheiro real do cliente.

{controllers.map((controller, index) => (

Controlador #{index + 1}

updateController(controller.id, { name: value.replace( /[^\w\- ]/g, '', ), }) } /> updateController(controller.id, { routerVpnIp: value, }) } /> updateController(controller.id, { externalPort: value, }) } /> updateController(controller.id, { password: value .replace(/\D/g, '') .slice(0, 6), }) } />
))}

Preview .txt

Conteúdo exato a guardar no ficheiro.

{controllers.length} entradas
                                    {txtPreview}
                                
)}

Dados carregados diretamente do ficheiro do cliente.

); } function RuleModal({ title, routerHost, routerPassword, rule, defaultExternalPort = '', onClose, onSaved, }: { title: string; routerHost: string; routerPassword: string; rule?: RouterFirewallRule; defaultExternalPort?: string; onClose: () => void; onSaved: () => Promise | void; }) { const editing = Boolean(rule); const [id, setId] = useState(rule?.id ?? ''); const [name, setName] = useState(rule?.name ?? ''); const [proto, setProto] = useState(rule?.proto ?? 'tcp'); const [srcZone, setSrcZone] = useState(rule?.srcZone ?? 'vpn'); const [destZone, setDestZone] = useState(rule?.destZone ?? 'lan'); const [externalPort, setExternalPort] = useState(rule?.externalPort ?? defaultExternalPort); const [internalIp, setInternalIp] = useState(rule?.internalIp ?? ''); const [internalPort, setInternalPort] = useState(rule?.internalPort ?? '5900'); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const cleanId = id.trim(); const formIsValid = cleanId && name.trim() && proto.trim() && srcZone.trim() && destZone.trim() && isValidPort(externalPort) && isValidPort(internalPort) && IPV4_REGEX.test(internalIp.trim()); async function saveRule() { if (!formIsValid) return; const payload: RouterFirewallRule = { id: cleanId, name: name.trim(), proto: proto.trim(), srcZone: srcZone.trim(), destZone: destZone.trim(), externalPort: externalPort.trim(), internalIp: internalIp.trim(), internalPort: internalPort.trim(), target: 'DNAT', }; try { setSaving(true); setError(''); if (editing) { await vpsApi.updateRouterFirewallRule(routerHost, routerPassword, payload); } else { await vpsApi.createRouterFirewallRule(routerHost, routerPassword, payload); } await onSaved(); } catch (err) { setError(String(err)); } finally { setSaving(false); } } return (

Regra DNAT

{title}

Router: {routerHost}

{error && (
Erro ao guardar regra: {error}
)}
setId(value.replace(/[^\w-]/g, '').toLowerCase()) } /> setProto(value.toLowerCase())} /> setExternalPort(value.replace(/\D/g, ''))} /> setInternalPort(value.replace(/\D/g, ''))} />

Preview

{externalPort || '—'} → {internalIp || '—'}:{internalPort || '—'} · {proto || 'tcp'}

); } function DeleteRuleModal({ rule, loading, onClose, onConfirm, }: { rule: RouterFirewallRule; loading: boolean; onClose: () => void; onConfirm: () => Promise | void; }) { return (

Apagar Regra

Tem a certeza?

Vai apagar a regra{' '} {rule.name || rule.id} .

{rule.externalPort} → {rule.internalIp}:{rule.internalPort}

); } function parseClientTxt(content: string): { password: string; controllers: EditableController[]; } { const lines = content .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .split('\n') .filter((line) => line.length > 0); const password = lines[0] ?? ''; const controllers = lines.slice(1).map((line, index) => { const [name = '', routerVpnIp = '', externalPort = '', password = ''] = line.split('xxx'); return { id: crypto.randomUUID(), name, routerVpnIp, externalPort, password, }; }); return { password, controllers, }; } function Field({ label, value, onChange, error, inputMode, maxLength, type = 'text', min, max, step, hideLabel = false, placeholder, autoFocus = false, disabled = false }: { label: string; value: string; onChange: (value: string) => void; error?: string; inputMode?: React.HTMLAttributes['inputMode']; maxLength?: number; type?: React.HTMLInputTypeAttribute; min?: number; max?: number; step?: number; hideLabel?: boolean; placeholder?: string; autoFocus?: boolean; disabled?: boolean; }) { return (
{!hideLabel && ( )}
onChange(event.target.value)} className={`w-full rounded-xl border bg-slate-950 px-3 py-3 pr-12 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${error ? 'border-red-500/60 focus:border-red-400' : 'border-white/10 focus:border-blue-500/40' }`} /> {type === 'number' && (
)}
{error && (

{error}

)}
); } function IpField({ label, value, onChange, error, hideLabel = false, autoFocusFirstOctet = false, autoFocusOctetIndex = 0, }: { label: string; value: string; onChange: (value: string) => void; error?: string; hideLabel?: boolean; autoFocusFirstOctet?: boolean; autoFocusOctetIndex?: number; }) { const inputRefs = useRef>([]); useEffect(() => { if (!autoFocusFirstOctet) return; requestAnimationFrame(() => { const input = inputRefs.current[autoFocusOctetIndex]; input?.focus(); input?.setSelectionRange(input.value.length, input.value.length); }); }, [autoFocusFirstOctet, autoFocusOctetIndex]); const parts = value.split('.'); const octets = Array.from({ length: 4 }, (_, index) => parts[index] ?? ''); function updateOctet(index: number, rawValue: string) { const nextValue = rawValue.replace(/\D/g, '').slice(0, 3); if (nextValue && Number(nextValue) > 255) return; const nextOctets = [...octets]; nextOctets[index] = nextValue; onChange(nextOctets.join('.')); } function handleKeyDown( index: number, event: React.KeyboardEvent, ) { if (event.key === '.') { event.preventDefault(); inputRefs.current[index + 1]?.focus(); return; } if (event.key !== 'Backspace') return; const current = octets[index]; if (current.length > 0) { const nextOctets = [...octets]; nextOctets[index] = current.slice(0, -1); onChange(nextOctets.join('.')); event.preventDefault(); return; } if (index > 0) { event.preventDefault(); inputRefs.current[index - 1]?.focus(); } } return (
{!hideLabel && ( )}
{octets.map((octet, index) => ( { inputRefs.current[index] = element; }} value={octet} inputMode="numeric" maxLength={3} onChange={(event) => updateOctet(index, event.target.value) } onKeyDown={(event) => handleKeyDown(index, event) } className="w-full min-w-0 bg-transparent px-1 py-1 text-center outline-none" /> {index < 3 && ( . )} ))}
{error && (

{error}

)}
); } type FieldErrors = Partial>; const IPV4_REGEX = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; const HOSTNAME_REGEX = /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; function isValidRouterHost(value: string) { const trimmed = value.trim(); return IPV4_REGEX.test(trimmed) || HOSTNAME_REGEX.test(trimmed); } const INVALID_CONTROLLER_NAME_REGEX = /(xxx|\r|\n)/i; function hasTxtSeparator(value: string) { return value.includes('xxx') || value.includes('\n') || value.includes('\r'); } function validatePort(value: string) { const port = Number(value); if (!value.trim()) { return 'A porta externa é obrigatória.'; } if (!/^\d+$/.test(value)) { return 'A porta deve conter apenas números.'; } if (port < 5900 || port > 5999) { return 'A porta deve estar entre 5900 e 5999.'; } return ''; } function isValidPort(value: string) { const port = Number(value); return /^\d+$/.test(value) && port >= 1 && port <= 65535; } function getNextAvailablePort(controllers: EditableController[]) { const usedPorts = new Set( controllers .map((controller) => Number(controller.externalPort)) .filter((port) => Number.isInteger(port) && port >= 5900), ); let port = 5900; while (usedPorts.has(port)) { port += 1; } return String(port); } function validateController(controller: EditableController): FieldErrors { const errors: FieldErrors = {}; if (!controller.name.trim()) { errors.name = 'O nome é obrigatório.'; } else if (INVALID_CONTROLLER_NAME_REGEX.test(controller.name)) { errors.name = 'O nome não pode conter "xxx" ou quebras de linha.'; } if (!controller.routerVpnIp.trim()) { errors.routerVpnIp = 'O router/host é obrigatório.'; } else if (!isValidRouterHost(controller.routerVpnIp)) { errors.routerVpnIp = 'Introduz um IPv4 ou hostname válido.'; } const portError = validatePort(controller.externalPort); if (portError) errors.externalPort = portError; if (!/^\d{6}$/.test(controller.password)) { errors.password = 'O PIN/password deve ter exatamente 6 dígitos.'; } return errors; } function hasErrors(errors: FieldErrors) { return Object.values(errors).some(Boolean); } type RouterFieldMode = 'host' | 'ip'; function SmartRouterField({ label, value, onChange, error, }: { label: string; value: string; onChange: (value: string) => void; error?: string; }) { const [mode, setMode] = useState(() => IPV4_REGEX.test(value.trim()) ? 'ip' : 'host', ); const [focusOctetIndex, setFocusOctetIndex] = useState(0); const [isFocused, setIsFocused] = useState(false); useEffect(() => { if (!value.trim()) { setMode('host'); setFocusOctetIndex(0); } }, [value]); function handleHostChange(nextValue: string) { const shouldSwitchToIp = /^[\d.]+$/.test(nextValue) && /\d+\.$/.test(nextValue); if (shouldSwitchToIp) { setFocusOctetIndex( Math.min(nextValue.split('.').length - 1, 3), ); setMode('ip'); } onChange(nextValue); } function handleIpChange(nextValue: string) { if (!nextValue.replace(/\./g, '').trim()) { onChange(''); return; } onChange(nextValue); } return (
setIsFocused(true)} onBlur={() => { setIsFocused(false); if (!value.trim()) { setMode('host'); setFocusOctetIndex(0); } }} >
{mode === 'ip' ? 'Auto · IPv4' : 'Auto · Host'}
{mode === 'ip' ? ( ) : ( )}
); } function generateClientPassword() { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; return Array.from({ length: 8 }, () => alphabet[Math.floor(Math.random() * alphabet.length)], ).join(''); }