platform: modularize api/gui, add docs-tests-web foundation, and refresh root config

This commit is contained in:
beckline
2026-03-26 22:40:54 +03:00
parent 0e2d7f61ea
commit 6a56d734c2
562 changed files with 70151 additions and 16423 deletions

View 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
View 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?

View 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.

View 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,
},
},
])

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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>
)
}

View 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>
}

View 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>
)
}

View 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 }))
},
}))

View 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;
}
}

View 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>,
)

View 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."
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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."
/>
)
}

View 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."
/>
)
}

View 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."
/>
)
}

View 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."
/>
)
}

View 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
}

View 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'),
}

View 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)
}
}

View 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

View 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 }
}

View 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>
)
}

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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,
},
},
},
}
})