Files
opsxdb/src/api.ts
T
logan 2c0bfafd8d 同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、
  packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件
- 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源)
- 构建产物(bin/target/dist)与签名私钥仍按规则排除
2026-10-16 03:47:33 +08:00

984 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SavedConnection, QueryResult, TreeNode, ColumnInfo } from "./store";
import { decodeSecret } from "./secrets";
/** C2:桌面 sidecar 注入的运行时配置(缺省时行为与现状一致) */
declare global {
interface Window {
__OPSXDB_API__?: string;
__OPSXDB_TOKEN__?: string;
__OPSXDB_BOOT_ERROR__?: string;
}
}
function injectedApi(): string | undefined {
if (typeof window === "undefined") return undefined;
const v = window.__OPSXDB_API__;
return v ? v.replace(/\/$/, "") : undefined;
}
function injectedToken(): string {
if (typeof window === "undefined") return "";
return window.__OPSXDB_TOKEN__ ?? "";
}
/** 开发态走 Vite 代理(空串);桌面生产包优先 sidecar 注入地址,回落默认 8788。 */
function resolveApiBase(): string {
const injected = injectedApi();
if (injected) return injected;
const fromEnv = (import.meta as { env?: Record<string, string> }).env?.VITE_OPSXDB_API;
if (fromEnv) return fromEnv.replace(/\/$/, "");
if (typeof window === "undefined") return "";
const { protocol, hostname, port } = window.location;
if (protocol === "tauri:" || hostname === "tauri.localhost" || hostname === "localhost" && port === "") {
return "http://127.0.0.1:8788";
}
return "";
}
const API = resolveApiBase();
/** C2:token 存在时统一带 Authorization 头;token 为空(本机 dev)行为不变 */
function authHeaders(): Record<string, string> {
const token = injectedToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${API}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(body),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
}
return data as T;
}
async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${API}${path}`, { headers: { ...authHeaders() }, signal });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
}
return data as T;
}
export type ConnBody = {
driver?: string;
host?: string;
port?: number;
user?: string;
password?: string;
database?: string;
service?: string;
ssl?: boolean;
sslMode?: string;
sslCA?: string;
sslCert?: string;
sslKey?: string;
sessionId?: string;
sql?: string;
table?: string;
limit?: number;
/** E1:执行请求标识,配合 POST /api/db/cancel 取消在途语句 */
requestId?: string;
/** E2:单语句执行超时(毫秒),0/缺省 = 服务端默认不额外限制 */
timeoutMs?: number;
/** E6:对象搜索关键字 */
keyword?: string;
/** E7:服务端导出格式 */
format?: string;
/** C5:结果分页,跳过前 offset 行 */
offset?: number;
/** C1:前端已完成危险语句确认后重发时置 true */
confirmDanger?: boolean;
key?: string;
pattern?: string;
keyCursor?: string;
cursor?: number;
redisMode?: string;
redisNodes?: string;
masterName?: string;
sentinelPassword?: string;
sshEnabled?: boolean;
sshHost?: string;
sshPort?: number;
sshUser?: string;
sshAuth?: string;
sshPassword?: string;
sshPrivateKey?: string;
sshPassphrase?: string;
};
export function fullConnPayload(c: SavedConnection): ConnBody {
const sslOn = !!(c.ssl || (c.sslMode && c.sslMode !== "disable"));
return {
driver: c.driver,
host: c.host,
port: c.port,
user: c.user,
password: decodeSecret(c.password),
database: c.database,
service: c.service,
ssl: sslOn,
sslMode: sslOn ? (c.sslMode && c.sslMode !== "disable" ? c.sslMode : "require") : "disable",
sslCA: c.sslCA || "",
sslCert: c.sslCert || "",
sslKey: c.sslKey || "",
redisMode: c.redisMode || "standalone",
redisNodes: c.redisNodes || "",
masterName: c.masterName || "",
sentinelPassword: c.sentinelPassword ? decodeSecret(c.sentinelPassword) : "",
sshEnabled: !!c.sshEnabled,
sshHost: c.sshHost,
sshPort: c.sshPort,
sshUser: c.sshUser,
sshAuth: c.sshAuth,
sshPassword: c.sshPassword ? decodeSecret(c.sshPassword) : "",
sshPrivateKey: c.sshPrivateKey,
sshPassphrase: c.sshPassphrase ? decodeSecret(c.sshPassphrase) : "",
};
}
export function sessionPayload(c: SavedConnection, sessionId: string, extra?: Partial<ConnBody>): ConnBody {
return {
sessionId,
database: extra?.database ?? c.database,
sql: extra?.sql,
table: extra?.table,
limit: extra?.limit,
offset: extra?.offset,
confirmDanger: extra?.confirmDanger,
key: extra?.key,
pattern: extra?.pattern,
keyCursor: extra?.keyCursor,
service: extra?.service ?? c.service,
requestId: extra?.requestId,
timeoutMs: extra?.timeoutMs,
keyword: extra?.keyword,
format: extra?.format,
};
}
export async function testConnection(c: SavedConnection) {
return post<{ ok: boolean; message?: string }>("/api/db/test-connection", fullConnPayload(c));
}
export async function openSession(c: SavedConnection) {
return post<{ ok: boolean; sessionId: string; message?: string }>("/api/db/open", fullConnPayload(c));
}
export async function closeSession(sessionId: string) {
return post<{ ok: boolean }>("/api/db/close", { sessionId });
}
function body(c: SavedConnection, sessionId: string | undefined, extra?: Partial<ConnBody>): ConnBody {
if (sessionId) return sessionPayload(c, sessionId, extra);
return { ...fullConnPayload(c), ...extra };
}
export async function listDatabases(c: SavedConnection, sessionId?: string) {
return post<{ databases: string[] }>("/api/db/databases", body(c, sessionId));
}
/**
* C8:schema 缓存 —— connId → database → table → columns。
* listTables/listColumns 成功时写入,供 SqlEditor 补全与 AiPanel 上下文注入共用。
*/
const schemaCache = new Map<string, Map<string, Map<string, ColumnInfo[]>>>();
function cacheTables(connId: string, db: string, tables: string[]) {
let dbMap = schemaCache.get(connId);
if (!dbMap) {
dbMap = new Map();
schemaCache.set(connId, dbMap);
}
let tblMap = dbMap.get(db);
if (!tblMap) {
tblMap = new Map();
dbMap.set(db, tblMap);
}
for (const t of tables) {
if (!tblMap.has(t)) tblMap.set(t, []);
}
}
function cacheColumns(connId: string, db: string, table: string, columns: ColumnInfo[]) {
let dbMap = schemaCache.get(connId);
if (!dbMap) {
dbMap = new Map();
schemaCache.set(connId, dbMap);
}
let tblMap = dbMap.get(db);
if (!tblMap) {
tblMap = new Map();
dbMap.set(db, tblMap);
}
tblMap.set(table, columns);
}
/** 连接关闭/删除后丢弃其缓存,避免跨连接串数据 */
export function dropSchemaCache(connId: string) {
schemaCache.delete(connId);
}
/** SqlEditor 补全数据源:已加载的表与列 */
export function getCachedSchema(
connId: string | null | undefined,
): Map<string, Map<string, ColumnInfo[]>> | null {
if (!connId) return null;
return schemaCache.get(connId) ?? null;
}
/**
* AiPanel 上下文注入:把已加载的库表列拼成文本,按 connId 隔离,截断 4000 字符。
* 优先输出当前库,其余库只列库.表名。
*/
export function schemaContextFor(connId: string | null | undefined, currentDb?: string): string {
const dbMap = connId ? schemaCache.get(connId) : undefined;
if (!dbMap || dbMap.size === 0) return "";
const MAX = 4000;
const lines: string[] = [];
const dbs = [...dbMap.keys()].sort((a, b) => {
if (a === currentDb) return -1;
if (b === currentDb) return 1;
return a.localeCompare(b);
});
for (const db of dbs) {
const tblMap = dbMap.get(db)!;
for (const [table, cols] of tblMap) {
const colText = cols.length
? `(${cols.map((c) => `${c.name} ${c.type}`.trim()).join(", ")})`
: "";
lines.push(`${db}.${table}${colText}`);
if (lines.join("\n").length > MAX) {
return lines.join("\n").slice(0, MAX);
}
}
}
return lines.join("\n").slice(0, MAX);
}
export async function listTables(c: SavedConnection, database: string, sessionId?: string) {
const res = await post<{ tables: string[] }>("/api/db/tables", body(c, sessionId, { database }));
if (Array.isArray(res.tables)) cacheTables(c.id, database, res.tables);
return res;
}
export async function listColumns(c: SavedConnection, database: string, table: string, sessionId?: string) {
const res = await post<{ columns: ColumnInfo[] }>("/api/db/columns", body(c, sessionId, { database, table }));
if (Array.isArray(res.columns)) cacheColumns(c.id, database, table, res.columns);
return res;
}
/** E1:最近一次 execSql 的 requestId,供 cancelExec() 取消在途语句 */
let lastExecRequestId: string | undefined;
export async function execSql(
c: SavedConnection,
sql: string,
opts?: {
database?: string;
sessionId?: string;
limit?: number;
offset?: number;
confirmDanger?: boolean;
/** E2:单语句执行超时(毫秒),0/缺省 = 不额外限制 */
timeoutMs?: number;
signal?: AbortSignal;
},
) {
// E1:每次执行生成独立 requestId(模块级记录最后一次),取消端点按 id 精准取消
const requestId = crypto.randomUUID();
lastExecRequestId = requestId;
return post<QueryResult>(
"/api/db/exec",
body(c, opts?.sessionId, {
sql,
database: opts?.database || c.database,
limit: opts?.limit,
offset: opts?.offset,
confirmDanger: opts?.confirmDanger,
requestId,
timeoutMs: opts?.timeoutMs,
}),
opts?.signal,
);
}
/**
* E1:取消最近一次执行 —— fire-and-forget 调 POST /api/db/cancel。
* 与前端 abort 互补:服务端收到取消后释放连接/杀查询,错误一律吞掉。
*/
export function cancelExec(): void {
const rid = lastExecRequestId;
if (!rid) return;
void fetch(`${API}/api/db/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ requestId: rid }),
}).catch(() => {
/* 取消失败不影响本地中止流程 */
});
}
/** E5:查看表/视图 DDL(方言差异由服务端处理) */
export async function getDdl(
c: SavedConnection,
table: string,
opts?: { database?: string; sessionId?: string },
) {
return post<{ ddl?: string; error?: string }>(
"/api/db/ddl",
body(c, opts?.sessionId, { table, database: opts?.database || c.database }),
);
}
export type SearchObjectHit = { schema: string; name: string; type: "table" | "view" };
/** E6:跨库对象搜索(服务端 LIKE,大小写策略由驱动决定) */
export async function searchObjects(
c: SavedConnection,
keyword: string,
opts?: { database?: string; sessionId?: string },
) {
return post<{ results: SearchObjectHit[]; error?: string }>(
"/api/db/search",
body(c, opts?.sessionId, { keyword, database: opts?.database || c.database }),
);
}
export type ExportRequest = {
format: "csv" | "json" | "insert";
/** 自定义查询导出(单条 SELECT);命中危险/非 SELECT 时服务端返回 needConfirm */
sql?: string;
/** 整表导出 */
table?: string;
limit?: number;
database?: string;
sessionId?: string;
/** C1:危险确认二段流重发时置 true */
confirmDanger?: boolean;
};
/** E7 服务端导出失败:Error 上挂 needConfirm,由调用侧弹二段确认后重发 */
export type ExportError = Error & { needConfirm?: boolean };
/**
* E7:服务端导出 —— 文件流下载(Content-Disposition attachment)。
* 返回 blob 与解析出的文件名;失败时抛 ExportError(needConfirm=true 表示需二段确认)。
*/
export async function exportData(c: SavedConnection, opts: ExportRequest): Promise<{ blob: Blob; filename?: string }> {
const res = await fetch(`${API}/api/db/export`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(
body(c, opts.sessionId, {
format: opts.format,
sql: opts.sql,
table: opts.table,
limit: opts.limit,
database: opts.database || c.database,
confirmDanger: opts.confirmDanger,
}),
),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const err = new Error((data as { error?: string }).error || res.statusText || "导出失败") as ExportError;
if ((data as { needConfirm?: boolean }).needConfirm) err.needConfirm = true;
throw err;
}
const cd = res.headers.get("Content-Disposition") || "";
const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd);
const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined;
const blob = await res.blob();
return { blob, filename };
}
/** C3:服务端专用执行计划端点(方言包装在服务端完成,前端不再拼 EXPLAIN) */
export type ExplainResult = {
columns?: string[];
rows?: Record<string, unknown>[];
/** MSSQL SHOWPLAN_XML / Oracle 文本计划 */
text?: string;
timeMs: number;
error?: string;
};
export async function explainSql(
c: SavedConnection,
sql: string,
opts?: { database?: string; sessionId?: string; signal?: AbortSignal },
) {
return post<ExplainResult>(
"/api/db/explain",
body(c, opts?.sessionId, { sql, database: opts?.database || c.database }),
opts?.signal,
);
}
export type RedisKeyInfo = { name: string; type: string; ttl: number; exact?: boolean };
export type RedisKeysResult = {
keys: RedisKeyInfo[];
nextCursor?: string;
exactHit?: boolean;
truncated?: boolean;
timeMs?: number;
error?: string;
};
export type RedisKeyValue = {
name: string;
type: string;
ttl: number;
columns: string[];
rows: Record<string, unknown>[];
rowCount: number;
timeMs: number;
truncated?: boolean;
limit?: number;
/** >0 表示还有更多;续载时作为 cursor 回传 */
nextCursor?: number;
error?: string;
};
export async function listRedisKeys(
c: SavedConnection,
database: string,
opts?: { sessionId?: string; pattern?: string; limit?: number; keyCursor?: string },
) {
return post<RedisKeysResult>(
"/api/db/redis/keys",
body(c, opts?.sessionId, { database, pattern: opts?.pattern, limit: opts?.limit, keyCursor: opts?.keyCursor, driver: c.driver }),
);
}
export async function getRedisKey(
c: SavedConnection,
database: string,
key: string,
opts?: { sessionId?: string; limit?: number; cursor?: number },
) {
return post<RedisKeyValue>(
"/api/db/redis/key",
body(c, opts?.sessionId, { database, key, table: key, limit: opts?.limit, cursor: opts?.cursor, driver: c.driver }),
);
}
export async function deleteRedisKey(
c: SavedConnection,
database: string,
key: string,
opts?: { sessionId?: string },
) {
return post<QueryResult>(
"/api/db/redis/key/delete",
body(c, opts?.sessionId, { database, key, table: key, driver: c.driver }),
);
}
/** 目录(前缀)批量删除:pattern 如 `agent:*`;后端 SCAN+UNLINK,拒全库与超上限 */
export async function deleteRedisPattern(
c: SavedConnection,
database: string,
pattern: string,
opts?: { sessionId?: string },
) {
return post<{ deleted: number; scanned: number; truncated: boolean; error?: string; timeMs?: number }>(
"/api/db/redis/del-pattern",
body(c, opts?.sessionId, { database, pattern, driver: c.driver }),
);
}
export async function loadTree(c: SavedConnection, sessionId?: string): Promise<TreeNode[]> {
const { databases } = await listDatabases(c, sessionId);
return databases.map((name) => ({ name, kind: "database" as const }));
}
export type AiAction =
| "ask"
| "generate"
| "explain"
| "optimize"
| "fix"
| "dialect"
| "sample";
export async function callAi(
payload: {
action: AiAction;
prompt?: string;
sql?: string;
driver?: string;
database?: string;
error?: string;
/** C4/C8:前端注入的库表列上下文(截断 4000 字符) */
schema?: string;
model?: string;
},
signal?: AbortSignal,
) {
return post<{ content: string; sql?: string }>("/api/ai/chat", payload, signal);
}
/** 阶段 2:Agent 模式 — 服务端多轮调同一 aitools Runtime(Ask 仍用 callAi) */
export type AiAgentStep = {
tool: string;
args?: string;
ok: boolean;
summary: string;
error?: string;
ms: number;
};
export type AiAgentRes = {
content: string;
sql?: string;
steps: AiAgentStep[];
rounds: number;
runId?: string;
sessionId?: string;
runOutcome?: string;
incompleteReason?: string;
resumable?: boolean;
stepsBudget?: number;
};
export async function callAiAgent(payload: AiAgentStreamPayload) {
return post<AiAgentRes>("/api/ai/agent", payload);
}
export type AiSessionSummary = {
id: string;
title?: string;
status: string;
createdAt: string;
updatedAt: string;
msgCount: number;
lastRunId?: string;
};
export async function listAiSessions() {
return get<{ sessions: AiSessionSummary[] }>("/api/ai/sessions");
}
export async function getAiSession(id: string) {
return get<{
id: string;
title?: string;
status: string;
messages: unknown[];
runs?: unknown[];
}>(`/api/ai/sessions/${encodeURIComponent(id)}`);
}
export async function continueAiSession(
id: string,
payload: {
prompt?: string;
connectionId?: string;
driver?: string;
database?: string;
model?: string;
maxRounds?: number;
stream?: boolean;
},
) {
return post<AiAgentRes>(`/api/ai/sessions/${encodeURIComponent(id)}/continue`, payload);
}
export type AiAgentQueryResult = {
columns: string[];
rows: Record<string, unknown>[];
rowCount: number;
sql?: string;
tool?: string;
};
export type AiAgentStepExt = AiAgentStep & {
pending?: boolean;
needConfirm?: boolean;
confirmSql?: string;
confirmBind?: string;
result?: AiAgentQueryResult;
};
export type AiAgentStreamHandlers = {
onStepStart?: (step: AiAgentStepExt) => void;
onStep?: (step: AiAgentStepExt) => void;
onDelta?: (delta: string) => void;
onDone?: (res: AiAgentRes) => void;
onIncomplete?: (res: AiAgentRes) => void;
onSession?: (meta: { sessionId?: string; runId?: string; runOutcome?: string; resumable?: boolean }) => void;
onError?: (error: string, partial?: Partial<AiAgentRes>) => void;
};
/** Agent SSE(stream=true);不可用时回落非流式。signal 供「停止」。 */
export type AiAgentStreamPayload = {
prompt: string;
connectionId?: string;
driver?: string;
database?: string;
model?: string;
sessionId?: string;
continue?: boolean;
maxRounds?: number;
prior?: unknown[];
};
export async function callAiAgentStream(
payload: AiAgentStreamPayload,
handlers: AiAgentStreamHandlers,
signal?: AbortSignal,
): Promise<AiAgentRes> {
try {
const res = await fetch(`${API}/api/ai/agent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
...authHeaders(),
},
body: JSON.stringify({ ...payload, stream: true }),
signal,
});
if (!res.ok || !res.body || !(res.headers.get("content-type") || "").includes("text/event-stream")) {
const plain = await callAiAgent(payload);
handlers.onDone?.(plain);
return plain;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let content = "";
const steps: AiAgentStep[] = [];
let rounds = 0;
let finalRes: AiAgentRes | null = null;
const handleEvent = (event: string, data: string) => {
let ev: Record<string, unknown>;
try {
ev = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
}
const typ = event || String(ev.type || "");
if (typ === "step_start" && ev.step) {
handlers.onStepStart?.(ev.step as AiAgentStepExt);
} else if (typ === "step" && ev.step) {
const st = ev.step as AiAgentStepExt;
steps.push(st);
handlers.onStep?.(st);
} else if (typ === "delta") {
const d = String(ev.delta || "");
content += d;
handlers.onDelta?.(d);
} else if (typ === "done" || typ === "incomplete") {
finalRes = {
content: String(ev.content ?? content),
sql: ev.sql ? String(ev.sql) : undefined,
steps: (ev.steps as AiAgentStep[]) || steps,
rounds: Number(ev.rounds ?? rounds) || 0,
runId: ev.runId ? String(ev.runId) : undefined,
sessionId: ev.sessionId ? String(ev.sessionId) : undefined,
runOutcome: ev.runOutcome ? String(ev.runOutcome) : typ === "incomplete" ? "incomplete" : undefined,
incompleteReason: ev.incompleteReason ? String(ev.incompleteReason) : undefined,
resumable: !!ev.resumable,
stepsBudget: ev.stepsBudget != null ? Number(ev.stepsBudget) : undefined,
};
rounds = finalRes.rounds;
if (typ === "incomplete") handlers.onIncomplete?.(finalRes);
else handlers.onDone?.(finalRes);
} else if (typ === "session") {
handlers.onSession?.({
sessionId: ev.content ? String(ev.content) : ev.sessionId ? String(ev.sessionId) : undefined,
runId: ev.runId ? String(ev.runId) : undefined,
runOutcome: ev.runOutcome ? String(ev.runOutcome) : undefined,
resumable: !!ev.resumable,
});
if (finalRes) {
finalRes = {
...finalRes,
sessionId: (ev.content ? String(ev.content) : finalRes.sessionId) || finalRes.sessionId,
runId: ev.runId ? String(ev.runId) : finalRes.runId,
runOutcome: ev.runOutcome ? String(ev.runOutcome) : finalRes.runOutcome,
resumable: ev.resumable != null ? !!ev.resumable : finalRes.resumable,
};
}
} else if (typ === "error") {
handlers.onError?.(String(ev.error || "agent error"), { content, steps, rounds: Number(ev.rounds) || 0 });
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf("\n\n")) >= 0) {
const chunk = buf.slice(0, idx);
buf = buf.slice(idx + 2);
let event = "";
let data = "";
for (const line of chunk.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trim();
}
if (data) handleEvent(event, data);
}
}
if (finalRes) return finalRes;
const fallback: AiAgentRes = { content, steps, rounds };
handlers.onDone?.(fallback);
return fallback;
} catch (e) {
if (signal?.aborted) throw e;
const plain = await callAiAgent(payload);
handlers.onDone?.(plain);
return plain;
}
}
export type McpStatus = {
configured: boolean;
mode: string;
whitelistCount: number;
writesEnabled: boolean;
allowDangerous?: boolean;
active: boolean;
recent?: { time: string; tool: string; ok: boolean; summary?: string; error?: string; source?: string }[];
policyPath?: string;
};
export type McpPolicy = {
allowed_connection_ids: string[];
mode: "read_only" | "read_write" | string;
allow_dangerous?: boolean;
default_limit?: number;
max_limit?: number;
};
export type McpInstallInfo = {
status: "ready" | "partial" | "not_installed" | string;
installed: boolean;
currentVersion?: string;
latestVersion?: string;
nodeVersion?: string;
npmAvailable: boolean;
npmVersion?: string;
npmPackageInstalled: boolean;
npmPackageVersion?: string;
goBinaryFound: boolean;
goBinaryPath?: string;
installCommand: string;
npxCommand?: string;
policyPath?: string;
hint?: string;
};
export async function getMcpStatus(signal?: AbortSignal) {
return get<McpStatus>("/api/ai/mcp-status", signal);
}
export async function getMcpPolicy(signal?: AbortSignal) {
return get<{ policy: McpPolicy; path?: string }>("/api/ai/mcp-policy", signal);
}
export async function saveMcpPolicy(policy: McpPolicy, signal?: AbortSignal) {
const res = await fetch(`${API}/api/ai/mcp-policy`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(policy),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
}
return data as { ok: boolean; policy: McpPolicy; path?: string };
}
export async function getMcpInstall(signal?: AbortSignal) {
return get<McpInstallInfo>("/api/ai/mcp-install", signal);
}
export async function confirmAiAgentWrite(payload: {
connectionId: string;
sql?: string;
command?: string;
database?: string;
kind?: "query" | "redis";
confirmBind?: string;
runId?: string;
}) {
return post<Record<string, unknown>>("/api/ai/agent/confirm", payload);
}
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置);protocol/authStyle/allowInsecureHttp 见 aiPresets.ts */
export type AiConfig = {
provider: string;
baseURL: string;
model: string;
hasKey: boolean;
apiKey?: string;
protocol?: string;
authStyle?: string;
allowInsecureHttp?: boolean;
};
export async function getAiConfig(signal?: AbortSignal) {
return get<AiConfig>("/api/ai/config", signal);
}
let connectionSyncQueue: Promise<void> = Promise.resolve();
/** 按顺序同步桌面连接;Agent 必须等待成功,避免旧快照覆盖最新连接。 */
export function syncConnectionsToDisk(connections: SavedConnection[]): Promise<void> {
const body = JSON.stringify({ connections });
const sync = connectionSyncQueue.catch(() => {}).then(async () => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(`${API}/api/db/connections-store`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...authHeaders() },
body,
signal: controller.signal,
});
if (!res.ok) throw new Error(`本机服务返回 ${res.status}`);
const result = await res.json();
if (result?.ok !== true) throw new Error("本机服务未确认连接已保存");
} catch (e) {
throw new Error(`连接同步失败,Agent 尚未执行任务:${controller.signal.aborted ? "本机服务响应超时" : e instanceof Error ? e.message : String(e)}`);
} finally {
clearTimeout(timer);
}
});
connectionSyncQueue = sync;
return sync;
}
export async function loadConnectionsFromDisk(): Promise<SavedConnection[] | null> {
try {
const res = await fetch(`${API}/api/db/connections-store`, { headers: { ...authHeaders() } });
if (!res.ok) return null;
const data = (await res.json()) as { connections?: SavedConnection[] };
return Array.isArray(data.connections) ? data.connections : null;
} catch {
return null;
}
}
export async function saveAiConfig(cfg: {
provider: string;
baseURL: string;
apiKey?: string;
model: string;
protocol?: string;
authStyle?: string;
allowInsecureHttp?: boolean;
}) {
return post<{ ok: boolean }>("/api/ai/config", cfg);
}
export async function health() {
const res = await fetch(`${API}/api/health`, { headers: { ...authHeaders() } });
if (!res.ok) throw new Error(`health ${res.status}`);
const data = await res.json() as { service?: string; version?: string };
if (data.service !== "opsxdb") throw new Error("当前地址不是 OpsxDB 后端");
return data;
}
/** fetch AbortController / DOMException AbortError */
export function isAbortError(e: unknown): boolean {
if (!e || typeof e !== "object") return false;
const name = (e as { name?: string }).name;
if (name === "AbortError") return true;
const msg = e instanceof Error ? e.message : String(e);
return /aborted|abort/i.test(msg) && !/session/i.test(msg);
}
// ---------- 驱动管理(agent 驱动) ----------
export type DriverJarStatus = { file: string; size: number; installed: boolean };
export type DriverStatusItem = {
id: string;
label: string;
runtime: string;
license: string;
autoFetch: boolean;
jars: DriverJarStatus[];
allInstalled: boolean;
jdkRequired: boolean;
jdkOk: boolean;
javaVersion: string;
agentInstalled: boolean;
offlinePack: boolean;
};
export async function driverStatus() {
const res = await fetch(`${API}/api/drivers/status`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
const drivers = (data as { drivers?: DriverStatusItem[] }).drivers;
if (!Array.isArray(drivers)) throw new Error("本机服务未提供驱动状态,请重启桌面应用后重试");
return drivers;
}
export async function driverInstall(driver: string) {
return post<{ ok: boolean; error?: string; drivers?: DriverStatusItem[] }>("/api/drivers/install", { driver });
}
export async function driverImportOffline(file: File) {
const form = new FormData();
form.append("file", file);
const res = await fetch(`${API}/api/drivers/import`, { method: "POST", body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return data as { ok: boolean; error?: string; imported?: number; drivers?: DriverStatusItem[] };
}
/** 表转储范围:仅结构 / 仅数据 / 结构+数据(服务端 DumpTable 对应值) */
export type DumpMode = "structure" | "data" | "structure_data";
/** 表右键「转储 SQL 文件」:POST /api/db/download → blob 下载(.sql) */
export async function dumpTable(
c: SavedConnection,
opts: { table: string; database?: string; mode: DumpMode; sessionId?: string },
): Promise<{ blob: Blob; filename?: string }> {
const res = await fetch(`${API}/api/db/dump`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(
body(c, opts.sessionId, { format: opts.mode, table: opts.table, database: opts.database || c.database }),
),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error || res.statusText || "转储失败");
}
const cd = res.headers.get("Content-Disposition") || "";
const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd);
const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined;
const blob = await res.blob();
return { blob, filename };
}
/** 触发浏览器下载 blob(导出/转储共用) */
export function downloadBlob(blob: Blob, filename: string) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}