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
+58
View File
@@ -0,0 +1,58 @@
# =========================
# Node / Frontend
# =========================
node_modules/
dist/
build/
.vite/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# =========================
# Tauri / Rust
# =========================
src-tauri/target/
target/
# Generated Tauri artifacts
src-tauri/gen/
# =========================
# Environment / Secrets
# =========================
.env
.env.*
!.env.example
# =========================
# IDE
# =========================
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# =========================
# OS
# =========================
.DS_Store
Thumbs.db
# =========================
# Tauri local config/cache
# =========================
src-tauri/.cargo/
src-tauri/.rustup/
# =========================
# Temporary exports/logs
# =========================
exports/
logs/
*.tmp
+117
View File
@@ -0,0 +1,117 @@
# Litoral Regas VPN Orchestrator
A dark-themed Tauri desktop technician app for provisioning Litoral_Regas OpenWrt WireGuard production routers.
## Production baseline
The app is structured around the validated router baseline:
- OpenWrt 23.05 only
- ZBT-WE826 16M target firmware
- fw4/nftables only
- no `opkg upgrade`
- 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`
- Hostname format: `Litoral_Regas_XXX`
- LuCI over WireGuard
- VPN firewall zone and DNAT rules:
- router WG IP `:5900` -> `198.51.100.10:5900`
- router WG IP `:20248` -> `198.51.100.10:20249`
- router WG IP `:8000` -> `198.51.100.10:8000`
- router WG IP `:81` -> `198.51.100.50:81`
## Install
```bash
npm install
npm install lucide-react recharts clsx tailwind-merge
npm install -D tailwindcss postcss autoprefixer
```
## Development
```bash
npm run tauri:dev
```
Equivalent bootstrap command sequence:
```bash
npm create tauri-app@latest lr-openwrt-tool
# choose React, TypeScript, npm
cd lr-openwrt-tool
npm install
npm run tauri:dev
```
## Build
```bash
npm run tauri:build
```
## Backend assumptions
Development defaults are stored in app settings, not hardcoded as production secrets:
- Base URL: `http://localhost:8080`
- Header: `X-API-Key: dev-api-key`
Implemented API client calls:
- `GET /api/vpn/available-ip`
- `GET /api/vpn/used-ips`
- `POST /api/vpn/peers`
- `GET /api/vps/health`
- `POST /api/vps/wireguard/rollback-last-backup`
## Provisioning flow
The provisioning wizard models the technician workflow as a state machine:
1. `IDLE`
2. `DETECT_ROUTER`
3. `UPLOAD_FIRMWARE`
4. `FLASHING`
5. `WAITING_FOR_REBOOT`
6. `WAITING_FOR_RECONNECT`
7. `UPLOAD_PROVISIONING_BUNDLE`
8. `RUN_PROVISIONING`
9. `CAPTURE_PUBLIC_KEY`
10. `REGISTER_PEER`
11. `VERIFY`
12. `COMPLETE`
13. `ERROR`
New router flow:
1. Detect router at `192.168.1.1`.
2. Request next available VPN IP from the backend.
3. Generate `router.env` using the production baseline.
4. Upload firmware, flash router, and wait for SSH at `198.51.100.1`.
5. Upload `router.env` and `provision.sh`.
6. Run provisioning.
7. Capture WireGuard public key.
8. Register peer with `POST /api/vpn/peers`.
9. Verify final router state.
Reprovision flow keeps the selected existing VPN IP and replaces the public key through the same `POST /api/vpn/peers` endpoint.
## Current Tauri command status
The following commands are implemented as mock placeholders with production-ready signatures:
- `detect_router(ip)`
- `upload_firmware(ip, firmwarePath)`
- `flash_router(ip, remoteFirmwarePath)`
- `wait_for_ssh(ip)`
- `upload_provisioning_bundle(ip, envContent, scriptContent)`
- `run_provisioning(ip)`
- `capture_wireguard_public_key(ip)`
- `verify_router(ip)`
Future work should replace the mock bodies with real SSH/SCP/sysupgrade logic and explicitly call `remove_known_host` before reconnect attempts for `192.168.1.1` and `198.51.100.1`.
+1
View File
@@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
+2759
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"scripts":{"dev":"vite","build":"tsc && vite build","preview":"vite preview","tauri":"tauri","tauri:dev":"tauri dev","tauri:build":"tauri build"},"dependencies":{"@tauri-apps/api":"latest","@tauri-apps/plugin-dialog":"^2.7.1","@tauri-apps/plugin-fs":"^2.5.1","@vitejs/plugin-react":"latest","clsx":"latest","lucide-react":"latest","react":"latest","react-dom":"latest","recharts":"latest","tailwind-merge":"latest"},"devDependencies":{"@tauri-apps/cli":"latest","@types/node":"^25.6.2","@types/react":"^19.2.14","@types/react-dom":"^19.2.3","autoprefixer":"^10.5.0","postcss":"^8.5.14","tailwindcss":"^3.4.19","typescript":"latest","vite":"latest"}}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+4703
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "lr-openwrt-tool"
version = "0.1.0"
description = "Litoral Regas VPN Orchestrator"
edition = "2021"
[lib]
name = "lr_openwrt_tool_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
+1
View File
@@ -0,0 +1 @@
fn main() { tauri_build::build() }
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop permissions",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-save",
"fs:allow-write-text-file"
]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default desktop permissions","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-save","fs:allow-write-text-file"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+12
View File
@@ -0,0 +1,12 @@
#[tauri::command]
pub async fn read_text_file(
path: String,
) -> Result<String, String> {
std::fs::read_to_string(&path)
.map_err(|error| {
format!(
"failed to read {}: {}",
path, error,
)
})
}
+4
View File
@@ -0,0 +1,4 @@
pub mod files;
pub mod network;
pub mod router;
pub mod ssh;
+13
View File
@@ -0,0 +1,13 @@
#[tauri::command]
pub async fn ping_host(
ip: String,
) -> Result<bool, String> {
if ip.trim().is_empty() {
return Err(
"ip address cannot be empty"
.to_string(),
);
}
Ok(true)
}
+125
View File
@@ -0,0 +1,125 @@
use std::{
thread,
time::Duration,
};
fn delay() {
thread::sleep(Duration::from_millis(350));
}
#[tauri::command]
pub async fn detect_router(
ip: String,
) -> Result<bool, String> {
delay();
if ip.trim().is_empty() {
return Err(
"router IP is required".into(),
);
}
Ok(true)
}
#[tauri::command]
pub async fn upload_firmware(
ip: String,
firmware_path: String,
) -> Result<String, String> {
delay();
Ok(format!(
"uploaded {} to {}:/tmp/firmware.bin",
firmware_path, ip,
))
}
#[tauri::command]
pub async fn flash_router(
ip: String,
remote_firmware_path: String,
) -> Result<String, String> {
delay();
Ok(format!(
"sysupgrade started on {} with {}",
ip, remote_firmware_path,
))
}
#[tauri::command]
pub async fn wait_for_ssh(
ip: String,
) -> Result<bool, String> {
for _ in 0..3 {
delay();
}
if ip == "198.51.100.1"
|| ip == "192.168.1.1"
{
Ok(true)
} else {
Err(format!(
"SSH timeout waiting for {}",
ip,
))
}
}
#[tauri::command]
pub async fn upload_provisioning_bundle(
ip: String,
env_content: String,
script_content: String,
) -> Result<String, String> {
delay();
Ok(format!(
"uploaded router.env ({} bytes) and provision.sh ({} bytes) to {}",
env_content.len(),
script_content.len(),
ip,
))
}
#[tauri::command]
pub async fn run_provisioning(
ip: String,
) -> Result<String, String> {
delay();
Ok(format!(
"provision.sh completed on {}; fw4/nftables, wg0, DNAT, LuCI over WireGuard configured",
ip,
))
}
#[tauri::command]
pub async fn capture_wireguard_public_key(
_ip: String,
) -> Result<String, String> {
delay();
Ok(
"MOCK_ROUTER_WIREGUARD_PUBLIC_KEY_BASE64="
.into(),
)
}
#[tauri::command]
pub async fn verify_router(
ip: String,
) -> Result<bool, String> {
delay();
if ip == "198.51.100.1" {
Ok(true)
} else {
Err(
"router verification failed"
.into(),
)
}
}
+16
View File
@@ -0,0 +1,16 @@
#[tauri::command]
pub async fn remove_known_host(
ip: String,
) -> Result<String, String> {
if ip.trim().is_empty() {
return Err(
"ip address cannot be empty"
.into(),
);
}
Ok(format!(
"removed stale known_hosts entry for {}",
ip,
))
}
+39
View File
@@ -0,0 +1,39 @@
mod commands;
use commands::{
files::read_text_file,
network::ping_host,
router::{
capture_wireguard_public_key,
detect_router,
flash_router,
run_provisioning,
upload_firmware,
upload_provisioning_bundle,
verify_router,
wait_for_ssh,
},
ssh::remove_known_host,
};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![
read_text_file,
ping_host,
remove_known_host,
detect_router,
upload_firmware,
flash_router,
wait_for_ssh,
upload_provisioning_bundle,
run_provisioning,
capture_wireguard_public_key,
verify_router,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+1
View File
@@ -0,0 +1 @@
fn main() { lr_openwrt_tool_lib::run() }
+35
View File
@@ -0,0 +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"
]
}
}
+17
View File
@@ -0,0 +1,17 @@
import { useState } from 'react';
import { AppShell } from '@/components/layout/AppShell';
import { RouteView } from './routes';
export default function App() {
const [active, setActive] = useState('Dashboard');
return (
<AppShell
active={active}
onSelect={setActive}
>
<RouteView active={active} />
</AppShell>
);
}
+216
View File
@@ -0,0 +1,216 @@
import { useEffect, useMemo, useState } from 'react';
import {
Clock,
Database,
Server,
ShieldCheck,
} from 'lucide-react';
import { TopBar } from '@/components/layout/TopBar';
import { MetricCard } from '@/components/dashboard/MetricCard';
import { ProvisioningWorkflow } from '@/components/dashboard/ProvisioningWorkflow';
import { IpPoolChart } from '@/components/dashboard/IpPoolChart';
import { NetworkTrafficChart } from '@/components/dashboard/NetworkTrafficChart';
import { VpnPeersTable } from '@/components/vpn/VpnPeersTable';
import { IpManagementPanel } from '@/components/vpn/IpManagementPanel';
import { ProvisioningWizard } from '@/components/provisioning/ProvisioningWizard';
import { BackendSettings } from '@/components/settings/BackendSettings';
import { ActivityLogs } from '@/components/activity/ActivityLogs';
import { Card } from '@/components/ui/Card';
import { vpnApi } from '@/services/vpnApi';
import { vpsApi } from '@/services/vpsApi';
import type {
UsedIpsResponse,
VpsHealth,
} from '@/types/api';
export function DashboardRoute() {
const [health, setHealth] =
useState<VpsHealth | null>(null);
const [usedIps, setUsedIps] =
useState<UsedIpsResponse | null>(null);
const [error, setError] = useState('');
useEffect(() => {
async function loadDashboard() {
try {
setError('');
const [
healthResponse,
usedIpsResponse,
] = await Promise.all([
vpsApi.health(),
vpnApi.usedIps(),
]);
setHealth(healthResponse);
setUsedIps(usedIpsResponse);
} catch (err) {
setError(String(err));
}
}
loadDashboard();
const intervalId = window.setInterval(
loadDashboard,
15000,
);
return () => {
window.clearInterval(intervalId);
};
}, []);
const ipPoolTotal =
health?.ipPoolTotal ?? 65534;
const usedCount =
health?.ipPoolUsed ??
usedIps?.count ??
0;
const ipPoolPercent = useMemo(() => {
return (
(usedCount / ipPoolTotal) *
100
).toFixed(2);
}, [usedCount, ipPoolTotal]);
const vpnHealthy =
health?.wireGuardRunning ?? false;
const backendHealthy =
health?.backend ?? !error;
return (
<div className="flex h-full flex-col overflow-hidden">
<TopBar />
{error && (
<div className="mb-3 rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-300">
Dashboard backend error:{' '}
{error}
</div>
)}
<div className="grid grid-cols-4 gap-4">
<MetricCard
title="VPN Status"
value={
vpnHealthy
? 'Connected'
: 'Offline'
}
subtitle={
health?.wireGuardInterface
? `${health.wireGuardInterface} interface`
: 'WireGuard wg0 status'
}
icon={<ShieldCheck />}
/>
<MetricCard
title="IP Pool Usage"
value={`${ipPoolPercent}%`}
subtitle={`${usedCount} / ${ipPoolTotal} IPs used`}
icon={<Database />}
/>
<MetricCard
title="VPS Uptime"
value={
health?.systemUptime ??
'Unknown'
}
subtitle="Reported by VPS health"
icon={<Clock />}
/>
<MetricCard
title="Backend Health"
value={
backendHealthy
? 'Healthy'
: 'Offline'
}
subtitle="API connectivity"
icon={<Server />}
/>
</div>
<div className="mt-4 grid flex-1 grid-cols-12 grid-rows-[minmax(0,1fr)_auto] gap-4 overflow-hidden">
<div className="col-span-9 min-h-0">
<NetworkTrafficChart />
</div>
<div className="col-span-3 min-h-0">
<IpPoolChart
used={usedCount}
total={ipPoolTotal}
/>
</div>
<div className="col-span-12">
<ProvisioningWorkflow />
</div>
</div>
</div>
);
}
export function PlaceholderRoute({
name,
}: {
name: string;
}) {
return (
<Card>
<h2 className="text-2xl font-bold">
{name}
</h2>
<p className="mt-2 text-slate-400">
Screen scaffold ready for production
implementation.
</p>
</Card>
);
}
export function RouteView({
active,
}: {
active: string;
}) {
if (active === 'Dashboard') {
return <DashboardRoute />;
}
if (active === 'Provisioning') {
return <ProvisioningWizard />;
}
if (active === 'UDP2RAW Config') {
return (
<PlaceholderRoute name="UDP2RAW Config" />
);
}
if (active === 'Activity Logs') {
return <ActivityLogs />;
}
if (active === 'Workstation') {
return <BackendSettings />;
}
return <PlaceholderRoute name={active} />;
}
+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>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './app/App';
import './styles/globals.css';
const rootElement =
document.getElementById('root');
if (!rootElement) {
throw new Error(
'Root element not found',
);
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+76
View File
@@ -0,0 +1,76 @@
import type {
ActivityLogEntry,
ActivityLogLevel,
ActivityLogSource,
} from "@/types/activity";
import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
const STORAGE_KEY = "lr-openwrt-tool.activity-logs";
export function getActivityLogs(): ActivityLogEntry[] {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
} catch {
return [];
}
}
export function addActivityLog(input: {
level: ActivityLogLevel;
source: ActivityLogSource;
action: string;
message: string;
routerIp?: string;
vpnIp?: string;
}) {
const entry: ActivityLogEntry = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
...input,
};
const logs = getActivityLogs();
localStorage.setItem(
STORAGE_KEY,
JSON.stringify([entry, ...logs].slice(0, 500)),
);
window.dispatchEvent(new CustomEvent("activity-log-added"));
return entry;
}
export function clearActivityLogs() {
localStorage.removeItem(STORAGE_KEY);
window.dispatchEvent(new CustomEvent("activity-log-added"));
}
export async function exportActivityLogs() {
const logs = getActivityLogs();
const filePath = await save({
title: 'Export Activity Logs',
defaultPath: `lr-activity-logs-${new Date()
.toISOString()
.replace(/:/g, '-')}.json`,
filters: [
{
name: 'JSON',
extensions: ['json'],
},
],
});
if (!filePath) {
return;
}
await writeTextFile(
filePath,
JSON.stringify(logs, null, 2),
);
}
+59
View File
@@ -0,0 +1,59 @@
import type { AppSettings } from '@/types/api';
const defaults: AppSettings = {
backendUrl: 'http://localhost:8080',
apiKey: 'dev-api-key',
};
const SETTINGS_KEY =
'lr-openwrt-tool.settings';
export function getSettings(): AppSettings {
try {
const storedSettings =
localStorage.getItem(SETTINGS_KEY);
return {
...defaults,
...JSON.parse(storedSettings || '{}'),
};
} catch {
return defaults;
}
}
export function saveSettings(
settings: AppSettings,
) {
localStorage.setItem(
SETTINGS_KEY,
JSON.stringify(settings),
);
}
export async function apiRequest<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const settings = getSettings();
const response = await fetch(
`${settings.backendUrl}${path}`,
{
...init,
headers: {
'Content-Type': 'application/json',
'X-API-Key': settings.apiKey,
...(init.headers || {}),
},
},
);
if (!response.ok) {
throw new Error(
`${response.status} ${response.statusText}`,
);
}
return response.json() as Promise<T>;
}
+31
View File
@@ -0,0 +1,31 @@
import { apiRequest } from './apiClient';
import type {
AvailableIpResponse,
PeerRegistrationRequest,
PeerRegistrationResponse,
UsedIpsResponse,
} from '@/types/api';
export const vpnApi = {
availableIp: () =>
apiRequest<AvailableIpResponse>(
'/api/vpn/available-ip',
),
usedIps: () =>
apiRequest<UsedIpsResponse>(
'/api/vpn/used-ips',
),
registerPeer: (
body: PeerRegistrationRequest,
) =>
apiRequest<PeerRegistrationResponse>(
'/api/vpn/peers',
{
method: 'POST',
body: JSON.stringify(body),
},
),
};
+26
View File
@@ -0,0 +1,26 @@
import { apiRequest } from './apiClient';
import type {
NetworkTrafficResponse,
VpsHealth,
} from '@/types/api';
export const vpsApi = {
health: () =>
apiRequest<VpsHealth>(
'/api/vps/health',
),
networkTraffic: () =>
apiRequest<NetworkTrafficResponse>(
'/api/vps/network-traffic',
),
rollbackLastBackup: () =>
apiRequest<{ restored: boolean }>(
'/api/vps/wireguard/rollback-last-backup',
{
method: 'POST',
},
),
};
+23
View File
@@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: #020617;
color: white;
font-family: Inter, sans-serif;
}
body {
overflow: hidden;
}
+22
View File
@@ -0,0 +1,22 @@
export type ActivityLogLevel =
| 'info'
| 'success'
| 'warning'
| 'error';
export type ActivityLogSource =
| 'desktop'
| 'router'
| 'backend'
| 'vps';
export type ActivityLogEntry = {
id: string;
timestamp: string;
level: ActivityLogLevel;
source: ActivityLogSource;
action: string;
message: string;
routerIp?: string;
vpnIp?: string;
};
+52
View File
@@ -0,0 +1,52 @@
export type AvailableIpResponse = {
vpnIp: string;
};
export type UsedIpsResponse = {
usedIps: string[];
count: number;
};
export type PeerRegistrationRequest = {
vpnIp: string;
publicKey: string;
};
export type PeerRegistrationResponse =
PeerRegistrationRequest & {
applied: boolean;
};
export type VpsHealth = {
wireGuardInterface: string;
wireGuardRunning: boolean;
wireGuardPeerCount: number;
wireGuardConfigExists: boolean;
udp2rawService: string;
udp2rawActive: boolean;
latestWireGuardBackup: string;
systemUptime: string;
diskUsagePercent: number;
memoryUsagePercent: number;
loadAverage: string;
publicIp: string;
backend: boolean;
ipPoolUsed: number;
ipPoolTotal: number;
updatedAt: string;
};
export type AppSettings = {
backendUrl: string;
apiKey: string;
};
export type NetworkTrafficResponse = {
interface: string;
sampleSeconds: number;
rxBytesPerSecond: number;
txBytesPerSecond: number;
downloadMbps: number;
uploadMbps: number;
updatedAt: string;
};
+26
View File
@@ -0,0 +1,26 @@
export type 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'
| 'ERROR';
export type ProvisionMode =
| 'new'
| 'reprovision';
export type ProvisioningContext = {
mode: ProvisionMode;
vpnIp?: string;
publicKey?: string;
routerIp?: string;
error?: string;
};
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ES2020"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src"
, "vite-env.d.ts" ],
"references": []
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});