netx/web/src/services/api.ts
oliver 924f9bd2f7 Add collect stop, cutover nav prefetch/cache, and lane labels.
Let operators abort queued/running biz_state batches, speed cutover page switches, and show light/heavy collect lanes on monitor items.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:09:18 +08:00

2294 lines
77 KiB
TypeScript

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<string, string>): Record<string, string> => {
// 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<boolean> | null = null;
const tryRefreshAccessToken = async (): Promise<boolean> => {
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<Record<string, unknown>> => {
const text = await res.text();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} 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<string, unknown>;
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 <T,>(path: string): Promise<T> => {
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 <T,>(path: string, body: unknown): Promise<T> => {
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 <T,>(path: string, body: unknown): Promise<T> => {
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 <T,>(path: string): Promise<T> => {
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 <T,>(path: string, body: unknown): Promise<T> => {
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<IntegrationStatus>("/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<string, number>;
by_status: Record<string, number>;
items: OpsTaskItem[];
};
export const fetchOpsTasks = () => apiGet<OpsTasksResponse>("/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<RuntimeMetrics>("/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<string, unknown>;
};
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<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/status");
export const establishUmeAlarmSubscription = (opts?: { forceReestablish?: boolean }) =>
apiPost<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/establish", {
force_reestablish: Boolean(opts?.forceReestablish),
});
export const cancelUmeAlarmSubscription = (opts?: { forceClearLocal?: boolean }) =>
apiPost<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/cancel", {
force_clear_local: Boolean(opts?.forceClearLocal),
});
export const clearLocalUmeAlarmSubscription = () =>
apiPost<UmeAlarmSubscriptionStatus>("/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<UmeKeyAlertMonitorResponse>(`/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<UmeSyncStatusResponse>(`/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<string, unknown>;
world?: Record<string, unknown>;
}>("/v1/topology/world/apply-ume", {});
export const fetchUmeTokenStatus = () => apiGet<UmeTokenStatus>("/v1/ume/token/status");
export const refreshUmeToken = () => apiPost<UmeTokenStatus>("/v1/ume/token/refresh", {});
export const disconnectUmeToken = () => apiPost<UmeTokenStatus>("/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<ManagedNeMeta>("/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<string, number>;
tags: string[];
no_tag_count: number;
tag_counts: Record<string, number>;
per_tag: Record<
string,
{
total: number;
by_status: Record<string, number>;
}
>;
};
export const fetchManagedNeStats = () => apiGet<ManagedNeStats>("/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<ManagedNeListResponse>(`/v1/managed-ne?${p.toString()}`);
};
export const createManagedNe = (body: Record<string, unknown>) =>
apiPost<ManagedNeItem>("/v1/managed-ne", body);
export const updateManagedNe = (id: string, body: Record<string, unknown>) =>
apiPatch<ManagedNeItem>(`/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<void> => {
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<ManagedNeImportResult> => {
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<CollectionJobItem>("/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<CollectionDashboard>("/v1/ne-collections/dashboard");
export const fetchCollectionPolicy = () => apiGet<CollectionPolicy>("/v1/ne-collections/policy");
export const updateCollectionPolicy = (body: Partial<CollectionPolicy>) =>
apiPut<CollectionPolicy>("/v1/ne-collections/policy", body);
export const startCollectionFromPolicy = () =>
apiPost<CollectionJobItem>("/v1/ne-collections/start-from-policy", {});
export const fetchCollectionJob = (jobId: string) => apiGet<CollectionJobDetail>(`/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<CollectionRunList>(`/v1/ne-collections/${params.jobId}/runs?${p.toString()}`);
};
export const pauseCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/pause`, {});
export const startCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/start`, {});
export const restartCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/restart`, {});
export const retryFailedCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/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<ManagedNeItem>(`/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<WebcrtSessionCreateResult>("/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<WebcrtQuickConnectResult>("/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<WebcrtSftpListResult>("/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<T>(
run: () => Promise<T>,
opts?: Pick<WebcrtSftpTransferOpts, "retries" | "signal" | "onRetry">,
): Promise<T> {
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<WebcrtSftpTransferOpts, "signal" | "onProgress">,
): Promise<Blob> {
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<Blob> {
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<WebcrtSftpTransferOpts, "signal" | "onProgress">,
): 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<string> {
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<string, string> }).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<CliMeta>("/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<CliTargetListResponse>(`/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<UmeCliOverrideItem | null>(`/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<TopologyTree>("/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<string, unknown>;
folder_id?: string;
kind?: string;
role?: string;
sort_order?: number;
}) => apiPost<TopologyViewItem>("/v1/topology/views", body);
export const populateTopologyView = (
viewId: string,
body?: { dry_run?: boolean; membership?: Record<string, unknown>; 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<TopologyViewGraph>(
`/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<string, unknown>;
viewport?: Record<string, unknown>;
folder_id?: string;
role?: string;
sort_order?: number;
},
) => apiPatch<TopologyViewItem>(`/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<TopologyViewGraph>(`/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<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body);
export const createTopologyPlaceholder = (
viewId: string,
body: { name: string; ip_address?: string; x?: number; y?: number },
) =>
apiPost<TopologyViewGraph>(
`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/create-placeholder`,
body,
);
export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) =>
apiPost<TopologyViewGraph>(`/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<TopologyViewGraph>(
`/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<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/edge-style`, body);
export const fetchFabricSummary = () => apiGet<FabricSummary>("/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<Record<string, string>>;
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<Record<string, string>>;
}>("/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<import("../types").FabricNodeSearchHit>(
`/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<TopologyDiscoverJob>("/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<TopologyDiscoverJob>(
`/v1/topology/fabric/discover/${encodeURIComponent(jobId)}?${p.toString()}`,
);
};
export const fetchLldpCollectDashboard = () =>
apiGet<LldpCollectDashboard>("/v1/topology/lldp-collect/dashboard");
export const updateLldpCollectPolicy = (body: Partial<LldpCollectPolicy>) =>
apiPut<LldpCollectPolicy>("/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<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/pause`, {});
export const resumeLldpCollectJob = (jobId: string) =>
apiPost<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/resume`, {});
export const stopLldpCollectJob = (jobId: string) =>
apiPost<TopologyDiscoverJob>(`/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<TopologyDiscoverJob>(
`/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<string, unknown> }>("/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<ConfigSyncDashboard>("/v1/config-sync/dashboard");
export const fetchConfigSyncPolicy = () => apiGet<ConfigSyncPolicy>("/v1/config-sync/policy");
export const updateConfigSyncPolicy = (body: Partial<ConfigSyncPolicy>) =>
apiPut<ConfigSyncPolicy>("/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<ConfigSyncCycle>("/v1/config-sync/cycles", body);
export const pauseConfigSyncCycle = (cycleId: string) =>
apiPost<ConfigSyncCycle>(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/pause`, {});
export const resumeConfigSyncCycle = (cycleId: string) =>
apiPost<ConfigSyncCycle>(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/resume`, {});
export const stopConfigSyncCycle = (cycleId: string) =>
apiPost<ConfigSyncCycle>(`/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<NeConfigSnapshotDetail>(
`/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}?field=${field}`,
);
export const downloadNeConfigSnapshot = async (
source: string,
targetId: string,
field: "primary" | "alt" | "both" = "primary",
): Promise<void> => {
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<PortTrafficDashboard>("/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<PortTrafficDevice>("/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<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`, body);
export const startPortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/start`, {});
export const pausePortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/pause`, {});
export const stopPortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/stop`, {});
export const collectPortTrafficNow = (deviceId: string) =>
apiPost<PortTrafficDevice & { ok: boolean; started: boolean; reason?: string }>(
`/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<PortTrafficDevice>(
`/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<PortTrafficDiscoverResponse>("/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<PortTrafficSamples>(`/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<PortTrafficCompare>(`/v1/port-traffic/compare?${p.toString()}`);
};
export const fetchPortTrafficBoards = () =>
apiGet<{ items: PortTrafficBoardSummary[] }>("/v1/port-traffic/boards");
export const fetchPortTrafficBoard = (boardId: string) =>
apiGet<PortTrafficBoard>(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`);
export const createPortTrafficBoard = (body: {
name: string;
remark?: string;
cols?: number;
panels?: PortTrafficBoardPanelIn[];
}) => apiPost<PortTrafficBoard>("/v1/port-traffic/boards", body);
export const updatePortTrafficBoard = (
boardId: string,
body: { name?: string; remark?: string; cols?: number },
) => apiPatch<PortTrafficBoard>(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, body);
export const putPortTrafficBoardPanels = (boardId: string, panels: PortTrafficBoardPanelIn[]) =>
apiPut<PortTrafficBoard>(`/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<string, unknown>[] }>(
`/v1/biz-state/profiles${qs ? `?${qs}` : ""}`,
);
};
export const bizStateDiscover = (body: Record<string, unknown>) =>
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<string, string>;
}>;
raw_preview?: string;
}>("/v1/biz-state/discover", body);
export const bizStateSetBindings = (
taskId: string,
itemId: string,
bindings: Array<{ placeholder: string; value: string }>,
) =>
apiPut<Record<string, unknown>>(
`/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<string, unknown>[] }>(`/v1/biz-state/tasks${q}`);
};
export const bizStateCreateTask = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/tasks", body);
export const bizStateGetTask = (taskId: string) =>
apiGet<Record<string, unknown>>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`);
export const bizStatePatchTask = (taskId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`, body);
export const bizStatePauseTask = (taskId: string) =>
apiPost<Record<string, unknown>>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/pause`, {});
export const bizStateStartTask = (taskId: string) =>
apiPost<Record<string, unknown>>(`/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<string, unknown>[] }>(
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches?limit=${limit}`,
);
export const bizStateSetBatchBaseline = (batchId: string, marked: boolean) =>
apiPost<Record<string, unknown>>(
`/v1/biz-state/batches/${encodeURIComponent(batchId)}/baseline`,
{ marked },
);
export const bizStateSetBatchAlias = (batchId: string, alias: string) =>
apiPatch<Record<string, unknown>>(
`/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<Record<string, unknown>>(
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/purge`,
{},
);
export const bizStateGetBatch = (batchId: string) =>
apiGet<Record<string, unknown>>(`/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<string, unknown>[];
}>(
`/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<void> => {
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<void> => {
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<string, unknown>[] }>("/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<Record<string, unknown>>;
}>;
}>("/v1/biz-state/compare/metrics");
export const bizCompareCreateTemplate = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/compare/templates", body);
export const bizCompareUpdateTemplate = (templateId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(
`/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<string, unknown>[] }>("/v1/biz-state/compare/mappings");
export const bizCompareCreateMapping = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/compare/mappings", body);
export const bizCompareUpdateMapping = (mappingId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(
`/v1/biz-state/compare/mappings/${encodeURIComponent(mappingId)}`,
body,
);
export const bizCompareValidateMapping = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/compare/mappings/validate", body);
export const bizCompareListJobs = () =>
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-state/compare/jobs");
export const bizCompareCreateJob = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/compare/jobs", body);
export const bizCompareUpdateJob = (jobId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(
`/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<Record<string, unknown>>(
`/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/run`,
{},
);
export const bizCompareListRuns = (jobId: string, limit = 20) =>
apiGet<{ items: Record<string, unknown>[] }>(
`/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`,
);
export const bizCompareGetRun = (runId: string) =>
apiGet<Record<string, unknown>>(`/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<string, unknown>[];
}>(`/v1/biz-state/compare/runs/${encodeURIComponent(params.runId)}/diffs?${p.toString()}`);
};
export const bizCompareDownloadRun = async (runId: string): Promise<void> => {
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<string, unknown>[] }>("/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<string, unknown> | 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<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-migration/projects", body);
export const bizMigrationPatchProject = (projectId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(
`/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<string, unknown>[] }>(
`/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<string, unknown>;
}>;
}>;
mapped?: Record<string, string>;
}>(`/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<string, unknown>;
new_ne?: Record<string, unknown>;
} = {},
) =>
apiPost<Record<string, unknown>>(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/ensure-highfreq`,
body,
);
/** @deprecated use bizMigrationEnsureHighfreq */
export const bizMigrationEnsurePortHighfreq = bizMigrationEnsureHighfreq;
export const bizMigrationCollectNow = (projectId: string) =>
apiPost<Record<string, unknown>>(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/collect-now`,
{},
);
export const bizMigrationFinishBatch = (batchId: string, markDone = false) =>
apiPost<Record<string, unknown>>(
`/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<string, unknown>[] }>(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets${q}`,
);
};
export const bizMigrationResolveRedTicket = (ticketId: string, note = "") =>
apiPost<Record<string, unknown>>(
`/v1/biz-migration/red-tickets/${encodeURIComponent(ticketId)}/resolve`,
{ note },
);
export const bizMigrationCreateBatch = (projectId: string, body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`,
body,
);
export const bizMigrationPatchBatch = (batchId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(
`/v1/biz-migration/batches/${encodeURIComponent(batchId)}`,
body,
);
export const bizMigrationEvaluate = (batchId: string, body: Record<string, unknown> = {}) =>
apiPost<Record<string, unknown>>(
`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/evaluate`,
body,
);
export const bizMigrationListRuns = (batchId: string, limit = 20) =>
apiGet<{ items?: Array<Record<string, unknown>> }>(
`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs?limit=${limit}`,
);
export const bizMigrationGetBoard = (batchId: string, runId = "") => {
const q = runId ? `?run_id=${encodeURIComponent(runId)}` : "";
return apiGet<Record<string, unknown>>(
`/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<string, unknown>[]; total: number }>(
`/v1/biz-migration/runs/${encodeURIComponent(params.runId)}/diffs${qs ? `?${qs}` : ""}`,
);
};
export const bizMonitorListTemplates = () =>
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-migration/monitor-templates");
export const bizMonitorCreateTemplate = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-migration/monitor-templates", body);
export const bizMonitorUpdateTemplate = (templateId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(
`/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`,
body,
);
export const bizMonitorDeleteTemplate = (templateId: string) =>
apiDelete<{ ok: boolean }>(
`/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`,
);