platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
6
selective-vpn-web/.env.example
Normal file
6
selective-vpn-web/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# Optional absolute API base URL.
|
||||
# Leave empty to use same-origin requests.
|
||||
VITE_API_BASE_URL=
|
||||
|
||||
# Dev-server proxy target (used by vite.config.ts).
|
||||
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8080
|
||||
24
selective-vpn-web/.gitignore
vendored
Normal file
24
selective-vpn-web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
36
selective-vpn-web/README.md
Normal file
36
selective-vpn-web/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Selective VPN Web (Foundation)
|
||||
|
||||
Web prototype foundation for Selective VPN control-plane.
|
||||
|
||||
## Stack
|
||||
- Vite
|
||||
- React + TypeScript
|
||||
- React Router
|
||||
- TanStack Query
|
||||
- Zustand
|
||||
|
||||
## Current scope
|
||||
- App shell (navigation + layout).
|
||||
- Read-only overview (`/healthz`, `/api/v1/status`, `/api/v1/vpn/status`, `/api/v1/vpn/login-state`).
|
||||
- SSE connectivity indicator for `/api/v1/events/stream`.
|
||||
- Placeholder pages for upcoming VPN/Routes/DNS/Transport/Trace screens.
|
||||
|
||||
No mutating controls are enabled at this stage.
|
||||
|
||||
## Run
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Environment
|
||||
Use `.env.example` as a base:
|
||||
- `VITE_API_BASE_URL` — absolute API base URL (optional).
|
||||
- `VITE_DEV_PROXY_TARGET` — Vite dev proxy target (default `http://127.0.0.1:8080`).
|
||||
|
||||
By default, frontend uses same-origin URLs and relies on Vite proxy in dev mode.
|
||||
23
selective-vpn-web/eslint.config.js
Normal file
23
selective-vpn-web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
selective-vpn-web/index.html
Normal file
13
selective-vpn-web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>selective-vpn-web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3159
selective-vpn-web/package-lock.json
generated
Normal file
3159
selective-vpn-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
selective-vpn-web/package.json
Normal file
33
selective-vpn-web/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "selective-vpn-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.48.0",
|
||||
"vite": "^6.4.1"
|
||||
}
|
||||
}
|
||||
14
selective-vpn-web/src/app/App.tsx
Normal file
14
selective-vpn-web/src/app/App.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
import { AppProviders } from './providers'
|
||||
import { AppRoutes } from './routes'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppProviders>
|
||||
<AppRoutes />
|
||||
</AppProviders>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
15
selective-vpn-web/src/app/providers.tsx
Normal file
15
selective-vpn-web/src/app/providers.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function AppProviders({ children }: PropsWithChildren) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
27
selective-vpn-web/src/app/routes.tsx
Normal file
27
selective-vpn-web/src/app/routes.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { DnsPage } from '../pages/dns/DnsPage'
|
||||
import { NotFoundPage } from '../pages/not-found/NotFoundPage'
|
||||
import { OverviewPage } from '../pages/overview/OverviewPage'
|
||||
import { RoutesPage } from '../pages/routes/RoutesPage'
|
||||
import { TracePage } from '../pages/trace/TracePage'
|
||||
import { TransportPage } from '../pages/transport/TransportPage'
|
||||
import { VpnPage } from '../pages/vpn/VpnPage'
|
||||
import { AppLayout } from '../shared/ui/AppLayout'
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<Navigate to="/overview" replace />} />
|
||||
<Route path="/overview" element={<OverviewPage />} />
|
||||
<Route path="/vpn" element={<VpnPage />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/dns" element={<DnsPage />} />
|
||||
<Route path="/transport" element={<TransportPage />} />
|
||||
<Route path="/trace" element={<TracePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
13
selective-vpn-web/src/app/store/useUiStore.ts
Normal file
13
selective-vpn-web/src/app/store/useUiStore.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
type UiState = {
|
||||
sidebarCollapsed: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }))
|
||||
},
|
||||
}))
|
||||
256
selective-vpn-web/src/index.css
Normal file
256
selective-vpn-web/src/index.css
Normal file
@@ -0,0 +1,256 @@
|
||||
:root {
|
||||
--bg: #eef3f7;
|
||||
--bg-soft: #f7fbff;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f6f9fc;
|
||||
--text: #122034;
|
||||
--muted: #607189;
|
||||
--border: #dbe5ef;
|
||||
--accent: #0d8b63;
|
||||
--warn: #d4a200;
|
||||
--danger: #bb2d3b;
|
||||
|
||||
font-family: "IBM Plex Sans", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
color: var(--text);
|
||||
background: linear-gradient(150deg, var(--bg) 0%, var(--bg-soft) 100%);
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.app-sidebar.is-collapsed {
|
||||
width: 90px;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.app-brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-brand-subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-nav-link {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
color: var(--muted);
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
|
||||
.app-nav-link:hover {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-nav-link.is-active {
|
||||
border-color: #c5d8cc;
|
||||
background: #edf8f3;
|
||||
color: #0d5f44;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
min-height: 64px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-header-left,
|
||||
.app-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.kv-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.kv-row span:first-child {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
border-color: #9fd4be;
|
||||
background: #eaf8f1;
|
||||
color: #0a6345;
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
border-color: #ebd58d;
|
||||
background: #fff8de;
|
||||
color: #996f00;
|
||||
}
|
||||
|
||||
.status-bad {
|
||||
border-color: #e9b0b8;
|
||||
background: #fff1f3;
|
||||
color: #9f2331;
|
||||
}
|
||||
|
||||
.status-neutral {
|
||||
border-color: var(--border);
|
||||
background: #f3f7fb;
|
||||
color: #37506a;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-button:hover {
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.inline-alert {
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.inline-alert-warn {
|
||||
border: 1px solid #f0d58a;
|
||||
background: #fff8e0;
|
||||
color: #9e7100;
|
||||
}
|
||||
|
||||
.text-bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.app-sidebar.is-collapsed {
|
||||
width: auto;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
}
|
||||
10
selective-vpn-web/src/main.tsx
Normal file
10
selective-vpn-web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { App } from './app/App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
10
selective-vpn-web/src/pages/dns/DnsPage.tsx
Normal file
10
selective-vpn-web/src/pages/dns/DnsPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from '../placeholder/PlaceholderPage'
|
||||
|
||||
export function DnsPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="DNS"
|
||||
description="DNS controls and benchmark widgets are planned after base web profile flow."
|
||||
/>
|
||||
)
|
||||
}
|
||||
15
selective-vpn-web/src/pages/not-found/NotFoundPage.tsx
Normal file
15
selective-vpn-web/src/pages/not-found/NotFoundPage.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<section className="panel">
|
||||
<h1 className="page-title">Page Not Found</h1>
|
||||
<p className="page-subtitle">
|
||||
The requested route does not exist in the web prototype.
|
||||
</p>
|
||||
<Link to="/overview" className="ghost-button">
|
||||
Go to Overview
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
158
selective-vpn-web/src/pages/overview/OverviewPage.tsx
Normal file
158
selective-vpn-web/src/pages/overview/OverviewPage.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { api } from '../../shared/api/endpoints'
|
||||
|
||||
function boolView(value: boolean | undefined): string {
|
||||
if (value === undefined) {
|
||||
return 'n/a'
|
||||
}
|
||||
return value ? 'ok' : 'missing'
|
||||
}
|
||||
|
||||
function queryErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return 'unknown error'
|
||||
}
|
||||
|
||||
export function OverviewPage() {
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ['healthz'],
|
||||
queryFn: api.healthz,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['status'],
|
||||
queryFn: api.status,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const vpnQuery = useQuery({
|
||||
queryKey: ['vpn-status'],
|
||||
queryFn: api.vpnStatus,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const loginQuery = useQuery({
|
||||
queryKey: ['vpn-login-state'],
|
||||
queryFn: api.vpnLoginState,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="page-title">Overview</h1>
|
||||
<p className="page-subtitle">
|
||||
Foundation mode: read-only checks and realtime connectivity indicators.
|
||||
</p>
|
||||
|
||||
<div className="panel-grid">
|
||||
<article className="panel">
|
||||
<h2>Core Health</h2>
|
||||
{healthQuery.isLoading ? <p>Loading...</p> : null}
|
||||
{healthQuery.error ? (
|
||||
<p className="text-bad">Error: {queryErrorMessage(healthQuery.error)}</p>
|
||||
) : null}
|
||||
{healthQuery.data ? (
|
||||
<div className="kv-list">
|
||||
<div className="kv-row">
|
||||
<span>Status</span>
|
||||
<span>{healthQuery.data.status}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>Time</span>
|
||||
<span>{healthQuery.data.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<h2>Routes Snapshot</h2>
|
||||
{statusQuery.isLoading ? <p>Loading...</p> : null}
|
||||
{statusQuery.error ? (
|
||||
<p className="text-bad">Error: {queryErrorMessage(statusQuery.error)}</p>
|
||||
) : null}
|
||||
{statusQuery.data ? (
|
||||
<div className="kv-list">
|
||||
<div className="kv-row">
|
||||
<span>iface</span>
|
||||
<span>{statusQuery.data.iface || '—'}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>table</span>
|
||||
<span>{statusQuery.data.table || '—'}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>mark</span>
|
||||
<span>{statusQuery.data.mark || '—'}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>ip_count</span>
|
||||
<span>{statusQuery.data.ip_count}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>domain_count</span>
|
||||
<span>{statusQuery.data.domain_count}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>policy_route</span>
|
||||
<span>{boolView(statusQuery.data.policy_route_ok)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<h2>VPN Snapshot</h2>
|
||||
{vpnQuery.isLoading ? <p>Loading...</p> : null}
|
||||
{vpnQuery.error ? (
|
||||
<p className="text-bad">Error: {queryErrorMessage(vpnQuery.error)}</p>
|
||||
) : null}
|
||||
{vpnQuery.data ? (
|
||||
<div className="kv-list">
|
||||
<div className="kv-row">
|
||||
<span>desired_location</span>
|
||||
<span>{vpnQuery.data.desired_location || '—'}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>status_word</span>
|
||||
<span>{vpnQuery.data.status_word || '—'}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>unit_state</span>
|
||||
<span>{vpnQuery.data.unit_state || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<h2>Login Snapshot</h2>
|
||||
{loginQuery.isLoading ? <p>Loading...</p> : null}
|
||||
{loginQuery.error ? (
|
||||
<p className="text-bad">Error: {queryErrorMessage(loginQuery.error)}</p>
|
||||
) : null}
|
||||
{loginQuery.data ? (
|
||||
<div className="kv-list">
|
||||
<div className="kv-row">
|
||||
<span>state</span>
|
||||
<span>{loginQuery.data.state || '—'}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>email</span>
|
||||
<span>{loginQuery.data.email || '—'}</span>
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span>message</span>
|
||||
<span>{loginQuery.data.msg || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
16
selective-vpn-web/src/pages/placeholder/PlaceholderPage.tsx
Normal file
16
selective-vpn-web/src/pages/placeholder/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
type PlaceholderPageProps = {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
||||
return (
|
||||
<section>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
<p className="page-subtitle">{description}</p>
|
||||
<div className="panel">
|
||||
<p>This section is scaffolded for the next implementation phase.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
10
selective-vpn-web/src/pages/routes/RoutesPage.tsx
Normal file
10
selective-vpn-web/src/pages/routes/RoutesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from '../placeholder/PlaceholderPage'
|
||||
|
||||
export function RoutesPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="Routes"
|
||||
description="Routes controls are reserved for the next implementation block."
|
||||
/>
|
||||
)
|
||||
}
|
||||
10
selective-vpn-web/src/pages/trace/TracePage.tsx
Normal file
10
selective-vpn-web/src/pages/trace/TracePage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from '../placeholder/PlaceholderPage'
|
||||
|
||||
export function TracePage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="Trace"
|
||||
description="Trace viewer foundation will be implemented after profile API wiring."
|
||||
/>
|
||||
)
|
||||
}
|
||||
10
selective-vpn-web/src/pages/transport/TransportPage.tsx
Normal file
10
selective-vpn-web/src/pages/transport/TransportPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from '../placeholder/PlaceholderPage'
|
||||
|
||||
export function TransportPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="Transport"
|
||||
description="Multi-client transport flow will use validate/confirm/apply foundation next."
|
||||
/>
|
||||
)
|
||||
}
|
||||
10
selective-vpn-web/src/pages/vpn/VpnPage.tsx
Normal file
10
selective-vpn-web/src/pages/vpn/VpnPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderPage } from '../placeholder/PlaceholderPage'
|
||||
|
||||
export function VpnPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="VPN"
|
||||
description="VPN controls will be connected after profile/API planning phase."
|
||||
/>
|
||||
)
|
||||
}
|
||||
37
selective-vpn-web/src/shared/api/contracts.ts
Normal file
37
selective-vpn-web/src/shared/api/contracts.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type HealthzResponse = {
|
||||
status: string
|
||||
time: string
|
||||
}
|
||||
|
||||
export type StatusResponse = {
|
||||
timestamp: string
|
||||
ip_count: number
|
||||
domain_count: number
|
||||
iface: string
|
||||
table: string
|
||||
mark: string
|
||||
policy_route_ok?: boolean
|
||||
route_ok?: boolean
|
||||
}
|
||||
|
||||
export type VpnStatusResponse = {
|
||||
desired_location: string
|
||||
status_word: string
|
||||
raw_text: string
|
||||
unit_state: string
|
||||
}
|
||||
|
||||
export type VpnLoginStateResponse = {
|
||||
state: string
|
||||
email?: string
|
||||
msg?: string
|
||||
text?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type SseEnvelope = {
|
||||
id?: number
|
||||
kind?: string
|
||||
ts?: string
|
||||
data?: unknown
|
||||
}
|
||||
14
selective-vpn-web/src/shared/api/endpoints.ts
Normal file
14
selective-vpn-web/src/shared/api/endpoints.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type {
|
||||
HealthzResponse,
|
||||
StatusResponse,
|
||||
VpnLoginStateResponse,
|
||||
VpnStatusResponse,
|
||||
} from './contracts'
|
||||
import { requestJson } from './http'
|
||||
|
||||
export const api = {
|
||||
healthz: () => requestJson<HealthzResponse>('/healthz'),
|
||||
status: () => requestJson<StatusResponse>('/api/v1/status'),
|
||||
vpnStatus: () => requestJson<VpnStatusResponse>('/api/v1/vpn/status'),
|
||||
vpnLoginState: () => requestJson<VpnLoginStateResponse>('/api/v1/vpn/login-state'),
|
||||
}
|
||||
62
selective-vpn-web/src/shared/api/http.ts
Normal file
62
selective-vpn-web/src/shared/api/http.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { env } from '../config/env'
|
||||
|
||||
type RequestJsonOptions = Omit<RequestInit, 'body'> & {
|
||||
timeoutMs?: number
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
public readonly status: number
|
||||
public readonly payload: string
|
||||
|
||||
constructor(message: string, status: number, payload: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.payload = payload
|
||||
}
|
||||
}
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
if (!env.apiBaseUrl) {
|
||||
return normalizedPath
|
||||
}
|
||||
return `${env.apiBaseUrl}${normalizedPath}`
|
||||
}
|
||||
|
||||
export async function requestJson<T>(path: string, options: RequestJsonOptions = {}): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeoutMs = options.timeoutMs ?? 8000
|
||||
|
||||
const headers = new Headers(options.headers || {})
|
||||
const hasBody = options.body !== undefined
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
...options,
|
||||
headers,
|
||||
body: hasBody ? JSON.stringify(options.body) : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new HttpError(`Request failed: ${response.status}`, response.status, text)
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return {} as T
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
11
selective-vpn-web/src/shared/config/env.ts
Normal file
11
selective-vpn-web/src/shared/config/env.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const rawApiBaseUrl = String(import.meta.env.VITE_API_BASE_URL || '').trim()
|
||||
const apiBaseUrl = rawApiBaseUrl ? trimTrailingSlash(rawApiBaseUrl) : ''
|
||||
|
||||
export const env = {
|
||||
apiBaseUrl,
|
||||
apiTargetLabel: apiBaseUrl || 'same-origin',
|
||||
} as const
|
||||
105
selective-vpn-web/src/shared/sse/useEventsStream.ts
Normal file
105
selective-vpn-web/src/shared/sse/useEventsStream.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { SseEnvelope } from '../api/contracts'
|
||||
import { apiUrl } from '../api/http'
|
||||
|
||||
const EVENT_NAMES = [
|
||||
'status_changed',
|
||||
'login_state_changed',
|
||||
'trace_changed',
|
||||
'trace_append',
|
||||
'autoloop_status_changed',
|
||||
'unit_state_changed',
|
||||
'routes_nft_progress',
|
||||
'vpn_locations_changed',
|
||||
'transport_client_state_changed',
|
||||
'transport_client_provisioned',
|
||||
'transport_policy_validated',
|
||||
'transport_policy_applied',
|
||||
'transport_conflict_detected',
|
||||
'traffic_mode_changed',
|
||||
'traffic_profiles_changed',
|
||||
'status_error',
|
||||
] as const
|
||||
|
||||
export type StreamState = 'connecting' | 'open' | 'error' | 'closed'
|
||||
|
||||
export type StreamEvent = {
|
||||
kind: string
|
||||
ts: string
|
||||
envelope: SseEnvelope
|
||||
}
|
||||
|
||||
function parseEnvelope(raw: string): SseEnvelope {
|
||||
try {
|
||||
return JSON.parse(raw) as SseEnvelope
|
||||
} catch {
|
||||
return { data: raw }
|
||||
}
|
||||
}
|
||||
|
||||
export function useEventsStream(path = '/api/v1/events/stream') {
|
||||
const [state, setState] = useState<StreamState>('connecting')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastEvent, setLastEvent] = useState<StreamEvent | null>(null)
|
||||
const [eventCount, setEventCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false
|
||||
|
||||
const source = new EventSource(apiUrl(path))
|
||||
const listenerEntries: Array<{ name: string; fn: EventListener }> = []
|
||||
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
|
||||
const envelope = parseEnvelope(event.data)
|
||||
const kind = String(envelope.kind || event.type || 'message')
|
||||
const ts = String(envelope.ts || new Date().toISOString())
|
||||
|
||||
setLastEvent({
|
||||
kind,
|
||||
ts,
|
||||
envelope,
|
||||
})
|
||||
setEventCount((prev) => prev + 1)
|
||||
}
|
||||
|
||||
for (const name of EVENT_NAMES) {
|
||||
const fn = (event: Event) => {
|
||||
handleMessage(event as MessageEvent<string>)
|
||||
}
|
||||
source.addEventListener(name, fn)
|
||||
listenerEntries.push({ name, fn })
|
||||
}
|
||||
|
||||
source.onopen = () => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
setState('open')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
setState('error')
|
||||
setError('stream disconnected, auto-retrying')
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
setState('closed')
|
||||
for (const entry of listenerEntries) {
|
||||
source.removeEventListener(entry.name, entry.fn)
|
||||
}
|
||||
source.close()
|
||||
}
|
||||
}, [path])
|
||||
|
||||
return { state, error, lastEvent, eventCount }
|
||||
}
|
||||
89
selective-vpn-web/src/shared/ui/AppLayout.tsx
Normal file
89
selective-vpn-web/src/shared/ui/AppLayout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
|
||||
import { useUiStore } from '../../app/store/useUiStore'
|
||||
import { env } from '../config/env'
|
||||
import { useEventsStream } from '../sse/useEventsStream'
|
||||
|
||||
type NavItem = {
|
||||
to: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/overview', label: 'Overview' },
|
||||
{ to: '/vpn', label: 'VPN' },
|
||||
{ to: '/routes', label: 'Routes' },
|
||||
{ to: '/dns', label: 'DNS' },
|
||||
{ to: '/transport', label: 'Transport' },
|
||||
{ to: '/trace', label: 'Trace' },
|
||||
]
|
||||
|
||||
function streamClassName(state: string): string {
|
||||
if (state === 'open') {
|
||||
return 'status-chip status-ok'
|
||||
}
|
||||
if (state === 'connecting') {
|
||||
return 'status-chip status-warn'
|
||||
}
|
||||
return 'status-chip status-bad'
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
const collapsed = useUiStore((state) => state.sidebarCollapsed)
|
||||
const toggleSidebar = useUiStore((state) => state.toggleSidebar)
|
||||
const stream = useEventsStream('/api/v1/events/stream')
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className={`app-sidebar${collapsed ? ' is-collapsed' : ''}`}>
|
||||
<div className="app-brand">
|
||||
<span className="app-brand-title">Selective VPN</span>
|
||||
<span className="app-brand-subtitle">Web Control Plane</span>
|
||||
</div>
|
||||
|
||||
<nav className="app-nav">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`app-nav-link${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="app-main">
|
||||
<header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<button type="button" className="ghost-button" onClick={toggleSidebar}>
|
||||
{collapsed ? 'Expand Menu' : 'Collapse Menu'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-header-right">
|
||||
<span className={streamClassName(stream.state)}>
|
||||
SSE: {stream.state}
|
||||
</span>
|
||||
<span className="status-chip status-neutral">
|
||||
API: {env.apiTargetLabel}
|
||||
</span>
|
||||
<span className="status-chip status-neutral">
|
||||
Last: {stream.lastEvent?.kind || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-content">
|
||||
{stream.error ? (
|
||||
<div className="inline-alert inline-alert-warn">{stream.error}</div>
|
||||
) : null}
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
selective-vpn-web/tsconfig.app.json
Normal file
28
selective-vpn-web/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
selective-vpn-web/tsconfig.json
Normal file
7
selective-vpn-web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
selective-vpn-web/tsconfig.node.json
Normal file
26
selective-vpn-web/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
selective-vpn-web/vite.config.ts
Normal file
25
selective-vpn-web/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const target = (env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:8080').trim()
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/healthz': {
|
||||
target,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user