Fixed responsiveness

This commit is contained in:
litoral05
2026-05-12 11:59:54 +01:00
parent 7c04ea5b2e
commit 17364e23d3
12 changed files with 1240 additions and 1166 deletions
+32 -32
View File
@@ -1,35 +1,35 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Litoral Regas VPN Orchestrator", "productName": "Litoral Regas VPN Orchestrator",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.litoralregas.vpnorchestrator", "identifier": "com.litoralregas.vpnorchestrator",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "npm run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "Litoral Regas VPN Orchestrator", "title": "Litoral Regas VPN Orchestrator",
"width": 1440, "width": 1440,
"height": 980, "height": 980,
"minWidth": 1100, "minWidth": 900,
"minHeight": 720, "minHeight": 650,
"resizable": false, "resizable": true,
"maximized": true "maximized": true
} }
], ],
"security": { "security": {
"csp": null "csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/icon.ico"
]
} }
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/icon.ico"
]
}
} }
+59 -59
View File
@@ -101,18 +101,17 @@ export function DashboardRoute() {
udp2rawHealthy; udp2rawHealthy;
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex min-h-full flex-col">
<TopBar healthy={dashboardReady} /> <TopBar healthy={dashboardReady} />
{error && ( {error && (
<div className="mb-4 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-300"> <div className="mb-4 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-300">
Erro no backend do painel:{' '} Erro no backend do painel: {error}
{error}
</div> </div>
)} )}
<div className="grid grid-cols-12 gap-4"> <div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-12">
<div className="col-span-3"> <div className="2xl:col-span-3">
<MetricCard <MetricCard
title="VPN" title="VPN"
value={vpnHealthy ? 'Ligada' : 'Offline'} value={vpnHealthy ? 'Ligada' : 'Offline'}
@@ -126,7 +125,7 @@ export function DashboardRoute() {
/> />
</div> </div>
<div className="col-span-3"> <div className="2xl:col-span-3">
<MetricCard <MetricCard
title="UDP2RAW" title="UDP2RAW"
value={udp2rawHealthy ? 'Ativo' : 'Offline'} value={udp2rawHealthy ? 'Ativo' : 'Offline'}
@@ -136,7 +135,7 @@ export function DashboardRoute() {
/> />
</div> </div>
<div className="col-span-2"> <div className="2xl:col-span-2">
<MetricCard <MetricCard
title="Pool IP" title="Pool IP"
value={`${ipPoolPercent}%`} value={`${ipPoolPercent}%`}
@@ -146,7 +145,7 @@ export function DashboardRoute() {
/> />
</div> </div>
<div className="col-span-2"> <div className="2xl:col-span-2">
<MetricCard <MetricCard
title="Disco" title="Disco"
value={`${health?.diskUsagePercent ?? 0}%`} value={`${health?.diskUsagePercent ?? 0}%`}
@@ -160,7 +159,7 @@ export function DashboardRoute() {
/> />
</div> </div>
<div className="col-span-2"> <div className="lg:col-span-2 2xl:col-span-2">
<MetricCard <MetricCard
title="Backend" title="Backend"
value={backendHealthy ? 'Saudável' : 'Offline'} value={backendHealthy ? 'Saudável' : 'Offline'}
@@ -171,8 +170,8 @@ export function DashboardRoute() {
</div> </div>
</div> </div>
<div className="mt-4 grid min-h-0 flex-1 grid-cols-12 gap-4 overflow-hidden pb-4"> <div className="mt-4 grid gap-4 2xl:grid-cols-12">
<div className="col-span-7 min-h-0"> <div className="min-h-[360px] 2xl:col-span-7">
<NetworkTrafficChart <NetworkTrafficChart
title="Tráfego WireGuard" title="Tráfego WireGuard"
subtitle="Débito RX/TX wg0 em direto" subtitle="Débito RX/TX wg0 em direto"
@@ -180,69 +179,70 @@ export function DashboardRoute() {
/> />
</div> </div>
<div className="col-span-3 grid min-h-0 grid-rows-2 gap-4"> <div className="min-h-[360px] 2xl:col-span-5">
<NetworkTrafficChart <NetworkTrafficChart
title="Tráfego UDP2RAW" title="Tráfego UDP2RAW"
subtitle="Faketcp na porta 444" subtitle="Faketcp na porta 444"
mode="udp2raw" mode="udp2raw"
compact
/> />
</div>
</div>
<Card className="overflow-hidden"> <div className="mt-4 grid gap-4 pb-4 xl:grid-cols-12">
<div className="flex h-full flex-col justify-between"> <Card className="min-h-[260px] xl:col-span-5">
<div> <div className="flex h-full flex-col justify-between">
<div className="mb-4 flex items-center gap-3"> <div>
<div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300"> <div className="mb-4 flex items-center gap-3">
<Clock size={20} /> <div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
</div> <Clock size={20} />
<div>
<h3 className="font-semibold text-white">
Estado da VPS
</h3>
<p className="text-xs text-slate-500">
Uptime e carga do sistema
</p>
</div>
</div> </div>
<p className="text-2xl font-black leading-tight text-white"> <div className="min-w-0">
{health?.systemUptime ?? 'Desconhecido'} <h3 className="font-semibold text-white">
</p> Estado da VPS
</h3>
<p className="mt-3 font-mono text-xs text-slate-400"> <p className="text-xs text-slate-500">
Load: {health?.loadAverage ?? '—'} Uptime e carga do sistema
</p> </p>
</div>
<p className="mt-2 font-mono text-xs text-slate-500">
IP: {health?.publicIp ?? '—'}
</p>
</div> </div>
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.03] p-3"> <p className="text-xl font-black leading-tight text-white 2xl:text-2xl">
<div className="mb-2 flex justify-between text-xs text-slate-500"> {health?.systemUptime ?? 'Desconhecido'}
<span>Memória</span> </p>
<span>{health?.memoryUsagePercent ?? 0}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-800"> <p className="mt-3 break-all font-mono text-xs text-slate-400">
<div Load: {health?.loadAverage ?? '—'}
className="h-full rounded-full bg-blue-400" </p>
style={{
width: `${Math.min( <p className="mt-2 break-all font-mono text-xs text-slate-500">
health?.memoryUsagePercent ?? 0, IP: {health?.publicIp ?? '—'}
100, </p>
)}%`, </div>
}}
/> <div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.03] p-3">
</div> <div className="mb-2 flex justify-between text-xs text-slate-500">
<span>Memória</span>
<span>{health?.memoryUsagePercent ?? 0}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-800">
<div
className="h-full rounded-full bg-blue-400"
style={{
width: `${Math.min(
health?.memoryUsagePercent ?? 0,
100,
)}%`,
}}
/>
</div> </div>
</div> </div>
</Card> </div>
</div> </Card>
<div className="col-span-2 min-h-0"> <div className="min-h-[340px] xl:col-span-7">
<IpPoolChart <IpPoolChart
used={usedCount} used={usedCount}
total={ipPoolTotal} total={ipPoolTotal}
+287 -279
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
Download, Download,
Search, Search,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -11,321 +11,329 @@ import { Badge } from '@/components/ui/Badge';
import { Select } from '@/components/ui/Select'; import { Select } from '@/components/ui/Select';
import { import {
clearActivityLogs, clearActivityLogs,
exportActivityLogs, exportActivityLogs,
getActivityLogs, getActivityLogs,
} from '@/services/activityLogService'; } from '@/services/activityLogService';
import type { import type {
ActivityLogEntry, ActivityLogEntry,
ActivityLogLevel, ActivityLogLevel,
ActivityLogSource, ActivityLogSource,
} from '@/types/activity'; } from '@/types/activity';
const levels: Array<'all' | ActivityLogLevel> = [ const levels: Array<'all' | ActivityLogLevel> = [
'all', 'all',
'info', 'info',
'success', 'success',
'warning', 'warning',
'error', 'error',
]; ];
const sources: Array<'all' | ActivityLogSource> = [ const sources: Array<'all' | ActivityLogSource> = [
'all', 'all',
'desktop', 'desktop',
'router', 'router',
'backend', 'backend',
'vps', 'vps',
]; ];
const levelLabels: Record<'all' | ActivityLogLevel, string> = { const levelLabels: Record<'all' | ActivityLogLevel, string> = {
all: 'Todos', all: 'Todos',
info: 'Info', info: 'Info',
success: 'Sucesso', success: 'Sucesso',
warning: 'Aviso', warning: 'Aviso',
error: 'Erro', error: 'Erro',
}; };
const sourceLabels: Record<'all' | ActivityLogSource, string> = { const sourceLabels: Record<'all' | ActivityLogSource, string> = {
all: 'Todas', all: 'Todas',
desktop: 'Desktop', desktop: 'Desktop',
router: 'Router', router: 'Router',
backend: 'Backend', backend: 'Backend',
vps: 'VPS', vps: 'VPS',
}; };
function levelTone(level: ActivityLogLevel) { const scrollClass =
if (level === 'success') return 'green'; '[scrollbar-width:thin] [scrollbar-color:rgba(59,130,246,0.45)_transparent] [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-blue-500/30 hover:[&::-webkit-scrollbar-thumb]:bg-blue-500/50';
if (level === 'warning') return 'purple';
if (level === 'error') return 'red';
return 'blue'; function levelTone(level: ActivityLogLevel) {
if (level === 'success') return 'green';
if (level === 'warning') return 'purple';
if (level === 'error') return 'red';
return 'blue';
} }
export function ActivityLogs() { export function ActivityLogs() {
const [logs, setLogs] = useState< const [logs, setLogs] = useState<
ActivityLogEntry[] ActivityLogEntry[]
>([]); >([]);
const [levelFilter, setLevelFilter] = const [levelFilter, setLevelFilter] =
useState<'all' | ActivityLogLevel>('all'); useState<'all' | ActivityLogLevel>('all');
const [sourceFilter, setSourceFilter] = const [sourceFilter, setSourceFilter] =
useState<'all' | ActivityLogSource>('all'); useState<'all' | ActivityLogSource>('all');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
function refreshLogs() { function refreshLogs() {
setLogs(getActivityLogs()); setLogs(getActivityLogs());
} }
useEffect(() => { useEffect(() => {
refreshLogs(); refreshLogs();
window.addEventListener( window.addEventListener(
'activity-log-added', 'activity-log-added',
refreshLogs, refreshLogs,
); );
return () => { return () => {
window.removeEventListener( window.removeEventListener(
'activity-log-added', 'activity-log-added',
refreshLogs, refreshLogs,
); );
}; };
}, []); }, []);
const filteredLogs = useMemo(() => { const filteredLogs = useMemo(() => {
const normalizedQuery = const normalizedQuery =
query.trim().toLowerCase(); query.trim().toLowerCase();
return logs.filter((log) => { return logs.filter((log) => {
const matchesLevel = const matchesLevel =
levelFilter === 'all' || levelFilter === 'all' ||
log.level === levelFilter; log.level === levelFilter;
const matchesSource = const matchesSource =
sourceFilter === 'all' || sourceFilter === 'all' ||
log.source === sourceFilter; log.source === sourceFilter;
const matchesQuery = const matchesQuery =
!normalizedQuery || !normalizedQuery ||
[ [
log.action, log.action,
log.message, log.message,
log.routerIp, log.routerIp,
log.vpnIp, log.vpnIp,
log.source, log.source,
log.level, log.level,
] ]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
.includes(normalizedQuery); .includes(normalizedQuery);
return ( return (
matchesLevel && matchesLevel &&
matchesSource && matchesSource &&
matchesQuery matchesQuery
); );
}); });
}, [logs, levelFilter, sourceFilter, query]); }, [logs, levelFilter, sourceFilter, query]);
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex min-h-full flex-col">
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex flex-wrap items-start justify-between gap-4 rounded-3xl border border-white/10 bg-white/[0.025] px-5 py-4">
<div> <div className="min-w-0">
<h2 className="text-3xl font-bold tracking-tight text-white"> <h2 className="text-3xl font-black tracking-tight text-white">
Registos de Atividade Registos de Atividade
</h2> </h2>
<p className="mt-1 text-slate-400"> <p className="mt-1 max-w-2xl text-sm leading-6 text-slate-400">
Histórico local de auditoria de Histórico local de auditoria de provisionamento para técnicos.
provisionamento para técnicos. </p>
</p> </div>
</div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
onClick={() => { onClick={exportActivityLogs}
exportActivityLogs(); >
}} <Download size={16} />
> Exportar JSON
<Download size={16} /> </Button>
Exportar JSON
</Button>
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
onClick={() => { onClick={() => {
clearActivityLogs(); clearActivityLogs();
refreshLogs(); refreshLogs();
}} }}
> >
<Trash2 size={16} /> <Trash2 size={16} />
Limpar Registos Limpar Registos
</Button> </Button>
</div> </div>
</div>
<Card className="relative z-30 mb-4 overflow-visible">
<div className="grid gap-4 xl:grid-cols-12">
<div className="xl:col-span-6">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Pesquisa
</label>
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-ink-950 px-3">
<Search
size={16}
className="shrink-0 text-slate-500"
/>
<input
value={query}
onChange={(event) =>
setQuery(event.target.value)
}
placeholder="Pesquisar ação, IP VPN, IP router, mensagem..."
className="w-full min-w-0 bg-transparent py-3 text-sm text-white outline-none placeholder:text-slate-600"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:col-span-6">
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Nível
</label>
<Select
value={levelFilter}
onChange={setLevelFilter}
options={levels.map((level) => ({
value: level,
label: levelLabels[level],
}))}
/>
</div> </div>
<Card className="relative z-30 mb-4 overflow-visible"> <div>
<div className="grid grid-cols-12 gap-4"> <label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
<div className="col-span-6"> Origem
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500"> </label>
Pesquisa
</label>
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-ink-950 px-3"> <Select
<Search value={sourceFilter}
size={16} onChange={setSourceFilter}
className="text-slate-500" options={sources.map((source) => ({
/> value: source,
label: sourceLabels[source],
<input }))}
value={query} />
onChange={(event) => </div>
setQuery(event.target.value) </div>
}
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>
</div>
<div className="col-span-3">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Nível
</label>
<Select
value={levelFilter}
onChange={setLevelFilter}
options={levels.map((level) => ({
value: 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">
Origem
</label>
<Select
value={sourceFilter}
onChange={setSourceFilter}
options={sources.map((source) => ({
value: source,
label: sourceLabels[source],
}))}
/>
</div>
</div>
</Card>
<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">
Eventos de Auditoria
</h3>
<span className="text-sm text-slate-500">
{filteredLogs.length} apresentados /{' '}
{logs.length} total
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl border border-white/10
[scrollbar-width:thin]
[scrollbar-color:rgba(59,130,246,0.45)_transparent]
[&::-webkit-scrollbar]:w-2
[&::-webkit-scrollbar-track]:bg-transparent
[&::-webkit-scrollbar-thumb]:rounded-full
[&::-webkit-scrollbar-thumb]:bg-blue-500/30
hover:[&::-webkit-scrollbar-thumb]:bg-blue-500/50">
<table className="w-full table-fixed text-sm">
<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">
Hora
</th>
<th className="w-[110px] px-4 py-3">
Nível
</th>
<th className="w-[120px] px-4 py-3">
Origem
</th>
<th className="w-[190px] px-4 py-3">
Ação
</th>
<th className="px-4 py-3">
Mensagem
</th>
<th className="w-[160px] px-4 py-3">
IP VPN
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{filteredLogs.map((log) => (
<tr
key={log.id}
className="bg-white/[0.01] hover:bg-white/[0.04]"
>
<td className="px-4 py-3 font-mono text-xs text-slate-400">
{new Date(
log.timestamp,
).toLocaleString()}
</td>
<td className="px-4 py-3">
<Badge tone={levelTone(log.level)}>
{levelLabels[log.level]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-300">
{sourceLabels[log.source]}
</td>
<td className="truncate px-4 py-3 font-medium text-white">
{log.action}
</td>
<td className="truncate px-4 py-3 text-slate-400">
{log.message}
</td>
<td className="px-4 py-3 font-mono text-slate-300">
{log.vpnIp ?? '—'}
</td>
</tr>
))}
{filteredLogs.length === 0 && (
<tr>
<td
colSpan={6}
className="px-4 py-10 text-center text-slate-500"
>
Nenhum registo de atividade encontrado.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div> </div>
); </Card>
<Card className="relative z-10 flex h-[520px] flex-col overflow-hidden">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h3 className="font-semibold text-white">
Eventos de Auditoria
</h3>
<span className="text-sm text-slate-500">
{filteredLogs.length} apresentados /{' '}
{logs.length} total
</span>
</div>
<div
className={`min-h-0 flex-1 overflow-auto rounded-xl border border-white/10 ${scrollClass}`}
>
<table className="min-w-[920px] w-full table-fixed text-sm">
<thead className="sticky top-0 z-10 bg-slate-950 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="w-[135px] px-4 py-3">
Hora
</th>
<th className="w-[100px] px-4 py-3">
Nível
</th>
<th className="w-[110px] px-4 py-3">
Origem
</th>
<th className="w-[160px] px-4 py-3">
Ação
</th>
<th className="px-4 py-3">
Mensagem
</th>
<th className="w-[135px] px-4 py-3">
Router
</th>
<th className="w-[130px] px-4 py-3">
IP VPN
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{filteredLogs.map((log) => (
<tr
key={log.id}
className="bg-white/[0.01] hover:bg-white/[0.04]"
>
<td className="px-4 py-3 font-mono text-xs text-slate-400">
{new Date(
log.timestamp,
).toLocaleTimeString()}
</td>
<td className="px-4 py-3">
<Badge tone={levelTone(log.level)}>
{levelLabels[log.level]}
</Badge>
</td>
<td className="px-4 py-3 text-slate-300">
{sourceLabels[log.source]}
</td>
<td className="truncate px-4 py-3 font-semibold text-white">
{log.action}
</td>
<td
title={log.message}
className="truncate px-4 py-3 text-slate-400"
>
{log.message}
</td>
<td className="px-4 py-3 font-mono text-slate-300">
{log.routerIp ?? '—'}
</td>
<td className="px-4 py-3 font-mono text-slate-300">
{log.vpnIp ?? '—'}
</td>
</tr>
))}
{filteredLogs.length === 0 && (
<tr>
<td
colSpan={7}
className="px-4 py-10 text-center text-slate-500"
>
Nenhum registo de atividade encontrado.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
);
} }
+32 -28
View File
@@ -38,26 +38,26 @@ export function IpPoolChart({
]; ];
return ( return (
<Card className="flex h-full flex-col"> <Card className="flex h-full min-h-[380px] flex-col overflow-hidden">
<div> <div className="shrink-0">
<h3 className="font-semibold text-white"> <h3 className="font-semibold text-white">
Uso da Pool IP Uso da Pool IP
</h3> </h3>
<p className="text-xs text-slate-500"> <p className="mt-1 text-xs text-slate-500">
Capacidade de atribuição WireGuard Capacidade de atribuição WireGuard
</p> </p>
</div> </div>
<div className="flex min-h-0 flex-1 flex-col justify-center"> <div className="flex min-h-0 flex-1 flex-col justify-center">
<div className="relative mx-auto h-56 w-56"> <div className="relative mx-auto h-40 w-40 sm:h-48 sm:w-48 xl:h-44 xl:w-44 2xl:h-56 2xl:w-56">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie <Pie
data={data} data={data}
dataKey="value" dataKey="value"
innerRadius={76} innerRadius="72%"
outerRadius={100} outerRadius="92%"
startAngle={90} startAngle={90}
endAngle={-270} endAngle={-270}
paddingAngle={2} paddingAngle={2}
@@ -77,7 +77,7 @@ export function IpPoolChart({
</ResponsiveContainer> </ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<p className="text-4xl font-bold text-white"> <p className="text-3xl font-black text-white 2xl:text-4xl">
{displayPercentage}% {displayPercentage}%
</p> </p>
@@ -87,31 +87,35 @@ export function IpPoolChart({
</div> </div>
</div> </div>
<div className="mt-8 space-y-4"> <div className="mt-5 grid gap-3 2xl:mt-8">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"> <div className="grid grid-cols-2 gap-3">
<p className="text-xs uppercase tracking-wide text-slate-500"> <div className="rounded-2xl border border-white/10 bg-white/[0.03] p-3 2xl:p-4">
IPs Usados <p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
</p> Usados
</p>
<p className="mt-1 text-2xl font-bold text-green-300"> <p className="mt-1 truncate text-xl font-black text-green-300 2xl:text-2xl">
{used} {used}
</p> </p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-3 2xl:p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Livres
</p>
<p className="mt-1 truncate text-xl font-black text-blue-300 2xl:text-2xl">
{available}
</p>
</div>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"> <div className="rounded-2xl border border-white/10 bg-white/[0.03] p-3 2xl:p-4">
<p className="text-xs uppercase tracking-wide text-slate-500"> <div className="mb-2 flex justify-between gap-3 text-xs text-slate-500">
IPs Disponíveis
</p>
<p className="mt-1 text-2xl font-bold text-blue-300">
{available}
</p>
</div>
<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>Capacidade</span> <span>Capacidade</span>
<span>{used} / {total}</span> <span className="truncate">
{used} / {total}
</span>
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-slate-800"> <div className="h-2 overflow-hidden rounded-full bg-slate-800">
+7 -10
View File
@@ -29,19 +29,16 @@ const toneStyles: Record<
iconText: 'text-blue-300', iconText: 'text-blue-300',
glow: 'from-blue-500/20', glow: 'from-blue-500/20',
}, },
green: { green: {
iconBg: 'bg-emerald-500/10', iconBg: 'bg-emerald-500/10',
iconText: 'text-emerald-300', iconText: 'text-emerald-300',
glow: 'from-emerald-500/20', glow: 'from-emerald-500/20',
}, },
purple: { purple: {
iconBg: 'bg-violet-500/10', iconBg: 'bg-violet-500/10',
iconText: 'text-violet-300', iconText: 'text-violet-300',
glow: 'from-violet-500/20', glow: 'from-violet-500/20',
}, },
red: { red: {
iconBg: 'bg-red-500/10', iconBg: 'bg-red-500/10',
iconText: 'text-red-300', iconText: 'text-red-300',
@@ -59,30 +56,30 @@ export function MetricCard({
const style = toneStyles[tone]; const style = toneStyles[tone];
return ( return (
<Card className="group relative h-full overflow-hidden border border-white/5 bg-[#07111f]/90 transition-all duration-300 hover:border-white/10 hover:bg-[#0a1728]"> <Card className="group relative h-full min-h-[118px] overflow-hidden border border-white/5 bg-[#07111f]/90 transition-all duration-300 hover:border-white/10 hover:bg-[#0a1728]">
<div <div
className={`absolute inset-x-0 top-0 h-px bg-gradient-to-r ${style.glow} via-white/40 to-transparent opacity-70`} className={`absolute inset-x-0 top-0 h-px bg-gradient-to-r ${style.glow} via-white/40 to-transparent opacity-70`}
/> />
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-white/[0.02] blur-3xl transition-all duration-500 group-hover:scale-125" /> <div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-white/[0.025] blur-3xl transition-all duration-500 group-hover:scale-125" />
<div className="relative flex items-start gap-4"> <div className="relative flex h-full items-start gap-4">
<div <div
className={`rounded-2xl ${style.iconBg} ${style.iconText} border border-white/5 p-3 shadow-lg backdrop-blur-xl`} className={`shrink-0 rounded-2xl ${style.iconBg} ${style.iconText} border border-white/5 p-3 shadow-lg backdrop-blur-xl`}
> >
{icon} {icon}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-400"> <p className="truncate text-sm font-medium text-slate-400">
{title} {title}
</p> </p>
<h3 className="mt-1 text-3xl font-black tracking-tight text-white"> <h3 className="mt-1 truncate text-2xl font-black tracking-tight text-white 2xl:text-3xl">
{value} {value}
</h3> </h3>
<p className="mt-2 truncate text-xs text-slate-500"> <p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">
{subtitle} {subtitle}
</p> </p>
</div> </div>
@@ -104,7 +104,7 @@ export function NetworkTrafficChart({
points[points.length - 1]; points[points.length - 1];
return ( return (
<Card className="relative h-full overflow-hidden"> <Card className="relative h-full min-h-[240px] overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-blue-400/30 to-transparent" /> <div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-blue-400/30 to-transparent" />
<div className="mb-4 flex items-start justify-between gap-4"> <div className="mb-4 flex items-start justify-between gap-4">
@@ -131,22 +131,22 @@ export function NetworkTrafficChart({
{compact && latestPoint && ( {compact && latestPoint && (
<div className="mb-3 grid grid-cols-2 gap-2"> <div className="mb-3 grid grid-cols-2 gap-2">
<div className="rounded-xl border border-white/10 bg-white/[0.025] p-3"> <div className="min-w-0 rounded-xl border border-white/10 bg-white/[0.025] p-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500"> <p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Entrada Entrada
</p> </p>
<p className="mt-1 font-mono text-sm font-bold text-green-300"> <p className="mt-1 truncate font-mono text-sm font-bold text-green-300">
{latestPoint.downloadMbps.toFixed(3)} Mbps {latestPoint.downloadMbps.toFixed(3)} Mbps
</p> </p>
</div> </div>
<div className="rounded-xl border border-white/10 bg-white/[0.025] p-3"> <div className="min-w-0 rounded-xl border border-white/10 bg-white/[0.025] p-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500"> <p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Saída Saída
</p> </p>
<p className="mt-1 font-mono text-sm font-bold text-blue-300"> <p className="mt-1 truncate font-mono text-sm font-bold text-blue-300">
{latestPoint.uploadMbps.toFixed(3)} Mbps {latestPoint.uploadMbps.toFixed(3)} Mbps
</p> </p>
</div> </div>
@@ -156,8 +156,8 @@ export function NetworkTrafficChart({
<div <div
className={ className={
compact compact
? 'h-[calc(100%-8.5rem)] min-h-[140px]' ? 'h-[calc(100%-8.5rem)] min-h-[130px]'
: 'h-[calc(100%-4.5rem)] min-h-[360px]' : 'h-[calc(100%-4.5rem)] min-h-[320px]'
} }
> >
<ResponsiveContainer <ResponsiveContainer
@@ -171,13 +171,13 @@ export function NetworkTrafficChart({
? { ? {
top: 8, top: 8,
right: 8, right: 8,
left: -28, left: -30,
bottom: 0, bottom: 0,
} }
: { : {
top: 8, top: 8,
right: 16, right: 16,
left: 0, left: -8,
bottom: 0, bottom: 0,
} }
} }
@@ -191,14 +191,14 @@ export function NetworkTrafficChart({
dataKey="time" dataKey="time"
stroke="#94a3b8" stroke="#94a3b8"
fontSize={compact ? 10 : 12} fontSize={compact ? 10 : 12}
minTickGap={compact ? 32 : 24} minTickGap={compact ? 36 : 24}
tick={compact ? false : undefined} tick={compact ? false : undefined}
/> />
<YAxis <YAxis
stroke="#94a3b8" stroke="#94a3b8"
fontSize={compact ? 10 : 12} fontSize={compact ? 10 : 12}
width={compact ? 42 : 60} width={compact ? 42 : 56}
tickFormatter={(value) => tickFormatter={(value) =>
`${value} Mbps` `${value} Mbps`
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export function AppShell({
onLogout={onLogout} onLogout={onLogout}
/> />
<main className="flex-1 overflow-hidden p-4"> <main className="min-w-0 flex-1 overflow-auto p-4">
{children} {children}
</main> </main>
</div> </div>
+29 -15
View File
@@ -4,7 +4,6 @@ import {
LogOut, LogOut,
RadioTower, RadioTower,
Settings, Settings,
Shield,
Wrench, Wrench,
} from 'lucide-react'; } from 'lucide-react';
@@ -30,8 +29,8 @@ export function Sidebar({
onLogout, onLogout,
}: SidebarProps) { }: SidebarProps) {
return ( return (
<aside className="flex h-screen w-64 flex-col border-r border-white/10 bg-ink-950 px-4 py-5"> <aside className="flex h-screen w-64 shrink-0 flex-col border-r border-white/10 bg-[#020817] px-4 py-5">
<div className="mb-10 flex items-center gap-4 px-1"> <div className="mb-8 flex items-center gap-4 px-1">
<img <img
src={logoIcon} src={logoIcon}
alt="Litoral Regas" alt="Litoral Regas"
@@ -39,17 +38,17 @@ export function Sidebar({
/> />
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate text-[20px] font-bold leading-none text-white"> <h1 className="truncate text-[28px] font-black leading-none text-white">
Litoral Regas Litoral Regas
</h1> </h1>
<p className="mt-1 text-[11px] font-medium uppercase tracking-[0.12em] text-blue-300/70"> <p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-300/70">
VPN Orchestrator VPN Orchestrator
</p> </p>
</div> </div>
</div> </div>
<nav className="space-y-1"> <nav className="space-y-2">
{items.map(([label, Icon]) => { {items.map(([label, Icon]) => {
const isActive = active === label; const isActive = active === label;
@@ -58,13 +57,25 @@ export function Sidebar({
key={label} key={label}
type="button" type="button"
onClick={() => onSelect(label)} 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={`group flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left text-sm font-semibold transition-all duration-200 ${
? 'bg-blue-500/15 text-blue-200' isActive
: 'text-slate-300 hover:bg-white/5 hover:text-white' ? 'border border-blue-500/20 bg-blue-500/15 text-white shadow-[0_0_25px_rgba(59,130,246,0.12)]'
}`} : 'border border-transparent text-slate-300 hover:border-white/5 hover:bg-white/[0.035] hover:text-white'
}`}
> >
<Icon size={18} /> <div
{label} className={`rounded-xl p-2 transition ${
isActive
? 'bg-blue-500/10 text-blue-300'
: 'bg-white/[0.03] text-slate-400 group-hover:text-slate-200'
}`}
>
<Icon size={17} />
</div>
<span className="truncate">
{label}
</span>
</button> </button>
); );
})} })}
@@ -74,10 +85,13 @@ export function Sidebar({
<button <button
type="button" type="button"
onClick={onLogout} 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" className="group flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left text-sm font-semibold text-slate-300 transition-all duration-200 hover:bg-red-500/10 hover:text-red-200"
> >
<LogOut size={18} /> <div className="rounded-xl bg-white/[0.03] p-2 text-slate-400 transition group-hover:bg-red-500/10 group-hover:text-red-300">
Terminar Sessão <LogOut size={17} />
</div>
<span>Terminar Sessão</span>
</button> </button>
</div> </div>
</aside> </aside>
+50 -14
View File
@@ -1,4 +1,8 @@
import { RefreshCw, ShieldCheck, ShieldX } from 'lucide-react'; import {
RefreshCw,
ShieldCheck,
ShieldX,
} from 'lucide-react';
type TopBarProps = { type TopBarProps = {
healthy?: boolean; healthy?: boolean;
@@ -8,11 +12,17 @@ export function TopBar({
healthy = false, healthy = false,
}: TopBarProps) { }: TopBarProps) {
return ( return (
<header className="mb-6 flex items-center justify-between rounded-3xl border border-white/10 bg-white/[0.025] px-5 py-4"> <header
<div> className="
<div className="flex items-center gap-3"> mb-4 flex flex-col gap-4 rounded-3xl border border-white/10
bg-white/[0.025] px-4 py-4
lg:mb-6 lg:flex-row lg:items-center lg:justify-between lg:px-5
"
>
<div className="min-w-0">
<div className="flex items-start gap-3">
<div <div
className={`rounded-2xl p-3 ${ className={`shrink-0 rounded-2xl p-3 ${
healthy healthy
? 'bg-emerald-500/10 text-emerald-300' ? 'bg-emerald-500/10 text-emerald-300'
: 'bg-red-500/10 text-red-300' : 'bg-red-500/10 text-red-300'
@@ -25,23 +35,49 @@ export function TopBar({
)} )}
</div> </div>
<div> <div className="min-w-0">
<h2 className="text-3xl font-black tracking-tight text-white"> <h2
className="
truncate text-2xl font-black tracking-tight text-white
xl:text-3xl
"
>
Painel Painel
</h2> </h2>
<p className="mt-1 text-sm text-slate-400"> <p
Provisionamento de routers OpenWrt 23.05 com WireGuard e UDP2RAW className="
mt-1 max-w-3xl text-xs leading-5 text-slate-400
sm:text-sm
"
>
Provisionamento de routers OpenWrt 23.05
com WireGuard e UDP2RAW
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div
<div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-black/20 px-4 py-2 text-sm text-slate-400"> className="
<RefreshCw size={15} /> flex flex-wrap items-center gap-3
lg:justify-end
"
>
<div
className="
flex items-center gap-2 rounded-2xl
border border-white/10 bg-black/20
px-3 py-2 text-xs text-slate-400
sm:px-4 sm:text-sm
"
>
<RefreshCw
size={15}
className="shrink-0"
/>
<span> <span className="whitespace-nowrap">
Atualizado às{' '} Atualizado às{' '}
<span className="font-mono text-slate-200"> <span className="font-mono text-slate-200">
{new Date().toLocaleTimeString()} {new Date().toLocaleTimeString()}
@@ -50,7 +86,7 @@ export function TopBar({
</div> </div>
<div <div
className={`rounded-2xl border px-4 py-2 text-sm font-bold ${ className={`rounded-2xl border px-3 py-2 text-xs font-bold sm:px-4 sm:text-sm ${
healthy healthy
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300' ? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300'
: 'border-red-500/20 bg-red-500/10 text-red-300' : 'border-red-500/20 bg-red-500/10 text-red-300'
+368 -359
View File
@@ -19,7 +19,7 @@ import {
ShieldAlert, ShieldAlert,
Terminal, Terminal,
UploadCloud, UploadCloud,
Wifi Wifi,
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
@@ -92,55 +92,55 @@ const workflowSteps: Array<{
description: string; description: string;
icon: typeof Search; icon: typeof Search;
}> = [ }> = [
{ {
id: 'DETECT_ROUTER', id: 'DETECT_ROUTER',
title: 'Detetar Router', title: 'Detetar Router',
description: 'Ping e inspeção do router via SSH.', description: 'Ping e inspeção do router via SSH.',
icon: Search, icon: Search,
}, },
{ {
id: 'UPLOAD_FIRMWARE', id: 'UPLOAD_FIRMWARE',
title: 'Enviar Firmware', title: 'Enviar Firmware',
description: 'Copiar imagem de firmware para /tmp.', description: 'Copiar imagem de firmware para /tmp.',
icon: UploadCloud, icon: UploadCloud,
}, },
{ {
id: 'FLASH_FIRMWARE', id: 'FLASH_FIRMWARE',
title: 'Gravar Firmware', title: 'Gravar Firmware',
description: 'Executar sysupgrade -n /tmp/firmware.bin.', description: 'Executar sysupgrade -n /tmp/firmware.bin.',
icon: Cpu, icon: Cpu,
}, },
{ {
id: 'WAIT_REBOOT', id: 'WAIT_REBOOT',
title: 'Aguardar Reinício', title: 'Aguardar Reinício',
description: 'Bloquear ações enquanto o router reinicia.', description: 'Bloquear ações enquanto o router reinicia.',
icon: RefreshCw, icon: RefreshCw,
}, },
{ {
id: 'RECONNECT_ROUTER', id: 'RECONNECT_ROUTER',
title: 'Reconectar Router', title: 'Reconectar Router',
description: 'Reconectar Ethernet e aguardar SSH.', description: 'Reconectar Ethernet e aguardar SSH.',
icon: PlugZap, icon: PlugZap,
}, },
{ {
id: 'UPLOAD_PROVISIONING', id: 'UPLOAD_PROVISIONING',
title: 'Enviar Pacote', title: 'Enviar Pacote',
description: 'Copiar router.env e provision.sh.', description: 'Copiar router.env e provision.sh.',
icon: FileUp, icon: FileUp,
}, },
{ {
id: 'RUN_PROVISIONING', id: 'RUN_PROVISIONING',
title: 'Executar Provisionamento', title: 'Executar Provisionamento',
description: 'Executar script de configuração no router.', description: 'Executar script de configuração no router.',
icon: Terminal, icon: Terminal,
}, },
{ {
id: 'REGISTER_PEER', id: 'REGISTER_PEER',
title: 'Registar Peer VPS', title: 'Registar Peer VPS',
description: 'Aplicar peer WireGuard na VPS.', description: 'Aplicar peer WireGuard na VPS.',
icon: Network, icon: Network,
} },
]; ];
const scrollClass = const scrollClass =
'[scrollbar-width:thin] [scrollbar-color:rgba(59,130,246,0.45)_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-blue-500/30 hover:[&::-webkit-scrollbar-thumb]:bg-blue-500/50'; '[scrollbar-width:thin] [scrollbar-color:rgba(59,130,246,0.45)_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-blue-500/30 hover:[&::-webkit-scrollbar-thumb]:bg-blue-500/50';
@@ -293,7 +293,7 @@ export function ProvisioningWizard() {
const effectivePassword = const effectivePassword =
customIp.trim() && customIp.trim() &&
selectedIp === customIp.trim() selectedIp === customIp.trim()
? customPassword ? customPassword
: routerPassword; : routerPassword;
@@ -1089,14 +1089,14 @@ export function ProvisioningWizard() {
} }
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex min-h-full flex-col">
<div className="mb-5 flex items-start justify-between"> <div className="mb-5 flex flex-wrap items-start justify-between gap-4 rounded-3xl border border-white/10 bg-white/[0.025] px-5 py-4">
<div> <div className="min-w-0">
<h1 className="text-3xl font-bold tracking-tight text-white"> <h1 className="text-3xl font-black tracking-tight text-white">
Provisionamento Provisionamento
</h1> </h1>
<p className="mt-1 text-slate-400"> <p className="mt-1 text-sm text-slate-400">
Provisionamento guiado do router, desde a deteção até ao registo do peer na VPS. Provisionamento guiado do router, desde a deteção até ao registo do peer na VPS.
</p> </p>
</div> </div>
@@ -1123,250 +1123,226 @@ export function ProvisioningWizard() {
</div> </div>
</div> </div>
<div className="grid min-h-0 flex-1 grid-cols-12 gap-5 overflow-hidden"> <div className="grid gap-4 2xl:grid-cols-12">
<Card className="col-span-5 flex min-h-0 flex-col overflow-hidden"> <Card className="2xl:col-span-5">
<div <div className="mb-5 flex items-center gap-4">
className={`min-h-0 flex-1 overflow-y-auto pr-1 ${scrollClass}`} <div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
> <Search size={24} />
<div className="mb-5 flex items-center gap-4">
<div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
<Search size={24} />
</div>
<div>
<h2 className="text-xl font-semibold text-white">
Ligação ao Router
</h2>
<p className="text-sm text-slate-400">
Selecione o alvo e valide o SSH antes do provisionamento.
</p>
</div>
</div> </div>
<div className="mb-4 grid grid-cols-2 gap-3"> <div className="min-w-0">
<button <h2 className="text-xl font-semibold text-white">
type="button" Ligação ao Router
disabled={controlsLocked} </h2>
onClick={() =>
setProvisioningMode('full')
}
className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${provisioningMode === 'full'
? 'border-blue-500/40 bg-blue-500/10'
: 'border-white/10 bg-white/[0.02]'
}`}
>
<p className="font-semibold text-white">
Provisionamento Completo
</p>
<p className="mt-1 text-xs text-slate-500">
Gravar firmware, reconectar e depois provisionar.
</p>
</button>
<button <p className="text-sm text-slate-400">
type="button" Selecione o alvo e valide o SSH antes do provisionamento.
disabled={controlsLocked} </p>
onClick={() =>
setProvisioningMode('provision_only')
}
className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${provisioningMode === 'provision_only'
? 'border-blue-500/40 bg-blue-500/10'
: 'border-white/10 bg-white/[0.02]'
}`}
>
<p className="font-semibold text-white">
Apenas Provisionar
</p>
<p className="mt-1 text-xs text-slate-500">
O router tem firmware gravado e está estável.
</p>
</button>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-3"> <div className="mb-4 grid gap-3 md:grid-cols-2">
{routerPresets.map((preset) => { <ModeButton
const active = active={provisioningMode === 'full'}
selectedIp === preset.ip && disabled={controlsLocked}
customIp.trim() === ''; title="Provisionamento Completo"
description="Gravar firmware, reconectar e depois provisionar."
onClick={() => setProvisioningMode('full')}
/>
return ( <ModeButton
<button active={provisioningMode === 'provision_only'}
key={preset.ip} disabled={controlsLocked}
type="button" title="Apenas Provisionar"
disabled={controlsLocked} description="O router já tem firmware gravado e está estável."
onClick={() => { onClick={() => setProvisioningMode('provision_only')}
setSelectedIp(preset.ip); />
setRouterPassword(preset.password); </div>
setCustomIp('');
}} <div className="grid gap-3 md:grid-cols-2">
className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${active {routerPresets.map((preset) => {
const active =
selectedIp === preset.ip &&
customIp.trim() === '';
return (
<button
key={preset.ip}
type="button"
disabled={controlsLocked}
onClick={() => {
setSelectedIp(preset.ip);
setRouterPassword(preset.password);
setCustomIp('');
}}
className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${
active
? 'border-blue-500/40 bg-blue-500/10' ? 'border-blue-500/40 bg-blue-500/10'
: 'border-white/10 bg-white/[0.02] hover:border-blue-500/20 hover:bg-white/[0.04]' : 'border-white/10 bg-white/[0.02] hover:border-blue-500/20 hover:bg-white/[0.04]'
}`}
>
<Network
size={18}
className={
active
? 'text-blue-300'
: 'text-slate-500'
}
/>
<p className="mt-3 font-mono text-sm font-semibold text-white">
{preset.ip}
</p>
<p className="mt-1 text-xs text-slate-500">
{preset.label}
</p>
<p className="mt-3 text-xs text-slate-500">
Palavra-passe:{' '}
<span className="font-mono text-slate-300">
{preset.password || 'vazia'}
</span>
</p>
</button>
);
})}
<div
className={`col-span-2 rounded-2xl border p-4 transition ${customIp.trim()
? 'border-blue-500/40 bg-blue-500/10'
: 'border-white/10 bg-white/[0.02]'
}`} }`}
> >
<div className="mb-3 flex items-center gap-3">
<Network <Network
size={18} size={18}
className={ className={
customIp.trim() active
? 'text-blue-300' ? 'text-blue-300'
: 'text-slate-500' : 'text-slate-500'
} }
/> />
<div> <p className="mt-3 font-mono text-sm font-semibold text-white">
<p className="text-sm font-semibold text-white"> {preset.ip}
Acesso Personalizado ao Router </p>
</p>
<p className="text-xs text-slate-500"> <p className="mt-1 text-xs text-slate-500">
Use para IPs de router ou palavras-passe não padrão. {preset.label}
</p> </p>
</div>
<p className="mt-3 text-xs text-slate-500">
Palavra-passe:{' '}
<span className="font-mono text-slate-300">
{preset.password || 'vazia'}
</span>
</p>
</button>
);
})}
<div
className={`rounded-2xl border p-4 transition md:col-span-2 ${
customIp.trim()
? 'border-blue-500/40 bg-blue-500/10'
: 'border-white/10 bg-white/[0.02]'
}`}
>
<div className="mb-3 flex items-center gap-3">
<Network
size={18}
className={
customIp.trim()
? 'text-blue-300'
: 'text-slate-500'
}
/>
<div>
<p className="text-sm font-semibold text-white">
Acesso Personalizado ao Router
</p>
<p className="text-xs text-slate-500">
Use para IPs de router ou palavras-passe não padrão.
</p>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
IP do Router
</label>
<input
disabled={controlsLocked}
value={customIp}
onChange={(event) => {
const value =
event.target.value;
setCustomIp(value);
setSelectedIp(value.trim());
}}
onFocus={() => {
setSelectedIp(customIp.trim());
}}
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>
<div className="grid grid-cols-2 gap-3"> <div>
<div> <label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500"> Palavra-passe SSH
IP do Router </label>
</label>
<input <input
disabled={controlsLocked} disabled={controlsLocked}
value={customIp} type="password"
onChange={(event) => { value={customPassword}
const value = onChange={(event) =>
event.target.value; setCustomPassword(
event.target.value,
setCustomIp(value); )
setSelectedIp(value.trim()); }
}} onFocus={() => {
onFocus={() => { if (customIp.trim()) {
setSelectedIp(customIp.trim()); setSelectedIp(
}} customIp.trim(),
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">
Palavra-passe SSH
</label>
<input
disabled={controlsLocked}
type="password"
value={customPassword}
onChange={(event) =>
setCustomPassword(
event.target.value,
)
} }
onFocus={() => { }}
if (customIp.trim()) { placeholder="Vazio se não existir"
setSelectedIp( 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"
customIp.trim(), />
);
}
}}
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>
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-slate-950/70 p-4"> <div className="mt-4 rounded-2xl border border-white/10 bg-slate-950/70 p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="rounded-2xl bg-green-500/10 p-3 text-green-300"> <div className="rounded-2xl bg-green-500/10 p-3 text-green-300">
<Wifi size={22} /> <Wifi size={22} />
</div>
<div className="flex-1">
<p className="font-semibold text-white">
Alvo de Deteção
</p>
<p className="mt-1 font-mono text-sm text-slate-400">
root@{selectedIp || '—'}
</p>
</div>
<Badge tone={statusTone(status)}>
{statusLabel(status)}
</Badge>
</div> </div>
</div>
<div className="mt-4 flex flex-wrap gap-3"> <div className="min-w-0 flex-1">
<Button <p className="font-semibold text-white">
type="button" Alvo de Deteção
onClick={detectRouter} </p>
disabled={
controlsLocked ||
status === 'checking' ||
!selectedIp.trim()
}
>
<Search size={16} />
Detetar Router
</Button>
<Button <p className="mt-1 truncate font-mono text-sm text-slate-400">
type="button" root@{selectedIp || '—'}
variant="secondary" </p>
onClick={fixKnownHost} </div>
disabled={
controlsLocked || <Badge tone={statusTone(status)}>
!selectedIp.trim() {statusLabel(status)}
} </Badge>
>
<ShieldAlert size={16} />
Corrigir Known Host
</Button>
</div> </div>
</div> </div>
<div className="mt-4 flex flex-wrap gap-3">
<Button
type="button"
onClick={detectRouter}
disabled={
controlsLocked ||
status === 'checking' ||
!selectedIp.trim()
}
>
<Search size={16} />
Detetar Router
</Button>
<Button
type="button"
variant="secondary"
onClick={fixKnownHost}
disabled={
controlsLocked ||
!selectedIp.trim()
}
>
<ShieldAlert size={16} />
Corrigir Known Host
</Button>
</div>
</Card> </Card>
<div className="col-span-4 flex min-h-0 flex-col gap-5"> <Card className="2xl:col-span-7">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="mb-4 flex flex-wrap items-start justify-between gap-4">
<div className="mb-4 flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300"> <div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
<Rocket size={22} /> <Rocket size={22} />
</div> </div>
@@ -1382,80 +1358,76 @@ export function ProvisioningWizard() {
</div> </div>
</div> </div>
<div <Button
className={`min-h-0 flex-1 space-y-3 overflow-y-auto pr-1 ${scrollClass}`} type="button"
onClick={continueProvisioning}
disabled={
!canContinue ||
status === 'flashing' ||
isReconnecting ||
isActionRunning
}
> >
{workflowState.map((step) => ( <Rocket size={16} />
<WorkflowStepCard {isActionRunning
key={step.id} ? 'A trabalhar...'
title={step.title} : isReconnecting
description={step.description} ? 'A reconectar...'
icon={step.icon} : 'Continuar'}
status={step.status} </Button>
/> </div>
))}
</div>
<div className="mt-4"> <div className="grid gap-3 md:grid-cols-2">
<Button {workflowState.map((step) => (
type="button" <WorkflowStepCard
onClick={continueProvisioning} key={step.id}
disabled={ title={step.title}
!canContinue || description={step.description}
status === 'flashing' || icon={step.icon}
isReconnecting || status={step.status}
isActionRunning />
} ))}
> </div>
<Rocket size={16} /> </Card>
{isActionRunning
? 'A trabalhar...'
: isReconnecting
? 'A reconectar...'
: 'Continuar Provisionamento'}
</Button>
</div>
</Card>
</div>
<div className="col-span-3 flex min-h-0 flex-col gap-5">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
<Terminal size={22} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">
Informação do Router
</h2>
<p className="text-sm text-slate-400">
Saída de ubus system board.
</p>
</div>
</div>
<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 || 'Ainda não foi capturada informação do router.'}
</pre>
</Card>
</div>
</div> </div>
<Card className="mt-4 flex h-52 flex-col overflow-hidden"> <div className="mt-4 grid gap-4 xl:grid-cols-12">
<h3 className="mb-2 text-sm font-semibold text-white"> <Card className="min-h-[260px] xl:col-span-5">
Registo Técnico <div className="mb-4 flex items-center gap-3">
</h3> <div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
<Terminal size={22} />
</div>
<pre <div>
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}`} <h2 className="text-lg font-semibold text-white">
> Informação do Router
{log.join('\n') || 'Ainda não há atividade de provisionamento.'} </h2>
</pre>
</Card> <p className="text-sm text-slate-400">
Saída de ubus system board.
</p>
</div>
</div>
<pre
className={`max-h-[260px] min-h-[160px] 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 || 'Ainda não foi capturada informação do router.'}
</pre>
</Card>
<Card className="min-h-[260px] xl:col-span-7">
<h3 className="mb-2 text-sm font-semibold text-white">
Registo Técnico
</h3>
<pre
className={`max-h-[260px] min-h-[200px] 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') || 'Ainda não há atividade de provisionamento.'}
</pre>
</Card>
</div>
{confirmFlashOpen && ( {confirmFlashOpen && (
<ConfirmFlashModal <ConfirmFlashModal
@@ -1517,6 +1489,41 @@ export function ProvisioningWizard() {
); );
} }
function ModeButton({
active,
disabled,
title,
description,
onClick,
}: {
active: boolean;
disabled: boolean;
title: string;
description: string;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed disabled:opacity-60 ${
active
? 'border-blue-500/40 bg-blue-500/10'
: 'border-white/10 bg-white/[0.02] hover:border-blue-500/20 hover:bg-white/[0.04]'
}`}
>
<p className="font-semibold text-white">
{title}
</p>
<p className="mt-1 text-xs text-slate-500">
{description}
</p>
</button>
);
}
function RouterEnvModal({ function RouterEnvModal({
routerEnv, routerEnv,
setRouterEnv, setRouterEnv,
@@ -1541,8 +1548,8 @@ function RouterEnvModal({
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm">
<div className="w-full max-w-3xl rounded-3xl border border-blue-500/30 bg-slate-950 p-6 shadow-2xl shadow-blue-500/10"> <div className="max-h-[90vh] w-full max-w-3xl overflow-auto rounded-3xl border border-blue-500/30 bg-slate-950 p-6 shadow-2xl shadow-blue-500/10">
<div className="mb-5 flex items-center gap-4"> <div className="mb-5 flex items-center gap-4">
<div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300"> <div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
<FileUp size={26} /> <FileUp size={26} />
@@ -1559,7 +1566,7 @@ function RouterEnvModal({
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid gap-4 md:grid-cols-2">
<EnvInput <EnvInput
label="ID do Router" label="ID do Router"
value={routerEnv.routerId} value={routerEnv.routerId}
@@ -1595,7 +1602,7 @@ function RouterEnvModal({
Valores estáticos de router.env Valores estáticos de router.env
</p> </p>
<div className="mt-3 grid grid-cols-2 gap-3 font-mono text-xs text-slate-300"> <div className="mt-3 grid gap-3 font-mono text-xs text-slate-300 md:grid-cols-2">
<p>LAN_IP=198.51.100.1</p> <p>LAN_IP=198.51.100.1</p>
<p>LAN_NETMASK=255.255.255.0</p> <p>LAN_NETMASK=255.255.255.0</p>
<p>WG_CIDR=32</p> <p>WG_CIDR=32</p>
@@ -1664,7 +1671,7 @@ function ConfirmFlashModal({
onConfirm: () => void; onConfirm: () => void;
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm">
<div className="w-full max-w-xl rounded-3xl border border-red-500/30 bg-slate-950 p-6 shadow-2xl shadow-red-500/10"> <div className="w-full max-w-xl rounded-3xl border border-red-500/30 bg-slate-950 p-6 shadow-2xl shadow-red-500/10">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="rounded-2xl bg-red-500/10 p-3 text-red-300"> <div className="rounded-2xl bg-red-500/10 p-3 text-red-300">
@@ -1717,7 +1724,7 @@ function FlashOverlay({
flashProgress: number; flashProgress: number;
}) { }) {
return ( return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/85 backdrop-blur-md"> <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/85 p-4 backdrop-blur-md">
<div className="w-full max-w-2xl rounded-3xl border border-purple-500/30 bg-slate-950 p-8 shadow-2xl shadow-purple-500/20"> <div className="w-full max-w-2xl rounded-3xl border border-purple-500/30 bg-slate-950 p-8 shadow-2xl shadow-purple-500/20">
<div className="flex items-start gap-5"> <div className="flex items-start gap-5">
<div className="rounded-3xl bg-purple-500/10 p-4 text-purple-300"> <div className="rounded-3xl bg-purple-500/10 p-4 text-purple-300">
@@ -1770,7 +1777,7 @@ function ProvisionOverlay({
provisionProgress: number; provisionProgress: number;
}) { }) {
return ( return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/85 backdrop-blur-md"> <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/85 p-4 backdrop-blur-md">
<div className="w-full max-w-2xl rounded-3xl border border-blue-500/30 bg-slate-950 p-8 shadow-2xl shadow-blue-500/20"> <div className="w-full max-w-2xl rounded-3xl border border-blue-500/30 bg-slate-950 p-8 shadow-2xl shadow-blue-500/20">
<div className="flex items-start gap-5"> <div className="flex items-start gap-5">
<div className="rounded-3xl bg-blue-500/10 p-4 text-blue-300"> <div className="rounded-3xl bg-blue-500/10 p-4 text-blue-300">
@@ -1831,7 +1838,7 @@ function StopProvisioningModal({
canConfirm: boolean; canConfirm: boolean;
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm">
<div className="w-full max-w-xl rounded-3xl border border-red-500/30 bg-slate-950 p-6 shadow-2xl shadow-red-500/10"> <div className="w-full max-w-xl rounded-3xl border border-red-500/30 bg-slate-950 p-6 shadow-2xl shadow-red-500/10">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="rounded-2xl bg-red-500/10 p-3 text-red-300"> <div className="rounded-2xl bg-red-500/10 p-3 text-red-300">
@@ -1899,23 +1906,25 @@ function WorkflowStepCard({
return ( return (
<div <div
className={`rounded-2xl border p-3 transition ${done className={`rounded-2xl border p-3 transition ${
? 'border-green-500/30 bg-green-500/10' done
: active ? 'border-green-500/30 bg-green-500/10'
? 'border-blue-500/40 bg-blue-500/10' : active
: blocked ? 'border-blue-500/40 bg-blue-500/10'
? 'border-white/5 bg-white/[0.015] opacity-60' : blocked
: 'border-white/10 bg-white/[0.02]' ? 'border-white/5 bg-white/[0.015] opacity-60'
}`} : 'border-white/10 bg-white/[0.02]'
}`}
> >
<div className="flex gap-3"> <div className="flex gap-3">
<div <div
className={`rounded-xl p-2 ${done className={`rounded-xl p-2 ${
? 'bg-green-500/10 text-green-300' done
: active ? 'bg-green-500/10 text-green-300'
? 'bg-blue-500/10 text-blue-300' : active
: 'bg-slate-900 text-slate-500' ? 'bg-blue-500/10 text-blue-300'
}`} : 'bg-slate-900 text-slate-500'
}`}
> >
<Icon size={16} /> <Icon size={16} />
</div> </div>
@@ -1946,7 +1955,7 @@ function SetupCompleteModal({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm">
<div className="w-full max-w-xl rounded-3xl border border-green-500/30 bg-slate-950 p-6 shadow-2xl shadow-green-500/10"> <div className="w-full max-w-xl rounded-3xl border border-green-500/30 bg-slate-950 p-6 shadow-2xl shadow-green-500/10">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="rounded-2xl bg-green-500/10 p-3 text-green-300"> <div className="rounded-2xl bg-green-500/10 p-3 text-green-300">
+163 -154
View File
@@ -11,7 +11,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { import {
@@ -50,16 +49,15 @@ export function BackendSettings() {
} }
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex min-h-full flex-col">
<div className="mb-6 flex items-start justify-between"> <div className="mb-5 flex flex-wrap items-start justify-between gap-4 rounded-3xl border border-white/10 bg-white/[0.025] px-5 py-4">
<div> <div className="min-w-0">
<h1 className="text-3xl font-bold tracking-tight text-white"> <h1 className="text-3xl font-black tracking-tight text-white">
Posto de Trabalho Posto de Trabalho
</h1> </h1>
<p className="mt-1 text-slate-400"> <p className="mt-1 max-w-2xl text-sm leading-6 text-slate-400">
Configuração local da consola técnica Configuração local da consola técnica para provisionamento de routers.
para provisionamento de routers.
</p> </p>
</div> </div>
@@ -68,100 +66,84 @@ export function BackendSettings() {
</Badge> </Badge>
</div> </div>
<div className="grid min-h-0 flex-1 grid-cols-12 gap-5 overflow-hidden"> {saved && (
<Card className="col-span-7 flex flex-col"> <div className="mb-4 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} />
Configuração do posto de trabalho guardada.
</div>
</div>
)}
<div className="grid gap-4 2xl:grid-cols-12">
<Card className="2xl:col-span-7">
<div className="mb-6 flex items-center gap-4"> <div className="mb-6 flex items-center gap-4">
<div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300"> <div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
<Server size={24} /> <Server size={24} />
</div> </div>
<div> <div className="min-w-0">
<h2 className="text-xl font-semibold text-white"> <h2 className="text-xl font-semibold text-white">
Ligação ao Backend Ligação ao Backend
</h2> </h2>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Endpoint API usado para atribuição Endpoint API usado para atribuição de IP VPN e registo de peers.
de IP VPN e registo de peers.
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-5"> <div className="grid gap-5 xl:grid-cols-2">
<div> <SettingsInput
<label className="mb-2 block text-xs font-semibold uppercase tracking-wide text-slate-500"> label="URL do Backend"
URL do Backend value={settings.backendUrl}
</label> icon={<Link2 size={16} />}
placeholder="http://localhost:8080"
onChange={(value) =>
setSettings({
...settings,
backendUrl: value,
})
}
/>
<div className="relative"> <SettingsInput
<Link2 label="Chave API"
size={16} value={settings.apiKey}
className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/70" icon={<KeyRound size={16} />}
/> placeholder="dev-api-key"
onChange={(value) =>
<input setSettings({
value={settings.backendUrl} ...settings,
onChange={(event) => apiKey: value,
setSettings({ })
...settings, }
backendUrl: event.target.value, />
})
}
className="w-full rounded-2xl border border-white/10 bg-slate-950/80 py-4 pl-11 pr-4 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/50 focus:bg-slate-950"
placeholder="http://localhost:8080"
/>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-wide text-slate-500">
Chave API
</label>
<div className="relative">
<KeyRound
size={16}
className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/70"
/>
<input
value={settings.apiKey}
onChange={(event) =>
setSettings({
...settings,
apiKey: event.target.value,
})
}
className="w-full rounded-2xl border border-white/10 bg-slate-950/80 py-4 pl-11 pr-4 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/50 focus:bg-slate-950"
placeholder="dev-api-key"
/>
</div>
</div>
</div> </div>
<div className="mt-6 rounded-2xl border border-white/10 bg-white/[0.025] p-5"> <div className="mt-6 grid gap-4 xl:grid-cols-[1fr_auto] xl:items-center">
<div className="flex items-start justify-between gap-4"> <div className="rounded-2xl border border-white/10 bg-white/[0.025] p-5">
<div> <div className="flex items-start justify-between gap-4">
<p className="text-sm font-semibold text-white"> <div className="min-w-0">
Alvo Ligado <p className="text-sm font-semibold text-white">
</p> Alvo Ligado
</p>
<p className="mt-1 font-mono text-sm text-slate-400"> <p className="mt-1 truncate font-mono text-sm text-slate-400">
{backendHost} {backendHost}
</p> </p>
</div>
<Badge tone="green">
Pronto
</Badge>
</div> </div>
<Badge tone="green">
Pronto
</Badge>
</div> </div>
</div>
<div className="mt-auto flex justify-end pt-6">
<button <button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
className="group inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.02] px-5 py-3 text-sm font-semibold text-white transition-all duration-200 hover:-translate-y-[1px] hover:border-blue-500/30 hover:bg-blue-500/10 active:translate-y-0" className="group inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.02] px-5 py-3 text-sm font-semibold text-white transition-all duration-200 hover:-translate-y-[1px] hover:border-blue-500/30 hover:bg-blue-500/10 active:translate-y-0 xl:w-auto"
> >
<Save <Save
size={16} size={16}
@@ -175,98 +157,125 @@ export function BackendSettings() {
</div> </div>
</Card> </Card>
<div className="col-span-5 flex min-h-0 flex-col gap-5"> <Card className="2xl:col-span-5">
<Card> <div className="mb-5 flex items-center gap-4">
<div className="mb-5 flex items-center gap-4"> <div className="rounded-2xl bg-purple-500/10 p-3 text-purple-300">
<div className="rounded-2xl bg-purple-500/10 p-3 text-purple-300"> <MonitorCog size={24} />
<MonitorCog size={24} />
</div>
<div>
<h2 className="text-xl font-semibold text-white">
Base de Provisionamento
</h2>
<p className="text-sm text-slate-400">
Valores de produção apenas leitura.
</p>
</div>
</div> </div>
<div className="grid gap-3"> <div className="min-w-0">
<InfoRow <h2 className="text-xl font-semibold text-white">
label="Rota Overlay" Base de Provisionamento
value={DEFAULT_OVERLAY_ROUTE} </h2>
/>
<InfoRow <p className="text-sm text-slate-400">
label="IP LAN Router" Valores de produção apenas leitura.
value={DEFAULT_ROUTER_IP} </p>
/> </div>
</div>
<InfoRow <div className="grid gap-3 sm:grid-cols-2 2xl:grid-cols-1">
label="IP Controlador" <InfoRow
value={DEFAULT_CONTROLLER_IP} label="Rota Overlay"
/> value={DEFAULT_OVERLAY_ROUTE}
/>
<InfoRow <InfoRow
label="IP PLC" label="IP LAN Router"
value={DEFAULT_PLC_IP} value={DEFAULT_ROUTER_IP}
/> />
<InfoRow
label="IP Controlador"
value={DEFAULT_CONTROLLER_IP}
/>
<InfoRow
label="IP PLC"
value={DEFAULT_PLC_IP}
/>
<div className="sm:col-span-2 2xl:col-span-1">
<InfoRow <InfoRow
label="Firmware Alvo" label="Firmware Alvo"
value={DEFAULT_FIRMWARE} value={DEFAULT_FIRMWARE}
/> />
</div> </div>
</Card> </div>
</Card>
</div>
<Card className="flex-1"> <Card className="mt-4">
<div className="flex items-start gap-4"> <div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div className="rounded-2xl bg-green-500/10 p-3 text-green-300"> <div className="flex items-start gap-4">
<ShieldCheck size={24} /> <div className="rounded-2xl bg-green-500/10 p-3 text-green-300">
</div> <ShieldCheck size={24} />
<div>
<h2 className="text-xl font-semibold text-white">
Perfil de Produção
</h2>
<p className="mt-3 text-sm leading-6 text-slate-400">
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">
<Badge tone="blue">
OpenWrt 23.05
</Badge>
<Badge tone="purple">
WireGuard
</Badge>
<Badge tone="green">
fw4/nftables
</Badge>
</div>
</div>
</div> </div>
</Card>
{saved && ( <div className="max-w-4xl">
<div className="rounded-2xl border border-green-500/20 bg-green-500/10 p-4 text-sm text-green-300"> <h2 className="text-xl font-semibold text-white">
<div className="flex items-center gap-2"> Perfil de Produção
<CheckCircle2 size={16} /> </h2>
Configuração do posto de trabalho guardada.
</div> <p className="mt-3 text-sm leading-6 text-slate-400">
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> </div>
)} </div>
<div className="flex shrink-0 flex-wrap gap-2 xl:justify-end">
<Badge tone="blue">
OpenWrt 23.05
</Badge>
<Badge tone="purple">
WireGuard
</Badge>
<Badge tone="green">
fw4/nftables
</Badge>
</div>
</div> </div>
</Card>
</div>
);
}
function SettingsInput({
label,
value,
icon,
placeholder,
onChange,
}: {
label: string;
value: string;
icon: React.ReactNode;
placeholder: string;
onChange: (value: string) => void;
}) {
return (
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-wide text-slate-500">
{label}
</label>
<div className="relative">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/70">
{icon}
</div>
<input
value={value}
onChange={(event) =>
onChange(event.target.value)
}
className="w-full rounded-2xl border border-white/10 bg-slate-950/80 py-4 pl-11 pr-4 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/50 focus:bg-slate-950"
placeholder={placeholder}
/>
</div> </div>
</div> </div>
); );
+201 -204
View File
@@ -4,18 +4,17 @@ import {
Activity, Activity,
CheckCircle2, CheckCircle2,
Cpu, Cpu,
Eye,
EyeOff,
Network, Network,
Play, Play,
RadioTower, RadioTower,
Search, Search,
Terminal, Terminal,
Eye,
EyeOff,
UploadCloud, UploadCloud,
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
type Udp2rawStep = { type Udp2rawStep = {
@@ -80,8 +79,8 @@ export function Udp2rawConfig() {
const [completedSteps, setCompletedSteps] = useState<string[]>([]); const [completedSteps, setCompletedSteps] = useState<string[]>([]);
const [failedStep, setFailedStep] = useState<string | null>(null); const [failedStep, setFailedStep] = useState<string | null>(null);
const [log, setLog] = useState<string[]>([]); const [log, setLog] = useState<string[]>([]);
const [showPassword, setShowPassword] = const [showPassword, setShowPassword] = useState(false);
useState(false);
const running = Boolean(runningStep); const running = Boolean(runningStep);
const statusTone = useMemo(() => { const statusTone = useMemo(() => {
@@ -148,9 +147,9 @@ export function Udp2rawConfig() {
setFailedStep(null); setFailedStep(null);
for (const step of steps) { for (const step of steps) {
try { await runStep(step);
await runStep(step);
} catch { if (failedStep) {
return; return;
} }
} }
@@ -163,10 +162,10 @@ export function Udp2rawConfig() {
} }
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex min-h-full flex-col">
<div className="mb-5 flex items-start justify-between"> <div className="mb-5 flex items-start justify-between gap-4 rounded-3xl border border-white/10 bg-white/[0.025] px-5 py-4">
<div className="flex items-center gap-4"> <div className="flex min-w-0 items-center gap-4">
<div className="relative"> <div className="relative shrink-0">
<div className="absolute inset-0 rounded-3xl bg-blue-500/20 blur-xl" /> <div className="absolute inset-0 rounded-3xl bg-blue-500/20 blur-xl" />
<div className="relative rounded-3xl border border-blue-400/20 bg-blue-500/10 p-4 text-blue-300"> <div className="relative rounded-3xl border border-blue-400/20 bg-blue-500/10 p-4 text-blue-300">
@@ -174,12 +173,12 @@ export function Udp2rawConfig() {
</div> </div>
</div> </div>
<div> <div className="min-w-0">
<h1 className="text-3xl font-black tracking-tight text-white"> <h1 className="truncate text-3xl font-black tracking-tight text-white">
Configuração UDP2RAW Configuração UDP2RAW
</h1> </h1>
<p className="mt-1 text-slate-400"> <p className="mt-1 text-sm text-slate-400">
Instalação, arranque e validação do túnel UDP2RAW no router. Instalação, arranque e validação do túnel UDP2RAW no router.
</p> </p>
</div> </div>
@@ -190,235 +189,233 @@ export function Udp2rawConfig() {
</Badge> </Badge>
</div> </div>
<div className="grid min-h-0 flex-1 grid-cols-12 gap-5 overflow-hidden"> <div className="grid gap-4 xl:grid-cols-12">
<div className="col-span-5 flex min-h-0 flex-col gap-5"> <Card className="relative overflow-hidden xl:col-span-5">
<Card className="relative overflow-hidden"> <div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-blue-400/30 to-transparent" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-blue-400/30 to-transparent" />
<div className="mb-5 flex items-center gap-4"> <div className="mb-5 flex items-center gap-4">
<div className="rounded-2xl border border-blue-400/10 bg-blue-500/10 p-3 text-blue-300"> <div className="rounded-2xl border border-blue-400/10 bg-blue-500/10 p-3 text-blue-300">
<Network size={24} /> <Network size={24} />
</div>
<div>
<h2 className="text-xl font-bold text-white">
Router Alvo
</h2>
<p className="text-sm text-slate-400">
Defina o router onde o cliente UDP2RAW será configurado.
</p>
</div>
</div> </div>
<div className="grid gap-4"> <div className="min-w-0">
<div> <h2 className="text-xl font-bold text-white">
<label className="mb-2 block text-xs font-bold uppercase tracking-[0.16em] text-slate-500"> Router Alvo
IP do Router </h2>
</label>
<p className="text-sm text-slate-400">
Defina o router onde o cliente UDP2RAW será configurado.
</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-1">
<div>
<label className="mb-2 block text-xs font-bold uppercase tracking-[0.16em] text-slate-500">
IP do Router
</label>
<input
value={routerIp}
disabled={running}
onChange={(event) =>
setRouterIp(event.target.value)
}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/50 focus:bg-black/30 focus:shadow-[0_0_0_4px_rgba(59,130,246,0.10)] disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
<div>
<label className="mb-2 block text-xs font-bold uppercase tracking-[0.16em] text-slate-500">
Palavra-passe SSH
</label>
<div className="relative">
<input <input
value={routerIp} type={showPassword ? 'text' : 'password'}
value={password}
disabled={running} disabled={running}
onChange={(event) => onChange={(event) =>
setRouterIp(event.target.value) setPassword(event.target.value)
} }
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/50 focus:bg-black/30 focus:shadow-[0_0_0_4px_rgba(59,130,246,0.10)] disabled:cursor-not-allowed disabled:opacity-60" placeholder="Vazio se não existir"
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 pr-14 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/50 focus:bg-black/30 focus:shadow-[0_0_0_4px_rgba(59,130,246,0.10)] disabled:cursor-not-allowed disabled:opacity-60"
/> />
</div>
<div> <button
<label className="mb-2 block text-xs font-bold uppercase tracking-[0.16em] text-slate-500"> type="button"
Palavra-passe SSH onClick={() =>
</label> setShowPassword((current) => !current)
}
<div className="relative"> className="absolute right-3 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-xl text-slate-500 transition hover:bg-white/5 hover:text-blue-300"
<input
type={showPassword ? 'text' : 'password'}
value={password}
disabled={running}
onChange={(event) =>
setPassword(event.target.value)
}
placeholder="Vazio se não existir"
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 pr-14 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/50 focus:bg-black/30 focus:shadow-[0_0_0_4px_rgba(59,130,246,0.10)] disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={() =>
setShowPassword((current) => !current)
}
className="absolute right-3 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-xl text-slate-500 transition hover:bg-white/5 hover:text-blue-300"
>
{showPassword ? (
<EyeOff size={18} />
) : (
<Eye size={18} />
)}
</button>
</div>
</div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
{[
['VPS', '146.59.230.190:444'],
['Local', '127.0.0.1:4999'],
['Modo', 'faketcp'],
['Serviço', 'udp2raw-wg'],
].map(([label, value]) => (
<div
key={label}
className="rounded-2xl border border-white/10 bg-white/[0.025] p-4"
> >
<p className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500"> {showPassword ? (
{label} <EyeOff size={18} />
</p> ) : (
<Eye size={18} />
<p className="mt-2 break-all font-mono text-xs font-semibold text-slate-200"> )}
{value} </button>
</p> </div>
</div>
))}
</div> </div>
</Card> </div>
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="mt-5 grid grid-cols-2 gap-3">
<div className="mb-4 flex items-start justify-between gap-4"> {[
<div> ['VPS', '146.59.230.190:444'],
<h2 className="text-lg font-bold text-white"> ['Local', '127.0.0.1:4999'],
Fluxo de Instalação ['Modo', 'faketcp'],
</h2> ['Serviço', 'udp2raw-wg'],
].map(([label, value]) => (
<div
key={label}
className="rounded-2xl border border-white/10 bg-white/[0.025] p-4"
>
<p className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">
{label}
</p>
<p className="mt-1 text-sm text-slate-400"> <p className="mt-2 break-all font-mono text-xs font-semibold text-slate-200">
Execute passo a passo ou corra o fluxo completo. {value}
</p> </p>
</div> </div>
))}
</div>
</Card>
<button <Card className="overflow-hidden xl:col-span-7">
type="button" <div className="mb-4 flex flex-wrap items-start justify-between gap-4">
disabled={running} <div>
onClick={runFullFlow} <h2 className="text-lg font-bold text-white">
className="inline-flex shrink-0 items-center gap-2 rounded-2xl border border-blue-400/20 bg-blue-500/15 px-4 py-3 text-sm font-bold text-blue-100 shadow-lg shadow-blue-500/10 transition hover:-translate-y-[1px] hover:bg-blue-500/25 disabled:cursor-not-allowed disabled:opacity-60" Fluxo de Instalação
> </h2>
<Activity size={16} />
Executar Tudo <p className="mt-1 text-sm text-slate-400">
</button> Execute passo a passo ou corra o fluxo completo.
</p>
</div> </div>
<div className="mb-4"> <button
<div className="mb-2 flex justify-between text-xs text-slate-500"> type="button"
<span>Progresso</span> disabled={running}
<span>{completedSteps.length}/{steps.length}</span> onClick={runFullFlow}
</div> className="inline-flex shrink-0 items-center gap-2 rounded-2xl border border-blue-400/20 bg-blue-500/15 px-4 py-3 text-sm font-bold text-blue-100 shadow-lg shadow-blue-500/10 transition hover:-translate-y-[1px] hover:bg-blue-500/25 disabled:cursor-not-allowed disabled:opacity-60"
<div className="h-2 overflow-hidden rounded-full bg-slate-800">
<div
className="h-full rounded-full bg-blue-400 transition-all duration-500"
style={{
width: `${progress}%`,
}}
/>
</div>
</div>
<div
className={`min-h-0 flex-1 space-y-3 overflow-y-auto pr-1 ${scrollClass}`}
> >
{steps.map((step, index) => { <Activity size={16} />
const done = completedSteps.includes(step.id); Executar Tudo
const active = runningStep === step.id; </button>
const failed = failedStep === step.id; </div>
const Icon = step.icon;
return ( <div className="mb-4">
<button <div className="mb-2 flex justify-between text-xs text-slate-500">
key={step.id} <span>Progresso</span>
type="button" <span>{completedSteps.length}/{steps.length}</span>
disabled={running} </div>
onClick={() => runStep(step)}
className={`group w-full rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed ${failed <div className="h-2 overflow-hidden rounded-full bg-slate-800">
<div
className="h-full rounded-full bg-blue-400 transition-all duration-500"
style={{
width: `${progress}%`,
}}
/>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
{steps.map((step, index) => {
const done = completedSteps.includes(step.id);
const active = runningStep === step.id;
const failed = failedStep === step.id;
const Icon = step.icon;
return (
<button
key={step.id}
type="button"
disabled={running}
onClick={() => runStep(step)}
className={`group rounded-2xl border p-4 text-left transition disabled:cursor-not-allowed ${
failed
? 'border-red-500/30 bg-red-500/10' ? 'border-red-500/30 bg-red-500/10'
: done : done
? 'border-green-500/30 bg-green-500/10' ? 'border-green-500/30 bg-green-500/10'
: active : active
? 'border-blue-500/40 bg-blue-500/10 shadow-lg shadow-blue-500/10' ? 'border-blue-500/40 bg-blue-500/10 shadow-lg shadow-blue-500/10'
: 'border-white/10 bg-white/[0.02] hover:border-blue-500/25 hover:bg-white/[0.04]' : 'border-white/10 bg-white/[0.02] hover:border-blue-500/25 hover:bg-white/[0.04]'
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl ${failed className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl ${
failed
? 'bg-red-500/10 text-red-300' ? 'bg-red-500/10 text-red-300'
: done : done
? 'bg-green-500/10 text-green-300' ? 'bg-green-500/10 text-green-300'
: active : active
? 'bg-blue-500/10 text-blue-300' ? 'bg-blue-500/10 text-blue-300'
: 'bg-slate-900 text-slate-500 group-hover:text-blue-300' : 'bg-slate-900 text-slate-500 group-hover:text-blue-300'
}`} }`}
> >
<Icon size={17} /> <Icon size={17} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-bold text-white">
{step.title}
</p>
<span className="font-mono text-[10px] text-slate-600">
{String(index + 1).padStart(2, '0')}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
{step.description}
</p>
</div>
</div> </div>
</button>
);
})}
</div>
</Card>
</div>
<Card className="col-span-7 flex min-h-0 flex-col overflow-hidden"> <div className="min-w-0 flex-1">
<div className="mb-4 flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <p className="truncate text-sm font-bold text-white">
<div className="rounded-2xl border border-blue-400/10 bg-blue-500/10 p-3 text-blue-300"> {step.title}
<Terminal size={22} /> </p>
</div>
<div> <span className="font-mono text-[10px] text-slate-600">
<h2 className="text-lg font-bold text-white"> {String(index + 1).padStart(2, '0')}
Log UDP2RAW </span>
</h2> </div>
<p className="text-sm text-slate-400"> <p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">
Saída dos comandos executados no router. {step.description}
</p> </p>
</div> </div>
</div> </div>
</button>
<button );
type="button" })}
disabled={running}
onClick={clearLog}
className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-bold text-slate-300 transition hover:border-blue-500/30 hover:bg-blue-500/10 hover:text-blue-100 disabled:cursor-not-allowed disabled:opacity-60"
>
Limpar
</button>
</div> </div>
<pre
className={`min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words rounded-3xl border border-white/10 bg-black/25 p-5 font-mono text-xs leading-6 text-slate-300 ${scrollClass}`}
>
{log.join('\n\n') || 'Ainda não há atividade UDP2RAW.'}
</pre>
</Card> </Card>
</div> </div>
<Card className="mt-4 flex min-h-[240px] flex-col overflow-hidden">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl border border-blue-400/10 bg-blue-500/10 p-3 text-blue-300">
<Terminal size={22} />
</div>
<div>
<h2 className="text-lg font-bold text-white">
Log UDP2RAW
</h2>
<p className="text-sm text-slate-400">
Saída dos comandos executados no router.
</p>
</div>
</div>
<button
type="button"
disabled={running}
onClick={clearLog}
className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-bold text-slate-300 transition hover:border-blue-500/30 hover:bg-blue-500/10 hover:text-blue-100 disabled:cursor-not-allowed disabled:opacity-60"
>
Limpar
</button>
</div>
<pre
className={`max-h-[280px] min-h-[150px] overflow-auto whitespace-pre-wrap break-words rounded-3xl border border-white/10 bg-black/25 p-5 font-mono text-xs leading-6 text-slate-300 ${scrollClass}`}
>
{log.join('\n\n') || 'Ainda não há atividade UDP2RAW.'}
</pre>
</Card>
</div> </div>
); );
} }