const TOKEN_KEY = 'archnest_token' export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY) } export function setToken(token: string | null) { if (token) localStorage.setItem(TOKEN_KEY, token) else localStorage.removeItem(TOKEN_KEY) } export class ApiError extends Error { status: number constructor(status: number, message: string) { super(message) this.status = status } } export async function apiFetch(path: string, options: RequestInit = {}): Promise { const token = getToken() const headers: Record = { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers as Record | undefined), } if (token) headers.Authorization = `Bearer ${token}` const res = await fetch(`/api${path}`, { ...options, headers }) if (!res.ok) { let message = res.statusText try { const body = await res.json() message = body.error ?? message } catch { // ignore non-JSON error bodies } throw new ApiError(res.status, message) } if (res.status === 204) return undefined as T return res.json() as Promise } export const api = { getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'), setup: (username: string, password: string) => apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }), login: (username: string, password: string) => apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }), me: () => apiFetch<{ user: AuthUser }>('/auth/me'), updateMe: (data: Partial<{ displayName: string | null; email: string | null; avatarDataUrl: string | null }>) => apiFetch<{ user: AuthUser }>('/auth/me', { method: 'PUT', body: JSON.stringify(data) }), changePassword: (currentPassword: string, newPassword: string) => apiFetch<{ ok: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword, newPassword }) }), listSessions: () => apiFetch<{ sessions: AuthSession[] }>('/auth/sessions'), revokeSession: (id: string) => apiFetch<{ ok: boolean }>(`/auth/sessions/${id}`, { method: 'DELETE' }), logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }), listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`), listUsers: () => apiFetch<{ users: ManagedUser[] }>('/users'), createUser: (data: { username: string; password: string; role: 'admin' | 'member'; displayName?: string | null; email?: string | null }) => apiFetch<{ user: ManagedUser }>('/users', { method: 'POST', body: JSON.stringify(data) }), updateUser: (id: number, data: Partial<{ role: 'admin' | 'member'; active: boolean }>) => apiFetch<{ user: ManagedUser }>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteUser: (id: number) => apiFetch<{ ok: boolean }>(`/users/${id}`, { method: 'DELETE' }), listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'), createIntegration: (data: { type: string; name: string; config?: Record; secrets?: Record }) => apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }), updateIntegration: (id: number, data: Partial<{ name: string; config: Record; secrets: Record }>) => apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteIntegration: (id: number) => apiFetch(`/integrations/${id}`, { method: 'DELETE' }), testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }), listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'), listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'), createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) => apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }), createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) => apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }), updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) => apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteBookmark: (id: number) => apiFetch(`/bookmarks/${id}`, { method: 'DELETE' }), deleteAllBookmarks: () => apiFetch('/bookmarks', { method: 'DELETE' }), listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`), listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'), listTunnels: () => apiFetch<{ tunnels: Tunnel[] }>('/tunnels'), createTunnel: (data: { name: string integrationId: number mode: 'local' | 'remote' | 'dynamic' sourcePort: number endpointHost?: string endpointPort?: number autoStart?: boolean maxRetries?: number retryIntervalMs?: number }) => apiFetch<{ tunnel: Tunnel }>('/tunnels', { method: 'POST', body: JSON.stringify(data) }), deleteTunnel: (id: number) => apiFetch(`/tunnels/${id}`, { method: 'DELETE' }), connectTunnel: (id: number) => apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }), disconnectTunnel: (id: number) => apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }), listFiles: (integrationId: number, path: string) => apiFetch<{ path: string; entries: FileEntry[] }>(`/files/${integrationId}/list?path=${encodeURIComponent(path)}`), readFile: (integrationId: number, path: string) => apiFetch<{ content: string; encoding: 'utf8' | 'base64'; size: number; mode: number }>( `/files/${integrationId}/content?path=${encodeURIComponent(path)}`, ), writeFile: (integrationId: number, path: string, content: string, encoding: 'utf8' | 'base64' = 'utf8') => apiFetch<{ ok: boolean }>(`/files/${integrationId}/content`, { method: 'PUT', body: JSON.stringify({ path, content, encoding }) }), mkdir: (integrationId: number, path: string) => apiFetch<{ ok: boolean }>(`/files/${integrationId}/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }), renameFile: (integrationId: number, from: string, to: string) => apiFetch<{ ok: boolean }>(`/files/${integrationId}/rename`, { method: 'POST', body: JSON.stringify({ from, to }) }), deleteFile: (integrationId: number, path: string, isDirectory = false) => apiFetch<{ ok: boolean }>(`/files/${integrationId}/delete`, { method: 'POST', body: JSON.stringify({ path, isDirectory }) }), chmodFile: (integrationId: number, path: string, mode: string) => apiFetch<{ ok: boolean }>(`/files/${integrationId}/chmod`, { method: 'POST', body: JSON.stringify({ path, mode }) }), downloadFileUrl: (integrationId: number, path: string) => `/api/files/${integrationId}/download?path=${encodeURIComponent(path)}`, uploadFile: async (integrationId: number, path: string, file: File) => { const form = new FormData() form.append('path', path) form.append('file', file) const token = getToken() const res = await fetch(`/api/files/${integrationId}/upload`, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : undefined, body: form, }) if (!res.ok) { let message = res.statusText try { message = (await res.json()).error ?? message } catch { // ignore non-JSON error bodies } throw new ApiError(res.status, message) } return res.json() as Promise<{ ok: boolean; path: string }> }, listContainers: (integrationId: number) => apiFetch<{ containers: Container[] }>(`/docker/${integrationId}/containers`), containerStats: (integrationId: number, id: string) => apiFetch(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/stats`), containerLogs: (integrationId: number, id: string, tail = 200) => apiFetch<{ logs: string }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/logs?tail=${tail}`), containerAction: (integrationId: number, id: string, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') => apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/${action}`, { method: 'POST' }), removeContainer: (integrationId: number, id: string, force = false) => apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/remove`, { method: 'POST', body: JSON.stringify({ force }), }), getHostMetrics: (integrationId: number) => apiFetch(`/integrations/${integrationId}/metrics`), startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) => apiFetch<{ transferId: string }>('/transfers', { method: 'POST', body: JSON.stringify(data) }), listTransfers: () => apiFetch<{ transfers: TransferProgress[] }>('/transfers'), getTransfer: (id: string) => apiFetch(`/transfers/${id}`), cancelTransfer: (id: string) => apiFetch<{ ok: boolean }>(`/transfers/${id}/cancel`, { method: 'POST' }), exportData: () => apiFetch('/data/export'), importData: (data: DataExport) => apiFetch<{ ok: boolean; imported: { integrations: number; bookmarkCategories: number; bookmarks: number; tunnels: number } }>('/data/import', { method: 'POST', body: JSON.stringify(data), }), } export interface DataExport { version: number exportedAt?: string integrations: Array<{ id: number; type: string; name: string; enabled: boolean; config: Record; secrets: Record }> bookmarkCategories: Array<{ id: number; name: string; icon: string | null; sortOrder: number }> bookmarks: Array<{ categoryId: number | null; title: string; url: string; icon: string | null; favorite: boolean }> tunnels: Array<{ name: string; integrationId: number; mode: string; sourcePort: number; endpointHost: string; endpointPort: number; autoStart: boolean; maxRetries: number; retryIntervalMs: number }> } export interface AuthUser { id: number username: string display_name: string | null email: string | null avatar_data_url: string | null role?: 'admin' | 'member' active?: boolean } export interface ManagedUser { id: number username: string displayName: string | null email: string | null role: 'admin' | 'member' active: boolean createdAt: string } export interface AuthSession { id: string userAgent: string | null ip: string | null createdAt: string lastSeenAt: string current: boolean } export interface LoginEvent { id: number username: string | null ip: string | null userAgent: string | null success: boolean createdAt: string } export interface Integration { id: number type: string name: string enabled: boolean status: string config: Record secretKeys: string[] lastCheckedAt: string | null createdAt: string } export interface Tunnel { id: number name: string integrationId: number mode: 'local' | 'remote' | 'dynamic' sourcePort: number endpointHost: string endpointPort: number autoStart: boolean maxRetries: number retryIntervalMs: number createdAt: string status: 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error' error: string | null retryCount: number } export interface Bookmark { id: number category_id: number | null title: string url: string icon: string | null favorite: number status: string last_checked_at: string | null created_at: string } export interface BookmarkCategory { id: number name: string icon: string | null sort_order: number } export interface Event { id: number type: string title: string source: string | null created_at: string } export interface FileEntry { name: string isDirectory: boolean isSymlink: boolean size: number mode: number mtime: number } export interface Container { id: string name: string image: string state: string status: string ports: { privatePort: number; publicPort?: number; type: string }[] } export interface ContainerStats { cpuPercent: number memUsage: number memLimit: number netRx: number netTx: number } export interface Resource { name: string status: 'healthy' | 'warning' | 'critical' | 'unknown' detail?: string integration: string } export interface TransferProgress { transferId: string status: 'running' | 'completed' | 'failed' | 'cancelled' sourceIntegrationId: number destIntegrationId: number sourcePaths: string[] destPath: string move: boolean totalFiles: number totalBytes: number filesTransferred: number bytesTransferred: number currentFile: string | null error: string | null startedAt: number finishedAt: number | null } export interface HostMetrics { cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null } memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null } disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null } uptime: { seconds: number | null; formatted: string | null } network: { interfaces: Array<{ name: string; ip: string; state: string }> } system: { hostname: string | null; kernel: string | null; os: string | null } processes: { total: number | null running: number | null top: Array<{ pid: string; user: string; cpu: string; mem: string; command: string }> } ports: { source: 'ss' | 'netstat' | 'none'; ports: Array<{ protocol: string; localAddress: string; localPort: number; state?: string; process?: string }> } firewall: { type: 'iptables' | 'none'; status: 'active' | 'inactive' | 'unknown'; chains: Array<{ name: string; policy: string; rules: unknown[] }> } loginStats: { recentLogins: Array<{ user: string; ip: string; time: string; status: string }> failedLogins: Array<{ user: string; ip: string; time: string; status: string }> totalLogins: number uniqueIPs: number } }