Translate to pt, fix some ui details, add proper icon and logo

This commit is contained in:
litoral05
2026-05-11 17:17:50 +01:00
parent c1e9aeb386
commit 12358327c9
73 changed files with 529 additions and 598 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+29 -1
View File
@@ -1,15 +1,43 @@
import { useState } from 'react';
import { LoginScreen } from '@/components/login/LoginScreen';
import { AppShell } from '@/components/layout/AppShell';
import { RouteView } from './routes';
const AUTH_KEY = 'lr-openwrt-tool.authenticated';
export default function App() {
const [active, setActive] = useState('Dashboard');
const [authenticated, setAuthenticated] =
useState(() => localStorage.getItem(AUTH_KEY) === 'true');
const [active, setActive] =
useState('Painel');
function handleLogin(keepLoggedIn: boolean) {
setAuthenticated(true);
if (keepLoggedIn) {
localStorage.setItem(AUTH_KEY, 'true');
} else {
localStorage.removeItem(AUTH_KEY);
}
}
function handleLogout() {
localStorage.removeItem(AUTH_KEY);
setAuthenticated(false);
setActive('Painel');
}
if (!authenticated) {
return <LoginScreen onLogin={handleLogin} />;
}
return (
<AppShell
active={active}
onSelect={setActive}
onLogout={handleLogout}
>
<RouteView active={active} />
</AppShell>
+21 -21
View File
@@ -100,52 +100,52 @@ export function DashboardRoute() {
{error && (
<div className="mb-3 rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-300">
Dashboard backend error:{' '}
Erro no backend do painel:{' '}
{error}
</div>
)}
<div className="grid grid-cols-4 gap-4">
<MetricCard
title="VPN Status"
title="Estado da VPN"
value={
vpnHealthy
? 'Connected'
? 'Ligada'
: 'Offline'
}
subtitle={
health?.wireGuardInterface
? `${health.wireGuardInterface} interface`
: 'WireGuard wg0 status'
? `Interface ${health.wireGuardInterface}`
: 'Estado WireGuard wg0'
}
icon={<ShieldCheck />}
/>
<MetricCard
title="IP Pool Usage"
title="Uso da Pool IP"
value={`${ipPoolPercent}%`}
subtitle={`${usedCount} / ${ipPoolTotal} IPs used`}
subtitle={`${usedCount} / ${ipPoolTotal} IPs usados`}
icon={<Database />}
/>
<MetricCard
title="VPS Uptime"
title="Uptime da VPS"
value={
health?.systemUptime ??
'Unknown'
'Desconhecido'
}
subtitle="Reported by VPS health"
subtitle="Reportado pelo health da VPS"
icon={<Clock />}
/>
<MetricCard
title="Backend Health"
title="Estado do Backend"
value={
backendHealthy
? 'Healthy'
? 'Saudável'
: 'Offline'
}
subtitle="API connectivity"
subtitle="Conectividade API"
icon={<Server />}
/>
</div>
@@ -178,8 +178,8 @@ export function PlaceholderRoute({
</h2>
<p className="mt-2 text-slate-400">
Screen scaffold ready for production
implementation.
Estrutura do ecrã pronta para
implementação em produção.
</p>
</Card>
);
@@ -190,25 +190,25 @@ export function RouteView({
}: {
active: string;
}) {
if (active === 'Dashboard') {
if (active === 'Painel') {
return <DashboardRoute />;
}
if (active === 'Provisioning') {
if (active === 'Provisionamento') {
return <ProvisioningWizard />;
}
if (active === 'UDP2RAW Config') {
if (active === 'Configuração UDP2RAW') {
return (
<PlaceholderRoute name="UDP2RAW Config" />
<PlaceholderRoute name="Configuração UDP2RAW" />
);
}
if (active === 'Activity Logs') {
if (active === 'Registos de Atividade') {
return <ActivityLogs />;
}
if (active === 'Workstation') {
if (active === 'Posto de Trabalho') {
return <BackendSettings />;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

+38 -22
View File
@@ -38,6 +38,22 @@ const sources: Array<'all' | ActivityLogSource> = [
'vps',
];
const levelLabels: Record<'all' | ActivityLogLevel, string> = {
all: 'Todos',
info: 'Info',
success: 'Sucesso',
warning: 'Aviso',
error: 'Erro',
};
const sourceLabels: Record<'all' | ActivityLogSource, string> = {
all: 'Todas',
desktop: 'Desktop',
router: 'Router',
backend: 'Backend',
vps: 'VPS',
};
function levelTone(level: ActivityLogLevel) {
if (level === 'success') return 'green';
if (level === 'warning') return 'purple';
@@ -120,12 +136,12 @@ export function ActivityLogs() {
<div className="mb-5 flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white">
Activity Logs
Registos de Atividade
</h2>
<p className="mt-1 text-slate-400">
Local provisioning audit trail for
technicians.
Histórico local de auditoria de
provisionamento para técnicos.
</p>
</div>
@@ -138,7 +154,7 @@ export function ActivityLogs() {
}}
>
<Download size={16} />
Export JSON
Exportar JSON
</Button>
<Button
@@ -150,7 +166,7 @@ export function ActivityLogs() {
}}
>
<Trash2 size={16} />
Clear Logs
Limpar Registos
</Button>
</div>
</div>
@@ -159,7 +175,7 @@ export function ActivityLogs() {
<div className="grid grid-cols-12 gap-4">
<div className="col-span-6">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Search
Pesquisa
</label>
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-ink-950 px-3">
@@ -173,7 +189,7 @@ export function ActivityLogs() {
onChange={(event) =>
setQuery(event.target.value)
}
placeholder="Search action, VPN IP, router IP, message..."
placeholder="Pesquisar ação, IP VPN, IP router, mensagem..."
className="w-full bg-transparent py-3 text-sm text-white outline-none placeholder:text-slate-600"
/>
</div>
@@ -181,7 +197,7 @@ export function ActivityLogs() {
<div className="col-span-3">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Level
Nível
</label>
<Select
@@ -189,14 +205,14 @@ export function ActivityLogs() {
onChange={setLevelFilter}
options={levels.map((level) => ({
value: level,
label: level,
label: levelLabels[level],
}))}
/>
</div>
<div className="col-span-3">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Source
Origem
</label>
<Select
@@ -204,7 +220,7 @@ export function ActivityLogs() {
onChange={setSourceFilter}
options={sources.map((source) => ({
value: source,
label: source,
label: sourceLabels[source],
}))}
/>
</div>
@@ -214,11 +230,11 @@ export function ActivityLogs() {
<Card className="relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold text-white">
Audit Events
Eventos de Auditoria
</h3>
<span className="text-sm text-slate-500">
{filteredLogs.length} shown /{' '}
{filteredLogs.length} apresentados /{' '}
{logs.length} total
</span>
</div>
@@ -235,27 +251,27 @@ export function ActivityLogs() {
<thead className="sticky top-0 z-10 bg-slate-950 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="w-[180px] px-4 py-3">
Time
Hora
</th>
<th className="w-[110px] px-4 py-3">
Level
Nível
</th>
<th className="w-[120px] px-4 py-3">
Source
Origem
</th>
<th className="w-[190px] px-4 py-3">
Action
Ação
</th>
<th className="px-4 py-3">
Message
Mensagem
</th>
<th className="w-[160px] px-4 py-3">
VPN IP
IP VPN
</th>
</tr>
</thead>
@@ -274,12 +290,12 @@ export function ActivityLogs() {
<td className="px-4 py-3">
<Badge tone={levelTone(log.level)}>
{log.level}
{levelLabels[log.level]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-300">
{log.source}
{sourceLabels[log.source]}
</td>
<td className="truncate px-4 py-3 font-medium text-white">
@@ -302,7 +318,7 @@ export function ActivityLogs() {
colSpan={6}
className="px-4 py-10 text-center text-slate-500"
>
No activity logs found.
Nenhum registo de atividade encontrado.
</td>
</tr>
)}
+8 -8
View File
@@ -28,11 +28,11 @@ export function IpPoolChart({
const data = [
{
name: 'Used',
name: 'Usados',
value: used,
},
{
name: 'Available',
name: 'Disponíveis',
value: available,
},
];
@@ -41,11 +41,11 @@ export function IpPoolChart({
<Card className="flex h-full flex-col">
<div>
<h3 className="font-semibold text-white">
IP Pool Usage
Uso da Pool IP
</h3>
<p className="text-xs text-slate-500">
WireGuard allocation capacity
Capacidade de atribuição WireGuard
</p>
</div>
@@ -82,7 +82,7 @@ export function IpPoolChart({
</p>
<p className="mt-1 text-xs text-slate-500">
pool used
pool usada
</p>
</div>
</div>
@@ -90,7 +90,7 @@ export function IpPoolChart({
<div className="mt-8 space-y-4">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<p className="text-xs uppercase tracking-wide text-slate-500">
Used IPs
IPs Usados
</p>
<p className="mt-1 text-2xl font-bold text-green-300">
@@ -100,7 +100,7 @@ export function IpPoolChart({
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<p className="text-xs uppercase tracking-wide text-slate-500">
Available IPs
IPs Disponíveis
</p>
<p className="mt-1 text-2xl font-bold text-blue-300">
@@ -110,7 +110,7 @@ export function IpPoolChart({
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="mb-2 flex justify-between text-xs text-slate-500">
<span>Capacity</span>
<span>Capacidade</span>
<span>{used} / {total}</span>
</div>
@@ -84,16 +84,16 @@ export function NetworkTrafficChart() {
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-semibold text-white">
Network Traffic
Tráfego de Rede
</h3>
<p className="text-xs text-slate-500">
Live wg0 RX/TX throughput
Débito RX/TX wg0 em direto
</p>
</div>
<span className="rounded-lg border border-white/10 bg-slate-900 px-3 py-1 text-xs text-slate-400">
3s refresh
Atualização 3s
</span>
</div>
@@ -1,77 +0,0 @@
import {
CheckCircle,
Cpu,
FileUp,
KeyRound,
Network,
Plug,
RefreshCw,
ShieldCheck,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
const steps = [
['Connect Router', Plug],
['Upload Firmware', FileUp],
['Flash Firmware', Cpu],
['Wait & Reconnect', RefreshCw],
['Provision Router', Network],
['Get Public Key', KeyRound],
['Register Peer', ShieldCheck],
['Verify Connection', CheckCircle],
] as const;
export function ProvisioningWorkflow() {
return (
<Card className="h-full">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-white">
Provisioning Workflow
</h3>
<p className="mt-1 text-sm text-slate-400">
OpenWrt 23.05 production
provisioning pipeline
</p>
</div>
<span className="rounded-full border border-green-500/20 bg-green-500/10 px-3 py-1 text-xs text-green-300">
Technician Mode
</span>
</div>
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-8">
{steps.map(([step, Icon], index) => (
<div
key={step}
className="relative rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-all hover:border-blue-500/30 hover:bg-white/[0.05]"
>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">
Step {index + 1}
</span>
<Icon
className={`h-5 w-5 ${
index === 6
? 'text-green-300'
: 'text-slate-400'
}`}
/>
</div>
<p className="mt-4 text-sm font-semibold text-white">
{step}
</p>
{index !== steps.length - 1 && (
<div className="absolute -right-2 top-1/2 hidden h-px w-4 bg-white/10 xl:block" />
)}
</div>
))}
</div>
</Card>
);
}
@@ -1,43 +0,0 @@
import { Card } from '@/components/ui/Card';
const rows = [
'Peer added 198.19.254.3',
'Peer updated 198.19.254.2',
'Backup created wg0.conf.bak',
'Health check all systems operational',
];
export function RecentActivityPanel() {
return (
<Card className="h-full">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-white">
Recent Activity
</h3>
<span className="text-xs text-slate-500">
Live Feed
</span>
</div>
<div className="mt-4 divide-y divide-white/10">
{rows.map((row, index) => (
<div
key={row}
className="flex items-center justify-between py-3 text-sm"
>
<span className="text-slate-200">
{row}
</span>
<span className="text-xs text-slate-500">
{index
? `${index * 15}m ago`
: '2m ago'}
</span>
</div>
))}
</div>
</Card>
);
}
@@ -1,54 +0,0 @@
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { StatusDot } from '@/components/ui/StatusDot';
const services = [
'WireGuard wg0',
'DNAT rules',
'IP forwarding',
'Persistent keepalive',
'Backup system',
];
export function ServicesHealthPanel() {
return (
<Card className="h-full">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-white">
Services Health
</h3>
<span className="text-xs text-slate-500">
fw4 / nftables
</span>
</div>
<div className="mt-5 space-y-4">
{services.map((service) => (
<div
key={service}
className="flex items-center justify-between rounded-xl border border-white/5 bg-white/[0.02] px-4 py-3"
>
<div className="flex items-center gap-3">
<StatusDot />
<div>
<p className="text-sm font-medium text-white">
{service}
</p>
<p className="text-xs text-slate-500">
Running baseline check
</p>
</div>
</div>
<Badge>
Running
</Badge>
</div>
))}
</div>
</Card>
);
}
+7 -1
View File
@@ -5,16 +5,22 @@ import { Sidebar } from './Sidebar';
type AppShellProps = PropsWithChildren<{
active: string;
onSelect: (value: string) => void;
onLogout: () => void;
}>;
export function AppShell({
active,
onSelect,
onLogout,
children,
}: AppShellProps) {
return (
<div className="flex h-screen overflow-hidden bg-ink-950 text-white">
<Sidebar active={active} onSelect={onSelect} />
<Sidebar
active={active}
onSelect={onSelect}
onLogout={onLogout}
/>
<main className="flex-1 overflow-hidden p-4">
{children}
+29 -27
View File
@@ -1,42 +1,49 @@
import {
FileClock,
Gauge,
LogOut,
RadioTower,
Settings,
Shield,
Wrench,
} from 'lucide-react';
import logoIcon from '@/assets/logo-icon.png';
const items = [
['Dashboard', Gauge],
['UDP2RAW Config', RadioTower],
['Provisioning', Wrench],
['Activity Logs', FileClock],
['Workstation', Settings],
['Painel', Gauge],
['Configuração UDP2RAW', RadioTower],
['Provisionamento', Wrench],
['Registos de Atividade', FileClock],
['Posto de Trabalho', Settings],
] as const;
type SidebarProps = {
active: string;
onSelect: (value: string) => void;
onLogout: () => void;
};
export function Sidebar({
active,
onSelect,
onLogout,
}: SidebarProps) {
return (
<aside className="flex h-screen w-64 flex-col border-r border-white/10 bg-ink-950 px-4 py-5">
<div className="mb-8 flex items-center gap-3">
<div className="rounded-2xl bg-blue-500/15 p-3 text-blue-300">
<Shield />
</div>
<div className="mb-10 flex items-center gap-4 px-1">
<img
src={logoIcon}
alt="Litoral Regas"
className="h-12 w-12 shrink-0 object-contain"
/>
<div>
<h1 className="font-bold text-white">
<div className="min-w-0">
<h1 className="truncate text-[20px] font-bold leading-none text-white">
Litoral Regas
</h1>
<p className="text-xs text-slate-400">
<p className="mt-1 text-[11px] font-medium uppercase tracking-[0.12em] text-blue-300/70">
VPN Orchestrator
</p>
</div>
@@ -51,8 +58,7 @@ export function Sidebar({
key={label}
type="button"
onClick={() => onSelect(label)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-sm font-medium transition ${
isActive
className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-sm font-medium transition ${isActive
? 'bg-blue-500/15 text-blue-200'
: 'text-slate-300 hover:bg-white/5 hover:text-white'
}`}
@@ -64,19 +70,15 @@ export function Sidebar({
})}
</nav>
<div className="mt-auto rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex items-center gap-2 text-sm text-green-300">
<span className="h-2 w-2 rounded-full bg-green-400" />
Backend connected
</div>
<p className="mt-2 text-xs text-slate-400">
localhost:8080
</p>
<div className="mt-5 border-t border-white/10 pt-4 text-xs text-slate-500">
Version 1.0.0
</div>
<div className="mt-auto border-t border-white/10 pt-4">
<button
type="button"
onClick={onLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left text-sm font-medium text-slate-300 transition hover:bg-red-500/10 hover:text-red-200"
>
<LogOut size={18} />
Terminar Sessão
</button>
</div>
</aside>
);
+5 -5
View File
@@ -13,12 +13,12 @@ export function TopBar({
<header className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white">
Dashboard
Painel
</h2>
<p className="mt-1 text-slate-400">
Provisionamento de routers de produção
OpenWrt 23.05 WireGuard
production router provisioning
</p>
</div>
@@ -27,7 +27,7 @@ export function TopBar({
<RefreshCw size={16} />
<span>
Last updated:{' '}
Última atualização:{' '}
{new Date().toLocaleTimeString()}
</span>
</div>
@@ -36,8 +36,8 @@ export function TopBar({
tone={healthy ? 'green' : 'red'}
>
{healthy
? 'All Systems Operational'
: 'System Issues Detected'}
? 'Todos os sistemas operacionais'
: 'Problemas detetados no sistema'}
</Badge>
</div>
</header>
+181
View File
@@ -0,0 +1,181 @@
import { useState } from 'react';
import {
Eye,
EyeOff,
LockKeyhole,
Shield,
User,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { loginApi } from '@/services/loginApi';
type LoginScreenProps = {
onLogin: (keepLoggedIn: boolean) => void;
};
export function LoginScreen({
onLogin,
}: LoginScreenProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [keepLoggedIn, setKeepLoggedIn] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (submitting) {
return;
}
setSubmitting(true);
setError('');
try {
const response = await loginApi.login(
username.trim(),
password,
);
if (response.authenticated) {
onLogin(keepLoggedIn);
return;
}
setError('Credenciais inválidas.');
} catch {
setError('Credenciais inválidas.');
} finally {
setSubmitting(false);
}
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-6 text-white">
<div className="pointer-events-none absolute -left-32 top-10 h-96 w-96 rounded-full bg-blue-500/20 blur-3xl" />
<div className="pointer-events-none absolute -right-32 bottom-10 h-96 w-96 rounded-full bg-cyan-400/10 blur-3xl" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(59,130,246,0.16),transparent_42%)]" />
<div className="relative w-full max-w-md">
<div className="absolute -inset-px rounded-[2rem] bg-gradient-to-br from-blue-500/40 via-cyan-400/10 to-white/5 opacity-80 blur-sm" />
<div className="relative rounded-[2rem] border border-white/10 bg-slate-950/80 p-8 shadow-2xl shadow-blue-500/10 backdrop-blur-xl">
<div className="mb-8 flex items-center gap-4">
<div className="rounded-2xl border border-blue-400/20 bg-blue-500/15 p-4 text-blue-300 shadow-lg shadow-blue-500/10">
<Shield size={32} />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight text-white">
Litoral Regas
</h1>
<p className="mt-1 text-sm text-blue-200/80">
VPN Orchestrator
</p>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-5">
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
Utilizador
</label>
<div className="group relative">
<User
size={17}
className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/60 transition group-focus-within:text-blue-300"
/>
<input
value={username}
onChange={(event) =>
setUsername(event.target.value)
}
autoComplete="username"
className="w-full rounded-2xl border border-white/10 bg-black/25 py-4 pl-11 pr-4 text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-400/60 focus:bg-black/35 focus:shadow-[0_0_0_4px_rgba(59,130,246,0.12)]"
placeholder="Introduza o utilizador"
/>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
Palavra-passe
</label>
<div className="group relative">
<LockKeyhole
size={17}
className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/60 transition group-focus-within:text-blue-300"
/>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(event) =>
setPassword(event.target.value)
}
autoComplete="current-password"
className="w-full rounded-2xl border border-white/10 bg-black/25 py-4 pl-11 pr-12 text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-400/60 focus:bg-black/35 focus:shadow-[0_0_0_4px_rgba(59,130,246,0.12)]"
placeholder="Introduza a palavra-passe"
/>
<button
type="button"
onClick={() =>
setShowPassword((current) => !current)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 transition hover:text-blue-200"
>
{showPassword ? (
<EyeOff size={17} />
) : (
<Eye size={17} />
)}
</button>
</div>
</div>
</div>
{error && (
<div className="mt-5 rounded-2xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-200">
{error}
</div>
)}
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm text-slate-400 transition hover:text-slate-200">
<input
type="checkbox"
checked={keepLoggedIn}
onChange={(event) =>
setKeepLoggedIn(event.target.checked)
}
className="h-4 w-4 accent-blue-500"
/>
Manter sessão iniciada
</label>
<Button
type="submit"
disabled={submitting}
className="mt-7 w-full justify-center rounded-2xl py-4 text-sm font-bold shadow-lg shadow-blue-500/20 transition hover:-translate-y-[1px]"
>
{submitting ? 'A entrar...' : 'Entrar'}
</Button>
</form>
<div className="mt-8 border-t border-white/10 pt-5 text-center text-xs text-slate-600">
Acesso restrito a técnicos autorizados
</div>
</div>
</div>
</div>
);
}
@@ -1,33 +0,0 @@
import { Card } from '@/components/ui/Card';
import type { ProvisioningState } from '@/types/provisioning';
type ProvisioningStepCardProps = {
state: ProvisioningState;
active: boolean;
};
export function ProvisioningStepCard({
state,
active,
}: ProvisioningStepCardProps) {
return (
<Card
className={
active
? 'border border-blue-400/40 bg-blue-500/10'
: 'border border-white/10'
}
>
<p
className={`text-sm font-semibold ${
active
? 'text-blue-200'
: 'text-slate-300'
}`}
>
{state.replace(/_/g, ' ')}
</p>
</Card>
);
}
+139 -130
View File
@@ -71,12 +71,12 @@ type RouterEnvForm = {
const routerPresets = [
{
ip: '192.168.1.1',
label: 'Factory/default',
label: 'Fábrica/predefinido',
password: '',
},
{
ip: '198.51.100.1',
label: 'Provisioned LAN',
label: 'LAN provisionada',
password: 'litoralr',
},
];
@@ -84,7 +84,7 @@ const routerPresets = [
const FLASH_SECONDS = 300;
const PROVISION_SECONDS = 90;
const stopPhrase = 'STOP PROVISIONING';
const stopPhrase = 'PARAR PROVISIONAMENTO';
const workflowSteps: Array<{
id: WorkflowStep;
@@ -94,50 +94,50 @@ const workflowSteps: Array<{
}> = [
{
id: 'DETECT_ROUTER',
title: 'Detect Router',
description: 'Ping and inspect router over SSH.',
title: 'Detetar Router',
description: 'Ping e inspeção do router via SSH.',
icon: Search,
},
{
id: 'UPLOAD_FIRMWARE',
title: 'Upload Firmware',
description: 'Copy firmware image to /tmp.',
title: 'Enviar Firmware',
description: 'Copiar imagem de firmware para /tmp.',
icon: UploadCloud,
},
{
id: 'FLASH_FIRMWARE',
title: 'Flash Firmware',
description: 'Run sysupgrade -n /tmp/firmware.bin.',
title: 'Gravar Firmware',
description: 'Executar sysupgrade -n /tmp/firmware.bin.',
icon: Cpu,
},
{
id: 'WAIT_REBOOT',
title: 'Wait for Reboot',
description: 'Block actions while router restarts.',
title: 'Aguardar Reinício',
description: 'Bloquear ações enquanto o router reinicia.',
icon: RefreshCw,
},
{
id: 'RECONNECT_ROUTER',
title: 'Reconnect Router',
description: 'Reconnect Ethernet and wait for SSH.',
title: 'Reconectar Router',
description: 'Reconectar Ethernet e aguardar SSH.',
icon: PlugZap,
},
{
id: 'UPLOAD_PROVISIONING',
title: 'Upload Bundle',
description: 'Copy router.env and provision.sh.',
title: 'Enviar Pacote',
description: 'Copiar router.env e provision.sh.',
icon: FileUp,
},
{
id: 'RUN_PROVISIONING',
title: 'Run Provisioning',
description: 'Execute router-side setup script.',
title: 'Executar Provisionamento',
description: 'Executar script de configuração no router.',
icon: Terminal,
},
{
id: 'REGISTER_PEER',
title: 'Register VPS Peer',
description: 'Apply WireGuard peer on VPS.',
title: 'Registar Peer VPS',
description: 'Aplicar peer WireGuard na VPS.',
icon: Network,
}
];
@@ -171,7 +171,20 @@ function statusTone(status: DetectionStatus) {
}
function statusLabel(status: DetectionStatus) {
return status.replace(/_/g, ' ');
const labels: Record<DetectionStatus, string> = {
idle: 'inativo',
checking: 'a verificar',
reachable: 'acessível',
ssh_ok: 'ssh ok',
auth_required: 'autenticação necessária',
stale_host_key: 'chave SSH antiga',
failed: 'falhou',
flashing: 'a gravar firmware',
waiting_reboot: 'a aguardar reinício',
reconnecting: 'a reconectar',
};
return labels[status];
}
function formatSeconds(totalSeconds: number) {
@@ -272,7 +285,6 @@ export function ProvisioningWizard() {
const [provisionSecondsRemaining, setProvisionSecondsRemaining] =
useState(PROVISION_SECONDS);
const [isActionRunning, setIsActionRunning] =
useState(false);
@@ -404,7 +416,7 @@ export function ProvisioningWizard() {
if (shouldLog) {
addLog(
'Provisioning flow was reset.',
'O fluxo de provisionamento foi reposto.',
'warning',
);
}
@@ -414,7 +426,7 @@ export function ProvisioningWizard() {
resetProvisioning(false);
setLog((currentLog) => [
`${new Date().toLocaleTimeString()} Router setup completed successfully. Ready for next router.`,
`${new Date().toLocaleTimeString()} Configuração do router concluída com sucesso. Pronto para o próximo router.`,
...currentLog,
]);
}
@@ -451,7 +463,7 @@ export function ProvisioningWizard() {
setActiveStep('RECONNECT_ROUTER');
addLog(
'Flash wait window completed. Reconnect or replug Ethernet if needed, then continue with reconnect checks.',
'Janela de espera do flash concluída. Volte a ligar o cabo Ethernet se necessário e continue com a verificação de reconexão.',
'success',
);
}
@@ -488,7 +500,7 @@ export function ProvisioningWizard() {
setActiveStep('REGISTER_PEER');
addLog(
'Provisioning wait completed. Replug Ethernet now, wait a few seconds, then continue to verify WireGuard and register peer.',
'Tempo de provisionamento concluído. Desligue e volte a ligar o Ethernet, aguarde alguns segundos e continue para verificar o WireGuard e registar o peer.',
'warning',
);
@@ -507,7 +519,7 @@ export function ProvisioningWizard() {
async function detectRouter() {
if (controlsLocked) {
addLog(
'Router detection is locked while provisioning is active.',
'A deteção do router está bloqueada enquanto o provisionamento está ativo.',
'warning',
);
return;
@@ -515,7 +527,7 @@ export function ProvisioningWizard() {
if (!selectedIp.trim()) {
addLog(
'Router detection blocked: no IP selected',
'Deteção do router bloqueada: nenhum IP selecionado',
'warning',
);
return;
@@ -526,7 +538,7 @@ export function ProvisioningWizard() {
setRouterInfo('');
addLog(
`Starting safe router detection at ${selectedIp}`,
`A iniciar deteção segura do router em ${selectedIp}`,
);
try {
@@ -535,7 +547,7 @@ export function ProvisioningWizard() {
});
addLog(
`Router responded at ${selectedIp}`,
`Router respondeu em ${selectedIp}`,
'success',
);
@@ -558,7 +570,7 @@ export function ProvisioningWizard() {
);
addLog(
`SSH key authentication succeeded at ${selectedIp}`,
`Autenticação SSH por chave concluída com sucesso em ${selectedIp}`,
'success',
);
@@ -574,7 +586,7 @@ export function ProvisioningWizard() {
setStatus('stale_host_key');
addLog(
`Stale SSH host key detected for ${selectedIp}`,
`Detetada chave SSH antiga para ${selectedIp}`,
'warning',
);
@@ -587,7 +599,7 @@ export function ProvisioningWizard() {
)
) {
addLog(
'Router requires password authentication. Attempting automatic inspection...',
'O router requer autenticação por palavra-passe. A tentar inspeção automática...',
'warning',
);
@@ -614,7 +626,7 @@ export function ProvisioningWizard() {
);
addLog(
`Password SSH inspection succeeded at ${selectedIp}`,
`Inspeção SSH por palavra-passe concluída com sucesso em ${selectedIp}`,
'success',
);
@@ -623,7 +635,7 @@ export function ProvisioningWizard() {
setStatus('auth_required');
addLog(
`Password authentication failed: ${String(
`Falha na autenticação por palavra-passe: ${String(
passwordError,
)}`,
'error',
@@ -641,7 +653,7 @@ export function ProvisioningWizard() {
setStatus('failed');
addLog(
`Router detection failed: ${message}`,
`Falha na deteção do router: ${message}`,
'error',
);
}
@@ -650,7 +662,7 @@ export function ProvisioningWizard() {
async function fixKnownHost() {
if (controlsLocked) {
addLog(
'Known host cleanup is locked while provisioning is active.',
'A limpeza de known_hosts está bloqueada enquanto o provisionamento está ativo.',
'warning',
);
return;
@@ -658,7 +670,7 @@ export function ProvisioningWizard() {
if (!selectedIp.trim()) {
addLog(
'Cannot remove known_hosts entry: no IP selected',
'Não foi possível remover a entrada known_hosts: nenhum IP selecionado',
'warning',
);
return;
@@ -666,7 +678,7 @@ export function ProvisioningWizard() {
try {
addLog(
`Removing known_hosts entry for ${selectedIp}`,
`A remover entrada known_hosts para ${selectedIp}`,
);
const result = await invoke<string>(
@@ -703,7 +715,7 @@ export function ProvisioningWizard() {
if (activeStep === 'UPLOAD_FIRMWARE') {
try {
addLog(
`Uploading firmware to ${selectedIp}:/tmp/firmware.bin`,
`A enviar firmware para ${selectedIp}:/tmp/firmware.bin`,
);
const result = await invoke<string>(
@@ -728,7 +740,7 @@ export function ProvisioningWizard() {
return;
} catch (error) {
addLog(
`Firmware upload failed: ${String(error)}`,
`Falha no envio do firmware: ${String(error)}`,
'error',
);
@@ -750,13 +762,13 @@ export function ProvisioningWizard() {
setStatus('reconnecting');
addLog(
`Checking router SSH after flash at ${selectedIp}`,
`A verificar SSH do router após flash em ${selectedIp}`,
'warning',
);
for (let attempt = 1; attempt <= 30; attempt += 1) {
try {
addLog(`Reconnect attempt ${attempt}/30`);
addLog(`Tentativa de reconexão ${attempt}/30`);
const info = await invoke<string>(
'check_router_after_flash',
@@ -769,7 +781,7 @@ export function ProvisioningWizard() {
setRouterInfo(info);
addLog(
`Router SSH reconnected after flash at ${selectedIp}`,
`SSH do router reconectado após flash em ${selectedIp}`,
'success',
);
@@ -787,7 +799,7 @@ export function ProvisioningWizard() {
return;
} catch (error) {
addLog(
`Reconnect attempt ${attempt}/30 failed: ${String(error)}`,
`Tentativa de reconexão ${attempt}/30 falhou: ${String(error)}`,
'warning',
);
@@ -801,7 +813,7 @@ export function ProvisioningWizard() {
setIsReconnecting(false);
addLog(
'Router reconnect failed after 30 attempts. Replug Ethernet and retry.',
'Falha na reconexão do router após 30 tentativas. Volte a ligar o Ethernet e tente novamente.',
'error',
);
@@ -819,7 +831,7 @@ export function ProvisioningWizard() {
try {
addLog(
`Running provisioning script on ${selectedIp}`,
`A executar script de provisionamento em ${selectedIp}`,
'warning',
);
@@ -847,7 +859,7 @@ export function ProvisioningWizard() {
if (expectedProvisionDisconnect) {
addLog(
'Provisioning script reached network/service restart and SSH disconnected as expected.',
'O script de provisionamento atingiu o reinício de rede/serviços e o SSH desligou-se como esperado.',
'warning',
);
@@ -856,7 +868,7 @@ export function ProvisioningWizard() {
setProvisionOverlayOpen(false);
addLog(
`Provisioning script failed: ${message}`,
`Falha no script de provisionamento: ${message}`,
'error',
);
@@ -879,7 +891,7 @@ export function ProvisioningWizard() {
if (activeStep === 'REGISTER_PEER') {
try {
addLog(
`Capturing router WireGuard public key from ${routerPresets[1].ip}`,
`A capturar chave pública WireGuard do router em ${routerPresets[1].ip}`,
'warning',
);
@@ -892,7 +904,7 @@ export function ProvisioningWizard() {
);
addLog(
`Captured router public key ${publicKey.slice(0, 12)}...`,
`Chave pública do router capturada ${publicKey.slice(0, 12)}...`,
'success',
);
@@ -902,7 +914,7 @@ export function ProvisioningWizard() {
});
addLog(
`Registered VPS peer ${routerEnv.wgIp}`,
`Peer VPS ${routerEnv.wgIp} registado`,
'success',
);
@@ -919,7 +931,7 @@ export function ProvisioningWizard() {
return;
} catch (error) {
addLog(
`Peer registration failed: ${String(error)}`,
`Falha no registo do peer: ${String(error)}`,
'error',
);
@@ -928,7 +940,7 @@ export function ProvisioningWizard() {
}
addLog(
`Step ${activeStep} is not wired yet.`,
`O passo ${activeStep} ainda não está ligado.`,
'warning',
);
}
@@ -940,7 +952,7 @@ export function ProvisioningWizard() {
!routerEnv.wgIp.trim()
) {
addLog(
'router.env is missing router ID, hostname, or WireGuard IP.',
'router.env não contém o ID do router, hostname ou IP WireGuard.',
'error',
);
return;
@@ -950,7 +962,7 @@ export function ProvisioningWizard() {
const envContent = buildRouterEnv(routerEnv);
addLog(
`Uploading router.env and provision.sh to ${selectedIp}`,
`A enviar router.env e provision.sh para ${selectedIp}`,
'warning',
);
@@ -977,7 +989,7 @@ export function ProvisioningWizard() {
setActiveStep('RUN_PROVISIONING');
} catch (error) {
addLog(
`Provisioning bundle upload failed: ${String(error)}`,
`Falha no envio do pacote de provisionamento: ${String(error)}`,
'error',
);
}
@@ -991,7 +1003,7 @@ export function ProvisioningWizard() {
flashCompletionHandledRef.current = false;
addLog(
'Starting firmware flash with sysupgrade -n /tmp/firmware.bin',
'A iniciar gravação do firmware com sysupgrade -n /tmp/firmware.bin',
'warning',
);
@@ -1005,7 +1017,7 @@ export function ProvisioningWizard() {
);
addLog(
'Flash command submitted. SSH session may disconnect; entering protected wait window.',
'Comando de flash submetido. A sessão SSH pode desligar; a entrar na janela de espera protegida.',
'success',
);
} catch (error) {
@@ -1023,14 +1035,14 @@ export function ProvisioningWizard() {
loweredMessage.includes('sysupgrade')
) {
addLog(
'Router accepted sysupgrade and disconnected as expected during flash.',
'O router aceitou o sysupgrade e desligou-se como esperado durante o flash.',
'warning',
);
} else {
setStatus('failed');
addLog(
`Flash command failed before reboot: ${message}`,
`Falha no comando de flash antes do reinício: ${message}`,
'error',
);
@@ -1045,7 +1057,7 @@ export function ProvisioningWizard() {
async function prepareRouterEnv() {
try {
addLog(
'Requesting next available WireGuard IP from backend...',
'A pedir ao backend o próximo IP WireGuard disponível...',
);
const response = await vpnApi.availableIp();
@@ -1063,14 +1075,14 @@ export function ProvisioningWizard() {
}));
addLog(
`Reserved next available WireGuard IP candidate: ${vpnIp}`,
`Candidato a próximo IP WireGuard disponível reservado: ${vpnIp}`,
'success',
);
setEnvModalOpen(true);
} catch (error) {
addLog(
`Failed to get available WireGuard IP: ${String(error)}`,
`Falha ao obter IP WireGuard disponível: ${String(error)}`,
'error',
);
}
@@ -1081,12 +1093,11 @@ export function ProvisioningWizard() {
<div className="mb-5 flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-white">
Provisioning
Provisionamento
</h1>
<p className="mt-1 text-slate-400">
Guided router provisioning from
detection to VPS peer registration.
Provisionamento guiado do router, desde a deteção até ao registo do peer na VPS.
</p>
</div>
@@ -1102,7 +1113,7 @@ export function ProvisioningWizard() {
isActionRunning
}
>
Stop Provisioning
Parar Provisionamento
</Button>
)}
@@ -1124,12 +1135,11 @@ export function ProvisioningWizard() {
<div>
<h2 className="text-xl font-semibold text-white">
Router Connection
Ligação ao Router
</h2>
<p className="text-sm text-slate-400">
Select target and validate SSH
before provisioning.
Selecione o alvo e valide o SSH antes do provisionamento.
</p>
</div>
</div>
@@ -1147,10 +1157,10 @@ export function ProvisioningWizard() {
}`}
>
<p className="font-semibold text-white">
Full Provisioning
Provisionamento Completo
</p>
<p className="mt-1 text-xs text-slate-500">
Flash firmware, reconnect, then provision.
Gravar firmware, reconectar e depois provisionar.
</p>
</button>
@@ -1166,10 +1176,10 @@ export function ProvisioningWizard() {
}`}
>
<p className="font-semibold text-white">
Provision Only
Apenas Provisionar
</p>
<p className="mt-1 text-xs text-slate-500">
Router is already flashed and stable.
O router tem firmware gravado e está estável.
</p>
</button>
</div>
@@ -1213,9 +1223,9 @@ export function ProvisioningWizard() {
</p>
<p className="mt-3 text-xs text-slate-500">
Password:{' '}
Palavra-passe:{' '}
<span className="font-mono text-slate-300">
{preset.password || 'empty'}
{preset.password || 'vazia'}
</span>
</p>
</button>
@@ -1240,12 +1250,11 @@ export function ProvisioningWizard() {
<div>
<p className="text-sm font-semibold text-white">
Custom Router Access
Acesso Personalizado ao Router
</p>
<p className="text-xs text-slate-500">
Use this for non-standard router IPs
or passwords.
Use para IPs de router ou palavras-passe não padrão.
</p>
</div>
</div>
@@ -1253,7 +1262,7 @@ export function ProvisioningWizard() {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Router IP
IP do Router
</label>
<input
@@ -1269,14 +1278,14 @@ export function ProvisioningWizard() {
onFocus={() => {
setSelectedIp(customIp.trim());
}}
placeholder="Example: 192.168.8.1"
placeholder="Exemplo: 192.168.8.1"
className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 font-mono text-sm text-white outline-none transition focus:border-blue-500/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
SSH Password
Palavra-passe SSH
</label>
<input
@@ -1295,7 +1304,7 @@ export function ProvisioningWizard() {
);
}
}}
placeholder="Leave empty if no password"
placeholder="Vazio se não existir"
className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-2 font-mono text-sm text-white outline-none transition focus:border-blue-500/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
@@ -1311,7 +1320,7 @@ export function ProvisioningWizard() {
<div className="flex-1">
<p className="font-semibold text-white">
Detection Target
Alvo de Deteção
</p>
<p className="mt-1 font-mono text-sm text-slate-400">
@@ -1336,7 +1345,7 @@ export function ProvisioningWizard() {
}
>
<Search size={16} />
Detect Router
Detetar Router
</Button>
<Button
@@ -1349,7 +1358,7 @@ export function ProvisioningWizard() {
}
>
<ShieldAlert size={16} />
Fix Known Host
Corrigir Known Host
</Button>
</div>
</div>
@@ -1364,11 +1373,11 @@ export function ProvisioningWizard() {
<div>
<h2 className="text-lg font-semibold text-white">
Provisioning Flow
Fluxo de Provisionamento
</h2>
<p className="text-sm text-slate-400">
Full router onboarding sequence.
Sequência completa de preparação do router.
</p>
</div>
</div>
@@ -1400,10 +1409,10 @@ export function ProvisioningWizard() {
>
<Rocket size={16} />
{isActionRunning
? 'Working...'
? 'A trabalhar...'
: isReconnecting
? 'Reconnecting...'
: 'Continue Provisioning'}
? 'A reconectar...'
: 'Continuar Provisionamento'}
</Button>
</div>
</Card>
@@ -1418,11 +1427,11 @@ export function ProvisioningWizard() {
<div>
<h2 className="text-lg font-semibold text-white">
Router Info
Informação do Router
</h2>
<p className="text-sm text-slate-400">
ubus system board output.
Saída de ubus system board.
</p>
</div>
</div>
@@ -1430,7 +1439,7 @@ export function ProvisioningWizard() {
<pre
className={`min-h-0 flex-1 overflow-auto whitespace-pre-wrap rounded-2xl border border-white/10 bg-slate-950 p-4 font-mono text-xs text-slate-300 ${scrollClass}`}
>
{routerInfo || 'No router information captured yet.'}
{routerInfo || 'Ainda não foi capturada informação do router.'}
</pre>
</Card>
</div>
@@ -1438,13 +1447,13 @@ export function ProvisioningWizard() {
<Card className="mt-4 flex h-52 flex-col overflow-hidden">
<h3 className="mb-2 text-sm font-semibold text-white">
Technician Log
Registo Técnico
</h3>
<pre
className={`min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words rounded-xl border border-white/10 bg-slate-950/70 p-3 font-mono text-sm text-slate-300 ${scrollClass}`}
>
{log.join('\n') || 'No provisioning activity yet.'}
{log.join('\n') || 'Ainda não há atividade de provisionamento.'}
</pre>
</Card>
@@ -1541,18 +1550,18 @@ function RouterEnvModal({
<div>
<h2 className="text-2xl font-bold text-white">
Router Environment
Ambiente do Router
</h2>
<p className="text-sm text-slate-400">
Values used to generate router.env before upload.
Valores usados para gerar router.env antes do envio.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<EnvInput
label="Router ID"
label="ID do Router"
value={routerEnv.routerId}
onChange={(value) => updateField('routerId', value)}
placeholder="203"
@@ -1566,14 +1575,14 @@ function RouterEnvModal({
/>
<EnvInput
label="WireGuard IP"
label="IP WireGuard"
value={routerEnv.wgIp}
onChange={(value) => updateField('wgIp', value)}
placeholder="198.19.1.203"
/>
<EnvInput
label="Root Password"
label="Palavra-passe Root"
value={routerEnv.rootPassword}
onChange={(value) => updateField('rootPassword', value)}
placeholder="litoralr"
@@ -1583,7 +1592,7 @@ function RouterEnvModal({
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Static router.env values
Valores estáticos de router.env
</p>
<div className="mt-3 grid grid-cols-2 gap-3 font-mono text-xs text-slate-300">
@@ -1602,12 +1611,12 @@ function RouterEnvModal({
<div className="mt-6 flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={onCancel}>
Cancel
Cancelar
</Button>
<Button type="button" onClick={onUpload}>
<FileUp size={16} />
Upload Bundle
Enviar Pacote
</Button>
</div>
</div>
@@ -1664,33 +1673,33 @@ function ConfirmFlashModal({
<div>
<h2 className="text-2xl font-bold text-white">
Confirm Firmware Flash
Confirmar Gravação do Firmware
</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
This will run{' '}
Isto irá executar{' '}
<span className="font-mono text-red-200">
sysupgrade -n /tmp/firmware.bin
</span>{' '}
on router{' '}
no router{' '}
<span className="font-mono text-red-200">
{selectedIp}
</span>
. The SSH session will drop and the router may be unreachable for several minutes.
. A sessão SSH será interrompida e o router poderá ficar inacessível durante vários minutos.
</p>
<div className="mt-5 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-100">
Do not unplug router power. Do not close this application.
Não desligue a alimentação do router. Não feche esta aplicação.
</div>
<div className="mt-6 flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={onCancel}>
Cancel
Cancelar
</Button>
<Button type="button" onClick={onConfirm} className="bg-red-500 hover:bg-red-400">
<Cpu size={16} />
Flash Router
Gravar Router
</Button>
</div>
</div>
@@ -1717,17 +1726,17 @@ function FlashOverlay({
<div className="flex-1">
<h2 className="text-3xl font-bold text-white">
Flashing router firmware
A gravar firmware do router
</h2>
<p className="mt-3 leading-7 text-slate-300">
The router is applying firmware and rebooting. The SSH session will be unavailable during this window.
O router está a aplicar o firmware e a reiniciar. A sessão SSH ficará indisponível temporariamente.
</p>
<div className="mt-6 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3 flex items-center justify-between text-sm">
<span className="text-slate-400">
Protected wait window
Janela de espera protegida
</span>
<span className="font-mono text-purple-200">
@@ -1744,7 +1753,7 @@ function FlashOverlay({
</div>
<div className="mt-6 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm leading-6 text-red-100">
Do not unplug power. Do not close this app. Wait for this screen to finish before touching the Ethernet cable.
Não desligue a alimentação. Não feche esta aplicação. Aguarde até este ecrã terminar antes de mexer no cabo Ethernet.
</div>
</div>
</div>
@@ -1770,18 +1779,18 @@ function ProvisionOverlay({
<div className="flex-1">
<h2 className="text-3xl font-bold text-white">
Applying router provisioning
A aplicar provisionamento do router
</h2>
<p className="mt-3 leading-7 text-slate-300">
The router is changing LAN, firewall, WireGuard, LuCI,
and root password settings. SSH or Ethernet may drop during this step.
O router está a alterar definições de LAN, firewall, WireGuard, LuCI
e palavra-passe root. O SSH ou Ethernet poderá cair durante este passo.
</p>
<div className="mt-6 rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3 flex items-center justify-between text-sm">
<span className="text-slate-400">
Protected provisioning window
Janela de provisionamento protegida
</span>
<span className="font-mono text-blue-200">
@@ -1798,8 +1807,8 @@ function ProvisionOverlay({
</div>
<div className="mt-6 rounded-2xl border border-yellow-500/20 bg-yellow-500/10 p-4 text-sm leading-6 text-yellow-100">
Do not unplug power. When this timer finishes, unplug and replug Ethernet,
then continue to verify WireGuard and register the VPS peer.
Não desligue a alimentação. Quando este temporizador terminar, desligue e volte a ligar o cabo Ethernet,
depois continue para verificar o WireGuard e registar o peer VPS.
</div>
</div>
</div>
@@ -1831,19 +1840,19 @@ function StopProvisioningModal({
<div className="flex-1">
<h2 className="text-2xl font-bold text-white">
Stop Provisioning?
Parar Provisionamento?
</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Stopping in the middle of provisioning can leave the router in a partial or broken state. Only continue if you know the current router state is safe.
Parar a meio do provisionamento pode deixar o router num estado parcial ou avariado. Continue apenas se souber que o estado atual do router é seguro.
</p>
<p className="mt-5 text-sm text-slate-400">
Type{' '}
Escreva{' '}
<span className="font-mono text-red-200">
{stopPhrase}
</span>{' '}
to confirm.
para confirmar.
</p>
<input
@@ -1854,7 +1863,7 @@ function StopProvisioningModal({
<div className="mt-6 flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={onCancel}>
Cancel
Cancelar
</Button>
<Button
@@ -1863,7 +1872,7 @@ function StopProvisioningModal({
disabled={!canConfirm}
onClick={onConfirm}
>
Stop Provisioning
Parar Provisionamento
</Button>
</div>
</div>
@@ -1946,11 +1955,11 @@ function SetupCompleteModal({
<div className="flex-1">
<h2 className="text-2xl font-bold text-white">
Router setup completed
Configuração do router concluída
</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
Router provisioning finished successfully and the VPS WireGuard peer was registered.
O provisionamento do router terminou com sucesso e o peer WireGuard da VPS foi registado.
</p>
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.03] p-4 font-mono text-sm text-slate-300">
@@ -1961,7 +1970,7 @@ function SetupCompleteModal({
<div className="mt-6 flex justify-end">
<Button type="button" onClick={onClose}>
Done
Concluir
</Button>
</div>
</div>
+28 -28
View File
@@ -36,7 +36,7 @@ export function BackendSettings() {
try {
return new URL(settings.backendUrl).host;
} catch {
return 'Invalid backend URL';
return 'URL backend inválido';
}
}, [settings.backendUrl]);
@@ -54,17 +54,17 @@ export function BackendSettings() {
<div className="mb-6 flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-white">
Workstation
Posto de Trabalho
</h1>
<p className="mt-1 text-slate-400">
Local technician console configuration
for router provisioning.
Configuração local da consola técnica
para provisionamento de routers.
</p>
</div>
<Badge tone={saved ? 'green' : 'blue'}>
{saved ? 'Saved' : 'Local Config'}
{saved ? 'Guardado' : 'Configuração Local'}
</Badge>
</div>
@@ -77,12 +77,12 @@ export function BackendSettings() {
<div>
<h2 className="text-xl font-semibold text-white">
Backend Connectivity
Ligação ao Backend
</h2>
<p className="text-sm text-slate-400">
API endpoint used for VPN IP
assignment and peer registration.
Endpoint API usado para atribuição
de IP VPN e registo de peers.
</p>
</div>
</div>
@@ -90,7 +90,7 @@ export function BackendSettings() {
<div className="space-y-5">
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-wide text-slate-500">
Backend URL
URL do Backend
</label>
<div className="relative">
@@ -115,7 +115,7 @@ export function BackendSettings() {
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-wide text-slate-500">
API Key
Chave API
</label>
<div className="relative">
@@ -143,7 +143,7 @@ export function BackendSettings() {
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-white">
Connected Target
Alvo Ligado
</p>
<p className="mt-1 font-mono text-sm text-slate-400">
@@ -152,7 +152,7 @@ export function BackendSettings() {
</div>
<Badge tone="green">
Ready
Pronto
</Badge>
</div>
</div>
@@ -169,7 +169,7 @@ export function BackendSettings() {
/>
<span className="transition-colors group-hover:text-blue-200">
Save Workstation Config
Guardar Configuração
</span>
</button>
</div>
@@ -184,38 +184,38 @@ export function BackendSettings() {
<div>
<h2 className="text-xl font-semibold text-white">
Provisioning Baseline
Base de Provisionamento
</h2>
<p className="text-sm text-slate-400">
Read-only production values.
Valores de produção apenas leitura.
</p>
</div>
</div>
<div className="grid gap-3">
<InfoRow
label="Overlay Route"
label="Rota Overlay"
value={DEFAULT_OVERLAY_ROUTE}
/>
<InfoRow
label="Router LAN IP"
label="IP LAN Router"
value={DEFAULT_ROUTER_IP}
/>
<InfoRow
label="Controller IP"
label="IP Controlador"
value={DEFAULT_CONTROLLER_IP}
/>
<InfoRow
label="PLC IP"
label="IP PLC"
value={DEFAULT_PLC_IP}
/>
<InfoRow
label="Firmware Target"
label="Firmware Alvo"
value={DEFAULT_FIRMWARE}
/>
</div>
@@ -229,16 +229,16 @@ export function BackendSettings() {
<div>
<h2 className="text-xl font-semibold text-white">
Production Profile
Perfil de Produção
</h2>
<p className="mt-3 text-sm leading-6 text-slate-400">
OpenWrt 23.05 baseline,
ZBT-WE826 16M target,
fw4/nftables firewall,
LuCI over WireGuard, stable
LAN topology and automated VPS
peer registration.
Base OpenWrt 23.05,
alvo ZBT-WE826 16M,
firewall fw4/nftables,
LuCI sobre WireGuard, topologia
LAN estável e registo automático
de peers VPS.
</p>
<div className="mt-5 flex flex-wrap gap-2">
@@ -262,7 +262,7 @@ export function BackendSettings() {
<div className="rounded-2xl border border-green-500/20 bg-green-500/10 p-4 text-sm text-green-300">
<div className="flex items-center gap-2">
<CheckCircle2 size={16} />
Workstation configuration saved.
Configuração do posto de trabalho guardada.
</div>
</div>
)}
-54
View File
@@ -1,54 +0,0 @@
import { useState } from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { vpnApi } from '@/services/vpnApi';
export function IpManagementPanel() {
const [ip, setIp] = useState('');
async function handleReserveIp() {
try {
const response =
await vpnApi.availableIp();
setIp(response.vpnIp);
} catch (error) {
console.error(
'Failed to reserve IP:',
error,
);
}
}
return (
<Card className="h-full">
<h3 className="mb-3 font-semibold text-white">
IP Management
</h3>
<p className="mb-4 text-sm text-slate-400">
Overlay route: 198.19.0.0/16
</p>
<div className="flex items-center gap-3">
<Button onClick={handleReserveIp}>
Reserve Next Available IP
</Button>
</div>
{ip && (
<div className="mt-4 rounded-xl border border-green-500/20 bg-green-500/10 p-4">
<p className="text-sm text-green-300">
Assigned candidate:
</p>
<p className="mt-1 font-mono text-lg font-semibold text-white">
{ip}
</p>
</div>
)}
</Card>
);
}
-87
View File
@@ -1,87 +0,0 @@
import { Card } from '@/components/ui/Card';
const peers = [
[
'198.19.254.1',
'Q2o9...h8jK',
'2m ago',
'1.2 MB',
],
[
'198.19.254.2',
'P9xL...a7Bc',
'3m ago',
'532 KB',
],
[
'198.19.254.3',
'J4kM...z9Xp',
'1m ago',
'2.1 MB',
],
];
export function VpnPeersTable() {
return (
<Card className="h-full">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-white">
VPN Peers
</h3>
<span className="rounded-full bg-green-500/10 px-3 py-1 text-xs text-green-300">
3 Active
</span>
</div>
<div className="mt-4 overflow-hidden rounded-xl border border-white/10">
<table className="w-full text-sm">
<thead className="bg-slate-900/80 text-left text-slate-400">
<tr>
<th className="px-4 py-3">
VPN IP
</th>
<th className="px-4 py-3">
Public Key
</th>
<th className="px-4 py-3">
Last Handshake
</th>
<th className="px-4 py-3 text-right">
Transfer
</th>
</tr>
</thead>
<tbody>
{peers.map((peer) => (
<tr
key={peer[0]}
className="border-t border-white/10 hover:bg-white/[0.03]"
>
<td className="px-4 py-3 text-green-300">
{peer[0]}
</td>
<td className="px-4 py-3 font-mono text-slate-300">
{peer[1]}
</td>
<td className="px-4 py-3 text-slate-400">
{peer[2]}
</td>
<td className="px-4 py-3 text-right text-slate-300">
{peer[3]}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { apiRequest } from '@/services/apiClient';
type LoginResponse = {
authenticated: boolean;
};
export const loginApi = {
login(username: string, password: string) {
return apiRequest<LoginResponse>('/api/login', {
method: 'POST',
body: JSON.stringify({
username,
password,
}),
});
},
};
+10
View File
@@ -21,3 +21,13 @@ body,
body {
overflow: hidden;
}
input[type='password']::-ms-reveal,
input[type='password']::-ms-clear {
display: none;
}
input[type='password']::-webkit-credentials-auto-fill-button,
input[type='password']::-webkit-password-reveal-button {
display: none !important;
}
+1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"ignoreDeprecations": "6.0",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [