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:
2026-07-01 19:01:05 +07:00
parent 4c57848c35
commit 1a451f9dbb
22 changed files with 3175 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -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 }]
}
}
+32
View File
@@ -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.
+14
View File
@@ -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>
+1439
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+5
View File
@@ -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

+88
View File
@@ -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
+98
View File
@@ -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
View File
@@ -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;
}
+13
View File
@@ -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>
)
}
+98
View File
@@ -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;
}
+10
View File
@@ -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>,
)
+145
View File
@@ -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>
</>
)
}
+61
View File
@@ -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>
)
}
+289
View File
@@ -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>
</>
)
}
+156
View File
@@ -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 &amp; 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>
</>
)
}
+30
View File
@@ -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()
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+14
View File
@@ -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 },
},
},
})