import type { AccessLogEntry, ApiError, AppVersion, AuthStatus, CertificateCAOption, CertificateRecord, DashboardStatus, DDNSConfig, DDNSSavePayload, DDNSTestPayload, FrpBinary, FrpDownloadTask, FrpProxy, FrpProxyPayload, FrpProxiesTraffic, FrpRelease, FrpRuntimeInfo, FrpServer, FrpServerDiagnostics, FrpServerDetail, FrpServerPayload, FrpStatus, DiscoveredService, ProxyClientConn, ChinaCIDRStatus, ProxyRule, ProxySavePayload, ProxyTraffic, GlobalNginxView, RuleNginxView, SettingsMap, SystemLogEntry, NotifyTestPayload, UpdateStatus, CfMirror, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfAppSettings, CfAppSettingsInput, CfDriftResult, CfDiagnosticsResult, CfAccessStatus, CfZone, CfTunnelRoutes, CfPreflightResult, CfRouteTestResult, } from './types' import { withBase } from './base' export function asList(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : [] } const REQUEST_TIMEOUT_MS = 30_000 const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000 const AGENT_OPERATION_TIMEOUT_MS = 5 * 60_000 async function request(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) try { return await requestWithSignal(path, init, controller.signal) } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { throw new Error( timeoutMs > REQUEST_TIMEOUT_MS ? '证书操作超时,请稍后在证书列表查看状态,或在「日志 → 实时日志」查看 Nginx 输出' : '请求超时,请检查 Havline 服务或 Nginx 状态', ) } throw error } finally { clearTimeout(timeout) } } async function requestWithSignal(path: string, init?: RequestInit, signal?: AbortSignal): Promise { const response = await fetch(withBase(path), { credentials: 'include', signal, headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}), }, ...init, }) if (!response.ok) { let message = '请求失败' try { const body = (await response.json()) as ApiError if (body.error) { message = body.error } } catch { // ignore } throw new Error(message) } if (response.status === 204) { return undefined as T } return (await response.json()) as T } export const api = { getVersion: () => request('/api/version'), getUpdateStatus: () => request('/api/system/update/status'), applyUpdate: () => request('/api/system/update/apply', { method: 'POST' }), authStatus: () => request('/api/auth/status'), login: (username: string, password: string) => request<{ message: string }>('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }), }), logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }), changePassword: (old_password: string, new_password: string) => request<{ message: string }>('/api/auth/password', { method: 'POST', body: JSON.stringify({ old_password, new_password }), }), getStatus: () => request('/api/status'), listProxies: () => request('/api/proxies'), createProxy: (payload: ProxySavePayload) => request('/api/proxies', { method: 'POST', body: JSON.stringify(payload) }), updateProxy: (id: number, payload: Partial) => request(`/api/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), deleteProxy: (id: number) => request(`/api/proxies/${id}`, { method: 'DELETE' }), reorderProxies: (ids: number[]) => request('/api/proxies/reorder', { method: 'PUT', body: JSON.stringify({ ids }) }), getProxyTraffic: () => request('/api/proxies/traffic'), getProxyClients: (id: number) => request(`/api/proxies/${id}/clients`), getProxyNginx: (id: number) => request(`/api/proxies/${id}/nginx`), saveProxyNginx: (id: number, payload: { mode: 'auto' | 'custom'; content?: string }) => request(`/api/proxies/${id}/nginx`, { method: 'PUT', body: JSON.stringify(payload) }), rollbackProxyNginx: (id: number, backup?: string) => request(`/api/proxies/${id}/nginx/rollback`, { method: 'POST', body: JSON.stringify(backup ? { backup } : {}), }), listDDNS: () => request('/api/ddns'), listDDNSLite: () => request('/api/ddns?lite=1'), createDDNS: (payload: DDNSSavePayload) => request('/api/ddns', { method: 'POST', body: JSON.stringify(payload) }), updateDDNS: (id: number, payload: DDNSSavePayload) => request(`/api/ddns/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), deleteDDNS: (id: number) => request(`/api/ddns/${id}`, { method: 'DELETE' }), testDDNS: (payload: DDNSTestPayload) => request<{ message: string }>('/api/ddns/test', { method: 'POST', body: JSON.stringify(payload), }), updateAllDDNS: () => request('/api/ddns/update', { method: 'POST' }), updateDDNSOne: (id: number) => request(`/api/ddns/${id}/update`, { method: 'POST' }), listCertificates: () => request('/api/certificates'), listCertificateCAOptions: () => request('/api/certificates/ca-options'), applyCertificate: (payload: { dns_zone?: string ddns_config_id?: number domains: string[] ca?: string email?: string }) => request<{ job_id: string }>('/api/certificates/apply', { method: 'POST', body: JSON.stringify({ dns_zone: payload.dns_zone ?? '', ddns_config_id: payload.ddns_config_id ?? 0, domains: payload.domains, ca: payload.ca ?? '', email: payload.email ?? '', }), }), certificateApplyStreamURL: (jobId: string) => withBase(`/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`), importCertificate: (payload: { certificate?: string private_key?: string cert_path?: string key_path?: string }) => request('/api/certificates/import', { method: 'POST', body: JSON.stringify(payload), }), async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') { const response = await fetch( withBase(`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`), { credentials: 'include' }, ) if (!response.ok) { let message = '下载证书失败' try { const body = (await response.json()) as ApiError if (body.error) message = body.error } catch { // ignore } throw new Error(message) } const blob = await response.blob() const disposition = response.headers.get('Content-Disposition') ?? '' const match = disposition.match(/filename="?([^";\n]+)"?/) const filename = match?.[1] ?? `${domain}.zip` const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename link.click() URL.revokeObjectURL(url) }, deleteCertificate: (domain: string) => request<{ message: string }>(`/api/certificates/${encodeURIComponent(domain)}`, { method: 'DELETE' }), renewCertificate: (domain?: string, ca?: string) => request( '/api/certificates/renew', { method: 'POST', body: JSON.stringify({ domain: domain ?? '', ca: ca ?? '' }), }, CERT_REQUEST_TIMEOUT_MS, ), getSettings: () => request('/api/settings'), saveSettings: (payload: SettingsMap) => request('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }), getGlobalNginx: () => request('/api/settings/nginx/global'), saveGlobalNginx: (payload: { mode: 'auto' | 'custom'; content?: string }) => request('/api/settings/nginx/global', { method: 'PUT', body: JSON.stringify(payload) }), rollbackGlobalNginx: (backup?: string) => request('/api/settings/nginx/global/rollback', { method: 'POST', body: JSON.stringify(backup ? { backup } : {}), }), getNginxConfigVersions: () => request('/api/settings/nginx/config/versions'), getNginxConfigVersion: (name: string) => request(`/api/settings/nginx/config/versions/${encodeURIComponent(name)}`), rollbackNginxConfigVersion: (name: string) => request( `/api/settings/nginx/config/versions/${encodeURIComponent(name)}/rollback`, { method: 'POST' }, ), getChinaCIDRStatus: () => request('/api/settings/china-cidr'), listApiTokens: () => request<{ tokens: import('./types').ApiToken[] }>('/api/settings/tokens'), createApiToken: (payload: { name: string; scope: 'read' | 'write'; expires_in_days: number }) => request('/api/settings/tokens', { method: 'POST', body: JSON.stringify(payload), }), deleteApiToken: (id: number) => request<{ ok: boolean }>(`/api/settings/tokens/${id}`, { method: 'DELETE' }), refreshChinaCIDR: () => request('/api/settings/china-cidr/refresh', { method: 'POST' }), testNotify: (payload: NotifyTestPayload) => request<{ message: string }>('/api/settings/notify/test', { method: 'POST', body: JSON.stringify(payload), }), // ===== FRP 多服务端模块(/api/frpmulti)===== getFrpStatus: () => request('/api/frpmulti/status'), getFrpRuntime: () => request('/api/frpmulti/runtime'), setFrpDownloadProxy: (proxy: string) => request('/api/frpmulti/runtime/download-proxy', { method: 'PUT', body: JSON.stringify({ proxy }) }), listFrpReleases: () => request('/api/frpmulti/releases'), listFrpBinaries: () => request('/api/frpmulti/binaries'), getFrpDownloadStatus: () => request('/api/frpmulti/download'), downloadFrpRelease: (version: string) => request(`/api/frpmulti/releases/${encodeURIComponent(version)}/download`, { method: 'POST' }), activateFrpBinary: (version: string) => request(`/api/frpmulti/binaries/${encodeURIComponent(version)}/activate`, { method: 'POST' }), deleteFrpBinary: (version: string) => request(`/api/frpmulti/binaries/${encodeURIComponent(version)}`, { method: 'DELETE' }), listFrpServers: () => request('/api/frpmulti/servers'), getFrpServerDiagnostics: (id: number) => request(`/api/frpmulti/servers/${id}/diagnostics`), getFrpServerDetail: (id: number) => request(`/api/frpmulti/servers/${id}/detail`), startFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}/start`, { method: 'POST' }), stopFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}/stop`, { method: 'POST' }), createFrpServer: (payload: FrpServerPayload) => request('/api/frpmulti/servers', { method: 'POST', body: JSON.stringify(payload) }), updateFrpServer: (id: number, payload: FrpServerPayload) => request(`/api/frpmulti/servers/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), deleteFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}`, { method: 'DELETE' }), testFrpServer: (id: number) => request<{ message: string }>(`/api/frpmulti/servers/${id}/test`, { method: 'POST' }), listFrpProxies: () => request('/api/frpmulti/proxies'), getFrpProxyLogs: (id: number, limit = 200) => request(`/api/frpmulti/proxies/${id}/logs?limit=${limit}`), listFrpProxiesTraffic: () => request('/api/frpmulti/proxies/traffic'), createFrpProxy: (payload: FrpProxyPayload) => request('/api/frpmulti/proxies', { method: 'POST', body: JSON.stringify(payload) }), updateFrpProxy: (id: number, payload: FrpProxyPayload) => request(`/api/frpmulti/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), deleteFrpProxy: (id: number) => request(`/api/frpmulti/proxies/${id}`, { method: 'DELETE' }), startFrp: () => request('/api/frpmulti/start', { method: 'POST' }), stopFrp: () => request('/api/frpmulti/stop', { method: 'POST' }), reloadFrp: () => request('/api/frpmulti/reload', { method: 'POST' }), getFrpLogs: (limit = 200, serverID?: number) => { const query = new URLSearchParams({ limit: String(limit) }) if (serverID) query.set('server_id', String(serverID)) return request(`/api/frpmulti/logs?${query.toString()}`) }, exportFrpConfig: () => request<{ content: string }>('/api/frpmulti/config/export'), getFrpFRPSConfig: (id: number) => request<{ content: string }>(`/api/frpmulti/servers/${id}/frps-config`), testFrpAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/test`, { method: 'POST' }), restartFrpAgentTunnel: (id: number) => request(`/api/frpmulti/servers/${id}/agent/tunnel/restart`, { method: 'POST' }), setFrpAgentTransport: (id: number, transport: 'tunnel' | 'https-pin', port = 0) => request(`/api/frpmulti/servers/${id}/agent/transport`, { method: 'POST', body: JSON.stringify({ transport, port }), }, AGENT_OPERATION_TIMEOUT_MS), getFrpAgentTransport: (id: number) => request>(`/api/frpmulti/servers/${id}/agent/transport`), rotateFrpAgentTLSCert: (id: number) => request(`/api/frpmulti/servers/${id}/agent/transport/rotate`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS), getFrpAgentFirewall: (id: number, port: number) => request>(`/api/frpmulti/servers/${id}/agent/firewall?port=${port}`), allowFrpAgentFirewallPort: (id: number, port: number) => request>(`/api/frpmulti/servers/${id}/agent/firewall/allow`, { method: 'POST', body: JSON.stringify({ port }), }), probeFrpAgent: (id: number) => request>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS), installFrpAgent: (id: number, trustNewHostKey = false) => request( `/api/frpmulti/servers/${id}/agent/install${trustNewHostKey ? '?trust_new_host_key=true' : ''}`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS, ), uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS), sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS), getFrpAgentStatus: (id: number) => request(`/api/frpmulti/servers/${id}/agent/status`), getFrpAgentCerts: (id: number) => request(`/api/frpmulti/servers/${id}/agent/certs`), reloadFrpAgentNginx: (id: number) => request(`/api/frpmulti/servers/${id}/agent/nginx/reload`, { method: 'POST', }, AGENT_OPERATION_TIMEOUT_MS), actionFrpAgentNginx: (id: number, action: 'start' | 'stop' | 'restart') => request(`/api/frpmulti/servers/${id}/agent/nginx/action`, { method: 'POST', body: JSON.stringify({ action }), }, AGENT_OPERATION_TIMEOUT_MS), getFrpAgentMetrics: (id: number) => request(`/api/frpmulti/servers/${id}/agent/metrics`), getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) => request( `/api/frpmulti/servers/${id}/agent/logs/nginx?type=${type}&lines=${lines}`, ), getFrpAgentFRPSConfig: (id: number) => request(`/api/frpmulti/servers/${id}/agent/frps-config`), getFrpAgentRouteDrift: (id: number) => request(`/api/frpmulti/servers/${id}/agent/routes/drift`), redeployFrpAgentRoutes: (id: number) => request(`/api/frpmulti/servers/${id}/agent/routes/redeploy`, { method: 'POST', }), getFrpAgentRoutes: (id: number) => request(`/api/frpmulti/servers/${id}/agent/routes`), getFrpAgentRouteCandidates: (id: number) => request(`/api/frpmulti/servers/${id}/agent/route-candidates`), deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean; basic_auth_user?: string; basic_auth_password?: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }), getFrpAgentRouteConf: (serverId: number, proxyId: number) => request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`), getFrpAgentRouteVersions: (serverId: number, proxyId: number) => request(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions`), getFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) => request( `/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}`, ), rollbackFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) => request<{ ok: boolean; domain: string; name: string }>( `/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}/rollback`, { method: 'POST' }, ), installFrpAgentNginx: (serverId: number) => request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS), getFrpAgentRouteHealth: (serverId: number) => request(`/api/frpmulti/servers/${serverId}/agent/routes/health`), getFrpRouteHealthSummary: () => request('/api/frpmulti/routes/summary'), getFrpRouteUptime: (hours = 24) => request<{ hours: number; items: import('./types').FrpRouteUptime[] }>(`/api/frpmulti/routes/uptime?hours=${hours}`), // 指标历史:窗口内的序列已按桶取平均,拿到就能直接画 getMetricsHistory: (hours = 24, buckets = 48) => request(`/api/metrics/history?hours=${hours}&buckets=${buckets}`), // 匿名状态页:未开启或访问码不对时后端回 404 getStatusPage: (code?: string) => request( `/api/status-page${code ? `?code=${encodeURIComponent(code)}` : ''}`, ), removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }), pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }, AGENT_OPERATION_TIMEOUT_MS), frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }, AGENT_OPERATION_TIMEOUT_MS), frpsServerActionRaw: (id: number, action: 'start' | 'stop' | 'restart') => request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }, AGENT_OPERATION_TIMEOUT_MS), installFrpServer: (id: number, version: string, proxy: string) => request>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }, AGENT_OPERATION_TIMEOUT_MS), getAccessLogs: (params?: { limit?: number; keyword?: string; status?: number }) => { const q = new URLSearchParams() if (params?.limit) q.set('limit', String(params.limit)) if (params?.keyword) q.set('keyword', params.keyword) if (params?.status) q.set('status', String(params.status)) return request(`/api/logs/access?${q}`) }, getErrorLogs: (params?: { limit?: number; keyword?: string }) => { const q = new URLSearchParams() if (params?.limit) q.set('limit', String(params.limit)) if (params?.keyword) q.set('keyword', params.keyword) return request(`/api/logs/error?${q}`) }, getSystemLogs: (params?: { limit?: number; level?: string; keyword?: string }) => { const q = new URLSearchParams() if (params?.limit) q.set('limit', String(params.limit)) if (params?.level) q.set('level', params.level) if (params?.keyword) q.set('keyword', params.keyword) return request(`/api/logs/system?${q}`) }, scanDiscovery: (host?: string) => { const q = new URLSearchParams() if (host) q.set('host', host) return request(`/api/discovery/scan?${q}`) }, async exportBackup() { const response = await fetch(withBase('/api/backup/export'), { credentials: 'include' }) if (!response.ok) { throw new Error('导出备份失败') } const blob = await response.blob() const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `havline-backup-${new Date().toISOString().slice(0, 10)}.tar.gz` link.click() URL.revokeObjectURL(url) }, listBackupArchives: () => request<{ archives: import('./types').BackupArchive[]; keep: number }>('/api/backup/archives'), createBackupArchive: () => request<{ name: string; removed: number }>('/api/backup/archives', { method: 'POST' }), restoreBackupArchive: (name: string) => request<{ message: string }>(`/api/backup/archives/${encodeURIComponent(name)}/restore`, { method: 'POST' }), deleteBackupArchive: (name: string) => request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }), async downloadBackupArchive(name: string) { const response = await fetch(withBase(`/api/backup/archives/${encodeURIComponent(name)}`), { credentials: 'include' }) if (!response.ok) { throw new Error('下载备份失败') } const blob = await response.blob() const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = name link.click() URL.revokeObjectURL(url) }, async restoreBackup(file: File) { const form = new FormData() form.append('file', file) const response = await fetch(withBase('/api/backup/restore'), { method: 'POST', credentials: 'include', body: form, }) if (!response.ok) { let message = '恢复备份失败' try { const body = (await response.json()) as ApiError if (body.error) message = body.error } catch { // ignore } throw new Error(message) } return (await response.json()) as { message: string } }, listCfTunnels: () => request('/api/cloudflare/tunnels'), getCfTunnel: (id: number) => request(`/api/cloudflare/tunnels/${id}`), createCfTunnel: (payload: CfTunnelPayload) => request('/api/cloudflare/tunnels', { method: 'POST', body: JSON.stringify(payload) }), updateCfTunnel: (id: number, payload: CfTunnelPayload) => request(`/api/cloudflare/tunnels/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), deleteCfTunnel: (id: number) => request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}`, { method: 'DELETE' }), startCfTunnel: (id: number) => request(`/api/cloudflare/tunnels/${id}/start`, { method: 'POST' }), stopCfTunnel: (id: number) => request(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }), restartCfTunnel: (id: number) => request(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }), refreshCfCredentials: (id: number) => request(`/api/cloudflare/tunnels/${id}/credentials/refresh`, { method: 'POST' }), getCfTunnelStatus: (id: number) => request(`/api/cloudflare/tunnels/${id}/status`), getCfTunnelLogs: (id: number, lines = 200) => request<{ output: string }>(`/api/cloudflare/tunnels/${id}/logs?lines=${lines}`), getCfTunnelConfig: (id: number) => request<{ content: string }>(`/api/cloudflare/tunnels/${id}/config`), getCfTunnelRoutes: (id: number) => request(`/api/cloudflare/tunnels/${id}/routes`), replaceCfTunnelRoutes: (id: number, routes: Array<{ hostname: string; path: string; service: string }>) => request(`/api/cloudflare/tunnels/${id}/routes`, { method: 'PUT', body: JSON.stringify({ routes }), }), getCfTunnelDrift: (id: number) => request(`/api/cloudflare/tunnels/${id}/drift`), syncCfTunnelRoutes: (id: number) => request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}/sync`, { method: 'POST' }), getCfTunnelDiagnostics: (id: number) => request(`/api/cloudflare/tunnels/${id}/diagnostics`), getCfBinary: () => request<{ version: string; mirrors: CfMirror[] }>('/api/cloudflare/binary'), downloadCfBinary: (mirror: string, version = '') => request<{ ok: boolean; version: string }>('/api/cloudflare/binary/download', { method: 'POST', body: JSON.stringify({ mirror, version }), }), getLatestCfBinary: () => request<{ version: string }>('/api/cloudflare/binary/latest'), rollbackCfBinary: () => request<{ ok: boolean; version: string }>('/api/cloudflare/binary/rollback', { method: 'POST' }), getCfIngressCandidates: () => request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'), getCfZones: () => request<{ zones: CfZone[] }>('/api/cloudflare/zones'), getCfAccessStatus: (hostname: string) => request(`/api/cloudflare/access-status?hostname=${encodeURIComponent(hostname)}`), getCfPreflight: () => request('/api/cloudflare/preflight'), testCfRoute: (service: string) => request('/api/cloudflare/routes/test', { method: 'POST', body: JSON.stringify({ service }), }), getCfSettings: () => request('/api/cloudflare/settings'), saveCfSettings: (payload: CfAppSettingsInput) => request('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }), testCfToken: (accountId: string, token: string, kind: 'tunnel' | 'api') => request<{ ok: boolean; message: string }>('/api/cloudflare/settings/test', { method: 'POST', body: JSON.stringify({ account_id: accountId, token, kind }), }, 60_000), setCfEnabled: (enabled: boolean) => request('/api/cloudflare/enabled', { method: 'POST', body: JSON.stringify({ enabled }), }), }