feat: OpsxDB 桌面数据库客户端首版入库

含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、
Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
logan committed 2026-09-19 22:43:45 +08:00
commit f8740d27f7
240 files changed
+21625

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
{
"name": "@opsxdb/transport",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@opsxdb/core": "*"
},
"devDependencies": {
"typescript": "^5.8.2"
},
"peerDependencies": {
"@tauri-apps/api": "^2.0.0"
},
"peerDependenciesMeta": {
"@tauri-apps/api": {
"optional": true
}
}
}
+81
View File
@@ -0,0 +1,81 @@
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
function buildUrl(spec: HttpSpec): string {
const u = new URL(spec.url);
if (spec.query) {
for (const [k, v] of Object.entries(spec.query)) {
if (v !== undefined && v !== "") u.searchParams.set(k, v);
}
}
return u.toString();
}
function headersToRecord(h: Headers): Record<string, string> {
const out: Record<string, string> = {};
h.forEach((v, k) => {
out[k] = v;
});
return out;
}
export const browserTransport: Transport = {
async http(spec: HttpSpec): Promise<HttpResult> {
const started = performance.now();
const init: RequestInit = {
method: spec.method,
headers: { ...spec.headers },
signal: spec.timeoutMs
? AbortSignal.timeout(spec.timeoutMs)
: undefined,
};
if (spec.body && spec.body.mode !== "none" && spec.method !== "GET") {
init.body = spec.body.raw ?? "";
if (spec.body.mode === "json" && !("content-type" in Object.fromEntries(
Object.entries(spec.headers).map(([k, v]) => [k.toLowerCase(), v]),
))) {
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
}
}
const res = await fetch(buildUrl(spec), init);
const body = await res.text();
const timeMs = Math.round(performance.now() - started);
return {
status: res.status,
headers: headersToRecord(res.headers),
body,
timeMs,
size: new TextEncoder().encode(body).length,
};
},
ws(spec: WsSpec): WsSession {
const socket = new WebSocket(spec.url, spec.protocols);
const messageCbs: Array<(data: string) => void> = [];
const statusCbs: Array<(s: string) => void> = [];
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
emitStatus("connecting");
socket.onopen = () => emitStatus("open");
socket.onclose = () => emitStatus("closed");
socket.onerror = () => emitStatus("error");
socket.onmessage = (ev) => {
const data = typeof ev.data === "string" ? ev.data : String(ev.data);
messageCbs.forEach((cb) => cb(data));
};
return {
onMessage(cb) {
messageCbs.push(cb);
},
onStatus(cb) {
statusCbs.push(cb);
},
send(data) {
socket.send(data);
},
close() {
socket.close();
},
};
},
};
+41
View File
@@ -0,0 +1,41 @@
import type { Transport } from "@opsxdb/core";
import { browserTransport } from "./browser";
import { ginProxyTransport } from "./ginProxy";
import { tauriNativeTransport } from "./tauriNative";
export type TransportKind = "tauri-native" | "gin-proxy" | "browser";
export type DetectOptions = {
ginBaseUrl?: string;
/** Force a transport regardless of environment */
force?: TransportKind;
};
function isTauri(): boolean {
if (typeof window === "undefined") return false;
const w = window as unknown as { __TAURI_INTERNALS__?: unknown };
return Boolean(w.__TAURI_INTERNALS__);
}
export function detectTransport(opts: DetectOptions = {}): {
kind: TransportKind;
transport: Transport;
} {
if (opts.force === "browser") {
return { kind: "browser", transport: browserTransport };
}
if (opts.force === "tauri-native") {
return { kind: "tauri-native", transport: tauriNativeTransport };
}
if (opts.force === "gin-proxy") {
const base = opts.ginBaseUrl ?? "http://127.0.0.1:8788";
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
}
if (isTauri()) {
return { kind: "tauri-native", transport: tauriNativeTransport };
}
const base = opts.ginBaseUrl ?? "http://127.0.0.1:8788";
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
}
+35
View File
@@ -0,0 +1,35 @@
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
import { browserTransport } from "./browser";
/**
* HTTP goes through Gin POST /v1/proxy/http.
* WS (MVP): falls back to browser WebSocket — custom headers require
* desktop/Gin tunnel later (Week 2).
*/
export function ginProxyTransport(baseUrl: string): Transport {
const root = baseUrl.replace(/\/$/, "");
return {
async http(spec: HttpSpec): Promise<HttpResult> {
const res = await fetch(`${root}/v1/proxy/http`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(spec),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`gin-proxy HTTP ${res.status}: ${text}`);
}
return (await res.json()) as HttpResult;
},
ws(spec: WsSpec): WsSession {
// TODO(week2): custom headers need desktop/Gin WS tunnel.
// For MVP, fall back to browser WebSocket (no custom headers).
console.warn(
"[opsxdb/transport] gin-proxy WS falls back to browser WebSocket; custom headers need desktop/Gin tunnel later",
);
return browserTransport.ws(spec);
},
};
}
+5
View File
@@ -0,0 +1,5 @@
export { browserTransport } from "./browser";
export { ginProxyTransport } from "./ginProxy";
export { tauriNativeTransport } from "./tauriNative";
export { detectTransport } from "./detect";
export type { TransportKind, DetectOptions } from "./detect";
+68
View File
@@ -0,0 +1,68 @@
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
try {
const { invoke: tauriInvoke } = await import("@tauri-apps/api/core");
return await tauriInvoke<T>(cmd, args);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(
`tauri-native invoke("${cmd}") failed (are you running inside OpsxDB desktop?): ${msg}`,
);
}
}
export const tauriNativeTransport: Transport = {
async http(spec: HttpSpec): Promise<HttpResult> {
return invoke<HttpResult>("http_send", { spec });
},
ws(spec: WsSpec): WsSession {
const messageCbs: Array<(data: string) => void> = [];
const statusCbs: Array<(s: string) => void> = [];
let sessionId: string | null = null;
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
void (async () => {
try {
emitStatus("connecting");
sessionId = await invoke<string>("ws_connect", { spec });
emitStatus("open");
// Full event listen pipeline is Week 2; stub keeps UI usable.
} catch (e) {
emitStatus("error");
messageCbs.forEach((cb) =>
cb(`[ws_connect error] ${e instanceof Error ? e.message : String(e)}`),
);
}
})();
return {
onMessage(cb) {
messageCbs.push(cb);
},
onStatus(cb) {
statusCbs.push(cb);
},
send(data) {
if (!sessionId) {
messageCbs.forEach((cb) => cb("[ws] not connected"));
return;
}
void invoke("ws_send", { sessionId, data }).catch((e) => {
messageCbs.forEach((cb) =>
cb(`[ws_send error] ${e instanceof Error ? e.message : String(e)}`),
);
});
},
close() {
if (!sessionId) return;
void invoke("ws_close", { sessionId }).finally(() => {
sessionId = null;
emitStatus("closed");
});
},
};
},
};
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src"
]
}