驱动接入(全部本机真实例验收): - Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用) - ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/) - 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收 - 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle - 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点) 驱动管理: - 驱动管理弹框(状态/预装/离线包导入/JDK 检测)+ /api/drivers/* 三接口 - opsxdb-driver 驱动商店仓库(清单/fetch.sh/pack.sh 离线包/import.sh) - 连接时懒加载:DMG 零 Java 产物,首连自动下载(实测 ZK 九件套 1.35s),离线包自动回退 UI/交互: - 设置中心(⚙):常规/执行/结果/驱动/快捷键/关于,左导航+右面板(对标 opsxshell) - 实例级展开箭头(行首 SVG chevron,替代字体回退失真的 Unicode 三角) - 树内嵌套渲染修复:对象树挂在激活连接下;工具栏窄窗口不断行(按钮 nowrap+整颗换行) - 侧栏连接行精简(图标+连接名)、SQLite SSH 守卫、Oracle 死参数清理 质量: - Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥) - 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { execSql, listColumns } from "./api";
|
||
import { DdlModal } from "./DdlModal";
|
||
import { ensureSession } from "./ObjectTree";
|
||
import { useWorkspaceStore } from "./store";
|
||
|
||
type MetaRow = Record<string, unknown>;
|
||
|
||
type TableMeta = {
|
||
indexes: MetaRow[];
|
||
fks: MetaRow[];
|
||
triggers: MetaRow[];
|
||
};
|
||
|
||
const EMPTY_META: TableMeta = { indexes: [], fks: [], triggers: [] };
|
||
|
||
/** MySQL 协议族才走 information_schema 元数据(PG/Oracle 的系统目录另行适配) */
|
||
function isMysqlFamily(driver?: string) {
|
||
return ["mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql"].includes((driver || "").toLowerCase());
|
||
}
|
||
|
||
/** 标识符转义进 information_schema 字符串比较 */
|
||
const esc = (s: string) => s.replace(/'/g, "''");
|
||
|
||
export function SchemaPane() {
|
||
const schema = useWorkspaceStore((s) => s.schemaView);
|
||
const set = useWorkspaceStore((s) => s.set);
|
||
const conn = useWorkspaceStore((s) => s.connections.find((c) => c.id === s.activeConnectionId));
|
||
const [meta, setMeta] = useState<TableMeta>(EMPTY_META);
|
||
/** R2-F3:DDL 查看弹窗 */
|
||
const [showDdl, setShowDdl] = useState(false);
|
||
const mysql = isMysqlFamily(conn?.driver);
|
||
/** Redis 键的结构页不展示 DDL(服务端仅支持表/视图) */
|
||
const ddlEnabled = !!conn && (conn.driver || "").toLowerCase() !== "redis";
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
setMeta(EMPTY_META);
|
||
if (!conn || !mysql || !schema || schema.loading || schema.error || !schema.table) return;
|
||
(async () => {
|
||
try {
|
||
const sid = await ensureSession(conn);
|
||
const db = esc(schema.database);
|
||
const t = esc(schema.table);
|
||
const run = (sqlText: string) => execSql(conn, sqlText, { database: schema.database, sessionId: sid, limit: 200 });
|
||
const [ix, fk, tg] = await Promise.all([
|
||
run(
|
||
`SELECT INDEX_NAME AS name, IF(NON_UNIQUE=0,'UNIQUE','') AS uniq, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', ') AS cols FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' GROUP BY INDEX_NAME, NON_UNIQUE ORDER BY INDEX_NAME`,
|
||
),
|
||
run(
|
||
`SELECT CONSTRAINT_NAME AS name, COLUMN_NAME AS col, REFERENCED_TABLE_NAME AS refTable, REFERENCED_COLUMN_NAME AS refCol FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||
),
|
||
run(
|
||
`SELECT TRIGGER_NAME AS name, ACTION_TIMING AS timing, EVENT_MANIPULATION AS event, EVENT_OBJECT_TABLE AS onTable FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA='${db}' AND EVENT_OBJECT_TABLE='${t}'`,
|
||
),
|
||
]);
|
||
if (alive) setMeta({ indexes: ix.rows, fks: fk.rows, triggers: tg.rows });
|
||
} catch {
|
||
/* 元数据不可用时静默降级:结构页至少保底显示列 */
|
||
}
|
||
})();
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [conn, mysql, schema?.database, schema?.table, schema?.loading, schema?.error]);
|
||
|
||
if (!schema) {
|
||
return (
|
||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>表结构</h2>
|
||
<p>在对象树点表,或右键「查看结构」。</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** E1:结构加载失败重试 —— 重新拉列元数据 */
|
||
const retryLoad = async () => {
|
||
if (!conn || !schema) return;
|
||
set("schemaView", { ...schema, loading: true, error: undefined });
|
||
try {
|
||
const sid = await ensureSession(conn);
|
||
const { columns } = await listColumns(conn, schema.database, schema.table, sid);
|
||
set("schemaView", { database: schema.database, table: schema.table, columns, loading: false });
|
||
const pkCols = columns.filter((c) => c.key === "PRI").map((c) => c.name);
|
||
set("resultMeta", { database: schema.database, table: schema.table, pkCols });
|
||
set("connState", "ok");
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
set("schemaView", { database: schema.database, table: schema.table, columns: [], loading: false, error: msg });
|
||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||
}
|
||
};
|
||
|
||
if (schema.loading) {
|
||
return (
|
||
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
|
||
加载 {schema.table} 结构…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (schema.error) {
|
||
return (
|
||
<div
|
||
className="m-1.5 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||
style={{
|
||
borderColor: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
|
||
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
|
||
color: "var(--ops-danger)",
|
||
}}
|
||
>
|
||
<div>{schema.error}</div>
|
||
<button
|
||
type="button"
|
||
className="mt-1 hover:underline"
|
||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||
onClick={() => void retryLoad()}
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const str = (v: unknown) => (v == null ? "" : String(v));
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="ops-section-label justify-between border-b">
|
||
<span>
|
||
结构 · {schema.database ? `${schema.database}.` : ""}
|
||
{schema.table}
|
||
</span>
|
||
<div className="flex shrink-0 items-center gap-2 normal-case tracking-normal">
|
||
{ddlEnabled && (
|
||
<button
|
||
type="button"
|
||
className="hover:underline"
|
||
style={{ color: "var(--ops-accent)" }}
|
||
title="查看建表语句(DDL)"
|
||
onClick={() => setShowDdl(true)}
|
||
>
|
||
DDL
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="hover:underline"
|
||
style={{ color: "var(--ops-accent)" }}
|
||
onClick={() => set("resultTab", "data")}
|
||
>
|
||
回结果
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-auto">
|
||
{schema.columns.length === 0 ? (
|
||
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
|
||
无列信息
|
||
</div>
|
||
) : (
|
||
<table className="ops-grid" style={{ width: "100%" }}>
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: 36 }}>#</th>
|
||
<th>列名</th>
|
||
<th>类型</th>
|
||
<th style={{ width: 52 }}>键</th>
|
||
<th style={{ width: 64 }}>可空</th>
|
||
<th>默认值</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{schema.columns.map((c, i) => (
|
||
<tr key={c.name}>
|
||
<td className="ops-rownum">{i + 1}</td>
|
||
<td>
|
||
{c.key === "PRI" ? <span title="主键">🔑 </span> : null}
|
||
{c.name}
|
||
</td>
|
||
<td className="font-mono text-[12px]">{c.type}</td>
|
||
<td>
|
||
{c.key === "PRI" ? (
|
||
<span
|
||
className="rounded px-1 font-semibold"
|
||
style={{
|
||
fontSize: "var(--ops-text-xs)",
|
||
color: "var(--ops-warning)",
|
||
background: "color-mix(in srgb, var(--ops-warning) 14%, transparent)",
|
||
}}
|
||
>
|
||
PRI
|
||
</span>
|
||
) : (
|
||
""
|
||
)}
|
||
</td>
|
||
<td>{c.nullable ? "YES" : "NO"}</td>
|
||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||
{c.default == null ? <span className="ops-null">NULL</span> : String(c.default)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
|
||
{mysql && (
|
||
<div className="border-t" style={{ borderColor: "var(--ops-border)" }}>
|
||
<details open className="px-3 py-1.5"> <summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||
索引({meta.indexes.length})
|
||
</summary>
|
||
{meta.indexes.length === 0 ? (
|
||
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无索引</div>
|
||
) : (
|
||
<table className="ops-grid" style={{ width: "100%" }}>
|
||
<thead>
|
||
<tr>
|
||
<th>索引名</th>
|
||
<th style={{ width: 70 }}>唯一</th>
|
||
<th>列</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{meta.indexes.map((r, i) => (
|
||
<tr key={i}>
|
||
<td className="font-mono text-[12px]">{str(r.name)}</td>
|
||
<td>{r.uniq ? "✓" : ""}</td>
|
||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>{str(r.cols)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</details>
|
||
|
||
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||
外键({meta.fks.length})
|
||
</summary>
|
||
{meta.fks.length === 0 ? (
|
||
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无外键</div>
|
||
) : (
|
||
<table className="ops-grid" style={{ width: "100%" }}>
|
||
<thead>
|
||
<tr>
|
||
<th>约束名</th>
|
||
<th>列</th>
|
||
<th>引用</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{meta.fks.map((r, i) => (
|
||
<tr key={i}>
|
||
<td className="font-mono text-[12px]">{str(r.name)}</td>
|
||
<td className="font-mono text-[12px]">{str(r.col)}</td>
|
||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||
{str(r.refTable)}.{str(r.refCol)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</details>
|
||
|
||
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||
触发器({meta.triggers.length})
|
||
</summary>
|
||
{meta.triggers.length === 0 ? (
|
||
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无触发器</div>
|
||
) : (
|
||
<table className="ops-grid" style={{ width: "100%" }}>
|
||
<thead>
|
||
<tr>
|
||
<th>触发器</th>
|
||
<th style={{ width: 80 }}>时机</th>
|
||
<th style={{ width: 80 }}>事件</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{meta.triggers.map((r, i) => (
|
||
<tr key={i}>
|
||
<td className="font-mono text-[12px]">{str(r.name)}</td>
|
||
<td>{str(r.timing)}</td>
|
||
<td>{str(r.event)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</details>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{showDdl && conn && (
|
||
<DdlModal conn={conn} database={schema.database} table={schema.table} onClose={() => setShowDdl(false)} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|