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