-
}
- disabled={!pendingCount}
- loading={batchSaving}
- onClick={savePendingChanges}
- >
- 保存
-
-
}
- disabled={!pendingCount || batchSaving}
- onClick={clearPendingChanges}
- >
- 取消
-
-
}
- disabled={
- !selectedRecordId
- || (!isDraftRecord(selectedRecordId) && !selectedTable.allow_delete)
- || batchSaving
- }
- onClick={toggleSelectedForDeletion}
- >
- {isDraftRecord(selectedRecordId)
- ? "删除新增行"
- : pendingDeletes.includes(selectedRecordId)
- ? "撤销删除"
- : "标记删除"}
-
-
}
- disabled={!selectedTable.allow_create || batchSaving}
- onClick={openCsvImport}
- >
- 批量导入
-
+ {canManage && (
+ <>
+
}
+ disabled={!pendingCount}
+ loading={batchSaving}
+ onClick={savePendingChanges}
+ >
+ 保存
+
+
}
+ disabled={!pendingCount || batchSaving}
+ onClick={clearPendingChanges}
+ >
+ 取消
+
+
}
+ disabled={
+ !selectedRecordId
+ || (!isDraftRecord(selectedRecordId) && !selectedTable.allow_delete)
+ || batchSaving
+ }
+ onClick={toggleSelectedForDeletion}
+ >
+ {isDraftRecord(selectedRecordId)
+ ? "删除新增行"
+ : pendingDeletes.includes(selectedRecordId)
+ ? "撤销删除"
+ : "标记删除"}
+
+
}
+ disabled={!selectedTable.allow_create || batchSaving}
+ onClick={openCsvImport}
+ >
+ 批量导入
+
+ >
+ )}
-
}
- disabled={batchSaving || pendingCount > 0}
- title={pendingCount ? "请先保存或取消待处理的数据修改" : "进入当前数据库的 SQL 控制台"}
- onClick={() => setWorkspaceView("console")}
- >
- SQL 控制台
-
+ {canManage && (
+
}
+ disabled={batchSaving || pendingCount > 0}
+ title={pendingCount ? "请先保存或取消待处理的数据修改" : "进入当前数据库的 SQL 控制台"}
+ onClick={() => setWorkspaceView("console")}
+ >
+ SQL 控制台
+
+ )}
- {pendingCount
+ {!canManage
+ ? "只读权限"
+ : pendingCount
? `${pendingSummary}待保存`
: "双击单元格编辑"}
@@ -2268,6 +2286,7 @@ export default function DataCenterPanel() {
tableCode={selectedTableCode}
inspection={tableInspection}
loading={inspectionLoading}
+ canManage={canManage}
onRefresh={loadTableInspection}
onChanged={reloadAfterStructureChange}
/>
diff --git a/admin-web/src/panels/system/AgentTasksPanel.tsx b/admin-web/src/panels/system/AgentTasksPanel.tsx
deleted file mode 100644
index f7d215c..0000000
--- a/admin-web/src/panels/system/AgentTasksPanel.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { useEffect, useState } from "react";
-import { Table, Tag, Space, Typography } from "antd";
-import {
- RobotOutlined, SwapOutlined, ScanOutlined,
- ThunderboltOutlined, ToolOutlined,
-} from "@ant-design/icons";
-import api from "../../api";
-
-const { Text } = Typography;
-
-const AGENT_META: Record
= {
- aligner: { label: "实体归一", icon: },
- field_mapping:{ label: "字段映射", icon: },
- schema_lint: { label: "Schema 审计", icon: },
- extraction: { label: "值归一", icon: },
-};
-
-const STATUS_LABELS: Record = {
- success: "成功", error: "失败", timeout: "超时",
-};
-const STATUS_COLORS: Record = {
- success: "green", error: "red", timeout: "orange",
-};
-
-function fmtBeijing(iso: string): string {
- if (!iso) return "—";
- const d = new Date(iso);
- if (isNaN(d.getTime())) return iso;
- // Render the absolute instant in Beijing time regardless of the ISO
- // suffix (Z / +00:00 / offset) or the viewer's local timezone.
- const parts = new Intl.DateTimeFormat("zh-CN", {
- timeZone: "Asia/Shanghai",
- year: "numeric", month: "2-digit", day: "2-digit",
- hour: "2-digit", minute: "2-digit", second: "2-digit",
- hour12: false,
- }).formatToParts(d);
- const p: Record = {};
- for (const x of parts) p[x.type] = x.value;
- return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
-}
-
-export default function AgentTasksPanel() {
- const [logs, setLogs] = useState>>([]);
- const [loading, setLoading] = useState(false);
-
- const fetch = async () => {
- setLoading(true);
- try {
- const { data } = await api.get("/agent-call-logs");
- setLogs(data.filter(
- (log: Record) => !["publisher", "auditor"].includes(String(log.agent_name)),
- ));
- } catch {
- // fallback sample
- setLogs([]);
- }
- setLoading(false);
- };
-
- useEffect(() => { fetch(); }, []);
-
- return (
-
-
Agent 任务
-
-
0 ? logs : []}
- loading={loading}
- rowKey="id"
- pagination={{ pageSize: 20, hideOnSinglePage: true, showSizeChanger: true }}
- locale={{ emptyText: "暂无调用记录,Agent 运行后将在这里显示日志" }}
- columns={[
- {
- title: "Agent",
- dataIndex: "agent_name",
- width: 220,
- render: (v: string) => {
- const meta = AGENT_META[v];
- return (
-
-
- {meta?.icon || }
-
- {meta?.label || v}
-
- );
- },
- },
- {
- title: "调用对象",
- dataIndex: "request_id",
- width: 120,
- ellipsis: true,
- render: (v: string) => v ? {v} : —,
- },
- {
- title: "模型",
- dataIndex: "model",
- width: 140,
- ellipsis: true,
- render: (v: string) => v || —,
- },
- {
- title: "提示词长度",
- dataIndex: "prompt_chars",
- width: 100,
- align: "right",
- render: (v: number) => v != null ? `${v.toLocaleString()} 字` : "—",
- },
- {
- title: "返回值长度",
- dataIndex: "response_chars",
- width: 110,
- align: "right",
- render: (v: number) => v != null ? `${v.toLocaleString()} 字` : "—",
- },
- {
- title: "延迟",
- dataIndex: "latency_ms",
- width: 120,
- align: "right",
- sorter: (a: any, b: any) => (a.latency_ms || 0) - (b.latency_ms || 0),
- render: (v: number) => v != null ? `${v} ms` : "—",
- },
- {
- title: "状态",
- dataIndex: "status",
- width: 80,
- align: "center",
- render: (v: string) => (
- {STATUS_LABELS[v] || v}
- ),
- },
- {
- title: "调用时间(北京)",
- dataIndex: "created_at",
- width: 180,
- sorter: (a: any, b: any) =>
- new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime(),
- defaultSortOrder: "descend",
- render: (v: string) => (
-
- {fmtBeijing(v)}
-
- ),
- },
- ]}
- />
-
- );
-}
diff --git a/admin-web/src/panels/system/InterfaceCenterPanel.tsx b/admin-web/src/panels/system/InterfaceCenterPanel.tsx
index 2c5a398..31a4ad9 100644
--- a/admin-web/src/panels/system/InterfaceCenterPanel.tsx
+++ b/admin-web/src/panels/system/InterfaceCenterPanel.tsx
@@ -12,136 +12,78 @@ import {
Select,
Space,
Spin,
- Switch,
Table,
Tag,
Typography,
message,
} from "antd";
import {
- ApiOutlined,
CheckCircleOutlined,
CopyOutlined,
DatabaseOutlined,
- EditOutlined,
+ DownloadOutlined,
ExclamationCircleOutlined,
FileProtectOutlined,
- GlobalOutlined,
- HistoryOutlined,
KeyOutlined,
- LinkOutlined,
LockOutlined,
- PlusOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
- SettingOutlined,
UserOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import {
- createInterfaceClient,
- createInterfaceCredential,
- createInterfacePolicy,
- deleteInterfaceClient,
- deleteInterfacePolicy,
getInterfaceCenterCatalog,
getInterfaceCenterSummary,
- listInterfaceCallLogs,
- listInterfaceClients,
- listInterfaceCredentials,
- listInterfacePolicies,
- revokeInterfaceCredential,
- updateInterfaceClient,
- updateInterfacePolicy,
- type InterfaceCallLog,
+ issueDbeaverAccess,
+ listDbeaverAccessGrants,
+ revokeDbeaverAccess,
+ type DbeaverAccessGrant,
type InterfaceCatalogDatabase,
- type InterfaceClient,
- type InterfaceCredential,
- type InterfacePolicy,
type InterfaceSummary,
} from "../../api";
+import {
+ generateDbeaverSshKeyPair,
+ type BrowserSshKeyPair,
+} from "../../sshKey";
-const { Text, Title, Paragraph } = Typography;
-
-type AccessMode = "mysql" | "api";
-type WorkspaceView = "connection" | "security";
-
-const ACTION_LABELS: Record = {
- metadata: "查看结构",
- read: "查询",
- create: "新增",
- update: "修改",
- delete: "删除",
-};
-
-const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
- value,
- label,
-}));
-
-const formatTime = (value?: string | null) =>
- value ? dayjs(value).format("YYYY-MM-DD HH:mm:ss") : "-";
+const { Paragraph, Text, Title } = Typography;
const errorDetail = (error: any, fallback: string) =>
- error?.response?.data?.detail || fallback;
+ error?.response?.data?.detail || error?.message || fallback;
-const credentialIsActive = (credential: InterfaceCredential) =>
- !credential.revoked_at
- && (!credential.expires_at || dayjs(credential.expires_at).isAfter(dayjs()));
+const formatTime = (value?: string | null) =>
+ value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
export default function InterfaceCenterPanel() {
const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState("");
const [summary, setSummary] = useState(null);
- const [clients, setClients] = useState([]);
- const [credentials, setCredentials] = useState([]);
- const [policies, setPolicies] = useState([]);
const [catalog, setCatalog] = useState([]);
- const [logs, setLogs] = useState([]);
-
- const [accessMode, setAccessMode] = useState("mysql");
- const [workspaceView, setWorkspaceView] = useState("connection");
- const [selectedClientId, setSelectedClientId] = useState("");
- const [selectedAccessDatabaseId, setSelectedAccessDatabaseId] = useState("");
+ const [grants, setGrants] = useState([]);
+ const [selectedDatabaseId, setSelectedDatabaseId] = useState("");
const [docsOpen, setDocsOpen] = useState(false);
- const [logsOpen, setLogsOpen] = useState(false);
-
- const [clientOpen, setClientOpen] = useState(false);
- const [editingClient, setEditingClient] = useState(null);
- const [credentialOpen, setCredentialOpen] = useState(false);
- const [credentialClient, setCredentialClient] = useState(null);
- const [issuedKey, setIssuedKey] = useState(null);
- const [policyOpen, setPolicyOpen] = useState(false);
- const [editingPolicy, setEditingPolicy] = useState(null);
- const [saving, setSaving] = useState(false);
-
- const [clientForm] = Form.useForm();
- const [credentialForm] = Form.useForm();
- const [policyForm] = Form.useForm();
- const policyDatabaseId = Form.useWatch("database_id", policyForm);
- const policyTableCode = Form.useWatch("table_code", policyForm);
+ const [accessOpen, setAccessOpen] = useState(false);
+ const [issuedAccess, setIssuedAccess] = useState<{
+ grant: DbeaverAccessGrant;
+ keyPair: BrowserSshKeyPair;
+ } | null>(null);
+ const [accessForm] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
setLoadError("");
try {
- const [summaryResult, clientResult, credentialResult, policyResult, catalogResult, logResult] =
- await Promise.all([
- getInterfaceCenterSummary(),
- listInterfaceClients(),
- listInterfaceCredentials(),
- listInterfacePolicies(),
- getInterfaceCenterCatalog(),
- listInterfaceCallLogs(),
- ]);
+ const [summaryResult, catalogResult, grantsResult] = await Promise.all([
+ getInterfaceCenterSummary(),
+ getInterfaceCenterCatalog(),
+ listDbeaverAccessGrants(),
+ ]);
setSummary(summaryResult.data);
- setClients(clientResult.data);
- setCredentials(credentialResult.data);
- setPolicies(policyResult.data);
setCatalog(catalogResult.data);
- setLogs(logResult.data);
+ setGrants(grantsResult.data);
} catch (error: any) {
- setLoadError(errorDetail(error, "接口中心加载失败"));
+ setLoadError(errorDetail(error, "数据库管理接入加载失败"));
} finally {
setLoading(false);
}
@@ -151,100 +93,39 @@ export default function InterfaceCenterPanel() {
void load();
}, [load]);
- useEffect(() => {
- if (!clients.length) {
- setSelectedClientId("");
- return;
- }
- setSelectedClientId((current) => {
- if (current && clients.some((item) => item.id === current)) return current;
- return clients.find((item) => item.status === "active")?.id || clients[0].id;
- });
- }, [clients]);
-
useEffect(() => {
if (!catalog.length) {
- setSelectedAccessDatabaseId("");
+ setSelectedDatabaseId("");
return;
}
- setSelectedAccessDatabaseId((current) =>
+ setSelectedDatabaseId((current) =>
current && catalog.some((item) => item.project_id === current)
? current
: catalog[0].project_id,
);
}, [catalog]);
- const selectedClient = useMemo(
- () => clients.find((item) => item.id === selectedClientId) || null,
- [clients, selectedClientId],
- );
- const selectedAccessDatabase = useMemo(
- () => catalog.find((item) => item.project_id === selectedAccessDatabaseId) || null,
- [catalog, selectedAccessDatabaseId],
- );
- const selectedClientCredentials = useMemo(
- () => credentials.filter((item) => item.client_id === selectedClientId),
- [credentials, selectedClientId],
- );
- const selectedCredential = useMemo(
- () => selectedClientCredentials.find(credentialIsActive) || null,
- [selectedClientCredentials],
- );
- const selectedClientPolicies = useMemo(
- () => policies.filter((item) => item.client_id === selectedClientId),
- [policies, selectedClientId],
- );
- const selectedDatabasePolicies = useMemo(
- () => selectedClientPolicies.filter(
- (item) => item.database_id === selectedAccessDatabaseId && item.status === "active",
- ),
- [selectedAccessDatabaseId, selectedClientPolicies],
- );
- const selectedActions = useMemo(
- () => new Set(selectedDatabasePolicies.flatMap((item) => item.actions)),
- [selectedDatabasePolicies],
- );
- const policyDatabase = useMemo(
- () => catalog.find((item) => item.project_id === policyDatabaseId),
- [catalog, policyDatabaseId],
- );
- const policyTable = useMemo(
- () => policyDatabase?.tables.find((item) => item.code === policyTableCode),
- [policyDatabase, policyTableCode],
+ const selectedDatabase = useMemo(
+ () => catalog.find((item) => item.project_id === selectedDatabaseId) || null,
+ [catalog, selectedDatabaseId],
);
const browserHost = window.location.hostname || "服务器域名";
const sshHost = summary?.ssh_host || summary?.direct_access_host || browserHost;
- const sshPort = summary?.ssh_port || 22;
- const sshAuthMethod = summary?.ssh_auth_method || "SSH 私钥 / SSH Agent";
+ const sshPort = summary?.ssh_port || 2222;
+ const sshUsername = summary?.ssh_username || "dbeaver";
+ const sshFingerprint = summary?.ssh_host_key_fingerprint || "SSH 网关启动后自动显示";
const mysqlHost = summary?.database_host || "127.0.0.1";
const mysqlPort = summary?.database_port || summary?.direct_access_port || 3307;
- const mysqlTransport = summary?.direct_access_transport || "SSH 隧道(强制)";
- const mysqlAccountPolicy = summary?.database_account_policy || "一人一号 · 单库授权 · 禁止 root";
+ const databaseName = selectedDatabase?.database_name || "请选择数据库";
+ const mysqlBindingKnown = typeof summary?.mysql_publicly_bound === "boolean";
const mysqlPubliclyBound = summary?.mysql_publicly_bound === true;
- const mysqlAuditEnabled = summary?.database_audit_enabled === true;
- const backupEnabled = summary?.backup_enabled === true;
- const backupRetentionDays = summary?.backup_retention_days || 30;
- const mysqlSecurityReady = !mysqlPubliclyBound && mysqlAuditEnabled && backupEnabled;
- const databaseName = selectedAccessDatabase?.database_name || "请选择数据库";
- const apiBaseUrl = `${window.location.origin}${summary?.public_base_path || "/v1/openapi/data"}`;
- const apiCatalogUrl = `${apiBaseUrl}/catalog`;
- const dbeaverParameters = [
- "DBeaver Main",
- `Server Host: ${mysqlHost}`,
- `Port: ${mysqlPort}`,
- `Database: ${databaseName}`,
- "Show all databases: 关闭",
- "Username: 由运维签发的个人 MySQL 账号",
- "",
- "DBeaver SSH",
- `Host/IP: ${sshHost}`,
- `Port: ${sshPort}`,
- `Authentication: ${sshAuthMethod}`,
- "",
- `安全策略: ${mysqlAccountPolicy}`,
- `网络通道: ${mysqlTransport}`,
- ].join("\n");
+ const managedAccessReady = summary?.direct_access_enabled === true
+ && summary?.managed_access_enabled === true
+ && summary?.ssh_key_auto_install === true
+ && summary?.account_provisioning_ready === true
+ && !!summary?.ssh_host_key_fingerprint;
+ const activeGrantCount = grants.filter((item) => item.status === "active").length;
const copyText = async (value: string, success = "已复制") => {
try {
@@ -255,681 +136,152 @@ export default function InterfaceCenterPanel() {
}
};
- const openCreateClient = () => {
- setEditingClient(null);
- clientForm.resetFields();
- setClientOpen(true);
+ const downloadTextFile = (filename: string, content: string) => {
+ const url = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
};
- const openEditClient = (client: InterfaceClient) => {
- setEditingClient(client);
- clientForm.setFieldsValue({
- name: client.name,
- description: client.description,
- status: client.status,
+ const openAccess = () => {
+ accessForm.resetFields();
+ accessForm.setFieldsValue({
+ database_id: selectedDatabaseId || undefined,
+ permission: "read",
});
- setClientOpen(true);
+ setAccessOpen(true);
};
- const submitClient = async () => {
- const values = await clientForm.validateFields();
+ const submitAccess = async () => {
+ const values = await accessForm.validateFields();
setSaving(true);
try {
- if (editingClient) {
- await updateInterfaceClient(editingClient.id, values);
- } else {
- const result = await createInterfaceClient(values);
- setSelectedClientId(result.data.id);
- }
- message.success(editingClient ? "接入方已更新" : "接入方已创建");
- setClientOpen(false);
+ // The private key is generated and retained only in this browser tab.
+ // The server receives the public key in the request below.
+ const keyPair = await generateDbeaverSshKeyPair(values.display_name);
+ const result = await issueDbeaverAccess({
+ display_name: values.display_name,
+ database_id: values.database_id,
+ permission: values.permission,
+ public_key: keyPair.publicKey,
+ });
+ setSelectedDatabaseId(values.database_id);
+ setAccessOpen(false);
+ setIssuedAccess({ grant: result.data, keyPair });
await load();
} catch (error: any) {
- message.error(errorDetail(error, "保存失败"));
+ message.error(errorDetail(error, "DBeaver 管理接入生成失败"));
} finally {
setSaving(false);
}
};
- const openCredential = (client: InterfaceClient) => {
- setCredentialClient(client);
- credentialForm.resetFields();
- credentialForm.setFieldsValue({ name: "服务器访问密钥" });
- setCredentialOpen(true);
- };
+ const issuedParameters = issuedAccess ? [
+ "DBeaver · Main",
+ `Server Host: ${issuedAccess.grant.database_host || mysqlHost}`,
+ `Port: ${issuedAccess.grant.database_port || mysqlPort}`,
+ `Database: ${issuedAccess.grant.database_name}`,
+ `Username: ${issuedAccess.grant.mysql_username}`,
+ `Password: ${issuedAccess.grant.mysql_password || ""}`,
+ "Show all databases: 关闭",
+ "",
+ "DBeaver · SSH",
+ `Host/IP: ${issuedAccess.grant.ssh_host || sshHost}`,
+ `Port: ${issuedAccess.grant.ssh_port || sshPort}`,
+ `User Name: ${issuedAccess.grant.ssh_username || sshUsername}`,
+ "Authentication Method: Public Key",
+ `Private Key: ${issuedAccess.keyPair.privateKeyFilename}`,
+ `Host Key Fingerprint: ${sshFingerprint}`,
+ "Bypass host verification: 关闭",
+ ].join("\n") : "";
- const submitCredential = async () => {
- if (!credentialClient) return;
- const values = await credentialForm.validateFields();
- setSaving(true);
- try {
- const result = await createInterfaceCredential(credentialClient.id, values);
- setCredentialOpen(false);
- setIssuedKey(result.data);
- await load();
- } catch (error: any) {
- message.error(errorDetail(error, "签发密钥失败"));
- } finally {
- setSaving(false);
- }
- };
-
- const openCreatePolicy = () => {
- setEditingPolicy(null);
- policyForm.resetFields();
- policyForm.setFieldsValue({
- client_id: selectedClientId || undefined,
- database_id: selectedAccessDatabaseId || undefined,
- table_code: "*",
- actions: ["metadata", "read"],
- readable_fields: ["*"],
- writable_fields: ["*"],
- row_filter: "{}",
- status: "active",
- });
- setPolicyOpen(true);
- };
-
- const openEditPolicy = (policy: InterfacePolicy) => {
- setEditingPolicy(policy);
- policyForm.setFieldsValue({
- client_id: policy.client_id,
- database_id: policy.database_id,
- table_code: policy.table_code,
- actions: policy.actions,
- readable_fields: policy.readable_fields,
- writable_fields: policy.writable_fields,
- row_filter: JSON.stringify(policy.row_filter || {}, null, 2),
- status: policy.status,
- });
- setPolicyOpen(true);
- };
-
- const submitPolicy = async () => {
- const values = await policyForm.validateFields();
- let rowFilter: Record;
- try {
- rowFilter = JSON.parse(values.row_filter || "{}");
- if (!rowFilter || Array.isArray(rowFilter) || typeof rowFilter !== "object") {
- throw new Error();
- }
- } catch {
- message.error("行级数据范围必须是合法 JSON 对象");
- return;
- }
- setSaving(true);
- try {
- const payload = { ...values, row_filter: rowFilter };
- if (editingPolicy) {
- await updateInterfacePolicy(editingPolicy.id, payload);
- } else {
- await createInterfacePolicy(payload);
- }
- message.success(editingPolicy ? "权限策略已更新" : "权限策略已创建");
- setPolicyOpen(false);
- await load();
- } catch (error: any) {
- message.error(errorDetail(error, "保存权限策略失败"));
- } finally {
- setSaving(false);
- }
- };
-
- const credentialColumns = [
+ const grantColumns = [
{
- title: "密钥",
- dataIndex: "name",
- render: (value: string, row: InterfaceCredential) => (
+ title: "数据管理员",
+ dataIndex: "display_name",
+ render: (value: string, row: DbeaverAccessGrant) => (
{value}
- {row.key_prefix}••••••••
+ {row.mysql_username}
),
},
- { title: "最近使用", dataIndex: "last_used_at", render: formatTime, width: 168 },
- { title: "到期时间", dataIndex: "expires_at", render: formatTime, width: 168 },
+ {
+ title: "数据库",
+ render: (_: unknown, row: DbeaverAccessGrant) => (
+
+ {catalog.find((item) => item.project_id === row.database_id)?.display_name || row.database_name}
+ {row.database_name}
+
+ ),
+ },
+ {
+ title: "权限",
+ dataIndex: "permission_level",
+ width: 86,
+ render: (value: DbeaverAccessGrant["permission_level"]) => (
+ {value === "write" ? "读写" : "只读"}
+ ),
+ },
+ {
+ title: "SSH 公钥",
+ dataIndex: "ssh_key_fingerprint",
+ width: 190,
+ render: (value: string) => {`${value.slice(0, 22)}…`},
+ },
+ {
+ title: "生成时间",
+ dataIndex: "created_at",
+ width: 142,
+ render: formatTime,
+ },
{
title: "状态",
- width: 84,
- render: (_: unknown, row: InterfaceCredential) => credentialIsActive(row)
+ dataIndex: "status",
+ width: 82,
+ render: (value: DbeaverAccessGrant["status"]) => value === "active"
? 有效
- : {row.revoked_at ? "已撤销" : "已过期"},
+ : 已撤销,
},
{
title: "操作",
width: 76,
- render: (_: unknown, row: InterfaceCredential) => credentialIsActive(row) ? (
+ render: (_: unknown, row: DbeaverAccessGrant) => row.status === "active" ? (
{
try {
- await revokeInterfaceCredential(row.id);
- message.success("密钥已撤销");
+ await revokeDbeaverAccess(row.id);
+ message.success("管理接入已撤销");
await load();
} catch (error: any) {
message.error(errorDetail(error, "撤销失败"));
}
}}
>
-
+
) : null,
},
];
- const policyColumns = [
- {
- title: "数据范围",
- render: (_: unknown, row: InterfacePolicy) => (
-
- {row.database_name || row.database_id}
- {row.table_code === "*" ? "全部数据表" : row.table_code}
-
- ),
- },
- {
- title: "允许动作",
- dataIndex: "actions",
- render: (values: string[]) => (
-
- {values.map((value) => (
-
- {ACTION_LABELS[value] || value}
-
- ))}
-
- ),
- },
- {
- title: "状态",
- dataIndex: "status",
- width: 84,
- render: (value: string) => value === "active"
- ? 启用
- : 停用,
- },
- {
- title: "操作",
- width: 128,
- render: (_: unknown, row: InterfacePolicy) => (
-
-
- {
- try {
- await deleteInterfacePolicy(row.id);
- message.success("权限策略已删除");
- await load();
- } catch (error: any) {
- message.error(errorDetail(error, "删除失败"));
- }
- }}
- >
-
-
-
- ),
- },
- ];
-
- const logColumns = [
- { title: "时间", dataIndex: "created_at", render: formatTime, width: 168 },
- { title: "接入方", dataIndex: "client_name", render: (value: string) => value || "未认证", width: 150 },
- {
- title: "请求",
- render: (_: unknown, row: InterfaceCallLog) => (
-
- {row.method}{row.action_name || "-"}
- {row.path}
-
- ),
- },
- {
- title: "结果",
- width: 112,
- render: (_: unknown, row: InterfaceCallLog) => (
-
- {row.status_code}
- {Number(row.duration_ms).toFixed(2)} ms
-
- ),
- },
- { title: "来源 IP", dataIndex: "source_ip", render: (value: string) => value || "-", width: 136 },
- ];
-
- const mysqlConnection = (
- <>
-
-
-
- 安全连接模式:SSH 隧道 + 独立 MySQL 账号
- MySQL 只监听服务器本机;DBeaver 先通过 SSH 加密通道,再访问数据库。
-
-
- {mysqlPubliclyBound ? "检测到公网绑定" : "公网端口已关闭"}
-
-
-
-
-
-
-
-
-
- 03
- 账号与权限
- 连接参数不包含密码,权限由服务器运维签发
-
-
-
独立凭证密码不在接口中心保存
-
单库授权{databaseName}
-
默认只读写权限临时开通
-
-
-
-
-
- } onClick={() => void copyText(
- dbeaverParameters,
- "安全接入参数已复制",
- )}>
- 复制安全接入参数
-
-
- >
- );
-
- const apiConnection = (
- <>
-
-
-
- 本服务器向外提供受控 HTTPS 数据接口
- 每个电脑、设备或业务系统使用独立身份、密钥和最小权限。
-
-
服务端接入
-
-
-
-
-
-
-
-
-
- } onClick={openCreateClient}>新建接入方
- }
- disabled={!selectedClient || selectedClient.status !== "active"}
- onClick={() => selectedClient && openCredential(selectedClient)}
- >
- 签发 API 密钥
-
-
- >
- );
-
- const connectionSummary = (
-
- );
-
- const mysqlSecurity = (
-
-
-
-
DBeaver 管理接入安全边界生产服务器只接受经过 SSH 隧道的管理员连接
-
- {mysqlPubliclyBound ? "需要修复公网绑定" : "MySQL 仅本机监听"}
-
-
-
-
01网络层拒绝公网 MySQLMySQL 绑定 127.0.0.1,安全组不开放数据库端口;公网只允许受控 SSH 与 HTTPS。
-
02SSH 使用私钥或 AgentDBeaver 的 SSH 标签页填写服务器地址;禁用共享 SSH 密码,并限制管理员来源 IP。
-
03Main 页只连接 127.0.0.1数据库端口通过 SSH 隧道抵达服务器本机,关闭 Show all databases,避免暴露无关库名。
-
04一人一号并按数据库授权禁止 root 和 data_center;默认只读,写权限按需临时授予,DROP 单独审批。
-
05数据库操作独立审计API 日志无法覆盖 DBeaver SQL,生产环境必须启用登录、DDL/DML 审计和 binlog 恢复。
-
06加密备份与恢复演练每日备份使用独立密钥加密并同步异地存储;每月至少完成一次隔离恢复演练。
-
-
-
-
-
- );
-
- const apiSecurity = (
-
-
-
-
- 接入方
-
-
} onClick={openCreateClient}>新建接入方
-
-
- {!selectedClient ? (
-
-
-
- ) : (
- <>
-
-
-
-
- {selectedClient.name}
-
- {selectedClient.status === "active" ? "已启用" : "已停用"}
-
-
-
{selectedClient.description || "未填写用途说明"}
-
负责人:{selectedClient.owner || "-"}
-
-
-
} onClick={() => openEditClient(selectedClient)}>编辑
- {selectedClient.status === "active" && (
-
{
- try {
- await deleteInterfaceClient(selectedClient.id);
- message.success("接入方已停用");
- await load();
- } catch (error: any) {
- message.error(errorDetail(error, "停用失败"));
- }
- }}
- >
-
-
- )}
-
-
-
-
-
-
客户端与密钥完整密钥只在签发时显示一次
-
}
- disabled={selectedClient.status !== "active"}
- onClick={() => openCredential(selectedClient)}
- >签发密钥
-
- }}
- />
-
-
-
-
-
权限策略限定数据库、数据表、字段、动作和行级范围
-
} onClick={openCreatePolicy}>新建策略
-
- }}
- />
-
- >
- )}
-
-
-
-
- );
-
return (
接口中心
- }>服务运行中
+ }>DBeaver 管理接入
-
管理外部电脑、设备和业务系统访问本服务器数据中心。
+
为电脑上的 DBeaver 生成连接服务器数据中心所需的密钥、账号和最小权限。
- } onClick={() => setDocsOpen(true)}>接口说明
- } onClick={() => setLogsOpen(true)}>调用日志
+ } onClick={() => setDocsOpen(true)}>连接说明
} loading={loading} onClick={() => void load()}>刷新
@@ -938,7 +290,7 @@ export default function InterfaceCenterPanel() {
void load()}>重试}
className="interface-center-load-error"
@@ -946,237 +298,311 @@ export default function InterfaceCenterPanel() {
)}
-
-
-
-
+
+
+
+
+
+ 电脑 DBeaver → SSH 加密隧道 → 服务器 MySQL
+ MySQL 不开放公网端口;SSH 账号只能转发数据库连接,不能进入服务器终端。
+
+
+ {!mysqlBindingKnown ? "等待安全状态" : mysqlPubliclyBound ? "检测到风险绑定" : "MySQL 公网端口关闭"}
+
+
-
-
-
-
+
+
+ 01
+ 选择服务器数据库
+ 每个账号只允许访问一个数据库
+
+
+
- {workspaceView === "connection" ? (
-
-
- {accessMode === "mysql" ? mysqlConnection : apiConnection}
-
- {connectionSummary}
-
- ) : accessMode === "mysql" ? mysqlSecurity : apiSecurity}
+
+
+
+
+
+ 03
+ 生成密钥与数据库账号
+ 私钥在当前浏览器生成,服务器只保存公钥
+
+
}
+ disabled={!managedAccessReady || !catalog.length}
+ onClick={openAccess}
+ >
+ 生成 DBeaver 接入
+
+
+ {!managedAccessReady && (
+
+ )}
+
+
私钥不上传遗失后只能撤销重建
+
一人一号、单库授权不共享 root 或业务账号
+
默认只读写权限按需选择并可随时撤销
+
+ 8 ? { pageSize: 8, size: "small" } : false}
+ locale={{ emptyText: }}
+ />
+
+
+
+
+
- setDocsOpen(false)} width={640}>
+ setDocsOpen(false)} width={620}>
-
- {sshHost}
- {sshPort}
- {sshAuthMethod}
- 仅允许管理员 IP 或 VPN;MySQL 端口不开放公网
-
-
+
{mysqlHost}
{mysqlPort}
- {databaseName}
+ {databaseName}
+ 使用本页面生成的个人 MySQL 凭证
关闭
- 运维签发的个人 MySQL 账号
- {mysqlAccountPolicy}
+
+
+ {sshHost}
+ {sshPort}
+ {sshUsername}
+ Public Key,选择下载的 .pem 私钥
+ {sshFingerprint}
+ 关闭
-
- Authorization: Bearer YOUR_API_KEY
- GET {summary?.public_base_path || "/v1/openapi/data"}/catalog
- GET .../databases/{`{database_id}`}/tables/{`{table_code}`}/records
- POST / PATCH / DELETE(必须单独授权)
-
-
-
查询示例} onClick={() => void copyText(`curl -H "Authorization: Bearer YOUR_API_KEY" \\\n "${apiCatalogUrl}"`)}>复制
-
{`curl -H "Authorization: Bearer YOUR_API_KEY" \\\n "${apiCatalogUrl}"`}
-
-
- setLogsOpen(false)}
- width={960}
- extra={} loading={loading} onClick={() => void load()}>刷新}
- >
- {logs.length ? (
-
- ) : (
-
- )}
-
-
setClientOpen(false)}
- onOk={() => void submitClient()}
+ title="生成 DBeaver 管理接入"
+ open={accessOpen}
+ onCancel={() => setAccessOpen(false)}
+ onOk={() => void submitAccess()}
+ okText="生成密钥与账号"
confirmLoading={saving}
destroyOnClose
>
-
-
+
+
+
-
-
-
- {editingClient && (
-
-
-
- )}
-
-
-
- setCredentialOpen(false)}
- onOk={() => void submitCredential()}
- confirmLoading={saving}
- destroyOnClose
- >
-
-
-
-
-
-
-
-
-
-
- setIssuedKey(null)}
- footer={}
- closable={false}
- maskClosable={false}
- >
-
-
- } className="interface-center-copy-key" onClick={() => void copyText(issuedKey?.api_key || "")}>复制完整密钥
-
-
- setPolicyOpen(false)}
- onOk={() => void submitPolicy()}
- confirmLoading={saving}
- width={760}
- destroyOnClose
- >
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ setIssuedAccess(null)}>
+ 我已下载并安全保存
+
+ )}
+ >
+
+
+ {issuedAccess?.grant.display_name}
+ {issuedAccess?.grant.database_name}
+ {issuedAccess?.grant.permission_level === "write" ? "读写" : "只读"}
+
+
+ {issuedAccess?.grant.mysql_username}
+ } onClick={() => void copyText(issuedAccess?.grant.mysql_username || "")} />
+
+
+
+ void copyText(issuedAccess?.grant.mysql_password || "")} />}
+ />
+
+
+ {issuedAccess?.grant.ssh_username || sshUsername}@{issuedAccess?.grant.ssh_host || sshHost}:{issuedAccess?.grant.ssh_port || sshPort}
+
+ {issuedAccess?.grant.ssh_key_fingerprint}
+ {sshFingerprint}
+
+
+ }
+ onClick={() => issuedAccess && downloadTextFile(
+ issuedAccess.keyPair.privateKeyFilename,
+ issuedAccess.keyPair.privateKeyPem,
+ )}
+ >
+ 下载 SSH 私钥
+
+ }
+ onClick={() => issuedAccess && downloadTextFile(
+ issuedAccess.keyPair.publicKeyFilename,
+ `${issuedAccess.keyPair.publicKey}\n`,
+ )}
+ >
+ 下载公钥
+
+ } onClick={() => void copyText(issuedParameters, "DBeaver 参数已复制")}>复制全部连接参数
+
+
+ 在 DBeaver 的 SSH 页选择 Public Key,并加载下载的 .pem 私钥文件。
+
+
);
}
diff --git a/admin-web/src/panels/system/LogsPanel.tsx b/admin-web/src/panels/system/LogsPanel.tsx
deleted file mode 100644
index 24625cc..0000000
--- a/admin-web/src/panels/system/LogsPanel.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { useEffect, useState } from "react";
-import { Table, Tag, Input, Space } from "antd";
-import api from "../../api";
-
-export default function LogsPanel() {
- const [logs, setLogs] = useState>>([]);
- const [search, setSearch] = useState("");
-
- useEffect(() => {
- api.get("/agent-call-logs").then(({ data }) => {
- setLogs(data.filter(
- (log: Record) => !["publisher", "auditor"].includes(String(log.agent_name)),
- ));
- }).catch(() => {});
- }, []);
-
- return (
-
-
LLM 调用日志
-
- setSearch(e.target.value)}
- style={{ width: 300 }}
- />
-
-
(
- !search
- || String(log.agent_name || "").includes(search)
- || String(log.request_id || "").includes(search)
- || String(log.model || "").includes(search)
- ))}
- rowKey="id"
- columns={[
- { title: "Agent", dataIndex: "agent_name", width: 150 },
- { title: "请求", dataIndex: "request_id", ellipsis: true },
- { title: "模型", dataIndex: "model", width: 160, ellipsis: true },
- { title: "状态", dataIndex: "status", width: 90, render: (v: string) => {v} },
- { title: "耗时", dataIndex: "latency_ms", width: 100, render: (v: number) => v == null ? "—" : `${v} ms` },
- { title: "错误", dataIndex: "error_message", ellipsis: true },
- { title: "时间", dataIndex: "created_at", width: 180 },
- ]}
- />
-
- );
-}
diff --git a/admin-web/src/panels/system/PermissionsAudit.tsx b/admin-web/src/panels/system/PermissionsAudit.tsx
deleted file mode 100644
index f28928f..0000000
--- a/admin-web/src/panels/system/PermissionsAudit.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import { useEffect, useState, useCallback } from "react";
-import {
- Table, Tag, Card, Button, Space, Modal, Form, Input, InputNumber,
- Select, Popconfirm, message, Spin,
-} from "antd";
-import { PlusOutlined, DeleteOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
-import {
- listRoles, createRole, deleteRole,
- listCapabilities, createCapability, deleteCapability,
- getPermissionMatrix, setPermissionCell,
-} from "../../api";
-
-type Role = { role_key: string; label: string; description?: string; is_system: boolean; sort_order: number };
-type Cap = { cap_key: string; label: string; sort_order: number };
-type Matrix = Record>;
-
-const CELL_PRESETS = ["✓", "—", "部分", "查看", "领任务", "仅会签"];
-
-function cellTag(v: string) {
- if (v === "✓") return ✓;
- if (v === "—" || !v) return —;
- return {v};
-}
-
-export default function PermissionsAudit() {
- const [loading, setLoading] = useState(false);
- const [roles, setRoles] = useState([]);
- const [caps, setCaps] = useState([]);
- const [matrix, setMatrix] = useState({});
- const [roleModal, setRoleModal] = useState(false);
- const [capModal, setCapModal] = useState(false);
- const [roleForm] = Form.useForm();
- const [capForm] = Form.useForm();
-
- const load = useCallback(async () => {
- setLoading(true);
- try {
- const { data } = await getPermissionMatrix();
- setRoles(data.roles);
- setCaps(data.capabilities);
- setMatrix(data.matrix || {});
- } catch {
- message.error("加载权限矩阵失败");
- }
- setLoading(false);
- }, []);
-
- useEffect(() => { load(); }, [load]);
-
- const changeCell = async (capKey: string, roleKey: string, value: string) => {
- const prev = matrix[capKey]?.[roleKey];
- setMatrix((m) => ({ ...m, [capKey]: { ...(m[capKey] || {}), [roleKey]: value } }));
- try {
- await setPermissionCell(roleKey, capKey, value);
- } catch {
- message.error("保存失败,已回滚");
- setMatrix((m) => ({ ...m, [capKey]: { ...(m[capKey] || {}), [roleKey]: prev || "—" } }));
- }
- };
-
- const addRole = async () => {
- const v = await roleForm.validateFields();
- try {
- await createRole(v);
- message.success("角色已新增");
- setRoleModal(false);
- roleForm.resetFields();
- load();
- } catch (e: any) {
- message.error(e?.response?.data?.detail || "新增失败");
- }
- };
-
- const removeRole = async (roleKey: string) => {
- try {
- await deleteRole(roleKey);
- message.success("角色已删除");
- load();
- } catch (e: any) {
- message.error(e?.response?.data?.detail || "删除失败(系统内置角色不可删)");
- }
- };
-
- const addCap = async () => {
- const v = await capForm.validateFields();
- try {
- await createCapability(v);
- message.success("能力项已新增");
- setCapModal(false);
- capForm.resetFields();
- load();
- } catch (e: any) {
- message.error(e?.response?.data?.detail || "新增失败");
- }
- };
-
- const removeCap = async (capKey: string) => {
- try {
- await deleteCapability(capKey);
- message.success("能力项已删除");
- load();
- } catch {
- message.error("删除失败");
- }
- };
-
- const columns = [
- {
- title: "能力 / 角色",
- dataIndex: "label",
- fixed: "left" as const,
- width: 200,
- render: (label: string, row: Cap) => (
-
- {label}
- removeCap(row.cap_key)}>
- } />
-
-
- ),
- },
- ...roles.map((r) => ({
- title: (
-
- {r.label}
- {r.is_system ? (
- 内置
- ) : (
- removeRole(r.role_key)}>
- }>删除
-
- )}
-
- ),
- dataIndex: r.role_key,
- key: r.role_key,
- align: "center" as const,
- width: 160,
- render: (_: any, row: Cap) => {
- const val = matrix[row.cap_key]?.[r.role_key] ?? "—";
- return (
-