559 lines
22 KiB
TypeScript
559 lines
22 KiB
TypeScript
import { useState } from "react";
|
|
import { ChevronDown } from "lucide-react";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import type { RouterItem } from "../types/router";
|
|
import {
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
Router,
|
|
Search,
|
|
ShieldCheck,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
|
|
type DetectionResult = {
|
|
detected: boolean;
|
|
ip: string | null;
|
|
method: string;
|
|
ssh_reachable: boolean;
|
|
message: string;
|
|
};
|
|
|
|
type PrepareResult = {
|
|
success: boolean;
|
|
hostname_updated: boolean;
|
|
password_updated: boolean;
|
|
message: string;
|
|
logs: string;
|
|
};
|
|
|
|
type CompatibilityResult = {
|
|
compatible: boolean;
|
|
version: string | null;
|
|
target: string | null;
|
|
arch: string | null;
|
|
reason: string | null;
|
|
};
|
|
|
|
type ReadinessResult = {
|
|
internet_ok: boolean;
|
|
dns_ok: boolean;
|
|
opkg_ok: boolean;
|
|
openvpn_installed: boolean;
|
|
free_space: string | null;
|
|
free_memory: string | null;
|
|
};
|
|
|
|
type SshResult = {
|
|
connected: boolean;
|
|
host: string;
|
|
hostname: string | null;
|
|
openwrt_release: string | null;
|
|
compatibility: CompatibilityResult | null;
|
|
readiness: ReadinessResult | null;
|
|
message: string;
|
|
};
|
|
|
|
type Props = {
|
|
routers: RouterItem[];
|
|
};
|
|
|
|
export function OpenWrtConfigPage({ routers }: Props) {
|
|
const [detecting, setDetecting] = useState(false);
|
|
const [connecting, setConnecting] = useState(false);
|
|
const [detection, setDetection] = useState<DetectionResult | null>(null);
|
|
const [sshResult, setSshResult] = useState<SshResult | null>(null);
|
|
|
|
const [host, setHost] = useState("");
|
|
const [username, setUsername] = useState("root");
|
|
const [password, setPassword] = useState("");
|
|
const [selectedRouterId, setSelectedRouterId] = useState("");
|
|
const [newRootPassword, setNewRootPassword] = useState("");
|
|
const [confirmRootPassword, setConfirmRootPassword] = useState("");
|
|
const [routerDropdownOpen, setRouterDropdownOpen] = useState(false);
|
|
|
|
const [preparing, setPreparing] = useState(false);
|
|
const [prepareResult, setPrepareResult] = useState<PrepareResult | null>(null);
|
|
|
|
const compatibility = sshResult?.compatibility;
|
|
|
|
async function detectRouter() {
|
|
try {
|
|
setDetecting(true);
|
|
setSshResult(null);
|
|
|
|
const result = await invoke<DetectionResult>("detect_openwrt_router");
|
|
|
|
setDetection(result);
|
|
|
|
if (result.ip) {
|
|
setHost(result.ip);
|
|
}
|
|
} finally {
|
|
setDetecting(false);
|
|
}
|
|
}
|
|
|
|
const sshAuthFailed =
|
|
sshResult &&
|
|
!sshResult.connected &&
|
|
sshResult.message.toLowerCase().includes("permission denied");
|
|
|
|
async function prepareRouter() {
|
|
if (!selectedRouter || !targetHostname) return;
|
|
|
|
try {
|
|
setPreparing(true);
|
|
setPrepareResult(null);
|
|
|
|
const result = await invoke<PrepareResult>("prepare_openwrt_router", {
|
|
host,
|
|
username,
|
|
password,
|
|
targetHostname,
|
|
newPassword: newRootPassword,
|
|
});
|
|
|
|
setPrepareResult(result);
|
|
|
|
if (result.success) {
|
|
setPassword(newRootPassword);
|
|
setNewRootPassword("");
|
|
setConfirmRootPassword("");
|
|
|
|
const refreshed = await invoke<SshResult>("test_openwrt_ssh", {
|
|
host,
|
|
username,
|
|
password: newRootPassword,
|
|
});
|
|
|
|
setSshResult(refreshed);
|
|
}
|
|
} finally {
|
|
setPreparing(false);
|
|
}
|
|
}
|
|
|
|
async function connectSsh() {
|
|
try {
|
|
setConnecting(true);
|
|
|
|
const result = await invoke<SshResult>("test_openwrt_ssh", {
|
|
host,
|
|
username,
|
|
password,
|
|
});
|
|
|
|
setSshResult(result);
|
|
} finally {
|
|
setConnecting(false);
|
|
}
|
|
}
|
|
|
|
const selectedRouter = routers.find((router) => router.id === selectedRouterId);
|
|
|
|
const vpnIpParts = selectedRouter?.vpnIp?.split(".") ?? [];
|
|
|
|
const targetHostname = selectedRouter?.vpnIp
|
|
? `Litoral_Regas_${vpnIpParts[vpnIpParts.length - 1]}`
|
|
: "";
|
|
|
|
return (
|
|
<section className="page-stack">
|
|
<div className="page-header">
|
|
<div>
|
|
<h1>Configuração OpenWRT</h1>
|
|
<p>Detetar router local, testar SSH e validar compatibilidade</p>
|
|
</div>
|
|
|
|
<button className="primary" onClick={detectRouter} disabled={detecting}>
|
|
<Search size={16} />
|
|
{detecting ? "A detetar..." : "Detetar Router"}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="openwrt-grid">
|
|
<div className="dashboard-card">
|
|
<div className="card-header">
|
|
<div>
|
|
<h2>Deteção do Router</h2>
|
|
<p>Procura pelo gateway local ou pelo IP padrão OpenWRT</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="status-note">
|
|
<Router size={24} />
|
|
|
|
<p>
|
|
{detection
|
|
? detection.message
|
|
: "Ainda não foi executada nenhuma deteção."}
|
|
</p>
|
|
</div>
|
|
|
|
{detection?.ip && (
|
|
<div className="detail-list">
|
|
<DetailRow label="IP detetado" value={detection.ip} />
|
|
<DetailRow label="Método" value={detection.method} />
|
|
<DetailRow
|
|
label="SSH"
|
|
value={detection.ssh_reachable ? "Disponível" : "Indisponível"}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="dashboard-card">
|
|
<div className="card-header">
|
|
<div>
|
|
<h2>Ligação SSH</h2>
|
|
<p>Introduza as credenciais do router OpenWRT</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="openwrt-form">
|
|
<label>
|
|
IP do Router
|
|
<input value={host} onChange={(e) => setHost(e.target.value)} />
|
|
</label>
|
|
|
|
<label>
|
|
Utilizador
|
|
<input
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
Password
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => {
|
|
setPassword(e.target.value);
|
|
|
|
if (sshResult && !sshResult.connected) {
|
|
setSshResult(null);
|
|
}
|
|
}}
|
|
placeholder="Password SSH"
|
|
/>
|
|
{sshAuthFailed && (
|
|
<span className="form-error">
|
|
Password SSH inválida ou em falta. Introduza uma password válida.
|
|
</span>
|
|
)}
|
|
</label>
|
|
<div className="openwrt-button-wrap">
|
|
<button
|
|
className="primary"
|
|
onClick={connectSsh}
|
|
disabled={connecting || !host || !username}
|
|
>
|
|
<ShieldCheck size={16} />
|
|
{connecting ? "A ligar..." : "Ligar ao Router"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{sshResult && (
|
|
<div className="dashboard-card wide-panel">
|
|
<div className="card-header">
|
|
<div>
|
|
<h2>Resultado da Ligação</h2>
|
|
<p>Estado da sessão SSH com o router</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="detail-list">
|
|
<DetailRow
|
|
label="Estado"
|
|
value={sshResult.connected ? "Ligado" : "Falhou"}
|
|
/>
|
|
|
|
<DetailRow label="Host" value={sshResult.host} />
|
|
<DetailRow label="Hostname" value={sshResult.hostname || "-"} />
|
|
</div>
|
|
|
|
{!sshResult.connected && (
|
|
<div className="status-note">
|
|
<XCircle size={24} />
|
|
<p>
|
|
{sshAuthFailed
|
|
? "Não foi possível autenticar. Verifique a password SSH."
|
|
: sshResult.message}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{sshResult?.connected && compatibility && (
|
|
<div className="dashboard-card wide-panel">
|
|
<div className="card-header">
|
|
<div>
|
|
<h2>Compatibilidade OpenWRT</h2>
|
|
<p>Validação da versão e arquitetura do firmware</p>
|
|
</div>
|
|
|
|
<span
|
|
className={`badge ${compatibility.compatible ? "success" : "failed"
|
|
}`}
|
|
>
|
|
{compatibility.compatible ? "Compatível" : "Não compatível"}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className={`status-note ${compatibility.compatible ? "compatible" : "unsupported"
|
|
}`}
|
|
>
|
|
{compatibility.compatible ? (
|
|
<CheckCircle2 size={24} />
|
|
) : (
|
|
<AlertTriangle size={24} />
|
|
)}
|
|
|
|
<p>
|
|
{compatibility.compatible
|
|
? "Este router cumpre os requisitos mínimos para configuração OpenVPN."
|
|
: compatibility.reason ||
|
|
"Este router não cumpre os requisitos mínimos."}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="detail-list">
|
|
<DetailRow
|
|
label="Versão OpenWRT"
|
|
value={compatibility.version || "-"}
|
|
/>
|
|
|
|
<DetailRow
|
|
label="Target"
|
|
value={compatibility.target || "-"}
|
|
/>
|
|
|
|
<DetailRow
|
|
label="Arquitetura"
|
|
value={compatibility.arch || "-"}
|
|
/>
|
|
|
|
<DetailRow
|
|
label="Versão mínima"
|
|
value="21.02.2"
|
|
/>
|
|
</div>
|
|
|
|
{sshResult.openwrt_release && (
|
|
<div className="log-box">
|
|
<pre>{sshResult.openwrt_release}</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{sshResult?.connected && sshResult.readiness && (
|
|
<div className="dashboard-card wide-panel">
|
|
<div className="card-header">
|
|
<div>
|
|
<h2>Estado do Router</h2>
|
|
<p>Verificações antes da configuração OpenVPN</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="detail-list">
|
|
<DetailRow
|
|
label="Internet"
|
|
value={sshResult.readiness.internet_ok ? "Disponível" : "Indisponível"}
|
|
/>
|
|
|
|
<DetailRow
|
|
label="DNS"
|
|
value={sshResult.readiness.dns_ok ? "Disponível" : "Indisponível"}
|
|
/>
|
|
|
|
<DetailRow
|
|
label="OPKG"
|
|
value={sshResult.readiness.opkg_ok ? "Disponível" : "Indisponível"}
|
|
/>
|
|
|
|
<DetailRow
|
|
label="OpenVPN"
|
|
value={sshResult.readiness.openvpn_installed ? "Instalado" : "Não instalado"}
|
|
/>
|
|
|
|
<DetailRow
|
|
label="Espaço livre"
|
|
value={sshResult.readiness.free_space || "-"}
|
|
/>
|
|
|
|
<DetailRow
|
|
label="Memória livre"
|
|
value={sshResult.readiness.free_memory || "-"}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sshResult?.connected && sshResult.readiness && (
|
|
<div className="dashboard-card wide-panel">
|
|
<div className="card-header">
|
|
<div>
|
|
<h2>Preparação do Router</h2>
|
|
<p>Associe este router físico a um router da plataforma</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="openwrt-prepare-form">
|
|
<label>
|
|
Router da Plataforma
|
|
<div className="custom-select">
|
|
<button
|
|
type="button"
|
|
className="custom-select-button"
|
|
onClick={() => setRouterDropdownOpen((prev) => !prev)}
|
|
>
|
|
<span>
|
|
{selectedRouter
|
|
? `${selectedRouter.name} - ${selectedRouter.vpnIp}`
|
|
: "Selecionar router..."}
|
|
</span>
|
|
|
|
<ChevronDown
|
|
size={16}
|
|
className={`chevron ${routerDropdownOpen ? "open" : ""}`}
|
|
/>
|
|
</button>
|
|
|
|
{routerDropdownOpen && (
|
|
<div className="custom-select-menu">
|
|
{routers.map((router) => (
|
|
<button
|
|
key={router.id}
|
|
type="button"
|
|
className="custom-select-option"
|
|
onClick={() => {
|
|
setSelectedRouterId(router.id);
|
|
setRouterDropdownOpen(false);
|
|
}}
|
|
>
|
|
{router.name} - {router.vpnIp}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</label>
|
|
|
|
<label>
|
|
Hostname Gerado
|
|
<input value={targetHostname || "-"} readOnly />
|
|
</label>
|
|
|
|
<label>
|
|
Nova Password Root
|
|
<input
|
|
type="password"
|
|
value={newRootPassword}
|
|
onChange={(e) => setNewRootPassword(e.target.value)}
|
|
placeholder="Nova password"
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
Confirmar Password Root
|
|
<input
|
|
type="password"
|
|
value={confirmRootPassword}
|
|
onChange={(e) => setConfirmRootPassword(e.target.value)}
|
|
placeholder="Confirmar password"
|
|
/>
|
|
|
|
{confirmRootPassword &&
|
|
newRootPassword !== confirmRootPassword && (
|
|
<span className="form-error">
|
|
As passwords não coincidem.
|
|
</span>
|
|
)}
|
|
</label>
|
|
</div>
|
|
|
|
{selectedRouter && (
|
|
<div className="detail-list">
|
|
<DetailRow label="Router selecionado" value={selectedRouter.name} />
|
|
<DetailRow label="IP VPN" value={selectedRouter.vpnIp || "-"} />
|
|
<DetailRow label="Hostname final" value={targetHostname} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="modal-actions">
|
|
<button
|
|
className="primary"
|
|
onClick={prepareRouter}
|
|
disabled={
|
|
preparing ||
|
|
!selectedRouter ||
|
|
!targetHostname ||
|
|
!newRootPassword ||
|
|
newRootPassword !== confirmRootPassword
|
|
}
|
|
>
|
|
{preparing ? "A preparar..." : "Preparar Router"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{prepareResult && (
|
|
<div className="dashboard-card wide-panel">
|
|
<div className="card-header">
|
|
<div>
|
|
<h2>Resultado da Preparação</h2>
|
|
<p>Estado da configuração inicial do router</p>
|
|
</div>
|
|
|
|
<span className={`badge ${prepareResult.success ? "success" : "failed"}`}>
|
|
{prepareResult.success ? "Sucesso" : "Falhou"}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="detail-list">
|
|
<DetailRow
|
|
label="Hostname"
|
|
value={prepareResult.hostname_updated ? "Atualizado" : "Falhou"}
|
|
/>
|
|
|
|
<DetailRow
|
|
label="Password Root"
|
|
value={prepareResult.password_updated ? "Atualizada" : "Falhou"}
|
|
/>
|
|
</div>
|
|
|
|
<div className="status-note">
|
|
{prepareResult.success ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
|
|
<p>{prepareResult.message}</p>
|
|
</div>
|
|
|
|
{prepareResult.logs && (
|
|
<div className="log-box">
|
|
<pre>{prepareResult.logs}</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
);
|
|
}
|
|
|
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="detail-row">
|
|
<span>{label}</span>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
);
|
|
} |