export type UserRole = "admin" | "operator" | "viewer"; export interface CurrentUser { id: number; sub: string; email?: string; name?: string; role: UserRole; } export interface UserRecord { id: number; oidcSub: string; email: string | null; name: string | null; role: UserRole; createdAt: string; lastLoginAt: string | null; } export interface SessionSummary { id: string; sub: string; email: string | null; name: string | null; role: UserRole | null; ip: string | null; userAgent: string | null; lastAccess: string; expiresAt: string | null; } export type MaintenanceTargetType = "server" | "integration" | "dns_provider"; export interface MaintenanceWindow { id: number; targetType: MaintenanceTargetType; targetId: number; targetName: string; targetKind: string; reason: string | null; startedAt: string; endsAt: string; createdBy: string | null; } export interface MaintenanceTargets { servers: { id: number; name: string }[]; integrations: { id: number; name: string; type: string }[]; dnsProviders: { id: number; name: string; providerType: string }[]; } export interface AuditLogEntry { id: number; actorUserId: number | null; actorLabel: string | null; category: string; action: string; targetType: string | null; targetId: string | null; detail: string | null; createdAt: string; } export interface DiagLogEntry { id: number; source: string; operation: string; ok: boolean; latencyMs: number; error: string | null; createdAt: string; } export interface DiagLogResponse { total: number; entries: DiagLogEntry[]; } export type SecretType = "api_token" | "ssl_certificate" | "password" | "generic"; export type SecretStatus = "ok" | "expiring" | "expired"; export interface SecretRecord { id: number; name: string; type: SecretType; description: string | null; expiryDate: string; warnDays: number; notes: string | null; createdAt: string; updatedAt: string; status: SecretStatus; daysLeft: number; /** ssl_certificate only — when set, expiryDate is read from the live certificate here rather than typed in. */ checkHost: string | null; checkPort: number | null; lastCheckedAt: string | null; lastCheckError: string | null; } export interface SecretInput { name: string; type: SecretType; description?: string; /** Omitted when a host to check is given — the date then comes from the live certificate. */ expiryDate?: string; warnDays: number; notes?: string; checkHost?: string | null; checkPort?: number | null; } export interface IpamEntry { id: number; ipAddress: string; label: string | null; vendor: string | null; location: string | null; notes: string | null; source: string | null; createdAt: string; updatedAt: string; matchingDnsRecords: string[]; } export interface IpamSyncResult { added: number; updated: number; skipped: number; skippedIps: string[]; errors: string[]; } export interface IpamInput { ipAddress: string; label?: string; vendor?: string; location?: string; notes?: string; } export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium"; export interface GotifySettings { enabled: boolean; url: string; token: string; priority: number; } export interface NtfySettings { enabled: boolean; url: string; topic: string; token: string; priority: number; } export interface SmtpSettings { enabled: boolean; host: string; port: number; secure: boolean; username: string; password: string; from: string; to: string; } export interface WebhookSettings { enabled: boolean; url: string; secret: string; } export interface NotificationEvents { dnsAdd: boolean; dnsUpdate: boolean; dnsDelete: boolean; secretCheck: boolean; tailscaleKeyCheck: boolean; dockerUpdateCheck: boolean; proxmoxBackupCheck: boolean; healthAlerts: boolean; automationAlerts: boolean; domainExpiryCheck: boolean; secretCheckTime: string; timezone: string; integrationFailureAlerts: boolean; integrationFailureThreshold: number; } export type DateFormat = "ymd" | "dmy" | "mdy"; export type TimeFormat = "24h" | "12h"; export interface DisplaySettings { dateFormat: DateFormat; timeFormat: TimeFormat; pageSize: number; } export interface LogRetentionSettings { enabled: boolean; retentionDays: number; intervalHours: number; } export interface HealthCheckSettings { serverOfflineMinutes: number; diskUsagePercent: number; domainWarnDays: number; } export interface QuietHoursSettings { enabled: boolean; start: string; end: string; } export interface AppSettings { gotify: GotifySettings; ntfy: NtfySettings; smtp: SmtpSettings; webhook: WebhookSettings; notifications: NotificationEvents; providerColors: Record; integrationColors: Record; display: DisplaySettings; logRetention: LogRetentionSettings; quietHours: QuietHoursSettings; healthChecks: HealthCheckSettings; } export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial }; export interface EncryptedExportFile { app: "homelab-manager-backup"; version: 1; salt: string; iv: string; authTag: string; ciphertext: string; } export interface ImportResult { integrationsCreated: number; integrationsSkipped: string[]; dnsProvidersCreated: number; dnsProvidersSkipped: string[]; } export interface DnsProviderField { key: string; label: string; secret: boolean; type?: "text" | "password" | "checkbox"; placeholder?: string; } export interface DnsProviderSummary { id: number; providerType: DnsProviderType; name: string; enabled: boolean; createdAt: string; } export interface DnsZone { id: string; name: string; syncedAt: string | null; recordCount: number; } export interface DnsRecord { id: string; type: string; name: string; content: string; ttl: number | null; priority: number | null; proxied?: boolean | null; } export interface DnsRecordInput { type: string; name: string; content: string; ttl?: number; priority?: number; proxied?: boolean; } export interface ServerRecord { id: number; name: string; hostname: string | null; osType: string; description: string | null; apiTokenPrefix: string; createdAt: string; lastSeenAt: string | null; proxmoxIntegrationId: number | null; proxmoxNode: string | null; proxmoxGuestType: "qemu" | "lxc" | null; proxmoxVmid: number | null; hideProxmoxLink: boolean; tags: string[]; } export interface ServerHardware { source: "proxmox" | "agent" | "none"; error?: string; integrationId?: number; integrationName?: string; status?: string; cpuModel?: string; cpuCores?: number | null; cpuUsagePercent?: number | null; cpuLoadPercent?: number | null; memTotalBytes?: number | null; memUsedBytes?: number | null; diskBytes?: number | null; disks?: { mount: string; sizeBytes: number; usedBytes: number }[]; uptime?: number | null; guestAgentAvailable?: boolean; } export interface DnsMatch { recordName: string; recordType: string; ip: string; zoneName: string | null; providerName: string; providerType: string; } export interface ServerLink { id: number; label: string; url: string; } export interface ServerDetail { server: ServerRecord; ipAddresses: string[]; hardware: ServerHardware; dnsMatches: DnsMatch[]; links: ServerLink[]; } export interface PortEntry { /** Null for a port only known from the agent that has no note yet. */ id: number | null; port: number; protocol: "tcp" | "udp"; label: string | null; comment: string | null; /** The last scan from this app connected to it. */ scanOpen: boolean; lastSeenOpenAt: string | null; agent: { addresses: string[]; process: string | null; localOnly: boolean } | null; state: "open" | "reserved"; } export interface PortScanSummary { at: string; address: string; from: number; to: number; open: number; refused: number; filtered: number; responded: boolean; } export interface PortList { ports: PortEntry[]; agentReporting: boolean; agentReportedAt: string | null; lastScan: PortScanSummary | null; } export interface PortScanResult extends Omit { scan: PortScanSummary; freeCount: number; freeRanges: [number, number][]; } export interface ServerUpdateInput { name?: string; hostname?: string; description?: string; proxmoxIntegrationId?: number | null; proxmoxNode?: string | null; proxmoxGuestType?: "qemu" | "lxc" | null; proxmoxVmid?: number | null; hideProxmoxLink?: boolean; } export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual"; export interface TaskRecord { id: number; serverId: number; serverName: string; scheduleType: ScheduleType; origin: "agent" | "manual"; name: string; command: string | null; scheduleExpression: string | null; source: string | null; enabled: boolean; nextRunAt: string | null; isStale: boolean; firstSeenAt: string; lastSeenAt: string; } export interface ManualTaskInput { serverId: number; scheduleType: ScheduleType; name: string; command?: string; scheduleExpression?: string; nextRunAt?: string; enabled?: boolean; } export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand"; export interface IntegrationField { key: string; label: string; secret: boolean; type?: "text" | "password" | "checkbox"; placeholder?: string; } export interface IntegrationSummary { id: number; type: IntegrationType; name: string; baseUrl: string; enabled: boolean; createdAt: string; } export interface TailscaleDevice { id: string; nodeId: string; hostname: string; label: string; addresses: string[]; primaryAddress: string; os: string; lastSeen: string | null; isExitNode: boolean; authorized: boolean; online: boolean; keyExpiry: string | null; keyExpiryDisabled: boolean; } export interface TailscaleDevicesResponse { devices: TailscaleDevice[]; summary: { total: number; online: number; unauthorized: number; expiringSoon: number }; } export interface GiteaWorkflowRun { id: number; displayTitle: string; status: string; conclusion: string | null; headBranch: string; event: string; runNumber: number; htmlUrl: string; startedAt: string | null; completedAt: string | null; } export interface GiteaRepo { owner: string; name: string; fullName: string; htmlUrl: string; private: boolean; hasActions: boolean; updatedAt: string; latestRun: GiteaWorkflowRun | null; } export interface DomainRecord { id: number; name: string; origin: "manual" | "zone"; expiresAt: string | null; registrar: string | null; lookupSource: "rdap" | "whois" | null; lastCheckedAt: string | null; lastCheckedOkAt: string | null; lastCheckError: string | null; createdAt: string; status: "ok" | "expiring" | "expired" | "unknown"; daysLeft: number | null; } export interface DomainList { domains: DomainRecord[]; warnDays: number; checking: boolean; } export type ConsistencyKind = "ip_conflict" | "dns_stale" | "ipam_stale" | "not_in_ipam" | "no_dns"; export type ConsistencySeverity = "error" | "warning" | "info"; export interface ConsistencyFinding { key: string; kind: ConsistencyKind; severity: ConsistencySeverity; title: string; detail: string; ip: string | null; servers: { id: number; name: string }[]; dnsNames: string[]; suggestedLabel: string | null; } export interface ConsistencyIgnore { id: number; key: string; title: string; reason: string | null; createdBy: string | null; createdAt: string; stillPresent: boolean; } export interface ConsistencyReport { findings: ConsistencyFinding[]; counts: Record; ignored: ConsistencyIgnore[]; excludedRanges: string[]; hiddenAddresses: number; sources: { servers: { total: number; withAddresses: number }; ipam: number; dns: { zones: number; syncedZones: number; records: number; oldestSyncedAt: string | null; newestSyncedAt: string | null }; }; generatedAt: string; } export interface PrivacyOverview { me: { user: { email: string | null; name: string | null; role: UserRole; subject: string; createdAt: string; lastLoginAt: string | null }; auditEntries: number; sessions: { ip: string | null; userAgent: string | null; lastAccess: string; expiresAt: string | null; current: boolean }[]; }; retention: { enabled: boolean; retentionDays: number; intervalHours: number }; outbound: { integrationTypes: string[]; dnsProviderTypes: string[]; domainsTracked: number; tlsCertificateChecks: number; servers: number; serversWithAgent: number; channels: Record<"gotify" | "ntfy" | "smtp" | "webhook", { enabled: boolean; host: string | null }>; }; } export interface TagEntry { name: string; /** "#rrggbb", or null for the automatic colour. */ color: string | null; /** Servers currently carrying it. */ count: number; } export interface DockhandContainer { id: string; name: string; image: string; state: string; status: string; environmentId: number; environmentName: string; updateAvailable: boolean | null; newerVersion: string | null; checkedAt: string | null; } export interface DockhandContainersResponse { containers: DockhandContainer[]; summary: { total: number; running: number }; } export type SemaphoreTaskStatus = | "waiting" | "starting" | "waiting_confirmation" | "confirmed" | "rejected" | "running" | "stopping" | "stopped" | "success" | "error"; export interface SemaphoreTask { id: number; status: SemaphoreTaskStatus; created: string | null; start: string | null; end: string | null; } export interface SemaphoreTemplate { id: number; projectId: number; projectName: string; name: string; playbook: string; lastTask: SemaphoreTask | null; } export interface SemaphoreTemplatesResponse { templates: SemaphoreTemplate[]; summary: { total: number; failing: number }; } export type ProxmoxGuestType = "qemu" | "lxc"; export interface ProxmoxGuest { vmid: number; name: string; node: string; type: ProxmoxGuestType; status: string; cpu: number | null; maxmem: number | null; mem: number | null; uptime: number | null; } export interface ProxmoxGuestsResponse { guests: ProxmoxGuest[]; summary: { total: number; running: number }; } export interface ProxmoxStorage { id: string; type: string; active: boolean; shared: boolean; totalBytes: number | null; usedBytes: number | null; availBytes: number | null; } export interface ProxmoxNodeStats { node: string; error: string | null; uptime: number | null; cpuUsagePercent: number | null; cpuCores: number | null; loadAverage: [number, number, number] | null; memTotalBytes: number | null; memUsedBytes: number | null; swapTotalBytes: number | null; swapUsedBytes: number | null; rootfsTotalBytes: number | null; rootfsUsedBytes: number | null; pveVersion: string | null; storages: ProxmoxStorage[]; } export interface ProxmoxNodesResponse { nodes: ProxmoxNodeStats[]; } export interface ProxmoxBackupJob { id: string; enabled: boolean; schedule: string; storage: string; node: string | null; allGuests: boolean; vmids: string | null; exclude: string | null; } export interface ProxmoxBackupTask { node: string; upid: string; guestId: string | null; status: string; ok: boolean; startTime: string; endTime: string | null; } export interface ProxmoxBackupsResponse { jobs: ProxmoxBackupJob[]; tasks: ProxmoxBackupTask[]; uncoveredGuests: ProxmoxGuest[]; } export interface SynologyVolume { id: string; status: string; deviceType: string; sizeTotal: number | null; sizeUsed: number | null; } export interface SynologyDisk { id: string; name: string; device: string; status: string; smartStatus: string; temp: number | null; exceedBadSectorThreshold: boolean; belowRemainLifeThreshold: boolean; } export interface SynologyStorageResponse { volumes: SynologyVolume[]; disks: SynologyDisk[]; summary: { volumeCount: number; volumesNotNormal: number; diskCount: number; disksNotNormal: number }; } export interface SynologySystemInfo { hostname: string | null; model: string | null; serial: string | null; firmwareVersion: string | null; uptime: string | null; ipAddresses: string[]; cpu: { cores: number | null; clockSpeedMHz: number | null; loadPercent: number | null }; memory: { totalBytes: number | null; usedBytes: number | null }; } export interface SearchResponse { servers: { id: number; name: string; hostname: string | null }[]; secrets: { id: number; name: string; type: SecretType }[]; ipam: { id: number; ipAddress: string; label: string | null }[]; integrations: { id: number; type: IntegrationType; name: string }[]; dnsProviders: { id: number; providerType: DnsProviderType; name: string }[]; dnsZones: { providerId: number; zoneId: string; zoneName: string; providerName: string }[]; dnsRecords: { providerId: number; zoneId: string; zoneName: string; type: string; name: string; content: string; providerName: string; }[]; } export class UnauthorizedError extends Error {} export class ForbiddenError extends Error {} async function request(path: string, init?: RequestInit): Promise { const res = await fetch(path, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, credentials: "same-origin", }); if (res.status === 401) { throw new UnauthorizedError("unauthorized"); } if (res.status === 403) { throw new ForbiddenError("forbidden"); } if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(`Request failed (${res.status}): ${body}`); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } export const api = { me: () => request<{ user: CurrentUser }>("/api/me"), search: { query: (q: string) => request(`/api/search?q=${encodeURIComponent(q)}`), }, users: { list: () => request<{ users: UserRecord[] }>("/api/users"), updateRole: (id: number, role: UserRole) => request<{ user: UserRecord }>(`/api/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }), }), }, maintenance: { list: () => request<{ windows: MaintenanceWindow[]; targets: MaintenanceTargets }>("/api/maintenance"), start: (data: { targetType: MaintenanceTargetType; targetId: number; minutes: number; reason?: string }) => request<{ window: MaintenanceWindow }>("/api/maintenance", { method: "POST", body: JSON.stringify(data) }), end: (id: number) => request(`/api/maintenance/${id}`, { method: "DELETE" }), }, sessions: { list: () => request<{ sessions: SessionSummary[]; currentSessionId: string }>("/api/sessions"), revoke: (id: string) => request(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }), }, auditLog: { list: (limit = 200) => request<{ entries: AuditLogEntry[] }>(`/api/audit-log?limit=${limit}`), }, diagLog: { list: (params: { source?: string; ok?: boolean; limit?: number; offset?: number } = {}) => { const qs = new URLSearchParams(); if (params.source) qs.set("source", params.source); if (params.ok !== undefined) qs.set("ok", String(params.ok)); if (params.limit !== undefined) qs.set("limit", String(params.limit)); if (params.offset !== undefined) qs.set("offset", String(params.offset)); const query = qs.toString(); return request(`/api/diag-log${query ? `?${query}` : ""}`); }, clear: () => request("/api/diag-log", { method: "DELETE" }), }, secrets: { list: () => request<{ secrets: SecretRecord[] }>("/api/secrets"), create: (data: SecretInput) => request<{ secret: SecretRecord }>("/api/secrets", { method: "POST", body: JSON.stringify(data) }), update: (id: number, data: Partial) => request<{ secret: SecretRecord }>(`/api/secrets/${id}`, { method: "PATCH", body: JSON.stringify(data), }), remove: (id: number) => request(`/api/secrets/${id}`, { method: "DELETE" }), checkTls: (id: number) => request<{ secret: SecretRecord; result: { ok: boolean; expiryDate?: string; error?: string } }>( `/api/secrets/${id}/check-tls`, { method: "POST" }, ), }, ipam: { list: () => request<{ entries: IpamEntry[] }>("/api/ipam"), create: (data: IpamInput) => request<{ entry: IpamEntry }>("/api/ipam", { method: "POST", body: JSON.stringify(data) }), update: (id: number, data: Partial>) => request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }), remove: (id: number) => request(`/api/ipam/${id}`, { method: "DELETE" }), syncTailscale: () => request("/api/ipam/sync-tailscale", { method: "POST" }), syncProxmox: () => request("/api/ipam/sync-proxmox", { method: "POST" }), }, dns: { providerFields: () => request<{ fields: Record }>("/api/dns/provider-fields"), stats: () => request<{ totalProviders: number; enabledProviders: number; totalZones: number; perProvider: { providerId: number; name: string; providerType: DnsProviderType; zoneCount: number; error: string | null }[]; totalRecords: number; recordsByType: { type: string; count: number }[]; }>("/api/dns/stats"), providers: { list: () => request<{ providers: DnsProviderSummary[] }>("/api/dns/providers"), create: (data: { providerType: DnsProviderType; name: string; config: Record }) => request<{ provider: DnsProviderSummary }>("/api/dns/providers", { method: "POST", body: JSON.stringify(data), }), update: (id: number, data: { name?: string; enabled?: boolean; config?: Record }) => request<{ provider: DnsProviderSummary }>(`/api/dns/providers/${id}`, { method: "PATCH", body: JSON.stringify(data), }), remove: (id: number) => request(`/api/dns/providers/${id}`, { method: "DELETE" }), test: (data: { providerType: DnsProviderType; config: Record }) => request<{ ok: boolean; zoneCount?: number; error?: string }>("/api/dns/providers/test", { method: "POST", body: JSON.stringify(data), }), }, zones: { list: (providerId: number) => request<{ zones: DnsZone[] }>(`/api/dns/providers/${providerId}/zones`), }, records: { list: (providerId: number, zoneId: string) => request<{ records: DnsRecord[]; syncedAt: string | null }>( `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`, ), sync: (providerId: number, zoneId: string, zoneName?: string) => request<{ records: DnsRecord[]; syncedAt: string }>( `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/sync`, { method: "POST", body: JSON.stringify({ zoneName }) }, ), create: (providerId: number, zoneId: string, data: DnsRecordInput) => request<{ record: DnsRecord }>( `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`, { method: "POST", body: JSON.stringify(data) }, ), update: (providerId: number, zoneId: string, recordId: string, data: DnsRecordInput) => request<{ record: DnsRecord }>( `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`, { method: "PUT", body: JSON.stringify(data) }, ), remove: (providerId: number, zoneId: string, recordId: string) => request( `/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`, { method: "DELETE" }, ), }, clearCache: () => request<{ ok: true }>("/api/dns/cache/clear", { method: "POST" }), }, servers: { list: () => request<{ servers: ServerRecord[] }>("/api/servers"), create: (data: { name: string; hostname?: string; description?: string }) => request<{ server: ServerRecord; token: string }>("/api/servers", { method: "POST", body: JSON.stringify(data), }), rotateToken: (id: number) => request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, { method: "POST", }), update: (id: number, data: ServerUpdateInput) => request<{ server: ServerRecord }>(`/api/servers/${id}`, { method: "PATCH", body: JSON.stringify(data) }), detail: (id: number) => request(`/api/servers/${id}/detail`), remove: (id: number) => request(`/api/servers/${id}`, { method: "DELETE" }), setTags: (id: number, tags: string[]) => request<{ tags: string[] }>(`/api/servers/${id}/tags`, { method: "PUT", body: JSON.stringify({ tags }) }), addLink: (id: number, data: { label: string; url: string }) => request<{ link: ServerLink }>(`/api/servers/${id}/links`, { method: "POST", body: JSON.stringify(data) }), updateLink: (id: number, linkId: number, data: { label?: string; url?: string }) => request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }), removeLink: (id: number, linkId: number) => request(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }), ports: { list: (id: number) => request(`/api/servers/${id}/ports`), scan: (id: number, data: { address: string; from: number; to: number }) => request(`/api/servers/${id}/ports/scan`, { method: "POST", body: JSON.stringify(data) }), save: (id: number, data: { port: number; protocol: "tcp" | "udp"; label?: string; comment?: string }) => request<{ ports: PortEntry[] }>(`/api/servers/${id}/ports`, { method: "PUT", body: JSON.stringify(data) }), remove: (id: number, portId: number) => request(`/api/servers/${id}/ports/${portId}`, { method: "DELETE" }), }, }, tags: { list: () => request<{ tags: TagEntry[] }>("/api/tags"), create: (name: string, color?: string) => request<{ tags: TagEntry[] }>("/api/tags", { method: "POST", body: JSON.stringify({ name, color }) }), setColor: (name: string, color: string | null) => request<{ tags: TagEntry[] }>("/api/tags/color", { method: "PUT", body: JSON.stringify({ name, color }) }), rename: (from: string, to: string) => request<{ tags: TagEntry[]; updatedServers: number; merged: boolean }>("/api/tags/rename", { method: "POST", body: JSON.stringify({ from, to }) }), remove: (name: string) => request<{ tags: TagEntry[]; updatedServers: number }>("/api/tags/delete", { method: "POST", body: JSON.stringify({ name }) }), }, privacy: { overview: () => request("/api/privacy"), exportOwnData: async (): Promise => { const res = await fetch("/api/privacy/export", { credentials: "same-origin" }); if (!res.ok) throw new Error(`Request failed (${res.status}): ${await res.text().catch(() => "")}`); return res.blob(); }, }, consistency: { report: () => request("/api/consistency"), ignore: (key: string, reason?: string) => request<{ ignore: ConsistencyIgnore }>("/api/consistency/ignore", { method: "POST", body: JSON.stringify({ key, reason }) }), unignore: (id: number) => request(`/api/consistency/ignore/${id}`, { method: "DELETE" }), setExcludedRanges: (ranges: string[]) => request<{ excludedRanges: string[] }>("/api/consistency/excluded-ranges", { method: "PUT", body: JSON.stringify({ ranges }) }), }, domains: { list: () => request("/api/domains"), add: (name: string) => request<{ domain: DomainRecord; resolvedFrom: string | null }>("/api/domains", { method: "POST", body: JSON.stringify({ name }) }), refresh: () => request("/api/domains/refresh", { method: "POST" }), check: (id: number) => request<{ domain: DomainRecord }>(`/api/domains/${id}/check`, { method: "POST" }), remove: (id: number) => request(`/api/domains/${id}`, { method: "DELETE" }), }, tasks: { list: ( params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {}, ) => { const qs = new URLSearchParams(); if (params.serverId) qs.set("serverId", String(params.serverId)); if (params.scheduleType) qs.set("scheduleType", params.scheduleType); if (params.search) qs.set("search", params.search); if (params.includeStale) qs.set("includeStale", "true"); const query = qs.toString(); return request<{ tasks: TaskRecord[] }>(`/api/tasks${query ? `?${query}` : ""}`); }, create: (data: ManualTaskInput) => request<{ task: TaskRecord }>("/api/tasks", { method: "POST", body: JSON.stringify(data) }), update: (id: number, data: Partial>) => request<{ task: TaskRecord }>(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }), remove: (id: number) => request(`/api/tasks/${id}`, { method: "DELETE" }), }, integrations: { fields: () => request<{ fields: Partial> }>("/api/integrations/fields"), list: () => request<{ integrations: IntegrationSummary[] }>("/api/integrations"), create: (data: { type: IntegrationType; name: string; config: Record }) => request<{ integration: IntegrationSummary }>("/api/integrations", { method: "POST", body: JSON.stringify(data), }), update: (id: number, data: { name?: string; enabled?: boolean; config?: Record }) => request<{ integration: IntegrationSummary }>(`/api/integrations/${id}`, { method: "PATCH", body: JSON.stringify(data), }), remove: (id: number) => request(`/api/integrations/${id}`, { method: "DELETE" }), test: (data: { type: IntegrationType; config: Record }) => request<{ ok: boolean; latencyMs?: number; error?: string }>("/api/integrations/test", { method: "POST", body: JSON.stringify(data), }), getConfig: (id: number) => request<{ integration: { id: number; type: IntegrationType; name: string; enabled: boolean }; config: Record; }>(`/api/integrations/${id}/config`), testExisting: (id: number, config: Record) => request<{ ok: boolean; latencyMs?: number; error?: string }>(`/api/integrations/${id}/test`, { method: "POST", body: JSON.stringify({ config }), }), tailscale: { devices: (integrationId: number) => request(`/api/integrations/${integrationId}/tailscale/devices`), setAuthorized: (integrationId: number, deviceId: string, authorized: boolean) => request(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}/authorize`, { method: "POST", body: JSON.stringify({ authorized }), }), remove: (integrationId: number, deviceId: string) => request(`/api/integrations/${integrationId}/tailscale/devices/${encodeURIComponent(deviceId)}`, { method: "DELETE", }), }, gitea: { repos: (integrationId: number) => request<{ repos: GiteaRepo[] }>(`/api/integrations/${integrationId}/gitea/repos`), rerunFailed: (integrationId: number, owner: string, repo: string, runId: number) => request( `/api/integrations/${integrationId}/gitea/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/runs/${runId}/rerun-failed`, { method: "POST" }, ), }, dockhand: { containers: (integrationId: number) => request(`/api/integrations/${integrationId}/dockhand/containers`), start: (integrationId: number, envId: number, containerId: string) => request( `/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/start`, { method: "POST" }, ), stop: (integrationId: number, envId: number, containerId: string) => request( `/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/stop`, { method: "POST" }, ), restart: (integrationId: number, envId: number, containerId: string) => request( `/api/integrations/${integrationId}/dockhand/environments/${envId}/containers/${encodeURIComponent(containerId)}/restart`, { method: "POST" }, ), checkForUpdates: (integrationId: number) => request<{ total: number; updatesFound: number }>(`/api/integrations/${integrationId}/dockhand/check-updates`, { method: "POST", }), }, semaphore: { templates: (integrationId: number) => request(`/api/integrations/${integrationId}/semaphore/templates`), run: (integrationId: number, projectId: number, templateId: number) => request<{ task: SemaphoreTask }>( `/api/integrations/${integrationId}/semaphore/projects/${projectId}/templates/${templateId}/run`, { method: "POST" }, ), }, proxmox: { guests: (integrationId: number) => request(`/api/integrations/${integrationId}/proxmox/guests`), nodes: (integrationId: number) => request(`/api/integrations/${integrationId}/proxmox/nodes`), backups: (integrationId: number) => request(`/api/integrations/${integrationId}/proxmox/backups`), start: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => request(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/start`, { method: "POST" }), stop: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => request(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/stop`, { method: "POST" }), restart: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => request(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/restart`, { method: "POST" }), shutdown: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => request(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/shutdown`, { method: "POST" }), }, synology: { storage: (integrationId: number) => request(`/api/integrations/${integrationId}/synology/storage`), system: (integrationId: number) => request(`/api/integrations/${integrationId}/synology/system`), }, }, settings: { get: () => request<{ settings: AppSettings }>("/api/settings"), providerColors: () => request<{ providerColors: Record }>("/api/settings/provider-colors"), integrationColors: () => request<{ integrationColors: Record }>("/api/settings/integration-colors"), display: () => request<{ display: DisplaySettings }>("/api/settings/display"), update: (data: AppSettingsPatch) => request<{ settings: AppSettings }>("/api/settings", { method: "PUT", body: JSON.stringify(data) }), testGotify: (data: { url: string; token: string; priority?: number }) => request<{ ok: true }>("/api/settings/test-gotify", { method: "POST", body: JSON.stringify(data) }), testNtfy: (data: { url: string; topic: string; token?: string; priority?: number }) => request<{ ok: true }>("/api/settings/test-ntfy", { method: "POST", body: JSON.stringify(data) }), testSmtp: (data: { host: string; port?: number; secure?: boolean; username?: string; password?: string; from: string; to: string }) => request<{ ok: true }>("/api/settings/test-smtp", { method: "POST", body: JSON.stringify(data) }), testWebhook: (data: { url: string; secret?: string }) => request<{ ok: true }>("/api/settings/test-webhook", { method: "POST", body: JSON.stringify(data) }), purgeLogs: () => request<{ diagDeleted: number; auditDeleted: number }>("/api/settings/purge-logs", { method: "POST" }), exportConfig: (passphrase: string) => request("/api/settings/export", { method: "POST", body: JSON.stringify({ passphrase }) }), importConfig: (passphrase: string, file: EncryptedExportFile) => request("/api/settings/import", { method: "POST", body: JSON.stringify({ passphrase, file }) }), quietHoursQueue: () => request<{ count: number; items: { id: number; title: string; message: string; createdAt: string }[] }>( "/api/settings/quiet-hours-queue", ), flushQuietHours: () => request<{ flushed: number }>("/api/settings/flush-quiet-hours", { method: "POST" }), }, };