feat(web): React SPA with realtime task detail over WebSocket
Vite + React 19 + TS console-style operator UI: hash-routed Login, Endpoints, Tasks, and TaskDetail (realtime accounts table over /ws, Run gated on all accounts testing ok on both sides). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
This commit is contained in:
@@ -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?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>imap/copier — operator console</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1439
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/big-shoulders-display": "^5.2.5",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
||||||
|
<rect width="48" height="48" rx="6" fill="#0a0f0d"/>
|
||||||
|
<path d="M10 15 L20 24 L10 33" fill="none" stroke="#ffb238" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
|
||||||
|
<rect x="24" y="30" width="14" height="4" fill="#ffb238"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 336 B |
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import './app.css'
|
||||||
|
import { logout } from './api'
|
||||||
|
import { Login } from './pages/Login'
|
||||||
|
import { Endpoints } from './pages/Endpoints'
|
||||||
|
import { Tasks } from './pages/Tasks'
|
||||||
|
import { TaskDetail } from './pages/TaskDetail'
|
||||||
|
|
||||||
|
type Route =
|
||||||
|
| { name: 'login' }
|
||||||
|
| { name: 'tasks' }
|
||||||
|
| { name: 'endpoints' }
|
||||||
|
| { name: 'task'; id: number }
|
||||||
|
| { name: 'notfound' }
|
||||||
|
|
||||||
|
function parseRoute(hash: string): Route {
|
||||||
|
const path = hash.replace(/^#/, '') || '/'
|
||||||
|
if (path === '/login') return { name: 'login' }
|
||||||
|
if (path === '/') return { name: 'tasks' }
|
||||||
|
if (path === '/endpoints') return { name: 'endpoints' }
|
||||||
|
const m = path.match(/^\/tasks\/(\d+)$/)
|
||||||
|
if (m) return { name: 'task', id: Number(m[1]) }
|
||||||
|
return { name: 'notfound' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHashRoute(): Route {
|
||||||
|
const [hash, setHash] = useState(location.hash)
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setHash(location.hash)
|
||||||
|
window.addEventListener('hashchange', onChange)
|
||||||
|
return () => window.removeEventListener('hashchange', onChange)
|
||||||
|
}, [])
|
||||||
|
return parseRoute(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const route = useHashRoute()
|
||||||
|
|
||||||
|
if (route.name === 'login') {
|
||||||
|
return <Login onSuccess={() => (location.hash = '#/')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => (location.hash = '#/login'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="bracket">[</span>IMAP<span className="dim">/COPIER</span>
|
||||||
|
<span className="bracket">]</span>
|
||||||
|
</div>
|
||||||
|
<nav className="topnav">
|
||||||
|
<a href="#/" className={route.name === 'tasks' || route.name === 'task' ? 'active' : ''}>
|
||||||
|
Tasks
|
||||||
|
</a>
|
||||||
|
<a href="#/endpoints" className={route.name === 'endpoints' ? 'active' : ''}>
|
||||||
|
Endpoints
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div className="session-indicator">
|
||||||
|
<span className="pulse-dot" /> session active
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<main className="main">
|
||||||
|
{route.name === 'tasks' && <Tasks />}
|
||||||
|
{route.name === 'endpoints' && <Endpoints />}
|
||||||
|
{route.name === 'task' && <TaskDetail id={route.id} />}
|
||||||
|
{route.name === 'notfound' && (
|
||||||
|
<div className="panel">
|
||||||
|
<p>Unknown route.</p>
|
||||||
|
<a className="crumb" href="#/">
|
||||||
|
← back to tasks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// REST client for the imap-copier control API.
|
||||||
|
// All requests carry the session cookie; a 401 anywhere bounces to #/login.
|
||||||
|
|
||||||
|
export type TLSMode = 'ssl' | 'starttls' | 'plain'
|
||||||
|
|
||||||
|
export interface Endpoint {
|
||||||
|
id: number
|
||||||
|
role_label: string
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
tls_mode: TLSMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
src_endpoint_id: number
|
||||||
|
dst_endpoint_id: number
|
||||||
|
status: string
|
||||||
|
folder_mapping?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TestStatus = 'pending' | 'ok' | 'fail' | string
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: number
|
||||||
|
src_login: string
|
||||||
|
dst_login: string
|
||||||
|
test_src_status: TestStatus
|
||||||
|
test_dst_status: TestStatus
|
||||||
|
status: string
|
||||||
|
copied: number
|
||||||
|
skipped: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskDetail {
|
||||||
|
task: Task
|
||||||
|
accounts: Account[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
|
export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, { credentials: 'include', ...opts })
|
||||||
|
if (res.status === 401) {
|
||||||
|
location.hash = '#/login'
|
||||||
|
throw new ApiError('unauthorized')
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text()
|
||||||
|
throw new ApiError(body || res.statusText)
|
||||||
|
}
|
||||||
|
const ct = res.headers.get('content-type') || ''
|
||||||
|
if (ct.includes('application/json')) return res.json() as Promise<T>
|
||||||
|
return res.text() as unknown as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonBody = (body: unknown): RequestInit => ({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const login = (user: string, pass: string) => api('/api/login', jsonBody({ user, pass }))
|
||||||
|
|
||||||
|
export const logout = () => api('/api/logout', { method: 'POST' })
|
||||||
|
|
||||||
|
export const listEndpoints = () => api<Endpoint[]>('/api/endpoints')
|
||||||
|
|
||||||
|
export const createEndpoint = (body: { role_label: string; host: string; port: number; tls_mode: TLSMode }) =>
|
||||||
|
api<{ id: number }>('/api/endpoints', jsonBody(body))
|
||||||
|
|
||||||
|
export const listTasks = () => api<Task[]>('/api/tasks')
|
||||||
|
|
||||||
|
export const getTask = (id: number) => api<TaskDetail>(`/api/tasks/${id}`)
|
||||||
|
|
||||||
|
export const createTask = (body: {
|
||||||
|
name: string
|
||||||
|
src_endpoint_id: number
|
||||||
|
dst_endpoint_id: number
|
||||||
|
folder_mapping?: Record<string, string>
|
||||||
|
}) => api<{ id: number }>('/api/tasks', jsonBody(body))
|
||||||
|
|
||||||
|
export const createAccount = (
|
||||||
|
id: number,
|
||||||
|
body: { src_login: string; src_pass: string; dst_login: string; dst_pass: string },
|
||||||
|
) => api<{ id: number }>(`/api/tasks/${id}/accounts`, jsonBody(body))
|
||||||
|
|
||||||
|
export const testAccounts = (id: number) => api(`/api/tasks/${id}/test`, { method: 'POST' })
|
||||||
|
|
||||||
|
export const runTask = (id: number) => api(`/api/tasks/${id}/run`, { method: 'POST' })
|
||||||
|
|
||||||
|
export const importCSV = (id: number, file: File) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd })
|
||||||
|
}
|
||||||
+568
@@ -0,0 +1,568 @@
|
|||||||
|
/* ---------- layout shell ---------- */
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 56px;
|
||||||
|
background: var(--bg-panel-raised);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand .bracket {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand .dim {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav a:hover {
|
||||||
|
color: var(--fg);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav a.active {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
background: rgba(255, 178, 56, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ok);
|
||||||
|
box-shadow: 0 0 6px 1px var(--ok);
|
||||||
|
animation: pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 34px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title .idx {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg-faint);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed var(--border-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb:hover {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- panels ---------- */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-label {
|
||||||
|
position: absolute;
|
||||||
|
top: -9px;
|
||||||
|
left: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- forms ---------- */
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 9px 11px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 178, 56, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- buttons ---------- */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border-bright);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--fg-dim);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #1a1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
color: #1a1200;
|
||||||
|
box-shadow: 0 0 16px -2px rgba(255, 178, 56, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
color: var(--fail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- status badges ---------- */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 9px 3px 7px;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ok {
|
||||||
|
color: var(--ok);
|
||||||
|
border-color: var(--ok-dim);
|
||||||
|
background: rgba(82, 230, 160, 0.06);
|
||||||
|
}
|
||||||
|
.badge-ok .dot { background: var(--ok); box-shadow: 0 0 5px var(--ok); }
|
||||||
|
|
||||||
|
.badge-fail {
|
||||||
|
color: var(--fail);
|
||||||
|
border-color: var(--fail-dim);
|
||||||
|
background: rgba(255, 93, 93, 0.06);
|
||||||
|
}
|
||||||
|
.badge-fail .dot { background: var(--fail); box-shadow: 0 0 5px var(--fail); }
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
color: var(--pending);
|
||||||
|
border-color: #4a4423;
|
||||||
|
background: rgba(240, 196, 25, 0.06);
|
||||||
|
}
|
||||||
|
.badge-pending .dot { background: var(--pending); }
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
color: var(--info);
|
||||||
|
border-color: #234456;
|
||||||
|
background: rgba(87, 194, 255, 0.06);
|
||||||
|
}
|
||||||
|
.badge-info .dot { background: var(--info); animation: pulse 1.4s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* ---------- tables ---------- */
|
||||||
|
|
||||||
|
.tbl-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.tbl {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.tbl thead th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
background: var(--bg-panel-raised);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.tbl tbody td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.tbl tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.tbl tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.tbl a.rowlink {
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
table.tbl a.rowlink:hover {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.num-cell {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-row td {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fg-faint);
|
||||||
|
padding: 28px 14px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- login ---------- */
|
||||||
|
|
||||||
|
.login-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-dim), transparent 40%);
|
||||||
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-sub {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: var(--fail);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- task detail ---------- */
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .val {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .lbl {
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.ok .val { color: var(--ok); }
|
||||||
|
.stat.fail .val { color: var(--fail); }
|
||||||
|
.stat.info .val { color: var(--info); }
|
||||||
|
|
||||||
|
.upload-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-btn input[type='file'] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-pane {
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
padding: 3px 0;
|
||||||
|
border-bottom: 1px dotted rgba(255, 255, 255, 0.04);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line .tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line .payload {
|
||||||
|
color: var(--fg);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-empty {
|
||||||
|
color: var(--fg-faint);
|
||||||
|
text-align: center;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 22px 0 14px;
|
||||||
|
color: var(--fg-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-label::before,
|
||||||
|
.divider-label::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
border: 1px solid var(--fail-dim);
|
||||||
|
background: rgba(255, 93, 93, 0.08);
|
||||||
|
color: var(--fail);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-note {
|
||||||
|
color: var(--fg-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const s = (status || 'pending').toLowerCase()
|
||||||
|
let cls = 'badge-pending'
|
||||||
|
if (s === 'ok' || s === 'done' || s === 'success') cls = 'badge-ok'
|
||||||
|
else if (s === 'fail' || s === 'failed' || s === 'error') cls = 'badge-fail'
|
||||||
|
else if (s === 'running' || s === 'testing' || s === 'in_progress') cls = 'badge-info'
|
||||||
|
return (
|
||||||
|
<span className={`badge ${cls}`}>
|
||||||
|
<span className="dot" />
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
@import '@fontsource/jetbrains-mono/400.css';
|
||||||
|
@import '@fontsource/jetbrains-mono/500.css';
|
||||||
|
@import '@fontsource/jetbrains-mono/700.css';
|
||||||
|
@import '@fontsource/big-shoulders-display/600.css';
|
||||||
|
@import '@fontsource/big-shoulders-display/800.css';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0d0b;
|
||||||
|
--bg-panel: #0f1512;
|
||||||
|
--bg-panel-raised: #141b17;
|
||||||
|
--bg-inset: #070a08;
|
||||||
|
--border: #23342b;
|
||||||
|
--border-bright: #3a5443;
|
||||||
|
--fg: #dbe8de;
|
||||||
|
--fg-dim: #6f8478;
|
||||||
|
--fg-faint: #4a5c50;
|
||||||
|
--accent: #ffb238;
|
||||||
|
--accent-strong: #ffd27a;
|
||||||
|
--accent-dim: #7a5a26;
|
||||||
|
--ok: #52e6a0;
|
||||||
|
--ok-dim: #234a37;
|
||||||
|
--fail: #ff5d5d;
|
||||||
|
--fail-dim: #4a2323;
|
||||||
|
--pending: #f0c419;
|
||||||
|
--info: #57c2ff;
|
||||||
|
|
||||||
|
--font-display: 'Big Shoulders Display', 'Arial Narrow', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% -10%, rgba(255, 178, 56, 0.06), transparent),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(255, 255, 255, 0.012) 0px,
|
||||||
|
rgba(255, 255, 255, 0.012) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 3px
|
||||||
|
),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-inset);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono-num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { createEndpoint, listEndpoints, type Endpoint, type TLSMode } from '../api'
|
||||||
|
|
||||||
|
const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode }
|
||||||
|
|
||||||
|
export function Endpoints() {
|
||||||
|
const [endpoints, setEndpoints] = useState<Endpoint[] | null>(null)
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
listEndpoints()
|
||||||
|
.then(setEndpoints)
|
||||||
|
.catch((e) => setError(String(e.message || e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(reload, [])
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await createEndpoint({
|
||||||
|
role_label: form.role_label,
|
||||||
|
host: form.host,
|
||||||
|
port: Number(form.port),
|
||||||
|
tls_mode: form.tls_mode,
|
||||||
|
})
|
||||||
|
setForm(emptyForm)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create endpoint')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">
|
||||||
|
Endpoints <span className="idx">/// mailbox servers</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-grid">
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Register endpoint</span>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="role_label">Role label</label>
|
||||||
|
<input
|
||||||
|
id="role_label"
|
||||||
|
placeholder="e.g. src-legacy, dst-office365"
|
||||||
|
value={form.role_label}
|
||||||
|
onChange={(e) => setForm({ ...form, role_label: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="host">Host</label>
|
||||||
|
<input
|
||||||
|
id="host"
|
||||||
|
placeholder="imap.example.com"
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => setForm({ ...form, host: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="port">Port</label>
|
||||||
|
<input
|
||||||
|
id="port"
|
||||||
|
type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm({ ...form, port: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="tls_mode">TLS mode</label>
|
||||||
|
<select
|
||||||
|
id="tls_mode"
|
||||||
|
value={form.tls_mode}
|
||||||
|
onChange={(e) => setForm({ ...form, tls_mode: e.target.value as TLSMode })}
|
||||||
|
>
|
||||||
|
<option value="ssl">ssl</option>
|
||||||
|
<option value="starttls">starttls</option>
|
||||||
|
<option value="plain">plain</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Saving…' : 'Add endpoint'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Registered ({endpoints?.length ?? 0})</span>
|
||||||
|
<div className="tbl-wrap">
|
||||||
|
<table className="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>TLS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{endpoints === null ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={5}>loading…</td>
|
||||||
|
</tr>
|
||||||
|
) : endpoints.length === 0 ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={5}>no endpoints registered yet</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
endpoints.map((ep) => (
|
||||||
|
<tr key={ep.id}>
|
||||||
|
<td className="num-cell">{ep.id}</td>
|
||||||
|
<td>{ep.role_label}</td>
|
||||||
|
<td>{ep.host}</td>
|
||||||
|
<td className="num-cell">{ep.port}</td>
|
||||||
|
<td>{ep.tls_mode}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { login } from '../api'
|
||||||
|
|
||||||
|
export function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [user, setUser] = useState('')
|
||||||
|
const [pass, setPass] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!user || !pass) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await login(user, pass)
|
||||||
|
onSuccess()
|
||||||
|
} catch {
|
||||||
|
setError('Access denied — check operator id and passphrase.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-wrap">
|
||||||
|
<form className="login-card" onSubmit={submit}>
|
||||||
|
<h1 className="login-brand">
|
||||||
|
<span style={{ color: 'var(--accent)' }}>[</span>IMAP/COPIER<span style={{ color: 'var(--accent)' }}>]</span>
|
||||||
|
</h1>
|
||||||
|
<p className="login-sub">OPERATOR CONSOLE — AUTHENTICATE TO CONTINUE</p>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="user">Operator ID</label>
|
||||||
|
<input
|
||||||
|
id="user"
|
||||||
|
autoFocus
|
||||||
|
value={user}
|
||||||
|
onChange={(e) => setUser(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="pass">Passphrase</label>
|
||||||
|
<input
|
||||||
|
id="pass"
|
||||||
|
type="password"
|
||||||
|
value={pass}
|
||||||
|
onChange={(e) => setPass(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" style={{ width: '100%' }} disabled={busy || !user || !pass}>
|
||||||
|
{busy ? 'Authenticating…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
<div className="login-error">{error}</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||||
|
import { createAccount, getTask, importCSV, runTask, testAccounts, type TaskDetail as TaskDetailData } from '../api'
|
||||||
|
import { connectTaskWS, type TaskEvent } from '../ws'
|
||||||
|
import { StatusBadge } from '../components/StatusBadge'
|
||||||
|
|
||||||
|
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
|
||||||
|
|
||||||
|
export function TaskDetail({ id }: { id: number }) {
|
||||||
|
const [data, setData] = useState<TaskDetailData | null>(null)
|
||||||
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
const [log, setLog] = useState<{ type: string; text: string }[]>([])
|
||||||
|
const [form, setForm] = useState(emptyAccount)
|
||||||
|
const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
getTask(id)
|
||||||
|
.then((d) => {
|
||||||
|
setData(d)
|
||||||
|
setNotFound(false)
|
||||||
|
})
|
||||||
|
.catch(() => setNotFound(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(reload, [id])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
connectTaskWS(id, (ev: TaskEvent) => {
|
||||||
|
setLog((l) => [{ type: ev.type, text: JSON.stringify(ev.data) }, ...l].slice(0, 300))
|
||||||
|
if (['account_started', 'account_test', 'account_done', 'progress', 'run_started', 'run_done', 'error'].includes(ev.type)) {
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
|
||||||
|
async function submitAccount(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy('add')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await createAccount(id, form)
|
||||||
|
setForm(emptyAccount)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to add account')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileChosen(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setBusy('import')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await importCSV(id, file)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'CSV import failed')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTest() {
|
||||||
|
setBusy('test')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await testAccounts(id)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to start connection tests')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRun() {
|
||||||
|
setBusy('run')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await runTask(id)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to start run')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<p>Task #{id} not found.</p>
|
||||||
|
<a className="crumb" href="#/">
|
||||||
|
← back to tasks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <div className="muted-note">loading task #{id}…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { task, accounts } = data
|
||||||
|
const allTested = accounts.length > 0 && accounts.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
|
||||||
|
const totals = accounts.reduce(
|
||||||
|
(acc, a) => ({ copied: acc.copied + a.copied, skipped: acc.skipped + a.skipped, errors: acc.errors + a.errors }),
|
||||||
|
{ copied: 0, skipped: 0, errors: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<a className="crumb" href="#/">
|
||||||
|
← all tasks
|
||||||
|
</a>
|
||||||
|
<h1 className="page-title" style={{ marginTop: 6 }}>
|
||||||
|
{task.name} <span className="idx">/// task #{task.id}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Run control</span>
|
||||||
|
<div className="stat-row">
|
||||||
|
<div className="stat ok">
|
||||||
|
<span className="val mono-num">{totals.copied}</span>
|
||||||
|
<span className="lbl">copied</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat info">
|
||||||
|
<span className="val mono-num">{totals.skipped}</span>
|
||||||
|
<span className="lbl">skipped</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat fail">
|
||||||
|
<span className="val mono-num">{totals.errors}</span>
|
||||||
|
<span className="lbl">errors</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="val mono-num">{accounts.length}</span>
|
||||||
|
<span className="lbl">accounts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="btn-row" style={{ marginTop: 16 }}>
|
||||||
|
<button className="btn" onClick={onTest} disabled={busy !== null || accounts.length === 0}>
|
||||||
|
{busy === 'test' ? 'Testing…' : 'Test connections'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={onRun} disabled={busy !== null || !allTested}>
|
||||||
|
{busy === 'run' ? 'Starting…' : 'Run migration'}
|
||||||
|
</button>
|
||||||
|
{!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-grid">
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Add account</span>
|
||||||
|
<form onSubmit={submitAccount}>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="src_login">Source login</label>
|
||||||
|
<input
|
||||||
|
id="src_login"
|
||||||
|
value={form.src_login}
|
||||||
|
onChange={(e) => setForm({ ...form, src_login: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="src_pass">Source password</label>
|
||||||
|
<input
|
||||||
|
id="src_pass"
|
||||||
|
type="password"
|
||||||
|
value={form.src_pass}
|
||||||
|
onChange={(e) => setForm({ ...form, src_pass: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="dst_login">Destination login</label>
|
||||||
|
<input
|
||||||
|
id="dst_login"
|
||||||
|
value={form.dst_login}
|
||||||
|
onChange={(e) => setForm({ ...form, dst_login: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="dst_pass">Destination password</label>
|
||||||
|
<input
|
||||||
|
id="dst_pass"
|
||||||
|
type="password"
|
||||||
|
value={form.dst_pass}
|
||||||
|
onChange={(e) => setForm({ ...form, dst_pass: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-primary" disabled={busy !== null}>
|
||||||
|
{busy === 'add' ? 'Adding…' : 'Add account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="divider-label">or bulk import</div>
|
||||||
|
<div className="upload-row">
|
||||||
|
<button className="btn file-btn" disabled={busy !== null}>
|
||||||
|
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
|
||||||
|
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
|
||||||
|
</button>
|
||||||
|
<span className="hint">columns: src_login, src_pass, dst_login, dst_pass</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Event log</span>
|
||||||
|
<div className="log-pane">
|
||||||
|
{log.length === 0 ? (
|
||||||
|
<div className="log-empty">awaiting events over websocket…</div>
|
||||||
|
) : (
|
||||||
|
log.map((l, i) => (
|
||||||
|
<div className="log-line" key={i}>
|
||||||
|
<span className="tag">{l.type}</span>
|
||||||
|
<span className="payload">{l.text}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Accounts ({accounts.length})</span>
|
||||||
|
<div className="tbl-wrap">
|
||||||
|
<table className="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Destination</th>
|
||||||
|
<th>Src test</th>
|
||||||
|
<th>Dst test</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Copied</th>
|
||||||
|
<th>Skipped</th>
|
||||||
|
<th>Errors</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={8}>no accounts yet — add one or import a CSV above</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
accounts.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{a.src_login}</td>
|
||||||
|
<td>{a.dst_login}</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={a.test_src_status} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={a.test_dst_status} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={a.status} />
|
||||||
|
</td>
|
||||||
|
<td className="num-cell">{a.copied}</td>
|
||||||
|
<td className="num-cell">{a.skipped}</td>
|
||||||
|
<td className="num-cell">{a.errors}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { createTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api'
|
||||||
|
import { StatusBadge } from '../components/StatusBadge'
|
||||||
|
|
||||||
|
export function Tasks() {
|
||||||
|
const [tasks, setTasks] = useState<Task[] | null>(null)
|
||||||
|
const [endpoints, setEndpoints] = useState<Endpoint[]>([])
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [srcId, setSrcId] = useState('')
|
||||||
|
const [dstId, setDstId] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
listTasks()
|
||||||
|
.then(setTasks)
|
||||||
|
.catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load tasks'))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload()
|
||||||
|
listEndpoints().then(setEndpoints).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await createTask({
|
||||||
|
name,
|
||||||
|
src_endpoint_id: Number(srcId),
|
||||||
|
dst_endpoint_id: Number(dstId),
|
||||||
|
})
|
||||||
|
setName('')
|
||||||
|
setSrcId('')
|
||||||
|
setDstId('')
|
||||||
|
reload()
|
||||||
|
location.hash = `#/tasks/${res.id}`
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create task')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = name.trim() !== '' && srcId !== '' && dstId !== '' && srcId !== dstId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">
|
||||||
|
Migration tasks <span className="idx">/// mailbox copy jobs</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">New task</span>
|
||||||
|
{endpoints.length < 2 ? (
|
||||||
|
<p className="muted-note">
|
||||||
|
Register at least two endpoints (source & destination) on the{' '}
|
||||||
|
<a className="crumb" href="#/endpoints">
|
||||||
|
Endpoints
|
||||||
|
</a>{' '}
|
||||||
|
screen before creating a task.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="name">Task name</label>
|
||||||
|
<input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="q3-office365-migration" required />
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="src">Source endpoint</label>
|
||||||
|
<select id="src" value={srcId} onChange={(e) => setSrcId(e.target.value)} required>
|
||||||
|
<option value="" disabled>
|
||||||
|
select…
|
||||||
|
</option>
|
||||||
|
{endpoints.map((ep) => (
|
||||||
|
<option key={ep.id} value={ep.id}>
|
||||||
|
{ep.role_label} — {ep.host}:{ep.port}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="dst">Destination endpoint</label>
|
||||||
|
<select id="dst" value={dstId} onChange={(e) => setDstId(e.target.value)} required>
|
||||||
|
<option value="" disabled>
|
||||||
|
select…
|
||||||
|
</option>
|
||||||
|
{endpoints.map((ep) => (
|
||||||
|
<option key={ep.id} value={ep.id}>
|
||||||
|
{ep.role_label} — {ep.host}:{ep.port}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-primary" disabled={busy || !canSubmit}>
|
||||||
|
{busy ? 'Creating…' : 'Create task'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">All tasks ({tasks?.length ?? 0})</span>
|
||||||
|
<div className="tbl-wrap">
|
||||||
|
<table className="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Route</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks === null ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={4}>loading…</td>
|
||||||
|
</tr>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={4}>no tasks yet — create one above</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
tasks.map((t) => (
|
||||||
|
<tr key={t.id}>
|
||||||
|
<td className="num-cell">{t.id}</td>
|
||||||
|
<td>
|
||||||
|
<a className="rowlink" href={`#/tasks/${t.id}`}>
|
||||||
|
{t.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
#{t.src_endpoint_id} → #{t.dst_endpoint_id}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={t.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Live task event stream. One socket per task-detail view.
|
||||||
|
|
||||||
|
export type TaskEventType =
|
||||||
|
| 'run_started'
|
||||||
|
| 'account_started'
|
||||||
|
| 'account_test'
|
||||||
|
| 'progress'
|
||||||
|
| 'account_done'
|
||||||
|
| 'error'
|
||||||
|
| 'run_done'
|
||||||
|
| string
|
||||||
|
|
||||||
|
export interface TaskEvent {
|
||||||
|
type: TaskEventType
|
||||||
|
task_id: number
|
||||||
|
data: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectTaskWS(taskId: number, onEvent: (ev: TaskEvent) => void): () => void {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
const ws = new WebSocket(`${proto}://${location.host}/ws?task_id=${taskId}`)
|
||||||
|
ws.onmessage = (m) => {
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(m.data))
|
||||||
|
} catch {
|
||||||
|
// ignore malformed frames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => ws.close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: { outDir: 'dist' },
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080',
|
||||||
|
'/ws': { target: 'ws://localhost:8080', ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user