feat: harden loose-offline sync for user JWT, schema, and console ops
Enable Binding-scoped agent push/pull, empty-table schema ensure, SyncPage inspect/drop-table, default module import, and agent-bound publish docs from the 宇恒联调意见. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,10 +26,14 @@ import {
|
||||
AgentAccount,
|
||||
Role,
|
||||
Session,
|
||||
SyncBinding,
|
||||
SyncChannel,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
listAgents,
|
||||
listRoles,
|
||||
listSyncBindings,
|
||||
listSyncChannels,
|
||||
rotateAgentSecret,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
@@ -39,6 +43,9 @@ type FormValues = {
|
||||
name: string;
|
||||
role_id: number;
|
||||
app_slugs: string[];
|
||||
channel_id?: string;
|
||||
online_db_id?: string;
|
||||
database_name?: string;
|
||||
};
|
||||
|
||||
export function AgentUsersPage(props: {
|
||||
@@ -52,6 +59,8 @@ export function AgentUsersPage(props: {
|
||||
const { message, modal } = AntApp.useApp();
|
||||
const [agents, setAgents] = useState<AgentAccount[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [channels, setChannels] = useState<SyncChannel[]>([]);
|
||||
const [bindings, setBindings] = useState<SyncBinding[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AgentAccount | null>(null);
|
||||
@@ -66,9 +75,16 @@ export function AgentUsersPage(props: {
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [a, r] = await Promise.all([listAgents(session), listRoles(session)]);
|
||||
const [a, r, ch, bind] = await Promise.all([
|
||||
listAgents(session),
|
||||
listRoles(session),
|
||||
listSyncChannels(session).catch(() => ({ items: [] as SyncChannel[] })),
|
||||
listSyncBindings(session).catch(() => ({ items: [] as SyncBinding[] })),
|
||||
]);
|
||||
setAgents(a.items || []);
|
||||
setRoles(r.items || []);
|
||||
setChannels(ch.items || []);
|
||||
setBindings(bind.items || []);
|
||||
} catch (e: any) {
|
||||
const msg = e.message || String(e);
|
||||
setError(msg);
|
||||
@@ -94,6 +110,9 @@ export function AgentUsersPage(props: {
|
||||
name: "",
|
||||
role_id: pref?.role_id,
|
||||
app_slugs: [],
|
||||
channel_id: undefined,
|
||||
online_db_id: undefined,
|
||||
database_name: undefined,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -104,6 +123,9 @@ export function AgentUsersPage(props: {
|
||||
name: a.name || "",
|
||||
role_id: a.role_id || undefined,
|
||||
app_slugs: a.app_slugs || [],
|
||||
channel_id: a.channel_id || undefined,
|
||||
online_db_id: a.online_db_id || undefined,
|
||||
database_name: a.database_name || undefined,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -112,11 +134,17 @@ export function AgentUsersPage(props: {
|
||||
const values = await form.validateFields();
|
||||
setBusy(true);
|
||||
try {
|
||||
const bindBody = {
|
||||
channel_id: values.channel_id || "",
|
||||
online_db_id: values.online_db_id || "",
|
||||
database_name: (values.database_name || "").trim() || "",
|
||||
};
|
||||
if (!editing) {
|
||||
const res = await createAgent(session, {
|
||||
name: values.name.trim(),
|
||||
role_id: values.role_id,
|
||||
app_slugs: values.app_slugs || [],
|
||||
...bindBody,
|
||||
});
|
||||
modal.success({
|
||||
title: "用户已创建",
|
||||
@@ -135,6 +163,7 @@ export function AgentUsersPage(props: {
|
||||
name: values.name.trim(),
|
||||
role_id: values.role_id,
|
||||
app_slugs: values.app_slugs || [],
|
||||
...bindBody,
|
||||
...(activate ? { status: "active" } : {}),
|
||||
});
|
||||
message.success(activate ? "已保存并启用" : "已保存");
|
||||
@@ -173,7 +202,7 @@ export function AgentUsersPage(props: {
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
当前租户 #{session.tenantId}。用户挂在本租户下;同角色跨公司靠租户隔离,不靠再拆一套角色。
|
||||
角色请在「角色管理」中维护。
|
||||
角色请在「角色管理」中维护。可绑定<strong>同步通道 / 线上库</strong>:该智能体发布的模块与松离线数据进同一目标库(Z8)。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Table
|
||||
@@ -222,6 +251,23 @@ export function AgentUsersPage(props: {
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "同步绑定",
|
||||
render: (_: unknown, r: AgentAccount) => {
|
||||
if (!r.channel_id && !r.online_db_id && !r.database_name) return "—";
|
||||
const ch = channels.find((c) => c.id === r.channel_id);
|
||||
return (
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{ch?.name || r.channel_id || "无通道"}
|
||||
<br />
|
||||
<Typography.Text type="secondary">
|
||||
{r.online_db_id || "—"}
|
||||
{r.database_name ? ` · ${r.database_name}` : ""}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 320,
|
||||
@@ -363,6 +409,53 @@ export function AgentUsersPage(props: {
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text strong>松离线 / 模块落库(Z8)</Typography.Text>
|
||||
<Form.Item
|
||||
name="channel_id"
|
||||
label="同步通道"
|
||||
style={{ marginTop: 8 }}
|
||||
extra="该智能体的模块与本机 sync 应对准同一通道线上库"
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="可选"
|
||||
options={channels.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name} (${c.id.slice(0, 8)}…)`,
|
||||
}))}
|
||||
onChange={(cid?: string) => {
|
||||
if (!cid) return;
|
||||
const b = bindings.find((x) => x.channel_id === cid);
|
||||
if (b) {
|
||||
form.setFieldsValue({
|
||||
online_db_id: b.online_db_id,
|
||||
database_name: b.display_name || b.database_name || form.getFieldValue("database_name"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="online_db_id"
|
||||
label="线上库 ID"
|
||||
extra="与 Binding.online_db_id 对齐;宇恒 push 须带此 id。可选从 Binding 带出。"
|
||||
>
|
||||
<Input placeholder="online_db_id" allowClear list="agent-online-db-options" />
|
||||
</Form.Item>
|
||||
<datalist id="agent-online-db-options">
|
||||
{[...new Set(bindings.map((b) => b.online_db_id).filter(Boolean))].map((id) => (
|
||||
<option key={id} value={id} />
|
||||
))}
|
||||
</datalist>
|
||||
<Form.Item
|
||||
name="database_name"
|
||||
label="模块落库名"
|
||||
extra="智能体新建发布时优先写入此 Postgres 库(database_per_app);与通道 remote 对齐为佳"
|
||||
>
|
||||
<Input placeholder="例如 appdb_t1_aisite" allowClear />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
|
||||
@@ -265,7 +265,7 @@ export function LoginShell(props: LoginProps) {
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
props.onUsername("13800000000");
|
||||
props.onUsername("13531041945");
|
||||
props.onPassword("ljk_admin");
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
App as AntApp,
|
||||
Button,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
@@ -18,13 +19,26 @@ import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
ApiOutlined,
|
||||
TableOutlined,
|
||||
EyeOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
AgentAccount,
|
||||
Session,
|
||||
SyncBinding,
|
||||
SyncChannel,
|
||||
SyncInspectResult,
|
||||
SyncPreviewResult,
|
||||
createSyncChannel,
|
||||
deleteSyncChannel,
|
||||
dropSyncTable,
|
||||
inspectSyncChannel,
|
||||
listAgents,
|
||||
listApps,
|
||||
listSyncBindings,
|
||||
listSyncChannels,
|
||||
previewSyncTable,
|
||||
reconcileSyncChannel,
|
||||
startSyncChannel,
|
||||
stopSyncChannel,
|
||||
@@ -43,6 +57,8 @@ type FormValues = {
|
||||
direction: string;
|
||||
conflict_policy: string;
|
||||
poll_interval_ms: number;
|
||||
agent_id?: number | null;
|
||||
app_slug?: string;
|
||||
local: EndpointForm;
|
||||
remote: EndpointForm;
|
||||
pk_columns: string; // table:pk,table:pk
|
||||
@@ -57,6 +73,56 @@ function parsePKs(s: string): Record<string, string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** DSN 脱敏;file 库尽量只露文件名 */
|
||||
function formatDsnHint(dsn: string): string {
|
||||
const masked = (dsn || "").replace(/:[^:@/]+@/, ":***@");
|
||||
const fileMatch = masked.match(/(?:^|[\\/])([^\\/?#]+\.db)(?:\?|$)/i);
|
||||
if (fileMatch) return fileMatch[1];
|
||||
if (masked.length > 56) return `${masked.slice(0, 56)}…`;
|
||||
return masked;
|
||||
}
|
||||
|
||||
function bindingLocalLabel(b: SyncBinding): string {
|
||||
return (b.database_name || "").trim() || b.local_database_id;
|
||||
}
|
||||
|
||||
function bindingOnlineLabel(b: SyncBinding): string {
|
||||
return (b.display_name || "").trim() || b.online_db_id;
|
||||
}
|
||||
|
||||
/** 联调烟雾 Binding:占位 id,易被误认为「库名对不上」 */
|
||||
function isSmokeBinding(b: SyncBinding): boolean {
|
||||
const note = (b.note || "").toLowerCase();
|
||||
if (note.includes("smoke") || note.includes("user-jwt")) return true;
|
||||
const local = (b.local_database_id || "").toLowerCase();
|
||||
const online = (b.online_db_id || "").toLowerCase();
|
||||
return local.startsWith("local_") || online.startsWith("online_");
|
||||
}
|
||||
|
||||
/** 产品:与本地一致的库名优先,勿用落库文件名当主标题 */
|
||||
function channelOnlineTitle(ch: SyncChannel, bindings: SyncBinding[]): string {
|
||||
const linked = bindings.filter((b) => b.channel_id === ch.id);
|
||||
const localNames = linked.map((b) => (b.database_name || "").trim()).filter(Boolean);
|
||||
if (localNames.length) return localNames[0];
|
||||
const onlineNames = linked.map((b) => (b.display_name || "").trim()).filter(Boolean);
|
||||
if (onlineNames.length) return onlineNames[0];
|
||||
if ((ch.name || "").trim()) return ch.name.trim();
|
||||
return ch.remote?.driver || "线上库";
|
||||
}
|
||||
|
||||
function channelOnlineIds(ch: SyncChannel, bindings: SyncBinding[]): string {
|
||||
const linked = bindings.filter((b) => b.channel_id === ch.id);
|
||||
const ids = linked.map((b) => b.online_db_id).filter(Boolean);
|
||||
const uniq = [...new Set(ids)];
|
||||
if (!uniq.length) return "";
|
||||
// 有可读本地名时副文案带上 local↔online id,便于对账
|
||||
const localName = linked.map((b) => (b.database_name || "").trim()).find(Boolean);
|
||||
if (localName && linked[0]?.local_database_id) {
|
||||
return `${linked[0].local_database_id} ↔ ${uniq.join(" · ")}`;
|
||||
}
|
||||
return uniq.join(" · ");
|
||||
}
|
||||
|
||||
function toChannelBody(v: FormValues, id?: string): Partial<SyncChannel> {
|
||||
return {
|
||||
id,
|
||||
@@ -64,6 +130,8 @@ function toChannelBody(v: FormValues, id?: string): Partial<SyncChannel> {
|
||||
direction: v.direction,
|
||||
conflict_policy: v.conflict_policy,
|
||||
poll_interval_ms: v.poll_interval_ms || 500,
|
||||
agent_id: v.agent_id || undefined,
|
||||
app_slug: (v.app_slug || "").trim() || undefined,
|
||||
local: {
|
||||
driver: v.local.driver,
|
||||
dsn: v.local.dsn,
|
||||
@@ -94,16 +162,48 @@ export function SyncPage(props: {
|
||||
const { session, busy, setBusy, setError, setInfo } = props;
|
||||
const { message } = AntApp.useApp();
|
||||
const [items, setItems] = useState<SyncChannel[]>([]);
|
||||
const [bindings, setBindings] = useState<SyncBinding[]>([]);
|
||||
const [agents, setAgents] = useState<AgentAccount[]>([]);
|
||||
const [appOptions, setAppOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SyncChannel | null>(null);
|
||||
const [showSmokeBindings, setShowSmokeBindings] = useState(false);
|
||||
const [filterAgentId, setFilterAgentId] = useState<number | undefined>(undefined);
|
||||
const [inspectOpen, setInspectOpen] = useState(false);
|
||||
const [inspectCh, setInspectCh] = useState<SyncChannel | null>(null);
|
||||
const [inspectSide, setInspectSide] = useState<"remote" | "local">("remote");
|
||||
const [inspectData, setInspectData] = useState<SyncInspectResult | null>(null);
|
||||
const [inspectLoading, setInspectLoading] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<SyncPreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const visibleBindings = showSmokeBindings
|
||||
? bindings
|
||||
: bindings.filter((b) => !isSmokeBinding(b));
|
||||
const smokeHidden = bindings.length - visibleBindings.length;
|
||||
const visibleChannels = filterAgentId
|
||||
? items.filter((c) => c.agent_id === filterAgentId || agents.find((a) => a.agent_id === filterAgentId)?.channel_id === c.id)
|
||||
: items;
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ch = await listSyncChannels(session);
|
||||
const [ch, bind, ag, apps] = await Promise.all([
|
||||
listSyncChannels(session),
|
||||
listSyncBindings(session).catch(() => ({ items: [] as SyncBinding[] })),
|
||||
listAgents(session).catch(() => ({ items: [] as AgentAccount[] })),
|
||||
listApps(session).catch(() => ({ items: [] as { slug: string; name: string; status: string }[] })),
|
||||
]);
|
||||
setItems(ch.items || []);
|
||||
setBindings(bind.items || []);
|
||||
setAgents(ag.items || []);
|
||||
setAppOptions(
|
||||
(apps.items || [])
|
||||
.filter((a) => a.status === "published" || !a.status)
|
||||
.map((a) => ({ value: a.slug, label: `${a.name || a.slug} (${a.slug})` }))
|
||||
);
|
||||
} catch (e: any) {
|
||||
const msg = e.message || String(e);
|
||||
setError(msg);
|
||||
@@ -125,10 +225,12 @@ export function SyncPage(props: {
|
||||
direction: "local_to_remote",
|
||||
conflict_policy: "lww_source",
|
||||
poll_interval_ms: 500,
|
||||
agent_id: undefined,
|
||||
app_slug: undefined,
|
||||
local: { driver: "sqlite", dsn: "file:./data/local.db", tables: "article" },
|
||||
remote: {
|
||||
driver: "mysql",
|
||||
dsn: "user:pass@tcp(127.0.0.1:3306)/app?parseTime=true&charset=utf8mb4",
|
||||
driver: "postgres",
|
||||
dsn: "postgres://user:pass@127.0.0.1:5432/app?sslmode=disable",
|
||||
tables: "article",
|
||||
},
|
||||
pk_columns: "article:id",
|
||||
@@ -143,6 +245,8 @@ export function SyncPage(props: {
|
||||
direction: ch.direction,
|
||||
conflict_policy: ch.conflict_policy,
|
||||
poll_interval_ms: ch.poll_interval_ms,
|
||||
agent_id: ch.agent_id || undefined,
|
||||
app_slug: ch.app_slug || undefined,
|
||||
local: {
|
||||
driver: ch.local.driver,
|
||||
dsn: ch.local.dsn,
|
||||
@@ -160,6 +264,78 @@ export function SyncPage(props: {
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function openInspect(ch: SyncChannel, side: "remote" | "local" = "remote") {
|
||||
setInspectCh(ch);
|
||||
setInspectSide(side);
|
||||
setInspectOpen(true);
|
||||
setInspectData(null);
|
||||
setInspectLoading(true);
|
||||
try {
|
||||
const data = await inspectSyncChannel(session, ch.id, { side, include_sync_meta: true });
|
||||
setInspectData(data);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || String(e));
|
||||
setInspectOpen(false);
|
||||
} finally {
|
||||
setInspectLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPreview(table: string) {
|
||||
if (!inspectCh) return;
|
||||
setPreviewLoading(true);
|
||||
setPreviewOpen(true);
|
||||
setPreviewData(null);
|
||||
try {
|
||||
const data = await previewSyncTable(session, inspectCh.id, {
|
||||
table,
|
||||
side: inspectSide,
|
||||
limit: 50,
|
||||
});
|
||||
setPreviewData(data);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || String(e));
|
||||
setPreviewOpen(false);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDropTable(table: string) {
|
||||
if (!inspectCh) return;
|
||||
const sideLabel = inspectSide === "remote" ? "线上库" : "本机端(通道 local)";
|
||||
Modal.confirm({
|
||||
title: `删除表「${table}」?`,
|
||||
content: (
|
||||
<div>
|
||||
<p>
|
||||
将从<strong>{sideLabel}</strong>删除该表及同步元数据残留。此操作不可恢复。
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
松离线<strong>不会</strong>把删表同步到另一侧。若本机仍有同名表,下次 push/ensure 可能再次出现在线上。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: "确认删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await dropSyncTable(session, inspectCh.id, {
|
||||
table,
|
||||
side: inspectSide,
|
||||
});
|
||||
message.success(res.message || (res.dropped ? "已删除" : "表不存在"));
|
||||
if (previewData?.table === table) setPreviewOpen(false);
|
||||
await openInspect(inspectCh, inspectSide);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || String(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const v = await form.validateFields();
|
||||
setBusy(true);
|
||||
@@ -204,25 +380,39 @@ export function SyncPage(props: {
|
||||
数据同步
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
仅本<strong>公司顶级权限(管理员)</strong>可配置;通道按公司隔离。
|
||||
默认客户<strong>无感</strong>:终端须显式开通 <Typography.Text code>local_dbsync</Typography.Text> 才走松离线。
|
||||
首期推荐方向「本地 → 线上」、策略「源端覆盖」;覆盖审计仅平台超管可见。
|
||||
开通/迁移说明见文档「数据同步-开通说明」「数据同步-迁移手册」。
|
||||
仅本<strong>公司顶级权限(管理员)</strong>可配置通道 DSN / 线上库落点;通道按公司隔离。
|
||||
终端用户以 <Typography.Text code>Binding</Typography.Text> + 登录 JWT 为同步权限;
|
||||
库级选「同步」后该库<strong>全部表</strong>可上云,管理员<strong>不必</strong>按业务表维护通道白名单。
|
||||
通道可绑定<strong>智能体 + 模块</strong>:在「用户管理」为智能体填写同步通道/线上库/落库名后,可按智能体筛通道;
|
||||
模块发布(智能体 Token)优先写入绑定库。本机库管理模块表见宇恒侧(Z8f)。
|
||||
仅线上/首启可用 pull·bootstrap 从线上灌回本机。默认客户<strong>无感</strong>:未开通不走松离线。
|
||||
线上库 A 推荐 <Typography.Text code>postgres</Typography.Text>;多服务器每库一条通道。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Space style={{ marginBottom: 12 }} wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建通道
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void refresh()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按智能体筛选通道"
|
||||
style={{ minWidth: 220 }}
|
||||
value={filterAgentId}
|
||||
onChange={(v) => setFilterAgentId(v)}
|
||||
options={agents.map((a) => ({
|
||||
value: a.agent_id,
|
||||
label: `${a.name}${a.channel_id ? " · 已绑通道" : ""}`,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
dataSource={visibleChannels}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "名称", dataIndex: "name" },
|
||||
@@ -237,15 +427,37 @@ export function SyncPage(props: {
|
||||
},
|
||||
{
|
||||
title: "线上",
|
||||
render: (_: unknown, r: SyncChannel) => (
|
||||
<span>
|
||||
{r.remote.driver}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{(r.remote.dsn || "").replace(/:[^:@/]+@/, ":***@")}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
render: (_: unknown, r: SyncChannel) => {
|
||||
const title = channelOnlineTitle(r, bindings);
|
||||
const ids = channelOnlineIds(r, bindings);
|
||||
const path = formatDsnHint(r.remote?.dsn || "");
|
||||
return (
|
||||
<span>
|
||||
<Typography.Text strong>{title}</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{r.remote?.driver}
|
||||
{ids ? ` · ${ids}` : ""}
|
||||
{path ? ` · ${path}` : ""}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "智能体/模块",
|
||||
render: (_: unknown, r: SyncChannel) => {
|
||||
const ag = agents.find((a) => a.agent_id === r.agent_id);
|
||||
return (
|
||||
<span>
|
||||
{ag ? ag.name : r.agent_id ? `#${r.agent_id}` : "—"}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{r.app_slug || "未绑模块"}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
@@ -253,14 +465,25 @@ export function SyncPage(props: {
|
||||
r.enabled ? <Tag color="green">运行中</Tag> : <Tag>已停止</Tag>,
|
||||
},
|
||||
{
|
||||
title: "统计",
|
||||
render: (_: unknown, r: SyncChannel) =>
|
||||
`↑${r.stats?.pushed_ok || 0} ↓${r.stats?.pulled_ok || 0}`,
|
||||
title: "推送次数",
|
||||
render: (_: unknown, r: SyncChannel) => (
|
||||
<span>
|
||||
↑{r.stats?.pushed_ok || 0}
|
||||
{r.stats?.pushed_skipped ? `(跳过 ${r.stats.pushed_skipped})` : ""}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
push 成功次数,非表数
|
||||
</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
render: (_: unknown, r: SyncChannel) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<TableOutlined />} onClick={() => void openInspect(r, "remote")}>
|
||||
查看线上表
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openEdit(r)}>
|
||||
配置
|
||||
</Button>
|
||||
@@ -336,10 +559,234 @@ export function SyncPage(props: {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||
库绑定(本地 ↔ 线上)
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
<Typography.Text code>local_database_id</Typography.Text> 与{" "}
|
||||
<Typography.Text code>online_db_id</Typography.Text>{" "}
|
||||
<strong>本就可以不同</strong>(映射关系,不是要求同名)。
|
||||
可读名靠宇恒 ensure 时带 <Typography.Text code>database_name</Typography.Text> /{" "}
|
||||
<Typography.Text code>display_name</Typography.Text>
|
||||
;通道「线上」列优先用本地可读名。默认隐藏联调烟雾 Binding(
|
||||
<Typography.Text code>local_*</Typography.Text> / note=smoke)。
|
||||
</Typography.Paragraph>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Checkbox
|
||||
checked={showSmokeBindings}
|
||||
onChange={(e) => setShowSmokeBindings(e.target.checked)}
|
||||
>
|
||||
显示联调烟雾 Binding
|
||||
{smokeHidden > 0 ? `(已隐藏 ${smokeHidden} 条)` : ""}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={visibleBindings}
|
||||
pagination={false}
|
||||
locale={{ emptyText: "暂无 Binding(客户端选「同步」并登记后出现)" }}
|
||||
columns={[
|
||||
{
|
||||
title: "本地库名",
|
||||
render: (_: unknown, b: SyncBinding) => (
|
||||
<span>
|
||||
<Typography.Text strong>{bindingLocalLabel(b)}</Typography.Text>
|
||||
{b.database_name ? (
|
||||
<>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{b.local_database_id}
|
||||
</Typography.Text>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "线上库名",
|
||||
render: (_: unknown, b: SyncBinding) => (
|
||||
<span>
|
||||
<Typography.Text strong>{bindingOnlineLabel(b)}</Typography.Text>
|
||||
{b.display_name ? (
|
||||
<>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{b.online_db_id}
|
||||
</Typography.Text>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "映射",
|
||||
render: (_: unknown, b: SyncBinding) => (
|
||||
<Typography.Text style={{ fontSize: 13 }}>
|
||||
{bindingLocalLabel(b)} ↔ {bindingOnlineLabel(b)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "通道",
|
||||
dataIndex: "channel_id",
|
||||
render: (id: string) => {
|
||||
if (!id) return "—";
|
||||
const ch = items.find((c) => c.id === id);
|
||||
if (!ch) {
|
||||
return (
|
||||
<Typography.Text type="danger" style={{ fontSize: 12 }}>
|
||||
通道已删除 · {id.slice(0, 8)}…
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
const tables = (ch.remote?.tables || []).join(", ") || "(按 Binding 任意表)";
|
||||
return (
|
||||
<span>
|
||||
{ch.name || id}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
通道表白名单配置:{tables}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: "备注", dataIndex: "note", ellipsis: true },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 16 }}>
|
||||
冲突已改为自动 LWW;覆盖审计仅平台超级管理员可查。「同步修复」有最小间隔限流(默认 5 分钟)。
|
||||
↑ 推送次数 = agent push 成功次数,不是业务表数量。
|
||||
本地有、线上没有的表(如尚未 push 的业务表)属正常,不是 Binding 映射错了。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
inspectCh
|
||||
? `线上库表 · ${channelOnlineTitle(inspectCh, bindings)}${inspectSide === "local" ? "(本机端)" : ""}`
|
||||
: "库表"
|
||||
}
|
||||
open={inspectOpen}
|
||||
onCancel={() => setInspectOpen(false)}
|
||||
width={820}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
loading={inspectLoading}
|
||||
onClick={() => inspectCh && void openInspect(inspectCh, inspectSide === "remote" ? "local" : "remote")}
|
||||
>
|
||||
切换到{inspectSide === "remote" ? "本机端" : "线上"}
|
||||
</Button>
|
||||
<Button
|
||||
loading={inspectLoading}
|
||||
onClick={() => inspectCh && void openInspect(inspectCh, inspectSide)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => setInspectOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{inspectData?.dsn_hint ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
{inspectData.driver} · {inspectData.dsn_hint}
|
||||
{inspectData.message ? ` · ${inspectData.message}` : ""}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="name"
|
||||
loading={inspectLoading}
|
||||
dataSource={(inspectData?.tables || []).filter((t) => !String(t.name || "").startsWith("_ajz_"))}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: "暂无业务表(尚未 push 或连不上库)" }}
|
||||
columns={[
|
||||
{ title: "表名", dataIndex: "name" },
|
||||
{
|
||||
title: "行数",
|
||||
dataIndex: "row_count",
|
||||
width: 90,
|
||||
render: (n: number) => <Typography.Text strong>{n}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "字段数",
|
||||
dataIndex: "column_count",
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: "字段",
|
||||
dataIndex: "columns",
|
||||
ellipsis: true,
|
||||
render: (cols: string[]) => (cols || []).join(", "),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 168,
|
||||
render: (_: unknown, t: { name: string; row_count: number }) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => void openPreview(t.name)}>
|
||||
预览
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => confirmDropTable(t.name)}
|
||||
>
|
||||
删表
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
{(inspectData?.tables || []).some((t) => String(t.name || "").startsWith("_ajz_")) ? (
|
||||
<>
|
||||
已隐藏同步系统表 <Typography.Text code>_ajz_*</Typography.Text>
|
||||
(版本/outbox,不是业务模块表)。
|
||||
</>
|
||||
) : null}
|
||||
本机有、这里没有的表 = 尚未 push/ensure。「删表」只删当前查看侧,不会同步到另一侧。
|
||||
</Typography.Paragraph>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={previewData ? `预览 · ${previewData.table}(共 ${previewData.total} 行,显示前 ${previewData.limit})` : "预览"}
|
||||
open={previewOpen}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
width={960}
|
||||
footer={
|
||||
<Button type="primary" onClick={() => setPreviewOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
loading={previewLoading}
|
||||
dataSource={(previewData?.rows || []).map((row, i) => ({ ...row, __k: i }))}
|
||||
rowKey="__k"
|
||||
size="small"
|
||||
scroll={{ x: true }}
|
||||
pagination={false}
|
||||
columns={(previewData?.columns || []).map((c) => ({
|
||||
title: c,
|
||||
dataIndex: c,
|
||||
ellipsis: true,
|
||||
render: (v: unknown) =>
|
||||
v === null || v === undefined ? (
|
||||
<Typography.Text type="secondary">null</Typography.Text>
|
||||
) : (
|
||||
String(v)
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editing ? "编辑同步通道" : "新建同步通道"}
|
||||
open={open}
|
||||
@@ -361,6 +808,27 @@ export function SyncPage(props: {
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="agent_id"
|
||||
label="所属智能体"
|
||||
extra="哪个智能体的库就选哪个;模块数据应落在该智能体对应线上库"
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="可选"
|
||||
options={agents.map((a) => ({
|
||||
value: a.agent_id,
|
||||
label: `${a.name} (#${a.agent_id})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="app_slug"
|
||||
label="关联模块"
|
||||
extra="已发布模块;本地上传/智能体管理的模块数据与此对照"
|
||||
>
|
||||
<Select allowClear showSearch placeholder="可选" options={appOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="direction" label="方向" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
@@ -403,20 +871,31 @@ export function SyncPage(props: {
|
||||
name={["local", "tables"]}
|
||||
label="表(逗号分隔)"
|
||||
rules={[{ required: true }]}
|
||||
extra="同步表白名单表须为 UUID TEXT 主键(不可自增);外键闭包表须一并列入"
|
||||
extra="通道表名单仅作提示;用户 JWT + Binding 下任意表可 push"
|
||||
>
|
||||
<Input placeholder="article,order" />
|
||||
</Form.Item>
|
||||
|
||||
<Typography.Text strong>线上库(可随时改地址)</Typography.Text>
|
||||
<Form.Item name={["remote", "driver"]} label="驱动" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: "mysql" }, { value: "postgres" }, { value: "sqlite" }]} />
|
||||
<Typography.Text strong>线上库 A(生产统一 Postgres)</Typography.Text>
|
||||
<Form.Item
|
||||
name={["remote", "driver"]}
|
||||
label="驱动"
|
||||
rules={[{ required: true }]}
|
||||
extra="生产推荐 postgres;mysql 兼容存量;sqlite 仅联调临时 A,不宜多 writer"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "postgres", label: "postgres(推荐)" },
|
||||
{ value: "mysql", label: "mysql" },
|
||||
{ value: "sqlite", label: "sqlite(仅联调)" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["remote", "dsn"]}
|
||||
label="线上 DSN"
|
||||
rules={[{ required: true }]}
|
||||
extra="MySQL 例:user:pass@tcp(host:3306)/dbname?parseTime=true"
|
||||
extra="Postgres 例:postgres://user:pass@127.0.0.1:5432/dbname?sslmode=disable"
|
||||
>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
@@ -424,7 +903,7 @@ export function SyncPage(props: {
|
||||
name={["remote", "tables"]}
|
||||
label="表(逗号分隔)"
|
||||
rules={[{ required: true }]}
|
||||
extra="与本地表白名单一致;须满足 UUID TEXT PK + FK 闭包"
|
||||
extra="与本地提示表一致即可;验同步请用「查看线上表」"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
134
web/src/api.ts
134
web/src/api.ts
@@ -537,6 +537,12 @@ export type AgentAccount = {
|
||||
status: string;
|
||||
permissions: string[];
|
||||
app_slugs: string[];
|
||||
/** Z8b:绑定同步通道 */
|
||||
channel_id?: string;
|
||||
/** Z8b:绑定线上库 id */
|
||||
online_db_id?: string;
|
||||
/** Z8b:模块落库名(database_per_app) */
|
||||
database_name?: string;
|
||||
created_by: number;
|
||||
created_at: string;
|
||||
last_token_at?: string;
|
||||
@@ -563,7 +569,15 @@ export async function listAgents(session: Session) {
|
||||
|
||||
export async function createAgent(
|
||||
session: Session,
|
||||
body: { name: string; role_id: number; app_slugs: string[]; permissions?: string[] }
|
||||
body: {
|
||||
name: string;
|
||||
role_id: number;
|
||||
app_slugs: string[];
|
||||
permissions?: string[];
|
||||
channel_id?: string;
|
||||
online_db_id?: string;
|
||||
database_name?: string;
|
||||
}
|
||||
) {
|
||||
const res = await apiFetch(`/api/v1/admin/agents`, {
|
||||
method: "POST",
|
||||
@@ -587,6 +601,9 @@ export async function updateAgent(
|
||||
role_id?: number;
|
||||
permissions?: string[];
|
||||
app_slugs?: string[];
|
||||
channel_id?: string;
|
||||
online_db_id?: string;
|
||||
database_name?: string;
|
||||
}
|
||||
) {
|
||||
const res = await apiFetch(`/api/v1/admin/agents/${id}`, {
|
||||
@@ -831,10 +848,16 @@ export type SyncChannel = {
|
||||
local: SyncEndpoint;
|
||||
remote: SyncEndpoint;
|
||||
pk_columns: Record<string, string>;
|
||||
/** 绑定智能体:模块/库归属对照 */
|
||||
agent_id?: number;
|
||||
/** 绑定已发布模块 slug */
|
||||
app_slug?: string;
|
||||
last_error?: string;
|
||||
last_sync_at?: string;
|
||||
stats?: {
|
||||
pushed_ok: number;
|
||||
pushed_applied?: number;
|
||||
pushed_skipped?: number;
|
||||
pulled_ok: number;
|
||||
conflicts: number;
|
||||
retries: number;
|
||||
@@ -842,6 +865,32 @@ export type SyncChannel = {
|
||||
};
|
||||
};
|
||||
|
||||
export type SyncTableInspect = {
|
||||
name: string;
|
||||
row_count: number;
|
||||
columns: string[];
|
||||
column_count: number;
|
||||
};
|
||||
|
||||
export type SyncInspectResult = {
|
||||
ok: boolean;
|
||||
side: string;
|
||||
driver: string;
|
||||
dsn_hint?: string;
|
||||
tables: SyncTableInspect[];
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type SyncPreviewResult = {
|
||||
ok: boolean;
|
||||
table: string;
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
total: number;
|
||||
limit: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type SyncConflict = {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
@@ -1002,12 +1051,77 @@ export async function ingestSyncRows(
|
||||
return data as { ok: boolean; ingested: number; hint?: string };
|
||||
}
|
||||
|
||||
/** 控制台验同步:列出通道线上/本机库的表、行数、字段 */
|
||||
export async function inspectSyncChannel(
|
||||
session: Session,
|
||||
id: string,
|
||||
opts?: { side?: "remote" | "local"; include_sync_meta?: boolean }
|
||||
) {
|
||||
const side = opts?.side || "remote";
|
||||
const q = new URLSearchParams({ side });
|
||||
if (opts?.include_sync_meta) q.set("include_sync_meta", "1");
|
||||
const res = await apiFetch(
|
||||
`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/inspect?${q}`,
|
||||
{ headers: { Authorization: `Bearer ${session.accessToken}` } }
|
||||
);
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "inspect failed");
|
||||
return data as SyncInspectResult;
|
||||
}
|
||||
|
||||
/** 预览单表内容 */
|
||||
export async function previewSyncTable(
|
||||
session: Session,
|
||||
id: string,
|
||||
body: { table: string; side?: "remote" | "local"; limit?: number }
|
||||
) {
|
||||
const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/preview`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "preview failed");
|
||||
return data as SyncPreviewResult;
|
||||
}
|
||||
|
||||
/** 控制台删业务表(仅当前 side;不同步 DDL 到另一侧) */
|
||||
export async function dropSyncTable(
|
||||
session: Session,
|
||||
id: string,
|
||||
body: { table: string; side?: "remote" | "local" }
|
||||
) {
|
||||
const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/drop-table`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "drop table failed");
|
||||
return data as {
|
||||
ok: boolean;
|
||||
side: string;
|
||||
table: string;
|
||||
dropped: boolean;
|
||||
meta_cleared?: number;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureSyncBinding(
|
||||
session: Session,
|
||||
body: {
|
||||
local_database_id: string;
|
||||
online_db_id: string;
|
||||
channel_id?: string;
|
||||
database_name?: string;
|
||||
display_name?: string;
|
||||
note?: string;
|
||||
user_id?: number;
|
||||
}
|
||||
@@ -1022,9 +1136,23 @@ export async function ensureSyncBinding(
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "ensure binding failed");
|
||||
return data;
|
||||
return data as SyncBinding;
|
||||
}
|
||||
|
||||
export type SyncBinding = {
|
||||
id: string;
|
||||
tenant_id: number;
|
||||
user_id?: number;
|
||||
local_database_id: string;
|
||||
online_db_id: string;
|
||||
channel_id?: string;
|
||||
database_name?: string;
|
||||
display_name?: string;
|
||||
note?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export async function listSyncBindings(session: Session, localDatabaseId?: string) {
|
||||
const q = localDatabaseId
|
||||
? `?local_database_id=${encodeURIComponent(localDatabaseId)}`
|
||||
@@ -1034,7 +1162,7 @@ export async function listSyncBindings(session: Session, localDatabaseId?: strin
|
||||
});
|
||||
const data = await readJson(res);
|
||||
throwIfBad(res, data, "list bindings failed");
|
||||
return data as { items: Array<Record<string, unknown>> };
|
||||
return data as { items: SyncBinding[] };
|
||||
}
|
||||
|
||||
export async function listPlatformLwwOverrides(
|
||||
|
||||
Reference in New Issue
Block a user