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 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; } export interface SecretInput { name: string; type: SecretType; description?: string; expiryDate: string; warnDays: number; notes?: string; } 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; secretCheckTime: string; timezone: string; } 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 AppSettings { gotify: GotifySettings; ntfy: NtfySettings; smtp: SmtpSettings; webhook: WebhookSettings; notifications: NotificationEvents; providerColors: Record; integrationColors: Record; display: DisplaySettings; logRetention: LogRetentionSettings; } export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial }; 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; } 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 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 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 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 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"), 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 }), }), }, 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" }), }, 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" }), 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" }), }, 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`), 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" }), }, };