Initial project structure cleanup
This commit is contained in:
+58
@@ -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
|
||||
@@ -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`.
|
||||
@@ -0,0 +1 @@
|
||||
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||
Generated
+2759
File diff suppressed because it is too large
Load Diff
@@ -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"}}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Generated
+4703
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
fn main() { tauri_build::build() }
|
||||
@@ -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
@@ -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 |
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod files;
|
||||
pub mod network;
|
||||
pub mod router;
|
||||
pub mod ssh;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
fn main() { lr_openwrt_tool_lib::run() }
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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": []
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user