Initial push

This commit is contained in:
2026-08-11 09:00:01 +02:00
commit fe8b354adc
54 changed files with 12640 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
import type {
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
Preflight, PreviewResponse, SourceResponse, TargetInventory,
} from './types'
// The token, when the server requires one, arrives as a query parameter the
// first time and is kept for the tab afterwards.
function readToken(): string {
const fromUrl = new URLSearchParams(location.search).get('token')
if (fromUrl) {
sessionStorage.setItem('dm.token', fromUrl)
const clean = location.pathname + location.hash
history.replaceState(null, '', clean)
return fromUrl
}
return sessionStorage.getItem('dm.token') ?? ''
}
const token = readToken()
/** ApiError carries the server's message plus anything it attached to it. */
export class ApiError extends Error {
status: number
body: Record<string, unknown>
constructor(status: number, message: string, body: Record<string, unknown> = {}) {
super(message)
this.status = status
this.body = body
}
/** True when the target's SSH host key still has to be approved. */
get needsTrust(): boolean {
return this.body.needsTrust === true
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) }
if (token) headers['X-Auth-Token'] = token
if (init?.body) headers['Content-Type'] = 'application/json'
const res = await fetch(path, { ...init, headers })
if (res.status === 204) return undefined as T
const text = await res.text()
let body: Record<string, unknown> = {}
if (text) {
try {
body = JSON.parse(text)
} catch {
if (!res.ok) throw new ApiError(res.status, text.slice(0, 400))
}
}
if (!res.ok) {
const msg = typeof body.error === 'string' ? body.error : `request failed (${res.status})`
throw new ApiError(res.status, msg, body)
}
return body as T
}
const post = <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
export const api = {
health: () => request<Health>('/api/health'),
source: () => request<SourceResponse>('/api/source'),
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
connections: () => request<Connection[]>('/api/connections'),
saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c),
deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }),
probe: (id: string) => post<HostKeyInfo>(`/api/connections/${id}/probe`),
trust: (id: string, fingerprint: string) => post<{ trusted: boolean }>(`/api/connections/${id}/trust`, { fingerprint }),
testConnection: (id: string) => post<Preflight>(`/api/connections/${id}/test`),
targetInventory: (id: string) => request<TargetInventory>(`/api/connections/${id}/inventory`),
preview: (plan: Plan) => post<PreviewResponse>('/api/plan/preview', plan),
migrateSSH: (connectionId: string, plan: Plan) => post<JobSnapshot>('/api/migrate/ssh', { connectionId, plan }),
buildPackage: (plan: Plan, format: 'tar' | 'dir') => post<JobSnapshot>('/api/migrate/package', { plan, format }),
jobs: () => request<JobSnapshot[]>('/api/jobs'),
job: (id: string) => request<JobSnapshot>(`/api/jobs/${id}`),
cancelJob: (id: string) => post<{ canceled: boolean }>(`/api/jobs/${id}/cancel`),
deleteJob: (id: string) => request<void>(`/api/jobs/${id}`, { method: 'DELETE' }),
packages: () => request<PackageInfo[]>('/api/packages'),
deletePackage: (name: string) => request<void>(`/api/packages/${encodeURIComponent(name)}`, { method: 'DELETE' }),
downloadUrl: (name: string) =>
`/api/packages/${encodeURIComponent(name)}/download` + (token ? `?token=${encodeURIComponent(token)}` : ''),
/** Opens the live progress stream for a job. */
jobEvents: (id: string) =>
new EventSource(`/api/jobs/${id}/events` + (token ? `?token=${encodeURIComponent(token)}` : '')),
}