export interface SambaShare { id: number; name: string; path: string; comment: string; read_only: boolean; guest_ok: boolean; valid_users: string[]; valid_groups: string[]; invalid_users: string[]; } export interface NFSClient { host: string; read_only: boolean; async: boolean; root_squash: boolean; subtree_check: boolean; advanced: NFSAdvanced; } export interface NFSAdvanced { all_squash: boolean; secure: boolean; wdelay: boolean; hide: boolean; crossmnt: boolean; [key: string]: boolean; } export interface NFSExport { id: number; path: string; clients: NFSClient[]; read_only: boolean; async: boolean; root_squash: boolean; subtree_check: boolean; fsid: number; advanced: string; } export interface User { id: number; username: string; groups: string[]; smb_enabled: boolean; disabled: boolean; } export interface DirtyModule { module: string; marked_at: string; } export interface ApplyLogEntry { id: number; module: string; message: string; success: boolean; created_at: string; } export interface DiskUsage { path: string; total_bytes: number; free_bytes: number; used_bytes: number; used_percent: number; sources: ("manual" | "samba" | "nfs")[]; used_by?: string[]; available: boolean; error?: string; } export interface WatchedMount { id: number; path: string; } export interface ServiceStatus { name: string; active: boolean; state: string; } export interface SystemStatus { disks: DiskUsage[]; services: ServiceStatus[]; } export interface VersionInfo { version: string; commit: string; } export interface FileEntry { name: string; path: string; is_dir: boolean; size: number; mode: string; mode_num: number; mod_time: number; } export interface FileInfo { path: string; name: string; is_dir: boolean; size: number; mode: string; mode_num: number; mod_time: number; uid: number; gid: number; total_bytes?: number; free_bytes?: number; } export interface DirList { entries: FileEntry[]; path: string; total: number; page: number; limit: number; has_more: boolean; } export interface SearchHit { name: string; path: string; is_dir: boolean; size: number; } export interface FileCapabilities { chmod: boolean; chown: boolean; } export interface FilePreview { type: "text" | "image" | "binary"; content?: string; mime: string; size: number; } export type JobKind = | "mergerfs_preview" | "mergerfs_move" | "snapraid_diff" | "snapraid_sync" | "snapraid_scrub" | "snapraid_check"; export interface StorageCapabilities { rsync: boolean; mergerfs: boolean; mergerfs_mounted: boolean; snapraid: boolean; } export interface StorageConfig { id: number; mover_source: string; mover_dest: string; mover_clean_macos: boolean; mover_remove_source: boolean; mover_inplace: boolean; mover_rsync_options: string; mover_warning_threshold: number; snapraid_conf: string; snapraid_data_dirs: string; snapraid_parity_dir: string; snapraid_scrub_plan: number; updated_at: string; } export interface PathStat { path: string; available: boolean; total_bytes: number; used_bytes: number; free_bytes: number; used_percent: number; error?: string; } export interface StorageDiskUsage { source: PathStat; } export interface StorageJob { id: number; kind: JobKind; status: "queued" | "running" | "success" | "failed" | "cancelled" | "interrupted"; args_json: string; pid: number; started_at: string | null; finished_at: string | null; exit_code: number; output: string; error: string; created_at: string; } export interface StorageEvent { type: "line" | "status" | "end" | "error"; line?: string; status?: string; exit_code?: number; message?: string; } export class ApiError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } async function request(method: string, path: string, body?: unknown): Promise { const isFormData = body instanceof FormData; const res = await fetch(`/api${path}`, { method, headers: isFormData ? undefined : body ? { "Content-Type": "application/json" } : undefined, body: isFormData ? body : body ? JSON.stringify(body) : undefined, }); if (res.status === 204) { return undefined as T; } const text = await res.text(); if (!text) { throw new ApiError(res.status, "empty response"); } const data = JSON.parse(text); if (!res.ok) { throw new ApiError(res.status, (data && data.error) || res.statusText); } return data as T; } export const api = { // auth status: () => request<{ authenticated: boolean; username: string }>("GET", "/auth/status"), login: (username: string, password: string) => request<{ username: string }>("POST", "/auth/login", { username, password }), logout: () => request<{ ok: boolean }>("POST", "/auth/logout"), changePassword: (oldPassword: string, newPassword: string) => request<{ ok: boolean }>("PUT", "/auth/password", { old_password: oldPassword, new_password: newPassword }), // dirty / apply dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"), apply: () => request<{ results: { module: string; applied: boolean; error?: string }[] }>("POST", "/apply"), applyLog: () => request<{ entries: ApplyLogEntry[] }>("GET", "/apply/log"), systemStatus: () => request("GET", "/system/status"), version: () => request("GET", "/version"), // samba listShares: () => request<{ shares: SambaShare[] | null }>("GET", "/samba/shares/"), createShare: (s: Partial) => request("POST", "/samba/shares/", s), updateShare: (id: number, s: Partial) => request("PUT", `/samba/shares/${id}/`, s), deleteShare: (id: number) => request("DELETE", `/samba/shares/${id}/`), // nfs listExports: () => request<{ exports: NFSExport[] | null }>("GET", "/nfs/exports/"), createExport: (e: Partial) => request("POST", "/nfs/exports/", e), updateExport: (id: number, e: Partial) => request("PUT", `/nfs/exports/${id}/`, e), deleteExport: (id: number) => request("DELETE", `/nfs/exports/${id}/`), // users listUsers: () => request<{ users: User[] | null }>("GET", "/users/"), createUser: (u: Partial & { password?: string }) => request("POST", "/users/", u), updateUser: (id: number, u: Partial & { password?: string }) => request("PUT", `/users/${id}/`, u), deleteUser: (id: number) => request("DELETE", `/users/${id}/`), // watched mounts listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"), createWatchedMount: (path: string) => request("POST", "/system/watched-mounts", { path }), deleteWatchedMount: (id: number) => request("DELETE", `/system/watched-mounts/${id}`), // import importSamba: () => request<{ imported: number; message?: string }>("POST", "/import/samba"), importNfs: () => request<{ imported: number; message?: string }>("POST", "/import/nfs"), importUsers: () => request<{ imported: number; message?: string }>("POST", "/import/users"), // files fileCapabilities: () => request("GET", "/files/capabilities"), fileRoots: () => request<{ roots: string[]; unrestricted: boolean }>("GET", "/files/roots"), listFiles: (path: string, page = 1, limit = 200) => request("GET", `/files/?path=${encodeURIComponent(path)}&page=${page}&limit=${limit}`), fileInfo: (path: string) => request("GET", `/files/info?path=${encodeURIComponent(path)}`), mkdirFile: (path: string) => request("POST", "/files/mkdir", { path }), renameFile: (path: string, newName: string) => request("POST", "/files/rename", { path, newName }), chmodFile: (path: string, mode: string) => request("POST", "/files/chmod", { path, mode }), chownFile: (path: string, uid: number, gid: number) => request("POST", "/files/chown", { path, uid, gid }), deleteFile: (path: string) => request("DELETE", `/files/?path=${encodeURIComponent(path)}`), uploadFile: (dir: string, file: File) => { const fd = new FormData(); fd.append("file", file); return request<{ path: string }>("POST", `/files/upload?path=${encodeURIComponent(dir)}`, fd); }, filePreview: (path: string) => request("GET", `/files/preview?path=${encodeURIComponent(path)}`), searchFiles: (path: string, q: string, limit = 100) => request<{ results: SearchHit[] }>(`GET`, `/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(q)}&limit=${limit}`), fileDownloadUrl: (path: string) => `/api/files/download?path=${encodeURIComponent(path)}`, // storage storageCapabilities: () => request("GET", "/storage/capabilities"), getStorageConfig: () => request("GET", "/storage/config"), updateStorageConfig: (c: Partial) => request("PUT", "/storage/config", c), storageDiskUsage: () => request("GET", "/storage/disk-usage"), listStorageJobs: (limit = 50) => request<{ jobs: StorageJob[] }>("GET", `/storage/jobs?limit=${limit}`), getStorageJob: (id: number) => request("GET", `/storage/jobs/${id}`), startStorageJob: (kind: JobKind, args?: Record) => request("POST", "/storage/jobs", { kind, args }), cancelStorageJob: (id: number) => request<{ ok: boolean }>("POST", `/storage/jobs/${id}/cancel`), storageJobStreamUrl: (id: number) => `/api/storage/jobs/${id}/stream`, getStorageConfFile: () => fetch(`/api/storage/conf-file`).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`); return r.text(); }), }; export function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB", "PB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; }