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,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 })
|
||||
}
|
||||
Reference in New Issue
Block a user