import { useEffect, useMemo, useRef, useState, Fragment } from 'react'; import { Monitor, Pencil, Plus, RefreshCw, Save, Search, Server, ShieldCheck, Trash2, X, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Select } from '@/components/ui/Select'; import { vpsApi } from '@/services/vpsApi'; import type { ControllerClient } 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); async function loadClients() { try { setLoading(true); setError(''); const response = await vpsApi.listControllerClients(); setClients(response.clients); setSelectedId((current) => 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]; return (

Controladores

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

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

Clientes

Ficheiros em /var/litoral_regas_app/maquinas

{filteredClients.length} / {clients.length} clientes
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.path}

Última alteração:{' '} {selectedClient.modified_at ?? '—'}

} label="Controladores" value={String( selectedClient.controller_count, )} /> } label="Ficheiro VPS" value={selectedClient.file} />

Próximas ações

• Abrir modal de edição do cliente.

• Listar controladores do ficheiro .txt.

• Detetar router associado pelo IP WireGuard.

• Adicionar, atualizar ou remover DNAT no router.

• Atualizar entrada correspondente no ficheiro VPS.

) : (
Nenhum cliente selecionado.
)}
{editingClient && ( setEditingClient(null)} /> )}
); } 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 EditClientModal({ client, onClose, }: { client: ControllerClient; onClose: () => void; }) { const [clientPassword, setClientPassword] = useState(''); const [controllers, setControllers] = useState([]); const [loadingClientFile, setLoadingClientFile] = useState(true); const [clientFileError, setClientFileError] = 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), ); } return (

Editar Cliente

{client.name}

{client.path}

{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( /[^A-Za-z0-9_-]/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 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 }: { 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; }) { 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 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' ? ( ) : ( )}
); }