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