Initial project structure cleanup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user