Initial project structure cleanup

This commit is contained in:
litoral05
2026-05-08 16:57:55 +01:00
commit 8075104243
59 changed files with 22335 additions and 0 deletions
+315
View File
@@ -0,0 +1,315 @@
import { useEffect, useMemo, useState } from 'react';
import {
Download,
Search,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Select } from '@/components/ui/Select';
import {
clearActivityLogs,
exportActivityLogs,
getActivityLogs,
} from '@/services/activityLogService';
import type {
ActivityLogEntry,
ActivityLogLevel,
ActivityLogSource,
} from '@/types/activity';
const levels: Array<'all' | ActivityLogLevel> = [
'all',
'info',
'success',
'warning',
'error',
];
const sources: Array<'all' | ActivityLogSource> = [
'all',
'desktop',
'router',
'backend',
'vps',
];
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 [levelFilter, setLevelFilter] =
useState<'all' | ActivityLogLevel>('all');
const [sourceFilter, setSourceFilter] =
useState<'all' | ActivityLogSource>('all');
const [query, setQuery] = useState('');
function refreshLogs() {
setLogs(getActivityLogs());
}
useEffect(() => {
refreshLogs();
window.addEventListener(
'activity-log-added',
refreshLogs,
);
return () => {
window.removeEventListener(
'activity-log-added',
refreshLogs,
);
};
}, []);
const filteredLogs = useMemo(() => {
const normalizedQuery =
query.trim().toLowerCase();
return logs.filter((log) => {
const matchesLevel =
levelFilter === 'all' ||
log.level === levelFilter;
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);
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">
Activity Logs
</h2>
<p className="mt-1 text-slate-400">
Local provisioning audit trail for
technicians.
</p>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="secondary"
onClick={() => {
exportActivityLogs();
}}
>
<Download size={16} />
Export JSON
</Button>
<Button
type="button"
variant="danger"
onClick={() => {
clearActivityLogs();
refreshLogs();
}}
>
<Trash2 size={16} />
Clear Logs
</Button>
</div>
</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">
Search
</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="Search action, VPN IP, router IP, message..."
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">
Level
</label>
<Select
value={levelFilter}
onChange={setLevelFilter}
options={levels.map((level) => ({
value: level,
label: level,
}))}
/>
</div>
<div className="col-span-3">
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-slate-500">
Source
</label>
<Select
value={sourceFilter}
onChange={setSourceFilter}
options={sources.map((source) => ({
value: source,
label: 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">
Audit Events
</h3>
<span className="text-sm text-slate-500">
{filteredLogs.length} shown /{' '}
{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">
Time
</th>
<th className="w-[110px] px-4 py-3">
Level
</th>
<th className="w-[120px] px-4 py-3">
Source
</th>
<th className="w-[190px] px-4 py-3">
Action
</th>
<th className="px-4 py-3">
Message
</th>
<th className="w-[160px] px-4 py-3">
VPN IP
</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)}>
{log.level}
</Badge>
</td>
<td className="px-4 py-3 text-slate-300">
{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"
>
No activity logs found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import {
Cell,
Pie,
PieChart,
ResponsiveContainer,
} from 'recharts';
import { Card } from '@/components/ui/Card';
type IpPoolChartProps = {
used: number;
total: number;
};
export function IpPoolChart({
used,
total,
}: IpPoolChartProps) {
const available = Math.max(
total - used,
0,
);
const percentage =
total > 0
? ((used / total) * 100).toFixed(2)
: '0.00';
const data = [
{
name: 'Used',
value: used,
},
{
name: 'Available',
value: available,
},
];
return (
<Card className="h-full">
<h3 className="mb-4 font-semibold text-white">
IP Pool Usage
</h3>
<div className="flex h-[calc(100%-2rem)] items-center justify-between gap-6">
<div className="h-44 w-44">
<ResponsiveContainer
width="100%"
height="100%"
>
<PieChart>
<Pie
data={data}
dataKey="value"
innerRadius={55}
outerRadius={78}
paddingAngle={2}
>
{data.map((_, index) => (
<Cell
key={index}
fill={
index === 0
? '#19d16f'
: '#263244'
}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
<div className="text-sm text-slate-300">
<p>
<span className="text-green-300">
{used}
</span>{' '}
used IPs
</p>
<p className="mt-2">
<span className="text-blue-300">
{available}
</span>{' '}
available
</p>
<p className="mt-4 text-3xl font-bold text-white">
{percentage}%
</p>
</div>
</div>
</Card>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { ReactNode } from 'react';
import { Card } from '@/components/ui/Card';
type MetricCardProps = {
title: string;
value: string;
subtitle: string;
icon: ReactNode;
};
export function MetricCard({
title,
value,
subtitle,
icon,
}: MetricCardProps) {
return (
<Card className="h-full">
<div className="flex items-start gap-4">
<div className="rounded-2xl bg-blue-500/10 p-3 text-blue-300">
{icon}
</div>
<div>
<p className="text-sm text-slate-400">
{title}
</p>
<h3 className="mt-1 text-2xl font-bold text-white">
{value}
</h3>
<p className="mt-1 text-xs text-slate-400">
{subtitle}
</p>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Card } from '@/components/ui/Card';
import { vpsApi } from '@/services/vpsApi';
type TrafficPoint = {
time: string;
downloadMbps: number;
uploadMbps: number;
};
const MAX_POINTS = 24;
export function NetworkTrafficChart() {
const [points, setPoints] = useState<
TrafficPoint[]
>([]);
const [error, setError] = useState('');
useEffect(() => {
let mounted = true;
async function loadTraffic() {
try {
const response =
await vpsApi.networkTraffic();
if (!mounted) {
return;
}
const point: TrafficPoint = {
time: new Date(
response.updatedAt,
).toLocaleTimeString(),
downloadMbps:
response.downloadMbps,
uploadMbps:
response.uploadMbps,
};
setPoints((currentPoints) => [
...currentPoints.slice(
-(MAX_POINTS - 1),
),
point,
]);
setError('');
} catch (err) {
if (mounted) {
setError(String(err));
}
}
}
loadTraffic();
const intervalId = window.setInterval(
loadTraffic,
3000,
);
return () => {
mounted = false;
window.clearInterval(intervalId);
};
}, []);
return (
<Card className="h-full">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-semibold text-white">
Network Traffic
</h3>
<p className="text-xs text-slate-500">
Live wg0 RX/TX throughput
</p>
</div>
<span className="rounded-lg border border-white/10 bg-slate-900 px-3 py-1 text-xs text-slate-400">
3s refresh
</span>
</div>
{error && (
<div className="mb-3 rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-xs text-red-300">
{error}
</div>
)}
<div className="h-72">
<ResponsiveContainer
width="100%"
height="100%"
>
<AreaChart data={points}>
<CartesianGrid
strokeDasharray="3 3"
stroke="rgba(148,163,184,.12)"
/>
<XAxis
dataKey="time"
stroke="#94a3b8"
fontSize={12}
minTickGap={24}
/>
<YAxis
stroke="#94a3b8"
fontSize={12}
tickFormatter={(value) =>
`${value} Mbps`
}
/>
<Tooltip
contentStyle={{
background: '#0b1522',
border:
'1px solid rgba(255,255,255,.1)',
borderRadius: 12,
}}
formatter={(value) => [
`${Number(value).toFixed(
3,
)} Mbps`,
'',
]}
/>
<Area
type="monotone"
dataKey="downloadMbps"
name="Download"
stroke="#19d16f"
fill="#19d16f"
fillOpacity={0.2}
/>
<Area
type="monotone"
dataKey="uploadMbps"
name="Upload"
stroke="#3b82f6"
fill="#3b82f6"
fillOpacity={0.16}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</Card>
);
}
@@ -0,0 +1,77 @@
import {
CheckCircle,
Cpu,
FileUp,
KeyRound,
Network,
Plug,
RefreshCw,
ShieldCheck,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
const steps = [
['Connect Router', Plug],
['Upload Firmware', FileUp],
['Flash Firmware', Cpu],
['Wait & Reconnect', RefreshCw],
['Provision Router', Network],
['Get Public Key', KeyRound],
['Register Peer', ShieldCheck],
['Verify Connection', CheckCircle],
] as const;
export function ProvisioningWorkflow() {
return (
<Card className="h-full">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-white">
Provisioning Workflow
</h3>
<p className="mt-1 text-sm text-slate-400">
OpenWrt 23.05 production
provisioning pipeline
</p>
</div>
<span className="rounded-full border border-green-500/20 bg-green-500/10 px-3 py-1 text-xs text-green-300">
Technician Mode
</span>
</div>
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-8">
{steps.map(([step, Icon], index) => (
<div
key={step}
className="relative rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-all hover:border-blue-500/30 hover:bg-white/[0.05]"
>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">
Step {index + 1}
</span>
<Icon
className={`h-5 w-5 ${
index === 6
? 'text-green-300'
: 'text-slate-400'
}`}
/>
</div>
<p className="mt-4 text-sm font-semibold text-white">
{step}
</p>
{index !== steps.length - 1 && (
<div className="absolute -right-2 top-1/2 hidden h-px w-4 bg-white/10 xl:block" />
)}
</div>
))}
</div>
</Card>
);
}
@@ -0,0 +1,43 @@
import { Card } from '@/components/ui/Card';
const rows = [
'Peer added 198.19.254.3',
'Peer updated 198.19.254.2',
'Backup created wg0.conf.bak',
'Health check all systems operational',
];
export function RecentActivityPanel() {
return (
<Card className="h-full">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-white">
Recent Activity
</h3>
<span className="text-xs text-slate-500">
Live Feed
</span>
</div>
<div className="mt-4 divide-y divide-white/10">
{rows.map((row, index) => (
<div
key={row}
className="flex items-center justify-between py-3 text-sm"
>
<span className="text-slate-200">
{row}
</span>
<span className="text-xs text-slate-500">
{index
? `${index * 15}m ago`
: '2m ago'}
</span>
</div>
))}
</div>
</Card>
);
}
@@ -0,0 +1,54 @@
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { StatusDot } from '@/components/ui/StatusDot';
const services = [
'WireGuard wg0',
'DNAT rules',
'IP forwarding',
'Persistent keepalive',
'Backup system',
];
export function ServicesHealthPanel() {
return (
<Card className="h-full">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-white">
Services Health
</h3>
<span className="text-xs text-slate-500">
fw4 / nftables
</span>
</div>
<div className="mt-5 space-y-4">
{services.map((service) => (
<div
key={service}
className="flex items-center justify-between rounded-xl border border-white/5 bg-white/[0.02] px-4 py-3"
>
<div className="flex items-center gap-3">
<StatusDot />
<div>
<p className="text-sm font-medium text-white">
{service}
</p>
<p className="text-xs text-slate-500">
Running baseline check
</p>
</div>
</div>
<Badge>
Running
</Badge>
</div>
))}
</div>
</Card>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { PropsWithChildren } from 'react';
import { Sidebar } from './Sidebar';
type AppShellProps = PropsWithChildren<{
active: string;
onSelect: (value: string) => void;
}>;
export function AppShell({
active,
onSelect,
children,
}: AppShellProps) {
return (
<div className="flex h-screen overflow-hidden bg-ink-950 text-white">
<Sidebar active={active} onSelect={onSelect} />
<main className="flex-1 overflow-hidden p-4">
{children}
</main>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import {
FileClock,
Gauge,
RadioTower,
Settings,
Shield,
Wrench,
} from 'lucide-react';
const items = [
['Dashboard', Gauge],
['UDP2RAW Config', RadioTower],
['Provisioning', Wrench],
['Activity Logs', FileClock],
['Workstation', Settings],
] as const;
type SidebarProps = {
active: string;
onSelect: (value: string) => void;
};
export function Sidebar({
active,
onSelect,
}: SidebarProps) {
return (
<aside className="flex h-screen w-64 flex-col border-r border-white/10 bg-ink-950 px-4 py-5">
<div className="mb-8 flex items-center gap-3">
<div className="rounded-2xl bg-blue-500/15 p-3 text-blue-300">
<Shield />
</div>
<div>
<h1 className="font-bold text-white">
Litoral Regas
</h1>
<p className="text-xs text-slate-400">
VPN Orchestrator
</p>
</div>
</div>
<nav className="space-y-1">
{items.map(([label, Icon]) => {
const isActive = active === label;
return (
<button
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'
}`}
>
<Icon size={18} />
{label}
</button>
);
})}
</nav>
<div className="mt-auto rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex items-center gap-2 text-sm text-green-300">
<span className="h-2 w-2 rounded-full bg-green-400" />
Backend connected
</div>
<p className="mt-2 text-xs text-slate-400">
localhost:8080
</p>
<div className="mt-5 border-t border-white/10 pt-4 text-xs text-slate-500">
Version 1.0.0
</div>
</div>
</aside>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { RefreshCw } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
export function TopBar() {
return (
<header className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white">
Dashboard
</h2>
<p className="mt-1 text-slate-400">
OpenWrt 23.05 WireGuard
production router provisioning
</p>
</div>
<div className="flex items-center gap-4 text-sm text-slate-400">
<div className="flex items-center gap-2">
<RefreshCw size={16} />
<span>
Last updated:{' '}
{new Date().toLocaleTimeString()}
</span>
</div>
<Badge>
All Systems Operational
</Badge>
</div>
</header>
);
}
@@ -0,0 +1,33 @@
import { Card } from '@/components/ui/Card';
import type { ProvisioningState } from '@/types/provisioning';
type ProvisioningStepCardProps = {
state: ProvisioningState;
active: boolean;
};
export function ProvisioningStepCard({
state,
active,
}: ProvisioningStepCardProps) {
return (
<Card
className={
active
? 'border border-blue-400/40 bg-blue-500/10'
: 'border border-white/10'
}
>
<p
className={`text-sm font-semibold ${
active
? 'text-blue-200'
: 'text-slate-300'
}`}
>
{state.replace(/_/g, ' ')}
</p>
</Card>
);
}
@@ -0,0 +1,279 @@
import { useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { ProvisioningStepCard } from './ProvisioningStepCard';
import { addActivityLog } from '@/services/activityLogService';
import { vpnApi } from '@/services/vpnApi';
import type {
ProvisioningState,
ProvisionMode,
} from '@/types/provisioning';
const states: ProvisioningState[] = [
'IDLE',
'DETECT_ROUTER',
'UPLOAD_FIRMWARE',
'FLASHING',
'WAITING_FOR_REBOOT',
'WAITING_FOR_RECONNECT',
'UPLOAD_PROVISIONING_BUNDLE',
'RUN_PROVISIONING',
'CAPTURE_PUBLIC_KEY',
'REGISTER_PEER',
'VERIFY',
'COMPLETE',
];
const waitMessage =
'Router rebooting or network changed. Please reconnect/replug the Ethernet cable if needed. Waiting for router at 198.51.100.1...';
function buildRouterEnv(vpnIp: string) {
const routerIdParts = vpnIp.split('.');
const routerId = routerIdParts[routerIdParts.length - 1];
return [
`ROUTER_ID=${routerId}`,
`ROUTER_HOSTNAME=Litoral_Regas_${routerId}`,
`ROUTER_WG_IP=${vpnIp}`,
'LAN_SUBNET=198.51.100.0/24',
'ROUTER_LAN_IP=198.51.100.1',
'CONTROLLER_IP=198.51.100.10',
'PLC_IP=198.51.100.50',
'OVERLAY_ROUTE=198.19.0.0/16',
'ROUTER_PASSWORD=litoralr',
'',
].join('\n');
}
export function ProvisioningWizard() {
const [mode, setMode] =
useState<ProvisionMode>('new');
const [state, setState] =
useState<ProvisioningState>('IDLE');
const [vpnIp, setVpnIp] = useState('');
const [log, setLog] = useState<string[]>([]);
const activeIndex = useMemo(
() => states.indexOf(state),
[state],
);
function addLog(message: string) {
setLog((currentLog) => [
`${new Date().toLocaleTimeString()} ${message}`,
...currentLog,
]);
addActivityLog({
level: 'info',
source: 'desktop',
action: state,
message,
routerIp:
state.includes('RECONNECT') ||
state.includes('PROVISION')
? '198.51.100.1'
: '192.168.1.1',
vpnIp: vpnIp || undefined,
});
}
async function runWorkflow() {
try {
setState('DETECT_ROUTER');
addLog(
'Removing stale known_hosts entries for 192.168.1.1 and 198.51.100.1',
);
await invoke('detect_router', {
ip: '192.168.1.1',
});
const selectedVpnIp =
mode === 'new'
? (await vpnApi.availableIp()).vpnIp
: vpnIp;
setVpnIp(selectedVpnIp);
addLog(`Using WireGuard IP ${selectedVpnIp}`);
setState('UPLOAD_FIRMWARE');
await invoke('upload_firmware', {
ip: '192.168.1.1',
firmwarePath:
'./firmware/openwrt-23.05-zbt-we826-16m.bin',
});
setState('FLASHING');
await invoke('flash_router', {
ip: '192.168.1.1',
remoteFirmwarePath: '/tmp/firmware.bin',
});
setState('WAITING_FOR_REBOOT');
addLog(waitMessage);
setState('WAITING_FOR_RECONNECT');
await invoke('wait_for_ssh', {
ip: '198.51.100.1',
});
setState('UPLOAD_PROVISIONING_BUNDLE');
await invoke('upload_provisioning_bundle', {
ip: '198.51.100.1',
envContent: buildRouterEnv(selectedVpnIp),
scriptContent:
'#!/bin/sh\n# future production provision.sh\n',
});
setState('RUN_PROVISIONING');
await invoke('run_provisioning', {
ip: '198.51.100.1',
});
setState('CAPTURE_PUBLIC_KEY');
const publicKey = await invoke<string>(
'capture_wireguard_public_key',
{
ip: '198.51.100.1',
},
);
addLog(
`Captured public key ${publicKey.slice(
0,
12,
)}...`,
);
setState('REGISTER_PEER');
await vpnApi.registerPeer({
vpnIp: selectedVpnIp,
publicKey,
});
setState('VERIFY');
await invoke('verify_router', {
ip: '198.51.100.1',
});
setState('COMPLETE');
addLog(
'Provisioning complete. LuCI is available over WireGuard.',
);
} catch (error) {
setState('ERROR');
addLog(String(error));
}
}
return (
<div className="space-y-5">
<Card>
<div className="flex flex-wrap items-end gap-4">
<div>
<h3 className="text-xl font-bold text-white">
Router Provisioning Wizard
</h3>
<p className="text-sm text-slate-400">
Validated baseline: OpenWrt
23.05, ZBT-WE826 16M,
fw4/nftables, no opkg upgrade.
</p>
</div>
<select
className="rounded-xl border border-white/10 bg-ink-950 p-2 text-sm text-white"
value={mode}
onChange={(event) =>
setMode(
event.target.value as ProvisionMode,
)
}
>
<option value="new">
New Router
</option>
<option value="reprovision">
Reprovision existing VPN IP
</option>
</select>
{mode === 'reprovision' && (
<input
className="rounded-xl border border-white/10 bg-ink-950 p-2 text-sm text-white"
placeholder="198.19.1.203"
value={vpnIp}
onChange={(event) =>
setVpnIp(event.target.value)
}
/>
)}
<Button onClick={runWorkflow}>
Start Workflow
</Button>
<Badge
tone={
state === 'ERROR'
? 'red'
: state === 'COMPLETE'
? 'green'
: 'blue'
}
>
{state}
</Badge>
</div>
{(state === 'WAITING_FOR_REBOOT' ||
state ===
'WAITING_FOR_RECONNECT') && (
<p className="mt-4 rounded-xl bg-purple-500/10 p-4 text-purple-200">
{waitMessage}
</p>
)}
</Card>
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{states.map((provisioningState, index) => (
<ProvisioningStepCard
key={provisioningState}
state={provisioningState}
active={index === activeIndex}
/>
))}
</div>
<Card>
<h4 className="mb-3 font-semibold text-white">
Technician Log
</h4>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap text-sm text-slate-300">
{log.join('\n')}
</pre>
</Card>
</div>
);
}
+293
View File
@@ -0,0 +1,293 @@
import { useMemo, useState } from 'react';
import {
CheckCircle2,
KeyRound,
Link2,
MonitorCog,
Save,
Server,
ShieldCheck,
} from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import {
getSettings,
saveSettings,
} from '@/services/apiClient';
const DEFAULT_OVERLAY_ROUTE = '198.19.0.0/16';
const DEFAULT_ROUTER_IP = '198.51.100.1';
const DEFAULT_CONTROLLER_IP = '198.51.100.10';
const DEFAULT_PLC_IP = '198.51.100.50';
const DEFAULT_FIRMWARE =
'openwrt-23.05-zbt-we826-16m.bin';
export function BackendSettings() {
const [settings, setSettings] =
useState(getSettings());
const [saved, setSaved] = useState(false);
const backendHost = useMemo(() => {
try {
return new URL(settings.backendUrl).host;
} catch {
return 'Invalid backend URL';
}
}, [settings.backendUrl]);
function handleSave() {
saveSettings(settings);
setSaved(true);
window.setTimeout(() => {
setSaved(false);
}, 2500);
}
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">
Workstation
</h1>
<p className="mt-1 text-slate-400">
Local technician console configuration
for router provisioning.
</p>
</div>
<Badge tone={saved ? 'green' : 'blue'}>
{saved ? 'Saved' : 'Local Config'}
</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">
<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>
<h2 className="text-xl font-semibold text-white">
Backend Connectivity
</h2>
<p className="text-sm text-slate-400">
API endpoint used for VPN IP
assignment and peer registration.
</p>
</div>
</div>
<div className="space-y-5">
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-wide text-slate-500">
Backend URL
</label>
<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">
API Key
</label>
<div className="relative">
<KeyRound
size={16}
className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/70"
/>
<input
value={settings.apiKey}
onChange={(event) =>
setSettings({
...settings,
apiKey: event.target.value,
})
}
className="w-full rounded-2xl border border-white/10 bg-slate-950/80 py-4 pl-11 pr-4 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/50 focus:bg-slate-950"
placeholder="dev-api-key"
/>
</div>
</div>
</div>
<div 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">
Connected Target
</p>
<p className="mt-1 font-mono text-sm text-slate-400">
{backendHost}
</p>
</div>
<Badge tone="green">
Ready
</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"
>
<Save
size={16}
className="transition-transform duration-200 group-hover:rotate-6"
/>
<span className="transition-colors group-hover:text-blue-200">
Save Workstation Config
</span>
</button>
</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">
Provisioning Baseline
</h2>
<p className="text-sm text-slate-400">
Read-only production values.
</p>
</div>
</div>
<div className="grid gap-3">
<InfoRow
label="Overlay Route"
value={DEFAULT_OVERLAY_ROUTE}
/>
<InfoRow
label="Router LAN IP"
value={DEFAULT_ROUTER_IP}
/>
<InfoRow
label="Controller IP"
value={DEFAULT_CONTROLLER_IP}
/>
<InfoRow
label="PLC IP"
value={DEFAULT_PLC_IP}
/>
<InfoRow
label="Firmware Target"
value={DEFAULT_FIRMWARE}
/>
</div>
</Card>
<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">
Production Profile
</h2>
<p className="mt-3 text-sm leading-6 text-slate-400">
OpenWrt 23.05 baseline,
ZBT-WE826 16M target,
fw4/nftables firewall,
LuCI over WireGuard, stable
LAN topology and automated VPS
peer registration.
</p>
<div className="mt-5 flex flex-wrap gap-2">
<Badge tone="blue">
OpenWrt 23.05
</Badge>
<Badge tone="purple">
WireGuard
</Badge>
<Badge tone="green">
fw4/nftables
</Badge>
</div>
</div>
</div>
</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} />
Workstation configuration saved.
</div>
</div>
)}
</div>
</div>
</div>
);
}
function InfoRow({
label,
value,
}: {
label: string;
value: string;
}) {
return (
<div className="rounded-2xl border border-white/10 bg-slate-950/70 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
{label}
</p>
<p className="mt-2 break-all font-mono text-sm text-slate-100">
{value}
</p>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { PropsWithChildren } from 'react';
import { clsx } from 'clsx';
type BadgeTone =
| 'green'
| 'blue'
| 'purple'
| 'slate'
| 'red';
type BadgeProps = PropsWithChildren<{
tone?: BadgeTone;
}>;
export function Badge({
children,
tone = 'green',
}: BadgeProps) {
const tones: Record<BadgeTone, string> = {
green:
'bg-green-500/15 text-green-300',
blue:
'bg-blue-500/15 text-blue-300',
purple:
'bg-purple-500/15 text-purple-300',
slate:
'bg-slate-500/15 text-slate-300',
red:
'bg-red-500/15 text-red-300',
};
return (
<span
className={clsx(
'rounded-lg px-2.5 py-1 text-xs font-semibold',
tones[tone],
)}
>
{children}
</span>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { ButtonHTMLAttributes } from 'react';
import { clsx } from 'clsx';
type ButtonVariant =
| 'primary'
| 'secondary'
| 'danger';
type ButtonProps =
ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
};
const variants: Record<ButtonVariant, string> = {
primary:
'bg-blue-500 text-white hover:bg-blue-400',
secondary:
'border border-white/10 bg-white/[0.04] text-slate-200 hover:bg-white/[0.08]',
danger:
'border border-red-500/20 bg-red-500/10 text-red-300 hover:bg-red-500/20',
};
export function Button({
className,
children,
variant = 'primary',
...props
}: ButtonProps) {
return (
<button
{...props}
className={clsx(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50',
variants[variant],
className,
)}
>
{children}
</button>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { PropsWithChildren } from 'react';
import { clsx } from 'clsx';
type CardProps = PropsWithChildren<{
className?: string;
}>;
export function Card({
className,
children,
}: CardProps) {
return (
<section
className={clsx(
'rounded-2xl border border-white/10 bg-ink-850/80 p-5 shadow-glow backdrop-blur',
className,
)}
>
{children}
</section>
);
}
+23
View File
@@ -0,0 +1,23 @@
type ProgressProps = {
value: number;
};
export function Progress({
value,
}: ProgressProps) {
const clampedValue = Math.max(
0,
Math.min(100, value),
);
return (
<div className="h-2 overflow-hidden rounded-full bg-slate-800">
<div
className="h-2 rounded-full bg-green-400 transition-all"
style={{
width: `${clampedValue}%`,
}}
/>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
type SelectOption<T extends string> = {
value: T;
label: string;
};
type SelectProps<T extends string> = {
value: T;
options: SelectOption<T>[];
onChange: (value: T) => void;
};
export function Select<T extends string>({
value,
options,
onChange,
}: SelectProps<T>) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
const selected =
options.find((option) => option.value === value) ??
options[0];
useEffect(() => {
function handleClick(event: MouseEvent) {
if (
ref.current &&
!ref.current.contains(event.target as Node)
) {
setOpen(false);
}
}
window.addEventListener('mousedown', handleClick);
return () => {
window.removeEventListener('mousedown', handleClick);
};
}, []);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="flex w-full items-center justify-between rounded-xl border border-white/10 bg-slate-950 px-3 py-3 text-left text-sm text-slate-200 outline-none transition hover:border-blue-500/30 focus:border-blue-500/40"
>
<span>{selected.label}</span>
<span className="text-xs text-slate-500"></span>
</button>
{open && (
<div className="absolute z-50 mt-2 w-full overflow-hidden rounded-xl border border-white/10 bg-slate-950 shadow-2xl">
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value);
setOpen(false);
}}
className={`block w-full px-3 py-2 text-left text-sm transition ${
active
? 'bg-blue-500/15 text-blue-200'
: 'text-slate-300 hover:bg-white/5 hover:text-white'
}`}
>
{option.label}
</button>
);
})}
</div>
)}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
type StatusDotProps = {
online?: boolean;
};
export function StatusDot({
online = true,
}: StatusDotProps) {
return (
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${
online
? 'bg-green-400 shadow-[0_0_14px_rgba(74,222,128,.8)]'
: 'bg-red-400'
}`}
/>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { vpnApi } from '@/services/vpnApi';
export function IpManagementPanel() {
const [ip, setIp] = useState('');
async function handleReserveIp() {
try {
const response =
await vpnApi.availableIp();
setIp(response.vpnIp);
} catch (error) {
console.error(
'Failed to reserve IP:',
error,
);
}
}
return (
<Card className="h-full">
<h3 className="mb-3 font-semibold text-white">
IP Management
</h3>
<p className="mb-4 text-sm text-slate-400">
Overlay route: 198.19.0.0/16
</p>
<div className="flex items-center gap-3">
<Button onClick={handleReserveIp}>
Reserve Next Available IP
</Button>
</div>
{ip && (
<div className="mt-4 rounded-xl border border-green-500/20 bg-green-500/10 p-4">
<p className="text-sm text-green-300">
Assigned candidate:
</p>
<p className="mt-1 font-mono text-lg font-semibold text-white">
{ip}
</p>
</div>
)}
</Card>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { Card } from '@/components/ui/Card';
const peers = [
[
'198.19.254.1',
'Q2o9...h8jK',
'2m ago',
'1.2 MB',
],
[
'198.19.254.2',
'P9xL...a7Bc',
'3m ago',
'532 KB',
],
[
'198.19.254.3',
'J4kM...z9Xp',
'1m ago',
'2.1 MB',
],
];
export function VpnPeersTable() {
return (
<Card className="h-full">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-white">
VPN Peers
</h3>
<span className="rounded-full bg-green-500/10 px-3 py-1 text-xs text-green-300">
3 Active
</span>
</div>
<div className="mt-4 overflow-hidden rounded-xl border border-white/10">
<table className="w-full text-sm">
<thead className="bg-slate-900/80 text-left text-slate-400">
<tr>
<th className="px-4 py-3">
VPN IP
</th>
<th className="px-4 py-3">
Public Key
</th>
<th className="px-4 py-3">
Last Handshake
</th>
<th className="px-4 py-3 text-right">
Transfer
</th>
</tr>
</thead>
<tbody>
{peers.map((peer) => (
<tr
key={peer[0]}
className="border-t border-white/10 hover:bg-white/[0.03]"
>
<td className="px-4 py-3 text-green-300">
{peer[0]}
</td>
<td className="px-4 py-3 font-mono text-slate-300">
{peer[1]}
</td>
<td className="px-4 py-3 text-slate-400">
{peer[2]}
</td>
<td className="px-4 py-3 text-right text-slate-300">
{peer[3]}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}