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