import type { CollectionJobDetail, CollectionJobItem, CollectionDashboard, CollectionPolicy, CollectionRunList, EligibleNeItem, IntegrationStatus, ManagedNeImportResult, ManagedNeItem, ManagedNeListResponse, ManagedNeMeta, UmeAlarmItem, UmeAlarmSubscriptionStatus, UmeKeyAlertMonitorResponse, UmeNeItem, UmeSyncStatusResponse, UmeTokenStatus, CliConnectProfileItem, CliMeta, CliTargetListResponse, UmeCliOverrideItem, FabricEdge, FabricSummary, TopologyDiscoverJob, TopologyOutsidePeer, TopologyTree, TopologyViewGraph, TopologyViewItem, LldpCollectDashboard, LldpCollectJobSummary, LldpCollectPolicy, ConfigSyncCycle, ConfigSyncDashboard, ConfigSyncPolicy, ConfigSyncTask, NeConfigSnapshotDetail, NeConfigSnapshotMeta, PortTrafficCompare, PortTrafficDashboard, PortTrafficDevice, PortTrafficDiscoverResponse, PortTrafficEvent, PortTrafficBoard, PortTrafficBoardPanelIn, PortTrafficBoardSummary, PortTrafficIfaceIn, PortTrafficSamples, PortTrafficTarget, } from "../types"; export const AUTH_TOKEN_KEY = "netx_access_token"; export const AUTH_REFRESH_KEY = "netx_refresh_token"; /** Clear legacy localStorage tokens; browser auth now uses HttpOnly cookies. */ export const clearAuthToken = (): void => { try { localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_REFRESH_KEY); } catch { // ignore } }; /** @deprecated Cookie session — always null for browser UI. */ export const getAuthToken = (): string | null => null; /** No-op kept for call-site compatibility during cookie migration. */ export const setAuthToken = (_token: string): void => { clearAuthToken(); }; export const setAuthTokens = (_access: string, _refresh?: string | null): void => { clearAuthToken(); }; const fetchCreds: RequestCredentials = "include"; const authHeaders = (extra?: Record): Record => { // Browser JWT rides HttpOnly cookies (credentials: include). // Authorization Bearer is only needed for non-browser API tokens if ever injected. return { accept: "application/json", ...(extra || {}) }; }; const handleUnauthorized = (path: string): void => { if (path.startsWith("/v1/auth/login") || path.startsWith("/v1/auth/refresh")) return; clearAuthToken(); if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) { const next = `${window.location.pathname}${window.location.search || ""}`; window.location.assign(`/login?next=${encodeURIComponent(next)}`); } }; let refreshInFlight: Promise | null = null; const tryRefreshAccessToken = async (): Promise => { if (!refreshInFlight) { refreshInFlight = (async () => { try { // Refresh token comes from HttpOnly cookie when body is empty. const res = await fetch("/v1/auth/refresh", { method: "POST", credentials: fetchCreds, headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify({}), }); if (!res.ok) { clearAuthToken(); return false; } return true; } catch { clearAuthToken(); return false; } finally { refreshInFlight = null; } })(); } return refreshInFlight; }; const shouldAttemptRefresh = (path: string): boolean => !path.startsWith("/v1/auth/login") && !path.startsWith("/v1/auth/refresh"); const parseApiResponse = async (res: Response): Promise> => { const text = await res.text(); if (!text) return {}; try { return JSON.parse(text) as Record; } catch { const snippet = text.replace(/\s+/g, " ").trim().slice(0, 160); throw new Error(res.ok ? "invalid_json_response" : `${res.status} ${snippet || res.statusText}`); } }; /** Preserve FastAPI structured ``detail`` objects (e.g. connect_failed + ne). */ export class ApiRequestError extends Error { status: number; detail: unknown; constructor(status: number, detail: unknown) { super(formatApiDetail(detail) || `${status}`); this.name = "ApiRequestError"; this.status = status; this.detail = detail; } } /** User-facing message from API or thrown errors (prefers ApiRequestError.message). */ export function formatErr(err: unknown): string { if (err instanceof ApiRequestError) return err.message; if (err instanceof Error) return err.message; return String(err); } export function formatApiDetail(detail: unknown): string { if (typeof detail === "string") return detail; if (Array.isArray(detail)) { return detail .map((item) => { if (typeof item === "string") return item; if (item && typeof item === "object" && "msg" in item) { return String((item as { msg?: unknown }).msg || ""); } return ""; }) .filter(Boolean) .join("; "); } if (detail && typeof detail === "object") { const obj = detail as Record; if (typeof obj.message === "string" && obj.message.trim()) return obj.message; if (typeof obj.error === "string" && obj.error.trim()) return obj.error; try { return JSON.stringify(detail); } catch { /* fall through */ } } return detail == null ? "" : String(detail); } export const apiGet = async (path: string): Promise => { const doFetch = () => fetch(path, { headers: authHeaders(), credentials: fetchCreds }); let res = await doFetch(); if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { res = await doFetch(); } const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); throw new ApiRequestError(401, data.detail || "unauthorized"); } if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); return data as T; }; export const apiPost = async (path: string, body: unknown): Promise => { const doFetch = () => fetch(path, { method: "POST", credentials: fetchCreds, headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify(body), }); let res = await doFetch(); if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { res = await doFetch(); } const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); throw new ApiRequestError(401, data.detail || "unauthorized"); } if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); return data as T; }; export const apiPatch = async (path: string, body: unknown): Promise => { const doFetch = () => fetch(path, { method: "PATCH", credentials: fetchCreds, headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify(body), }); let res = await doFetch(); if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { res = await doFetch(); } const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); throw new ApiRequestError(401, data.detail || "unauthorized"); } if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); return data as T; }; export const apiDelete = async (path: string): Promise => { let res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds }); if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds }); } const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); throw new ApiRequestError(401, data.detail || "unauthorized"); } if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); return data as T; }; export const apiPut = async (path: string, body: unknown): Promise => { const doFetch = () => fetch(path, { method: "PUT", credentials: fetchCreds, headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify(body), }); let res = await doFetch(); if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) { res = await doFetch(); } const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); throw new ApiRequestError(401, data.detail || "unauthorized"); } if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); return data as T; }; export const fetchIntegrationStatus = () => apiGet("/v1/integrations/status"); export type OpsTaskItem = { kind: string; id: string; title: string; status: string; trigger: string; actor: string; started_at: string | null; updated_at: string | null; progress: string; inflight?: number; detail: string; href: string; }; export type OpsTasksResponse = { generated_at: string; total: number; active: number; by_kind: Record; by_status: Record; items: OpsTaskItem[]; }; export const fetchOpsTasks = () => apiGet("/v1/ops/tasks"); export type RuntimeMetrics = { uptime_sec?: number; pid?: number; thread_count?: number; host?: { source?: string; platform?: string; cpu_percent?: number; mem_percent?: number; mem_used_bytes?: number; mem_total_bytes?: number; mem_available_bytes?: number; error?: string; }; db_pool?: { checked_out?: number; checked_in?: number; overflow?: number; size?: number; }; db_storage?: { db_name?: string; used_bytes?: number; source?: string; error?: string; }; cli_budget?: { limit?: number; in_use?: number; available?: number; }; webcrt?: { active_sessions?: number; max_sessions?: number; }; config_sync?: { running?: boolean }; lldp_collect?: { running?: boolean }; port_traffic?: { running?: boolean }; device_schedulers?: { stale?: boolean; mode?: string; source?: string; age_sec?: number | null; hint?: string | null; config_sync?: { running?: boolean; last_tick_age_sec?: number | null }; lldp_collect?: { running?: boolean; last_tick_age_sec?: number | null }; ne_collect?: { running?: boolean; last_tick_age_sec?: number | null }; port_traffic?: { running?: boolean; last_tick_age_sec?: number | null }; fabric_reconcile?: { running?: boolean; last_run_age_sec?: number | null }; }; [key: string]: unknown; }; export const fetchRuntimeMetrics = () => apiGet("/v1/metrics/json"); export type AuditLogItem = { id: string; ts: string | null; actor_username: string; action: string; method: string; path: string; status_code: number; client_ip: string; detail: Record; }; export const fetchAuditLogs = (params?: { page?: number; pageSize?: number; username?: string; action?: string; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params?.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20))))); if (params?.username?.trim()) p.set("username", params.username.trim()); if (params?.action?.trim()) p.set("action", params.action.trim()); return apiGet<{ total: number; page: number; page_size: number; items: AuditLogItem[] }>( `/v1/audit-logs?${p.toString()}`, ); }; export const fetchUmeAlarmSubscriptionStatus = () => apiGet("/v1/ume/alarm-subscription/status"); export const establishUmeAlarmSubscription = (opts?: { forceReestablish?: boolean }) => apiPost("/v1/ume/alarm-subscription/establish", { force_reestablish: Boolean(opts?.forceReestablish), }); export const cancelUmeAlarmSubscription = (opts?: { forceClearLocal?: boolean }) => apiPost("/v1/ume/alarm-subscription/cancel", { force_clear_local: Boolean(opts?.forceClearLocal), }); export const clearLocalUmeAlarmSubscription = () => apiPost("/v1/ume/alarm-subscription/clear-local", {}); export const fetchUmeKeyAlertMonitor = (params?: { page?: number; pageSize?: number; keyword?: string; enabled?: "" | "true" | "false"; matchType?: "" | "notification_id" | "keyword"; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params?.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(200, Number(params?.pageSize || 50))))); const kw = String(params?.keyword || "").trim(); if (kw) p.set("keyword", kw); const en = String(params?.enabled || "").trim(); if (en) p.set("enabled", en); const mt = String(params?.matchType || "").trim(); if (mt) p.set("match_type", mt); return apiGet(`/v1/ume/key-alert-monitor?${p.toString()}`); }; export const upsertUmeKeyAlertRule = (payload: { match_type: "notification_id" | "keyword"; match_value: string; label: string; enabled?: boolean; ne_types?: string[]; }) => apiPost<{ ok: boolean; item?: unknown }>("/v1/ume/key-alert-rules", payload); export const updateUmeKeyAlertMonitorConfig = (payload: { forward_on_clear: boolean }) => apiPatch<{ ok: boolean; config: { forward_on_clear: boolean } }>("/v1/ume/key-alert-monitor/config", payload); export const deleteUmeKeyAlertRule = (ruleKey: string) => apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`); export const patchUmeKeyAlertRule = ( ruleKey: string, payload: { enabled?: boolean; ne_types?: string[] }, ) => apiPatch<{ ok: boolean; item?: unknown }>( `/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`, payload, ); export const fetchUmeNotificationIds = (limit = 200) => apiGet<{ items: Array<{ notification_id: string; native_probable_cause_sample: string }> }>( `/v1/ume/notification-ids?limit=${encodeURIComponent(String(limit))}`, ); export const fetchUmeAlarmKeywords = (limit = 200) => apiGet<{ items: Array<{ keyword: string; alarm_count: number }> }>( `/v1/ume/alarm-keywords?limit=${encodeURIComponent(String(limit))}`, ); export const fetchUmeInventoryNeTypes = (limit = 500) => apiGet<{ items: Array<{ ne_type: string; ne_count: number }>; total: number }>( `/v1/ume/inventory/ne-types?limit=${encodeURIComponent(String(limit))}`, ); export const fetchUmeSyncStatus = (params: { page: number; pageSize: number }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 20))))); return apiGet(`/v1/ume/sync/status?${p.toString()}`); }; /** Dock tables → Fabric + UME World (does not pull from UME). */ export const applyUmeTopologyToFabric = () => apiPost<{ ok: boolean; fabric_apply?: Record; world?: Record; }>("/v1/topology/world/apply-ume", {}); export const fetchUmeTokenStatus = () => apiGet("/v1/ume/token/status"); export const refreshUmeToken = () => apiPost("/v1/ume/token/refresh", {}); export const disconnectUmeToken = () => apiPost("/v1/ume/token/disconnect", {}); export const fetchUmeNe = (params: { keyword: string; page: number; pageSize: number }) => { const p = new URLSearchParams(); if (params.keyword) p.set("keyword", params.keyword); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50))))); return apiGet<{ total: number; page: number; page_size: number; items: UmeNeItem[] }>( `/v1/ume/inventory/ne?${p.toString()}`, ); }; export const fetchManagedNeMeta = () => Promise.all([ apiGet("/v1/managed-ne/meta/device-types"), apiGet<{ configured: boolean }>("/v1/managed-ne/meta/credentials-configured"), ]).then(([types, creds]) => ({ device_types: types.device_types, vendors: types.vendors, credentials_configured: creds.configured, exec_policy_enabled: Boolean(types.exec_policy_enabled), })); export type ManagedNeStats = { total: number; by_status: Record; tags: string[]; no_tag_count: number; tag_counts: Record; per_tag: Record< string, { total: number; by_status: Record; } >; }; export const fetchManagedNeStats = () => apiGet("/v1/managed-ne/meta/stats"); export const fetchIdsByTag = (tag: string | null) => { const url = tag ? `/v1/managed-ne/meta/ids-by-tag?tag=${encodeURIComponent(tag)}` : "/v1/managed-ne/meta/ids-by-tag"; return apiGet<{ ids: string[] }>(url); }; export const fetchManagedNe = (params: { keyword: string; vendor: string; connectStatus: string; page: number; pageSize: number; }) => { const p = new URLSearchParams(); if (params.keyword) p.set("keyword", params.keyword); if (params.vendor) p.set("vendor", params.vendor); if (params.connectStatus) p.set("connect_status", params.connectStatus); p.set("page", String(Math.max(1, params.page))); p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize)))); return apiGet(`/v1/managed-ne?${p.toString()}`); }; export const createManagedNe = (body: Record) => apiPost("/v1/managed-ne", body); export const updateManagedNe = (id: string, body: Record) => apiPatch(`/v1/managed-ne/${id}`, body); export const deleteManagedNe = (id: string) => apiDelete<{ ok: boolean }>(`/v1/managed-ne/${id}`); export const batchDeleteManagedNe = (ids: string[]) => apiPost<{ ok: boolean; deleted: number }>("/v1/managed-ne/batch-delete", { ids }); export const connectTestManagedNe = (ids: string[]) => apiPost<{ ok: boolean; submitted: number }>("/v1/managed-ne/connect-test", { ids }); export const batchApplyHopManagedNe = ( ids: string[], hop: { hop_host: string; hop_port: number; hop_protocol: string; hop_username: string; hop_password: string; hop_command_template: string; hop_vrf: string; hop_vendor?: string; hop_target_auth_mode?: string; hop_enter_system_view?: boolean; }, ) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-hop", { ids, hop }); export const batchApplyAccountManagedNe = ( ids: string[], account: { username?: string; password?: string; }, ) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-account", { ids, account }); export const syncUmeManagedNe = () => apiPost<{ inserted: number; updated: number; deleted: number; total_inventory: number }>("/v1/managed-ne/ume-sync", {}); export const deleteUmeManagedNe = () => apiDelete<{ deleted: number }>("/v1/managed-ne/ume-sync"); export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") => `/v1/managed-ne/import/template?format=${format}`; export const downloadManagedNeImportTemplate = async (format: "xlsx" | "csv" = "xlsx"): Promise => { const path = managedNeImportTemplateUrl(format); const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds }); if (res.status === 401) { handleUnauthorized(path); throw new Error("unauthorized"); } if (!res.ok) throw new Error(`${res.status} template`); const blob = await res.blob(); const cd = res.headers.get("content-disposition") || ""; const matched = /filename\*?=(?:UTF-8''|")?([^\";]+)/i.exec(cd); const filename = matched ? decodeURIComponent(matched[1].replace(/"/g, "")) : `managed_ne_import_template.${format}`; const url = URL.createObjectURL(blob); try { const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); } finally { URL.revokeObjectURL(url); } }; export const importManagedNe = async (file: File): Promise => { const form = new FormData(); form.append("file", file); const path = "/v1/managed-ne/import"; // Do not set content-type — browser must add multipart boundary. const res = await fetch(path, { method: "POST", headers: authHeaders(), body: form, credentials: fetchCreds }); if (res.status === 401) { handleUnauthorized(path); throw new Error("unauthorized"); } const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); throw new ApiRequestError(401, data.detail || "unauthorized"); } if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} import`); return data as ManagedNeImportResult; }; export const fetchEligibleNe = (params: { page: number; pageSize: number; keyword?: string }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, params.page))); p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize)))); if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); return apiGet<{ total: number; page: number; page_size: number; items: EligibleNeItem[] }>( `/v1/ne-collections/eligible-ne?${p.toString()}`, ); }; export const createNeCollection = (body: { title?: string; commands: string; ne_ids?: string[]; ume_ne_ids?: string[]; }) => apiPost("/v1/ne-collections", body); export const fetchNeCollections = (params: { page: number; pageSize: number; status?: string; keyword?: string; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, params.page))); p.set("page_size", String(Math.max(1, Math.min(100, params.pageSize)))); if (params.status?.trim()) p.set("status", params.status.trim()); if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); return apiGet<{ total: number; page: number; page_size: number; items: CollectionJobItem[] }>( `/v1/ne-collections?${p.toString()}`, ); }; export const fetchCollectionDashboard = () => apiGet("/v1/ne-collections/dashboard"); export const fetchCollectionPolicy = () => apiGet("/v1/ne-collections/policy"); export const updateCollectionPolicy = (body: Partial) => apiPut("/v1/ne-collections/policy", body); export const startCollectionFromPolicy = () => apiPost("/v1/ne-collections/start-from-policy", {}); export const fetchCollectionJob = (jobId: string) => apiGet(`/v1/ne-collections/${jobId}`); export const fetchCollectionRuns = (params: { jobId: string; page: number; pageSize: number; status?: string; keyword?: string; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, params.page))); p.set("page_size", String(Math.max(1, Math.min(200, params.pageSize)))); if (params.status) p.set("status", params.status); if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); return apiGet(`/v1/ne-collections/${params.jobId}/runs?${p.toString()}`); }; export const pauseCollectionJob = (jobId: string) => apiPost(`/v1/ne-collections/${jobId}/pause`, {}); export const startCollectionJob = (jobId: string) => apiPost(`/v1/ne-collections/${jobId}/start`, {}); export const restartCollectionJob = (jobId: string) => apiPost(`/v1/ne-collections/${jobId}/restart`, {}); export const retryFailedCollectionJob = (jobId: string) => apiPost(`/v1/ne-collections/${jobId}/retry-failed`, {}); export const deleteCollectionJob = (jobId: string) => apiDelete<{ ok: boolean }>(`/v1/ne-collections/${jobId}`); export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/runs/${runId}/download`; export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`; export const fetchManagedNeById = (neId: string) => apiGet(`/v1/managed-ne/${encodeURIComponent(neId)}`); export type WebcrtSessionCreateResult = { session_id: string; ne_id: string; ne_name: string; ne_ip: string; protocol: string; cols: number; rows: number; ws_path: string; encoding?: string; state?: string; /** True when session used a vendor CLI hop (nested stelnet/telnet). */ cli_hop?: boolean; /** True when SFTP channel opened on the same SSH transport. */ sftp_ready?: boolean; }; export const createWebcrtSession = (body: { ne_id?: string; ume_ne_id?: string; cols?: number; rows?: number; encoding?: string; keepalive_sec?: number; post_login_commands?: string[]; async_connect?: boolean; username?: string; password?: string; }) => apiPost("/v1/webcrt/sessions", body); export type WebcrtQuickConnectResult = WebcrtSessionCreateResult & { ne: ManagedNeItem; ne_action: "created" | "updated" | "reused" | string; list_source: "webcrt" | "managed" | string; }; export const quickConnectWebcrtSession = (body: { name?: string; ip_address: string; port?: number; protocol?: string; username?: string; password?: string; save_password?: boolean; /** Claim existing LLDP / incomplete ManagedNE → promote to webcrt. */ ne_id?: string; cols?: number; rows?: number; encoding?: string; keepalive_sec?: number; async_connect?: boolean; }) => apiPost("/v1/webcrt/sessions/quick-connect", body); export const closeWebcrtSession = (sessionId: string) => apiDelete<{ ok: boolean; session_id: string; closed: boolean }>( `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`, ); /** Best-effort close during tab/window unload (fetch keepalive survives navigation). */ export function closeWebcrtSessionsKeepalive(sessionIds: string[]): void { const ids = [...new Set(sessionIds.map((id) => String(id || "").trim()).filter(Boolean))]; if (!ids.length) return; const headers = authHeaders(); for (const id of ids) { const path = `/v1/webcrt/sessions/${encodeURIComponent(id)}`; try { void fetch(path, { method: "DELETE", headers, credentials: fetchCreds, keepalive: true }); } catch { /* ignore unload failures */ } } } export type WebcrtSftpItem = { name: string; size: number; mtime: number; is_dir: boolean; mode?: string; owner?: string; group?: string; uid?: number; gid?: number; }; export type WebcrtSftpListResult = { ne_id: string; ne_name: string; path: string; items: WebcrtSftpItem[]; truncated?: boolean; max_entries?: number; }; export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) => apiPost("/v1/webcrt/sftp/list", body); export const webcrtSftpMkdir = (body: { ne_id?: string; ume_ne_id?: string; path: string }) => apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/mkdir", body); export const webcrtSftpRemove = (body: { ne_id?: string; ume_ne_id?: string; path: string; recursive?: boolean; }) => apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/remove", body); export const webcrtSftpRename = (body: { ne_id?: string; ume_ne_id?: string; old_path: string; new_path: string; }) => apiPost<{ ok: boolean; old_path: string; new_path: string; ne_id: string }>("/v1/webcrt/sftp/rename", body); export const webcrtSftpChmod = (body: { ne_id?: string; ume_ne_id?: string; path: string; mode: string; }) => apiPost<{ ok: boolean; path: string; mode: string; ne_id: string }>("/v1/webcrt/sftp/chmod", body); export type WebcrtSftpTransferProgress = { loaded: number; total: number; }; export type WebcrtSftpTransferOpts = { signal?: AbortSignal; onProgress?: (p: WebcrtSftpTransferProgress) => void; /** Extra attempts after the first failure. Default 2 (3 tries total). */ retries?: number; onRetry?: (attempt: number, err: unknown) => void; }; const isAbortError = (err: unknown): boolean => { const raw = String(err || "").toLowerCase(); return raw.includes("aborted") || raw.includes("abort"); }; async function withSftpRetries( run: () => Promise, opts?: Pick, ): Promise { const retries = Math.max(0, Number(opts?.retries ?? 2)); let lastErr: unknown; for (let attempt = 0; attempt <= retries; attempt += 1) { if (opts?.signal?.aborted) throw new Error("aborted"); try { return await run(); } catch (err) { lastErr = err; if (isAbortError(err) || attempt >= retries) break; opts?.onRetry?.(attempt + 1, err); } } throw lastErr instanceof Error ? lastErr : new Error(String(lastErr || "transfer_failed")); } async function webcrtSftpDownloadOnce( body: { ne_id?: string; ume_ne_id?: string; path: string }, opts?: Pick, ): Promise { const path = "/v1/webcrt/sftp/download"; const res = await fetch(path, { method: "POST", credentials: fetchCreds, headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(body), signal: opts?.signal, }); if (res.status === 401) { handleUnauthorized(path); throw new Error("401 unauthorized"); } if (!res.ok) { const text = await res.text(); throw new Error(text || `HTTP ${res.status}`); } const total = Number(res.headers.get("content-length") || 0); if (!res.body) { const blob = await res.blob(); opts?.onProgress?.({ loaded: blob.size, total: total || blob.size }); return blob; } const reader = res.body.getReader(); const chunks: Uint8Array[] = []; let loaded = 0; try { for (;;) { if (opts?.signal?.aborted) { await reader.cancel().catch(() => undefined); throw new Error("aborted"); } const { done, value } = await reader.read(); if (done) break; if (value) { chunks.push(value); loaded += value.byteLength; opts?.onProgress?.({ loaded, total }); } } } catch (err) { if (opts?.signal?.aborted || isAbortError(err)) throw new Error("aborted"); throw err; } return new Blob(chunks as BlobPart[], { type: "application/octet-stream" }); } export async function webcrtSftpDownload( body: { ne_id?: string; ume_ne_id?: string; path: string; }, optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void), ): Promise { const opts: WebcrtSftpTransferOpts = typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {}; return withSftpRetries( () => webcrtSftpDownloadOnce(body, opts), opts, ); } function webcrtSftpUploadOnce( body: { ne_id?: string; ume_ne_id?: string; remote_path: string; file: File; }, opts?: Pick, ): Promise<{ ok: boolean; path: string; size: number }> { const path = "/v1/webcrt/sftp/upload"; const fd = new FormData(); if (body.ne_id) fd.append("ne_id", body.ne_id); if (body.ume_ne_id) fd.append("ume_ne_id", body.ume_ne_id); fd.append("remote_path", body.remote_path); fd.append("file", body.file); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const onAbort = () => { try { xhr.abort(); } catch { /* ignore */ } }; if (opts?.signal) { if (opts.signal.aborted) { reject(new Error("aborted")); return; } opts.signal.addEventListener("abort", onAbort, { once: true }); } xhr.open("POST", path); xhr.withCredentials = true; xhr.responseType = "text"; xhr.upload.onprogress = (ev) => { if (!opts?.onProgress) return; opts.onProgress({ loaded: Number(ev.loaded || 0), total: ev.lengthComputable ? Number(ev.total || 0) : body.file.size || 0, }); }; xhr.onload = () => { opts?.signal?.removeEventListener("abort", onAbort); if (xhr.status === 401) { handleUnauthorized(path); reject(new Error("401 unauthorized")); return; } if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(xhr.responseText || `HTTP ${xhr.status}`)); return; } try { resolve(JSON.parse(xhr.responseText || "{}") as { ok: boolean; path: string; size: number }); } catch { reject(new Error("invalid_upload_response")); } }; xhr.onerror = () => { opts?.signal?.removeEventListener("abort", onAbort); reject(new Error("network_error")); }; xhr.onabort = () => { opts?.signal?.removeEventListener("abort", onAbort); reject(new Error("aborted")); }; xhr.send(fd); }); } export function webcrtSftpUpload( body: { ne_id?: string; ume_ne_id?: string; remote_path: string; file: File; }, optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void), ): Promise<{ ok: boolean; path: string; size: number }> { const opts: WebcrtSftpTransferOpts = typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {}; return withSftpRetries(() => webcrtSftpUploadOnce(body, opts), opts); } export async function webcrtWsUrl(sessionId: string): Promise { const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`; let qs = ""; try { const ticketResp = await apiPost<{ ticket: string; expires_in: number }>("/v1/webcrt/ws-ticket", {}); if (ticketResp?.ticket) { qs = `?ws_ticket=${encodeURIComponent(ticketResp.ticket)}`; } } catch { qs = ""; } // Optional override, e.g. ws://127.0.0.1:8890 const override = String((import.meta as ImportMeta & { env?: Record }).env?.VITE_NETX_WS_BASE || "").trim(); if (override) { return `${override.replace(/\/$/, "")}${path}${qs}`; } // Vite/preview: HTTP is proxied, but WS proxy is often flaky — hit API directly. const port = window.location.port; if (port === "5173" || port === "4173") { const apiHost = window.location.hostname === "localhost" ? "127.0.0.1" : window.location.hostname; return `${proto}//${apiHost}:8890${path}${qs}`; } return `${proto}//${window.location.host}${path}${qs}`; } export const fetchCliMeta = () => apiGet("/v1/cli/meta"); export const fetchCliProfiles = () => apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles"); export const fetchCliTargets = (params: { source?: "managed" | "ume" | "webcrt" | "all"; keyword?: string; page?: number; pageSize?: number; }) => { const p = new URLSearchParams(); p.set("source", params.source || "all"); if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50))))); return apiGet(`/v1/cli/targets?${p.toString()}`); }; export const postUmeConnectTest = (umeNeIds: string[]) => apiPost<{ ok: boolean; submitted: number }>("/v1/cli/ume-connect-test", { ume_ne_ids: umeNeIds }); export const fetchUmeCliOverride = (umeNeId: string) => apiGet(`/v1/cli/ume-overrides/${encodeURIComponent(umeNeId)}`); export const fetchUmeCurrentAlarms = (params: { severity: string; isCleared: string; hostName: string; keyword: string; page: number; pageSize: number; }) => { const p = new URLSearchParams(); if (params.severity) p.set("severity", params.severity); if (params.isCleared) p.set("is_cleared", params.isCleared); if (params.hostName) p.set("host_name", params.hostName); if (params.keyword) p.set("keyword", params.keyword); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50))))); return apiGet<{ total: number; page: number; page_size: number; items: UmeAlarmItem[] }>( `/v1/ume/alarms?${p.toString()}`, ); }; export const fetchTopologyViews = () => apiGet<{ total: number; items: TopologyViewItem[] }>("/v1/topology/views"); export const fetchTopologyTree = () => apiGet("/v1/topology/tree"); export const createTopologyFolder = (body: { name: string; kind?: string; parent_id?: string; sort_order?: number; /** UI locale so auto-spawned root map is「根图」or "Root map". */ locale?: string; }) => apiPost<{ id: string; parent_id: string; kind: string; name: string; sort_order: number; is_system: boolean; }>("/v1/topology/folders", body); export const updateTopologyFolder = ( folderId: string, body: { name?: string; parent_id?: string; sort_order?: number }, ) => apiPatch<{ id: string; parent_id: string; kind: string; name: string; sort_order: number; is_system: boolean; }>(`/v1/topology/folders/${encodeURIComponent(folderId)}`, body); export const deleteTopologyFolder = (folderId: string, force = false) => apiDelete<{ ok: boolean; folder_id: string; deleted: boolean }>( `/v1/topology/folders/${encodeURIComponent(folderId)}?force=${force ? "true" : "false"}`, ); export const createTopologyView = (body: { name: string; remark?: string; filter?: Record; folder_id?: string; kind?: string; role?: string; sort_order?: number; }) => apiPost("/v1/topology/views", body); export const populateTopologyView = ( viewId: string, body?: { dry_run?: boolean; membership?: Record; freeze_after?: boolean }, ) => apiPost<{ view_id: string; dry_run: boolean; candidate_count: number; would_add: number; added: number; max_nodes: number; truncated: boolean; outside_peers: TopologyOutsidePeer[]; graph: TopologyViewGraph | null; }>(`/v1/topology/views/${encodeURIComponent(viewId)}/populate`, body || {}); export const fetchTopologyViewGraph = ( viewId: string, opts?: { min_x?: number; max_x?: number; min_y?: number; max_y?: number; sbn_id?: string; folder_id?: string; lod?: "auto" | "sbn" | "me" | "overview" | "detail"; status?: string; }, ) => { const q = new URLSearchParams(); if (opts?.min_x != null) q.set("min_x", String(opts.min_x)); if (opts?.max_x != null) q.set("max_x", String(opts.max_x)); if (opts?.min_y != null) q.set("min_y", String(opts.min_y)); if (opts?.max_y != null) q.set("max_y", String(opts.max_y)); if (opts?.sbn_id) q.set("sbn_id", opts.sbn_id); if (opts?.folder_id) q.set("folder_id", opts.folder_id); if (opts?.lod) q.set("lod", opts.lod); if (opts?.status) q.set("status", opts.status); const qs = q.toString(); return apiGet( `/v1/topology/views/${encodeURIComponent(viewId)}${qs ? `?${qs}` : ""}`, ); }; export const fetchTopologyWorld = () => apiGet<{ view_id: string; folder_id: string; name: string }>("/v1/topology/world"); export const fetchTopologyFolderBBox = (folderId: string) => apiGet<{ folder_id: string; min_x: number; max_x: number; min_y: number; max_y: number; n: number }>( `/v1/topology/folders/${encodeURIComponent(folderId)}/bbox`, ); export const updateTopologyView = ( viewId: string, body: { name?: string; remark?: string; filter?: Record; viewport?: Record; folder_id?: string; role?: string; sort_order?: number; }, ) => apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}`, body); export const deleteTopologyView = (viewId: string, force = false) => apiDelete<{ ok: boolean; view_id: string; deleted: boolean }>( `/v1/topology/views/${encodeURIComponent(viewId)}?force=${force ? "true" : "false"}`, ); export const patchTopologyPositions = ( viewId: string, positions: Array<{ fabric_node_id: string; x?: number; y?: number; label?: string; locked?: boolean; }>, ) => apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, { positions, }); export const addTopologyViewNodes = ( viewId: string, body: { managed_ne_ids?: string[]; ume_ne_ids?: string[]; fabric_node_ids?: string[]; layout?: string; }, ) => apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body); export const createTopologyPlaceholder = ( viewId: string, body: { name: string; ip_address?: string; x?: number; y?: number }, ) => apiPost( `/v1/topology/views/${encodeURIComponent(viewId)}/nodes/create-placeholder`, body, ); export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) => apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, { fabric_node_ids: fabricNodeIds, }); export const projectTopologyNeighbors = ( viewId: string, body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[]; dry_run?: boolean }, ) => apiPost( `/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, body || {}, ); export const patchTopologyEdgeStyle = ( viewId: string, body: { fabric_edge_id: string; stroke_color?: string; stroke_width?: number; line_style?: string }, ) => apiPatch(`/v1/topology/views/${encodeURIComponent(viewId)}/edge-style`, body); export const fetchFabricSummary = () => apiGet("/v1/topology/fabric/summary"); export const fetchFabricNodes = (params?: { keyword?: string; role?: string; level?: string; levelMajor?: string; regionFolderId?: string; unmatched?: string; linkStatus?: string; page?: number; pageSize?: number; }) => { const p = new URLSearchParams(); if (params?.keyword) p.set("keyword", params.keyword); if (params?.role) p.set("role", params.role); if (params?.level) p.set("level", params.level); if (params?.levelMajor) p.set("level_major", params.levelMajor); if (params?.regionFolderId) p.set("region_folder_id", params.regionFolderId); if (params?.unmatched) p.set("unmatched", params.unmatched); if (params?.linkStatus) p.set("link_status", params.linkStatus); p.set("page", String(Math.max(1, Number(params?.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(500, Number(params?.pageSize || 50))))); return apiGet<{ total: number; page: number; page_size: number; items: import("../types").FabricNodeSearchHit[]; }>(`/v1/topology/fabric/nodes?${p.toString()}`); }; export const matchFabricNodes = (body: { pattern: string; match_field?: string; sample_limit?: number; }) => apiPost<{ pattern: string; match_field: string; total_matched: number; samples: Array>; fabric_node_ids: string[]; }>("/v1/topology/fabric/nodes/match", body); export const bulkTagFabricNodes = (body: { fabric_node_ids?: string[]; pattern?: string; match_field?: string; level?: number | null; role?: string | null; region_folder_id?: string | null; dry_run?: boolean; }) => apiPost<{ dry_run: boolean; matched: number; updated: number; level?: number | null; role?: string | null; region_folder_id?: string | null; samples: Array>; }>("/v1/topology/fabric/nodes/tags/bulk", body); export const patchFabricNodeTags = ( fabricNodeId: string, body: { level?: number | null; role?: string | null; region_folder_id?: string | null }, ) => apiPatch( `/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`, body, ); export const deleteFabricNode = (fabricNodeId: string) => apiDelete<{ deleted: number; edges_deleted: number; placements_deleted: number }>( `/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}`, ); export const deleteFabricNodes = (fabricNodeIds: string[]) => apiPost<{ deleted: number; edges_deleted: number; placements_deleted: number }>( "/v1/topology/fabric/nodes/delete", { fabric_node_ids: fabricNodeIds }, ); /** Hard-delete topology/LLDP placeholders (ManagedNE + fabric + edges). */ export const purgePlaceholderFabricNodes = (fabricNodeIds: string[]) => apiPost<{ deleted: number; edges_deleted: number; placements_deleted: number; managed_deleted: number; membership_views: number; }>("/v1/topology/fabric/nodes/purge-placeholders", { fabric_node_ids: fabricNodeIds, }); export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize?: number }) => { const p = new URLSearchParams(); if (params?.q) p.set("q", params.q); p.set("page", String(Math.max(1, Number(params?.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(200, Number(params?.pageSize || 50))))); return apiGet<{ total: number; page: number; page_size: number; items: import("../types").FabricNodeSearchHit[]; }>(`/v1/topology/fabric/nodes/search?${p.toString()}`); }; export const fetchFabricEdges = (params?: { keyword?: string; status?: string; source?: string; nodeId?: string; page?: number; pageSize?: number; }) => { const p = new URLSearchParams(); if (params?.keyword) p.set("keyword", params.keyword); if (params?.status) p.set("status", params.status); if (params?.source) p.set("source", params.source); if (params?.nodeId) p.set("node_id", params.nodeId); p.set("page", String(Math.max(1, Number(params?.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(200, Number(params?.pageSize || 20))))); return apiGet<{ total: number; page: number; page_size: number; items: FabricEdge[] }>( `/v1/topology/fabric/edges?${p.toString()}`, ); }; export const startLldpDiscover = (body?: { scope?: "all_inventory" | "ne_ids"; ne_ids?: string[]; auto_add_unmatched?: boolean; concurrency?: number; trigger_mode?: "manual" | "schedule" | "topology"; }) => apiPost("/v1/topology/fabric/discover", body || {}); export const fetchLldpDiscoverJob = ( jobId: string, params?: { page?: number; pageSize?: number }, ) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params?.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20))))); return apiGet( `/v1/topology/fabric/discover/${encodeURIComponent(jobId)}?${p.toString()}`, ); }; export const fetchLldpCollectDashboard = () => apiGet("/v1/topology/lldp-collect/dashboard"); export const updateLldpCollectPolicy = (body: Partial) => apiPut("/v1/topology/lldp-collect/policy", body); export const startLldpCollect = (body?: { mode?: "full" | "retry_failed"; job_id?: string }) => apiPost<{ ok: boolean; job: TopologyDiscoverJob }>("/v1/topology/lldp-collect/start", { mode: body?.mode || "full", ...(body?.job_id ? { job_id: body.job_id } : {}), }); export const pauseLldpCollectJob = (jobId: string) => apiPost(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/pause`, {}); export const resumeLldpCollectJob = (jobId: string) => apiPost(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/resume`, {}); export const stopLldpCollectJob = (jobId: string) => apiPost(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/stop`, {}); export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20))))); if (params.status?.trim()) p.set("status", params.status.trim()); if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); return apiGet<{ total: number; page: number; page_size: number; items: LldpCollectJobSummary[] }>( `/v1/topology/lldp-collect/jobs?${p.toString()}`, ); }; export const fetchLldpCollectJob = ( jobId: string, params?: { page?: number; pageSize?: number; status?: string; keyword?: string }, ) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params?.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20))))); if (params?.status?.trim()) p.set("status", params.status.trim()); if (params?.keyword?.trim()) p.set("keyword", params.keyword.trim()); return apiGet( `/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`, ); }; export const createFabricManualEdge = (body: { a_node_id: string; b_node_id: string; a_port?: string; b_port?: string; }) => apiPost<{ ok: boolean; action: string; edge: Record }>("/v1/topology/fabric/edges", body); export const deleteFabricEdges = (edgeIds: string[]) => apiPost<{ ok: boolean; deleted: number }>("/v1/topology/fabric/edges/delete", { edge_ids: edgeIds, }); export const deleteFabricEdge = (edgeId: string) => apiDelete<{ ok: boolean; deleted: number }>( `/v1/topology/fabric/edges/${encodeURIComponent(edgeId)}`, ); /** Back-compat aliases used by older call sites during cutover. */ export const fetchTopologyMaps = fetchTopologyViews; export const createTopologyMap = createTopologyView; export const fetchTopologyGraph = fetchTopologyViewGraph; export const updateTopologyMap = updateTopologyView; export const deleteTopologyMap = deleteTopologyView; export const fetchConfigSyncDashboard = () => apiGet("/v1/config-sync/dashboard"); export const fetchConfigSyncPolicy = () => apiGet("/v1/config-sync/policy"); export const updateConfigSyncPolicy = (body: Partial) => apiPut("/v1/config-sync/policy", body); export const fetchConfigSyncCycles = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20))))); if (params.status?.trim()) p.set("status", params.status.trim()); if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncCycle[] }>( `/v1/config-sync/cycles?${p.toString()}`, ); }; export const createConfigSyncCycle = (body: { mode: "full" | "retry_failed"; cycle_id?: string }) => apiPost("/v1/config-sync/cycles", body); export const pauseConfigSyncCycle = (cycleId: string) => apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/pause`, {}); export const resumeConfigSyncCycle = (cycleId: string) => apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/resume`, {}); export const stopConfigSyncCycle = (cycleId: string) => apiPost(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/stop`, {}); export const fetchConfigSyncCycleTasks = (params: { cycleId: string; page?: number; pageSize?: number; status?: string; keyword?: string; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 20))))); if (params.status) p.set("status", params.status); if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncTask[] }>( `/v1/config-sync/cycles/${encodeURIComponent(params.cycleId)}/tasks?${p.toString()}`, ); }; export const fetchNeConfigSnapshots = (params: { page?: number; pageSize?: number; keyword?: string; source?: string; vendor?: string; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20))))); if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); if (params.source) p.set("source", params.source); if (params.vendor?.trim()) p.set("vendor", params.vendor.trim()); return apiGet<{ total: number; page: number; page_size: number; items: NeConfigSnapshotMeta[] }>( `/v1/config-sync/snapshots?${p.toString()}`, ); }; export const fetchNeConfigSnapshotDetail = ( source: string, targetId: string, field: "primary" | "alt" | "both" = "both", ) => apiGet( `/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}?field=${field}`, ); export const downloadNeConfigSnapshot = async ( source: string, targetId: string, field: "primary" | "alt" | "both" = "primary", ): Promise => { const path = `/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}` + `/download?field=${encodeURIComponent(field)}`; const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds }); if (res.status === 401) { handleUnauthorized(path); throw new Error("unauthorized"); } if (!res.ok) throw new Error(`${res.status} download`); const blob = await res.blob(); const cd = res.headers.get("content-disposition") || ""; const star = /filename\*=UTF-8''([^;]+)/i.exec(cd); const plain = /filename="?([^";]+)"?/i.exec(cd); const filename = star ? decodeURIComponent(star[1]) : plain ? plain[1] : `ne-config-${source}-${targetId}.txt`; const url = URL.createObjectURL(blob); try { const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); } finally { URL.revokeObjectURL(url); } }; export const fetchPortTrafficDashboard = () => apiGet("/v1/port-traffic/dashboard"); export const fetchPortTrafficDevices = (params: { page?: number; pageSize?: number; status?: string; keyword?: string; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20))))); if (params.status?.trim()) p.set("status", params.status.trim()); if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>( `/v1/port-traffic/devices?${p.toString()}`, ); }; /** @deprecated */ export const fetchPortTrafficTasks = fetchPortTrafficDevices; export const createPortTrafficDevice = (body: { source: "managed" | "ume"; ne_id: string; ne_name?: string; ne_ip?: string; vendor?: string; note?: string; interval_sec?: number; retention_days?: number; concurrency?: number; interfaces?: PortTrafficIfaceIn[]; start_now?: boolean; }) => apiPost("/v1/port-traffic/devices", body); export const updatePortTrafficDevice = ( deviceId: string, body: { note?: string; interval_sec?: number; retention_days?: number; concurrency?: number; ne_name?: string; ne_ip?: string; vendor?: string; }, ) => apiPatch(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`, body); export const startPortTrafficDevice = (deviceId: string) => apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/start`, {}); export const pausePortTrafficDevice = (deviceId: string) => apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/pause`, {}); export const stopPortTrafficDevice = (deviceId: string) => apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/stop`, {}); export const collectPortTrafficNow = (deviceId: string) => apiPost( `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/collect-now`, {}, ); export const deletePortTrafficDevice = (deviceId: string) => apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`); export const rebindPortTrafficDevice = (deviceId: string, body: { ne_id: string }) => apiPost( `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/rebind`, body, ); export const fetchPortTrafficTargets = (deviceId: string) => apiGet<{ items: PortTrafficTarget[] }>( `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`, ); export const fetchPortTrafficEvents = (deviceId: string, limit = 100) => apiGet<{ items: PortTrafficEvent[]; total: number }>( `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/events?limit=${limit}`, ); export const putPortTrafficInterfaces = (deviceId: string, interfaces: PortTrafficIfaceIn[]) => apiPut<{ items: PortTrafficTarget[] }>( `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/interfaces`, { interfaces }, ); export const discoverPortTrafficPorts = (body: { source: "managed" | "ume"; id: string }) => apiPost("/v1/port-traffic/discover/ports", body); export const fetchPortTrafficSamples = (params: { targetId: string; from?: string; to?: string; }) => { const p = new URLSearchParams(); p.set("target_id", params.targetId); if (params.from) p.set("from", params.from); if (params.to) p.set("to", params.to); return apiGet(`/v1/port-traffic/samples?${p.toString()}`); }; export const fetchPortTrafficCompare = (params: { targetId: string; rangeHours: number; baseline: string; offsetHours?: number; aheadHours?: number; baselineTargetId?: string; to?: string; }) => { const p = new URLSearchParams(); p.set("target_id", params.targetId); p.set("range_hours", String(params.rangeHours)); p.set("baseline", params.baseline); if (params.offsetHours != null) p.set("offset_hours", String(params.offsetHours)); if (params.aheadHours != null && params.aheadHours > 0) { p.set("ahead_hours", String(params.aheadHours)); } if (params.baselineTargetId) p.set("baseline_target_id", params.baselineTargetId); if (params.to) p.set("to", params.to); return apiGet(`/v1/port-traffic/compare?${p.toString()}`); }; export const fetchPortTrafficBoards = () => apiGet<{ items: PortTrafficBoardSummary[] }>("/v1/port-traffic/boards"); export const fetchPortTrafficBoard = (boardId: string) => apiGet(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`); export const createPortTrafficBoard = (body: { name: string; remark?: string; cols?: number; panels?: PortTrafficBoardPanelIn[]; }) => apiPost("/v1/port-traffic/boards", body); export const updatePortTrafficBoard = ( boardId: string, body: { name?: string; remark?: string; cols?: number }, ) => apiPatch(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, body); export const putPortTrafficBoardPanels = (boardId: string, panels: PortTrafficBoardPanelIn[]) => apiPut(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}/panels`, { panels, }); export const deletePortTrafficBoard = (boardId: string) => apiDelete<{ ok: boolean; id: string }>( `/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, ); /* ---- biz_state (business state monitoring) ---- */ export const bizStateListProfiles = (params?: { vendor?: string; device_type?: string; kind?: string; }) => { const q = new URLSearchParams(); if (params?.vendor) q.set("vendor", params.vendor); if (params?.device_type) q.set("device_type", params.device_type); if (params?.kind) q.set("kind", params.kind); const qs = q.toString(); return apiGet<{ items: Record[] }>( `/v1/biz-state/profiles${qs ? `?${qs}` : ""}`, ); }; export const bizStateDiscover = (body: Record) => apiPost<{ ok: boolean; error?: string; command?: string; cache_hit?: boolean; pair_mode?: boolean; discover_profile_id?: string; candidates?: Array<{ value: string; label: string; rd?: string; protocols?: string; bindings?: Record; }>; raw_preview?: string; }>("/v1/biz-state/discover", body); export const bizStateSetBindings = ( taskId: string, itemId: string, bindings: Array<{ placeholder: string; value: string }>, ) => apiPut>( `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/items/${encodeURIComponent(itemId)}/bindings`, { bindings }, ); export const bizStateListTasks = (purpose = "") => { const q = purpose ? `?purpose=${encodeURIComponent(purpose)}` : ""; return apiGet<{ items: Record[] }>(`/v1/biz-state/tasks${q}`); }; export const bizStateCreateTask = (body: Record) => apiPost>("/v1/biz-state/tasks", body); export const bizStateGetTask = (taskId: string) => apiGet>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`); export const bizStatePatchTask = (taskId: string, body: Record) => apiPatch>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`, body); export const bizStatePauseTask = (taskId: string) => apiPost>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/pause`, {}); export const bizStateStartTask = (taskId: string) => apiPost>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/start`, {}); export const bizStateDeleteTask = (taskId: string) => apiDelete<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`); export const bizStateCollectNow = (taskId: string) => apiPost<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect`, {}); export const bizStateCollectStop = (taskId: string) => apiPost<{ ok: boolean; stopped?: boolean; reason?: string; cancelled_batches?: string[]; signaled_batches?: string[]; }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect/stop`, {}); export const bizStateListBatches = (taskId: string, limit = 50) => apiGet<{ items: Record[] }>( `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches?limit=${limit}`, ); export const bizStateSetBatchBaseline = (batchId: string, marked: boolean) => apiPost>( `/v1/biz-state/batches/${encodeURIComponent(batchId)}/baseline`, { marked }, ); export const bizStateSetBatchAlias = (batchId: string, alias: string) => apiPatch>( `/v1/biz-state/batches/${encodeURIComponent(batchId)}/alias`, { alias }, ); export const bizStateDeleteBatch = (batchId: string) => apiDelete<{ ok: boolean }>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`); export const bizStateBulkDeleteBatches = (batchIds: string[]) => apiPost<{ ok: boolean; deleted: string[]; skipped: { batch_id: string; reasons: string[] }[]; deleted_count: number; }>("/v1/biz-state/batches/bulk-delete", { batch_ids: batchIds }); export const bizStatePurgeTask = (taskId: string) => apiPost>( `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/purge`, {}, ); export const bizStateGetBatch = (batchId: string) => apiGet>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`); export const bizStateListBatchMetricRows = (params: { batchId: string; metricId: string; page?: number; pageSize?: number; kw?: string; column?: string; }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 50))))); if (params.kw) p.set("kw", params.kw); if (params.column) p.set("column", params.column); return apiGet<{ batch_id: string; metric_id: string; total: number; page: number; page_size: number; pages: number; columns: Array<{ key: string; header: string; role?: string; is_key?: boolean }>; items: Record[]; }>( `/v1/biz-state/batches/${encodeURIComponent(params.batchId)}/metrics/${encodeURIComponent(params.metricId)}?${p.toString()}`, ); }; export const bizStateGetBatchCommand = (batchId: string, commandId: string) => apiGet<{ id: string; batch_id: string; raw_command: string; metric_id: string; parse_status: string; row_count: number; message: string; raw_text: string; collected_at?: string | null; device?: { ne_id?: string; ne_name?: string; ne_ip?: string }; }>( `/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, ); export const bizStateDownloadExport = async (batchId: string): Promise => { const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`; const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); if (res.status === 401) { handleUnauthorized(path); throw new Error("unauthorized"); } if (!res.ok) throw new Error(`${res.status} export`); const blob = await res.blob(); const url = URL.createObjectURL(blob); try { const a = document.createElement("a"); a.href = url; a.download = `biz_state_${batchId}.zip`; a.click(); } finally { URL.revokeObjectURL(url); } }; export const bizStateDownloadTaskCommands = async ( taskId: string, opts?: { includeAux?: boolean; enabledOnly?: boolean }, ): Promise => { const q = new URLSearchParams(); // Default includes aux (matches live collect); pass includeAux:false to omit. q.set("include_aux", opts?.includeAux === false ? "false" : "true"); if (opts?.enabledOnly === false) q.set("enabled_only", "false"); const qs = q.toString(); const path = `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/commands/export` + (qs ? `?${qs}` : ""); const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); if (res.status === 401) { handleUnauthorized(path); throw new Error("unauthorized"); } if (!res.ok) { let detail = `${res.status} export commands`; try { const j = (await res.json()) as { detail?: unknown }; if (j?.detail != null) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); } catch { /* ignore */ } throw new Error(detail); } const blob = await res.blob(); const url = URL.createObjectURL(blob); try { const a = document.createElement("a"); a.href = url; a.download = `biz_state_commands_${taskId}.txt`; a.click(); } finally { URL.revokeObjectURL(url); } }; /* ---- biz_state compare (Phase2) ---- */ export const bizCompareListTemplates = () => apiGet<{ items: Record[] }>("/v1/biz-state/compare/templates"); export const bizCompareListMetrics = () => apiGet<{ items: Array<{ metric_id: string; fields: Array<{ name: string; display_name: string; dtype: string; is_key: boolean; is_interface: boolean; role: string; }>; }>; row_filter_presets?: Array<{ id: string; label: string; row_filters: Array>; }>; }>("/v1/biz-state/compare/metrics"); export const bizCompareCreateTemplate = (body: Record) => apiPost>("/v1/biz-state/compare/templates", body); export const bizCompareUpdateTemplate = (templateId: string, body: Record) => apiPatch>( `/v1/biz-state/compare/templates/${encodeURIComponent(templateId)}`, body, ); export const bizCompareDeleteTemplate = (templateId: string) => apiDelete<{ ok: boolean }>(`/v1/biz-state/compare/templates/${encodeURIComponent(templateId)}`); export const bizCompareListMappings = () => apiGet<{ items: Record[] }>("/v1/biz-state/compare/mappings"); export const bizCompareCreateMapping = (body: Record) => apiPost>("/v1/biz-state/compare/mappings", body); export const bizCompareUpdateMapping = (mappingId: string, body: Record) => apiPatch>( `/v1/biz-state/compare/mappings/${encodeURIComponent(mappingId)}`, body, ); export const bizCompareValidateMapping = (body: Record) => apiPost>("/v1/biz-state/compare/mappings/validate", body); export const bizCompareListJobs = () => apiGet<{ items: Record[] }>("/v1/biz-state/compare/jobs"); export const bizCompareCreateJob = (body: Record) => apiPost>("/v1/biz-state/compare/jobs", body); export const bizCompareUpdateJob = (jobId: string, body: Record) => apiPatch>( `/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}`, body, ); export const bizCompareDeleteJob = (jobId: string) => apiDelete<{ ok: boolean }>(`/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}`); export const bizCompareRunJob = (jobId: string) => apiPost>( `/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/run`, {}, ); export const bizCompareListRuns = (jobId: string, limit = 20) => apiGet<{ items: Record[] }>( `/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`, ); export const bizCompareGetRun = (runId: string) => apiGet>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`); export const bizCompareDeleteRun = (runId: string) => apiDelete<{ ok: boolean; job_id?: string; run_id?: string }>( `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`, ); export const bizCompareListRunDiffs = (params: { runId: string; metricId?: string; kind?: string; kw?: string; page?: number; pageSize?: number; }) => { const p = new URLSearchParams(); if (params.metricId) p.set("metric_id", params.metricId); if (params.kind) p.set("kind", params.kind); if (params.kw) p.set("kw", params.kw); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 100))))); return apiGet<{ total: number; page: number; page_size: number; metric_id: string; items: Record[]; }>(`/v1/biz-state/compare/runs/${encodeURIComponent(params.runId)}/diffs?${p.toString()}`); }; export const bizCompareDownloadRun = async (runId: string): Promise => { const path = `/v1/biz-state/compare/runs/${encodeURIComponent(runId)}/export`; const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); if (res.status === 401) { handleUnauthorized(path); throw new Error("unauthorized"); } if (!res.ok) throw new Error(`${res.status} export`); const blob = await res.blob(); const url = URL.createObjectURL(blob); try { const a = document.createElement("a"); a.href = url; a.download = `biz_compare_${runId}.zip`; a.click(); } finally { URL.revokeObjectURL(url); } }; // --- biz-migration (cutover monitor, separate from compare) --- export const bizMigrationListProjects = () => apiGet<{ items: Record[] }>("/v1/biz-migration/projects"); export const bizMigrationNePortrait = (params: { source: string; ne_id: string; limit?: number }) => { const q = new URLSearchParams(); q.set("source", params.source || "managed"); q.set("ne_id", params.ne_id); if (params.limit != null) q.set("limit", String(params.limit)); return apiGet<{ source: string; ne_id: string; task: Record | null; batches: Array<{ id: string; started_at?: string | null; status?: string; row_count?: number; alias?: string; }>; hint?: string; }>(`/v1/biz-migration/ne-portrait?${q.toString()}`); }; export const bizMigrationCreateProject = (body: Record) => apiPost>("/v1/biz-migration/projects", body); export const bizMigrationPatchProject = (projectId: string, body: Record) => apiPatch>( `/v1/biz-migration/projects/${encodeURIComponent(projectId)}`, body, ); export const bizMigrationDeleteProject = (projectId: string) => apiDelete<{ ok?: boolean }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}`); export const bizMigrationListBatches = (projectId: string) => apiGet<{ items: Record[] }>( `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, ); export const bizMigrationListBaselinePorts = (projectId: string) => apiGet<{ batch_id: string; metric_id: string; ports: { interface: string; admin?: string; phy?: string; prot?: string; description?: string; mapped_to?: string; }[]; }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/baseline-ports`); export const bizMigrationListBaselineExpect = (projectId: string) => apiGet<{ batch_id: string; monitor_template_id?: string; sheets: Array<{ metric_id: string; key_fields: string[]; iface_fields: string[]; items: Array<{ key: string; keys: string[]; mapped_to?: string; label?: string; row?: Record; }>; }>; mapped?: Record; }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/baseline-expect`); export const bizMigrationEnsureHighfreq = ( projectId: string, body: { interval_sec?: number; retention_days?: number; collect_now?: boolean; collect_metric_ids?: string[]; hf_start_at?: string | null; hf_end_at?: string | null; old_ne?: Record; new_ne?: Record; } = {}, ) => apiPost>( `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/ensure-highfreq`, body, ); /** @deprecated use bizMigrationEnsureHighfreq */ export const bizMigrationEnsurePortHighfreq = bizMigrationEnsureHighfreq; export const bizMigrationCollectNow = (projectId: string) => apiPost>( `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/collect-now`, {}, ); export const bizMigrationFinishBatch = (batchId: string, markDone = false) => apiPost>( `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/finish?mark_done=${markDone ? "true" : "false"}`, {}, ); export const bizMigrationListRedTickets = (projectId: string, status = "") => { const q = status ? `?status=${encodeURIComponent(status)}` : ""; return apiGet<{ open_count: number; items: Record[] }>( `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets${q}`, ); }; export const bizMigrationResolveRedTicket = (ticketId: string, note = "") => apiPost>( `/v1/biz-migration/red-tickets/${encodeURIComponent(ticketId)}/resolve`, { note }, ); export const bizMigrationCreateBatch = (projectId: string, body: Record) => apiPost>( `/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`, body, ); export const bizMigrationPatchBatch = (batchId: string, body: Record) => apiPatch>( `/v1/biz-migration/batches/${encodeURIComponent(batchId)}`, body, ); export const bizMigrationEvaluate = (batchId: string, body: Record = {}) => apiPost>( `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/evaluate`, body, ); export const bizMigrationListRuns = (batchId: string, limit = 20) => apiGet<{ items?: Array> }>( `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs?limit=${limit}`, ); export const bizMigrationGetBoard = (batchId: string, runId = "") => { const q = runId ? `?run_id=${encodeURIComponent(runId)}` : ""; return apiGet>( `/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board${q}`, ); }; export const bizMigrationListDiffs = (params: { runId: string; metricId?: string; verdict?: string; color?: string; kw?: string; offset?: number; limit?: number; }) => { const p = new URLSearchParams(); if (params.metricId) p.set("metric_id", params.metricId); if (params.verdict) p.set("verdict", params.verdict); if (params.color) p.set("color", params.color); if (params.kw) p.set("kw", params.kw); if (params.offset != null) p.set("offset", String(params.offset)); if (params.limit != null) p.set("limit", String(params.limit)); const qs = p.toString(); return apiGet<{ items: Record[]; total: number }>( `/v1/biz-migration/runs/${encodeURIComponent(params.runId)}/diffs${qs ? `?${qs}` : ""}`, ); }; export const bizMonitorListTemplates = () => apiGet<{ items: Record[] }>("/v1/biz-migration/monitor-templates"); export const bizMonitorCreateTemplate = (body: Record) => apiPost>("/v1/biz-migration/monitor-templates", body); export const bizMonitorUpdateTemplate = (templateId: string, body: Record) => apiPatch>( `/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`, body, ); export const bizMonitorDeleteTemplate = (templateId: string) => apiDelete<{ ok: boolean }>( `/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`, );