Files
LinkPort/web/src/react/ConnectionEditor.tsx
T

733 lines
39 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 { Alert, Button, Modal, Tabs } from "antd";
import { ValidationContext } from "./FormValidation";
import { Diagnostics } from "./resources/Diagnostics";
import { DetailPage } from "./DetailPage";
import { useEffect, useRef, useState } from "react";
import { createConnectionDraft, editConnectionDraft, connectionSavePayload, } from "../domain/connectionDraft";
import { validateTarget, validateGateway, translateAPIError, type FieldIssue, } from "../domain/formValidation";
import { connectionProtocolTypes, connectionProtocolLabel, } from "../domain/connectionProtocols";
import { parseConnectionURL, parseConnectionParameters, connectionURLExamples, } from "../domain/connectionURL";
import { clusterModes, clusterLabels, clusterDetails } from "../domain/cluster";
import { supportsNoAuth } from "../domain/targetAuth";
import { accessModeOptions } from "../domain/accessMode";
import { gatewayTestChoices } from "../domain/gatewayTest";
import { sanitizeConnectionDiagnostics, diagnosticCode, connectionFailureMessage, } from "../domain/connectionFailure";
import { Field, Choice, Toggle, Lines } from "./connection/Fields";
type Props = {
kind: "targets" | "proxies";
resource?: any;
targets: any[];
outbounds: any[];
assets: any[];
scope: {
project: string;
environment: string;
};
api: (path: string, method?: string, body?: unknown) => Promise<any>;
onSaved: () => void;
onClose: () => void;
};
export default function ConnectionEditor(p: Props) {
const target = p.kind === "targets";
const initial = () => p.resource
? editConnectionDraft(p.resource.config, target, createConnectionDraft(p.resource.config.type, p.scope), p.resource.secretStatus)
: createConnectionDraft("mysql", p.scope);
const [draft, setDraft] = useState<any>(initial), [tab, setTab] = useState(target ? "target" : "gateway"), [busy, setBusy] = useState(false), [issues, setIssues] = useState<FieldIssue[]>([]), [uri, setURI] = useState(""), [parameters, setParameters] = useState(""), [accountIndex, setAccountIndex] = useState(0), [testAccount, setTestAccount] = useState(""), [result, setResult] = useState<any>(), [batch, setBatch] = useState(1), [savedID, setSavedID] = useState(p.resource?.config.id || "");
const skipIssueFocus = useRef(false);
const issuePanel = useRef<HTMLDivElement>(null);
const fieldPath = (issue: FieldIssue) => issue.path.replace(/^(accounts|gatewayAccounts)\.([^.]+)\./, (_match, collection, id) => {
const index = (draft[collection] || []).findIndex((account: any) => account.id === id);
return `${collection}.${index}.`;
});
const showIssue = (issue: FieldIssue) => {
setTab(["color", "remark", "showRemark"].includes(issue.path) ? "identity" : issue.tab);
const match = /^(accounts|gatewayAccounts)\.([^.]+)\./.exec(issue.path);
if (match) {
const index = (draft[match[1]] || []).findIndex((account: any) => account.id === match[2]);
if (index >= 0) setAccountIndex(index);
}
requestAnimationFrame(() => {
const root = issuePanel.current?.closest("section");
const element = Array.from(root?.querySelectorAll<HTMLElement>("[data-validation-path]") || []).find(element => element.dataset.validationPath === fieldPath(issue));
element?.scrollIntoView({ block: "center" });
element?.querySelector<HTMLElement>("input,textarea,button")?.focus({ preventScroll: true });
});
};
useEffect(() => {
if (skipIssueFocus.current) { skipIssueFocus.current = false; return; }
if (issues.length) {
issuePanel.current?.scrollIntoView({ block: "center" });
if (issues[0].path) showIssue(issues[0]);
else issuePanel.current?.focus({ preventScroll: true });
}
}, [issues]);
const resultPanel = useRef<HTMLDivElement>(null);
useEffect(() => {
if (result) {
resultPanel.current?.scrollIntoView({ block: "center" });
resultPanel.current?.focus({ preventScroll: true });
}
}, [result]);
const savedSecrets = useRef(p.resource?.secretStatus || {});
const baseline = useRef(JSON.stringify(draft));
const [confirm, setConfirm] = useState<{
title: string;
message: string;
done: (yes: boolean) => void;
}>();
const dirty = JSON.stringify(draft) !== baseline.current || !!uri || !!parameters;
useEffect(() => {
const guard = (e: BeforeUnloadEvent) => {
if (dirty) {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", guard);
return () => window.removeEventListener("beforeunload", guard);
}, [dirty]);
const ask = (title: string, message: string) => new Promise<boolean>((resolve) => setConfirm({ title, message, done: resolve }));
const close = async () => {
if (!busy &&
(!dirty || (await ask("放弃未保存的配置?", "当前草稿的修改将丢失。"))))
p.onClose();
};
const mutate = (fn: (d: any) => void) => setDraft((old: any) => {
const d = structuredClone(old);
fn(d);
return d;
});
const set = (path: string, value: any) => mutate((d) => {
const keys = path.split(".");
let obj = d;
for (const k of keys.slice(0, -1))
obj = obj[k] ||= {};
obj[keys.at(-1)!] = value;
});
const get = (path: string) => path.split(".").reduce((o, k) => o?.[k], draft);
const field = (label: string, path: string, type = "text", disabled = false) => (<Field required={["name", "targetPort", "listenHost", "listenPort", "maxConnections"].includes(path) ||
path === "targetHost" && !((p.outbounds.find(row => row.config.id === draft.outboundId)?.config.outbound?.mode === "k8s" || draft.outbound?.mode === "k8s") && !draft.targetTLS && draft.type !== "https") ||
/^(accounts|gatewayAccounts)\.\d+\.id$/.test(path) ||
/^(accounts|gatewayAccounts)\.\d+\.username$/.test(path) && draft.type !== "redis"} label={label} path={path} value={get(path)} type={type} disabled={disabled} onChange={(v) => set(path, v)}/>);
const choice = (label: string, path: string, options: {
value: string;
label: string;
}[]) => (<Choice label={label} path={path} value={get(path)} onChange={(v) => set(path, v)} options={options}/>);
const assetOptions = (type: string) => [
{ value: "", label: "不使用资产 / 保留原配置" },
...p.assets
.filter((a) => (a.type || a.config?.type) === type)
.map((a) => ({
value: a.id || a.config.id,
label: a.name || a.config.name,
})),
];
const apply = () => {
let d = structuredClone(draft);
if (uri.trim()) {
const { credential, ...values } = parseConnectionURL(uri, d.type);
Object.assign(d, {
...values,
connectionOptions: {
...d.connectionOptions,
...values.connectionOptions,
},
});
if (credential) {
let a = d.accounts[accountIndex];
if (!a) {
a = { id: crypto.randomUUID(), remark: "" };
d.accounts.push(a);
}
Object.assign(a, credential, {
authMethod: "password",
clearPassword: false,
});
if (["ssh", "sftp"].includes(d.type))
Object.assign(a, {
privateKey: "",
privateKeyAssetId: "",
certificateAssetId: "",
certificate: "",
passphrase: "",
});
}
}
if (parameters.trim()) {
const values = parseConnectionParameters(parameters, d);
Object.assign(d, values);
}
setDraft(d);
setURI("");
setParameters("");
return d;
};
const validate = (d: any) => {
const found = (target ? validateTarget : validateGateway)(d, {
assets: p.assets,
outbounds: p.outbounds,
targets: p.targets,
secretStatus: savedSecrets.current,
editing: !!savedID,
});
setIssues(found);
if (found.length) {
setTab(found[0].tab);
return false;
}
return true;
};
async function save() {
if (busy)
return;
setIssues([]);
setBusy(true);
try {
const d = apply();
if (!validate(d))
return;
const payload = connectionSavePayload(d, {
target,
editing: !!savedID,
});
let response: any;
if (!target && !savedID && batch > 1) {
if (payload.managementListenPort) throw Error("启用 RabbitMQ 管理端口时请逐个创建网关,分别配置两个监听端口");
if (payload.listenPort + batch - 1 > 65535)
throw Error("批量监听端口超出范围");
const outcomes = [];
for (let i = 0; i < batch; i++) {
try {
await p.api("proxies", "POST", {
...payload,
name: `${payload.name} ${i + 1}`,
listenPort: payload.listenPort + i,
});
outcomes.push(`${i + 1}: 已创建`);
}
catch (e) {
outcomes.push(`${i + 1}: ${translateAPIError(e)}`);
}
}
setResult({ 批量创建: outcomes });
baseline.current = JSON.stringify(d);
p.onSaved();
return;
}
const path = savedID ? `${p.kind}/${savedID}` : p.kind;
try {
response = await p.api(path, savedID ? "PUT" : "POST", payload);
}
catch (e) {
const error = e as any;
if (!target ||
!savedID ||
error.stage !== "target_host_confirmation" ||
!error.fingerprint)
throw e;
if (!(await ask("确认候选 SSH 主机指纹", `目标 ${d.targetHost}:${d.targetPort}\nSSH 主机指纹:${error.fingerprint}\n请通过可信渠道核对。新连接使用新配置,已有连接不受影响。`)))
return;
response = await p.api(path, "PUT", {
...payload,
hostFingerprint: error.fingerprint,
});
}
if (response?.config) {
savedSecrets.current = response.secretStatus || {};
const next = editConnectionDraft(response.config, target, createConnectionDraft(response.config.type, p.scope), response.secretStatus);
setDraft(next);
baseline.current = JSON.stringify(next);
setSavedID(response.config.id);
}
else
baseline.current = JSON.stringify(d);
setResult({ 状态: "配置已保存" });
p.onSaved();
if (!target)
p.onClose();
}
catch (e) {
setIssues([
{ path: "", label: "操作失败", message: translateAPIError(e), tab },
]);
}
finally {
setBusy(false);
}
}
async function run(operation: string) {
if (busy)
return;
setBusy(true);
setResult(undefined);
const started = performance.now();
try {
let value: any;
if (operation === "listen")
value = await p.api("listen-preflight", "POST", {
listenHost: draft.listenHost,
listenPort: draft.listenPort,
allowCIDRs: draft.allowCIDRs || [],
});
else if (target) {
const d = operation === "test" ? apply() : draft;
const id = testAccount || (d.accounts.length === 1 ? d.accounts[0].id : "");
if (d.accounts.length && (!id || !d.accounts.some((account: any) => account.id === id)))
throw Error("请选择有效的测试账号");
if (operation === "cluster") {
if (dirty || !savedID) throw Error("识别集群前请先保存配置;连接测试可直接使用当前草稿");
value = Object.fromEntries(clusterDetails(await p.api(`targets/${savedID}/detect-cluster${id ? "?accountId=" + encodeURIComponent(id) : ""}`, "POST", {})));
} else {
const selectedDraft = { ...d, name: d.name?.trim() || "连接测试", accounts: d.accounts.filter((account: any) => account.id === id || account.id === d.cluster?.sentinelAccountId) };
if (!validate(selectedDraft)) return;
const payload = { targetId: savedID || undefined, accountId: id, config: connectionSavePayload(d, { target: true, editing: !!savedID }) };
try {
value = await p.api("targets/test", "POST", payload);
} catch (error) {
const failure = error as any;
if (failure.stage !== "draft_host_confirmation" || !failure.fingerprint) throw error;
if (!(await ask("确认测试服务器指纹", `服务器 ${d.targetHost}:${d.targetPort}\nSSH 主机指纹:${failure.fingerprint}\n确认后仅测试当前草稿,不保存配置。`))) return;
value = await p.api("targets/test", "POST", { ...payload, hostFingerprint: failure.fingerprint });
}
}
}
else {
const d = apply();
const choices = gatewayTestChoices(d, p.targets);
const available = choices.filter(choice => choice.available);
const selected = testAccount ? available.find(choice => choice.gatewayAccountId === testAccount) : available.length === 1 ? available[0] : undefined;
if (!selected)
throw Error("请选择有效的连接测试账号,并检查该账号关联的目标");
const config = connectionSavePayload(d, {
target: false,
editing: !!savedID,
});
if (selected.gatewayAccountId)
config.gatewayAccounts = config.gatewayAccounts.filter((a: any) => a.id === selected.gatewayAccountId);
if (!validate(config))
return;
value = await p.api("proxies/test", "POST", {
proxyId: savedID || undefined,
gatewayAccountId: selected.gatewayAccountId,
config,
});
}
setResult({
...value,
success: operation === "test" ? true : undefined,
耗时: `${Math.round(performance.now() - started)} ms`,
diagnostics: sanitizeConnectionDiagnostics(value?.diagnostics),
});
}
catch (e) {
const err = e as any;
setResult({
success: operation === "test" ? false : undefined,
stage: err.stage,
message: err.stage ? undefined : translateAPIError(e),
status: err.status,
requestPath: err.requestPath,
错误: err.stage
? connectionFailureMessage(err.stage)
: translateAPIError(e),
阶段: diagnosticCode(err.stage),
diagnostics: sanitizeConnectionDiagnostics(err.diagnostics),
});
}
finally {
setBusy(false);
}
}
const accounts = target ? draft.accounts : draft.gatewayAccounts;
const current = accounts[accountIndex];
const accountPath = (key: string) => `${target ? "accounts" : "gatewayAccounts"}.${accountIndex}.${key}`;
const accountField = (label: string, key: string, type = "text") => field(label, accountPath(key), type);
const targetOptions = [
{ value: "", label: "请选择目标服务" },
...p.targets
.filter((t) => t.config.type === draft.type)
.map((t) => ({ value: t.config.id, label: t.config.name })),
];
function mapping(prefix: string) {
const id = get(`${prefix}targetId`);
const list = p.targets.find((t) => t.config.id === id)?.config.accounts || [];
return (<>
{<Choice path={`${prefix}targetId`} label="目标服务" value={id} options={targetOptions} onChange={(v) => mutate((d) => {
const obj = prefix ? d.gatewayAccounts[accountIndex] : d;
obj.targetId = v;
const next = p.targets.find((t) => t.config.id === v)?.config.accounts ||
[];
obj.targetAccountId = next.length === 1 ? next[0].id : "";
})}/>}
{choice("目标账号", `${prefix}targetAccountId`, [
{ value: "", label: list.length ? "请选择目标账号" : "无认证" },
...list.map((a: any) => ({
value: a.id,
label: a.remark || a.username || a.id,
})),
])}
</>);
}
const panels = target
? [
["target", "连接"],
["accounts", "账号"],
["outbound", "出口"],
["advanced", "高级"],
["identity", "外观"],
]
: [
["gateway", "监听与目标"],
["accounts", "账号"],
["identity", "外观"],
];
return (<ValidationContext.Provider value={{ issues: issues.map(issue => ({ ...issue, path: fieldPath(issue) })), clear: path => { skipIssueFocus.current = true; setIssues(current => current.filter(issue => fieldPath(issue) !== path)); } }}>
<DetailPage open feedback={issues.length > 0 && <div ref={issuePanel} tabIndex={-1}><Alert type="error" showIcon role="alert" title="请检查以下问题" description={<div className="lp-stack" style={{ maxHeight: "30vh", overflowY: "auto" }}>{issues.map((issue, index) => issue.path ? <Button key={index} type="link" style={{ whiteSpace: "normal", height: "auto", textAlign: "left" }} onClick={() => showIssue(issue)}>{issue.label}:{issue.message}</Button> : <span key={index}>{issue.message}</span>)}</div>} /></div>} dirty={dirty} busy={busy} onClose={() => void close()} title={`${savedID ? "编辑" : "新建"}${target ? "目标服务" : "网关"}`} footer={<div className="lp-actions">
<Button onClick={() => void close()} disabled={busy} variant="filled" color={"default"}>
取消
</Button>
<Choice label="连接测试账号" value={testAccount} onChange={setTestAccount} options={[
{ value: "", label: "选择测试账号" },
...(target ? draft.accounts.map((a: any) => ({
value: a.id,
label: a.remark || a.username || a.id,
})) : gatewayTestChoices(draft, p.targets).filter(c => c.available).map(c => ({
value: c.gatewayAccountId,
label: c.label,
}))),
]}/>
<Button onClick={() => void run("test")} disabled={busy} variant="filled" color={"default"}>
测试连接
</Button>
<Button onClick={() => void save()} disabled={busy} variant="filled" color={"primary"}>
{busy ? "处理中…" : "保存配置"}
</Button>
</div>}><div className="lp-stack">
<div className="editor-grid">
{field(target ? "目标名称" : "网关名称", "name")}
{field("所属项目", "project")}
{field("环境", "environment")}
<Choice label="协议类型" disabled={!!savedID} help={savedID ? "已保存连接不能更改协议;如需使用其他协议,请新建连接。" : undefined} value={draft.type} options={connectionProtocolTypes.map(value => ({
value,
label: connectionProtocolLabel(value),
}))} onChange={v => {
if (savedID)
return;
const next = createConnectionDraft(v, p.scope);
for (const k of [
"name",
"group",
"project",
"environment",
"outboundId",
"outbound",
"color",
"remark",
"showRemark",
])
(next as any)[k] = draft[k];
setDraft(next);
setAccountIndex(0);
setURI("");
setParameters("");
}}/>
</div>
<>
<Tabs aria-label="连接配置分组" activeKey={String(tab)} onChange={value => setTab(value)} items={panels.map(([id, label]) => (({ key: String(id), label: label, disabled: false, id: `connection-tab-${id}` })))}/>
<div role="tabpanel" id={`connection-panel-${target ? "target" : "gateway"}`} aria-labelledby={`connection-tab-${target ? "target" : "gateway"}`} hidden={tab !== (target ? "target" : "gateway")}>
<div className="editor-grid">
{target ? (<>
{draft.type !== "tcp" && (<>
<Field label={draft.type === "mongodb" ? "MongoDB 连接串" : "连接 URL"} value={uri} type="password" onChange={setURI}/>
<Button onClick={() => {
try {
apply();
}
catch (e) {
setIssues([
{
path: "targetHost",
label: "URL",
message: translateAPIError(e),
tab: "target",
},
]);
}
}} variant="filled" color={"default"}>
解析并填入配置
</Button>
<small>{connectionURLExamples[draft.type]}</small>
{!["ssh", "sftp", "http", "https"].includes(draft.type) && (<Field label="连接参数" value={parameters} onChange={setParameters}/>)}
</>)}
{field("目标主机", "targetHost")}
{field("目标端口", "targetPort", "number")}
{draft.type === "rabbitmq" && <>
{field("管理目标端口(0 使用 15672)", "managementPort", "number")}
<Toggle label="管理接口启用 HTTPS" value={draft.managementTLS} onChange={v => set("managementTLS", v)}/>
</>}
{field("分组", "group")}
{["mysql", "redis"].includes(draft.type) && field(draft.type === "redis" ? "数据库编号" : "默认数据库", "database")}
{["mongodb", "pgsql", "sqlserver", "rabbitmq"].includes(draft.type) && field(draft.type === "rabbitmq" ? "Virtual Host" : "认证数据库", "authSource")}
{!["ssh", "sftp", "tcp"].includes(draft.type) && (<>
<Toggle label="启用 TLS" value={draft.targetTLS} onChange={v => set("targetTLS", v)}/>
{(draft.targetTLS || draft.type === "rabbitmq" && draft.managementTLS) && (<>
{choice("CA 证书资产", "targetCAAssetId", assetOptions("tls_certificate"))}
<Lines label="自定义 CA 证书(PEM)" value={draft.targetCA || ""} onChange={v => set("targetCA", v)}/>
</>)}
</>)}
{draft.type === "mongodb" && choice("压缩方式", "targetCompression", [
{ value: "none", label: "无压缩" },
{ value: "zlib", label: "zlib" },
])}
{["ssh", "sftp"].includes(draft.type) && (<Toggle label="忽略 SSH 主机密钥校验" value={draft.ssh.ignoreHostKey} onChange={async (v) => {
if (!v || (await ask("关闭 SSH 主机校验?", "关闭校验后无法验证远端主机身份。")))
set("ssh.ignoreHostKey", v);
}}/>)}
{clusterModes[draft.type] && (<section className="editor-section">
{choice("部署模式", "cluster.mode", [
"single",
...clusterModes[draft.type],
"failover",
].map(value => ({
value,
label: clusterLabels[value],
})))}
{draft.cluster?.mode && draft.cluster.mode !== "single" && (<>
{field("集群 / 主节点名称", "cluster.name")}
{(draft.cluster.nodes || []).map((_: any, i: number) => (<div className="editor-grid" key={i}>
{field(`集群节点 ${i + 2} 主机`, `cluster.nodes.${i}.host`)}
{field(`集群节点 ${i + 2} 端口`, `cluster.nodes.${i}.port`, "number")}
<Button onClick={() => mutate(d => d.cluster.nodes.splice(i, 1))} variant="filled" color={"default"}>
删除节点
</Button>
</div>))}
<Button onClick={() => mutate(d => {
d.cluster.nodes ||= [];
d.cluster.nodes.push({
host: "",
port: d.targetPort,
});
})} disabled={(draft.cluster.nodes?.length || 0) >= 31} variant="filled" color={"default"}>
添加节点
</Button>
{draft.type === "redis" && draft.cluster?.mode === "redis-sentinel" && choice("Sentinel 认证账号", "cluster.sentinelAccountId", [
{
value: "",
label: "自动尝试目标账号或无认证",
},
...draft.accounts.map((a: any) => ({
value: a.id,
label: a.remark || a.username || a.id,
})),
])}
</>)}
<Button disabled={busy || dirty || !savedID} onClick={() => void run("cluster")} variant="filled" color={"default"}>
识别集群
</Button>
</section>)}
</>) : (<>
{field("监听地址", "listenHost")}
{field("监听端口", "listenPort", "number")}
{draft.type === "rabbitmq" && <>
{field("管理监听端口(0 关闭)", "managementListenPort", "number")}
<small>AMQP 与 HTTP 管理入口共用账号和只读权限;修改管理监听设置前请停止网关。</small>
</>}
{field("最大连接数", "maxConnections", "number")}
<Field label="网关来源白名单" value={(draft.allowCIDRs || []).join(", ")} onChange={v => set("allowCIDRs", v.split(/[\s,,]+/).filter(Boolean))}/>
{!draft.gatewayAccounts.length && draft.type === "mongodb" && choice("免认证访问模式", "accessMode", accessModeOptions(draft.type))}
{!savedID && (<Field label="批量创建数量" value={batch} type="number" onChange={v => setBatch(Math.max(1, Math.min(100, v || 1)))}/>)}
<Button onClick={() => void run("listen")} disabled={busy} variant="filled" color={"default"}>
检查监听端口
</Button>
{draft.type === "tcp" && (<Toggle label="纯端口转发" value={!!draft.tcpForward} onChange={async (v) => {
if (!(await ask("切换 TCP 转发模式?", "切换模式会清除目标映射及网关账号配置。")))
return;
mutate(d => {
d.targetId = "";
d.targetAccountId = "";
d.gatewayAccounts = [];
d.tcpForward = v ? {
mode: "proxy",
outboundId: "",
host: "",
port: 80,
} : null;
});
}}/>)}
{draft.tcpForward ? (<>
{choice("转发方式", "tcpForward.mode", [
{ value: "proxy", label: "共享代理" },
{ value: "k8s", label: "Kubernetes 资产" },
])}
{draft.tcpForward.mode === "k8s" ? (<>
{choice("Kubeconfig 资产", "tcpForward.kubeConfigAssetId", assetOptions("kubeconfig"))}
{field("Context", "tcpForward.kubeContext")}
{field("命名空间", "tcpForward.namespace")}
{field("资源(pod/service/deployment/statefulset)", "tcpForward.resource")}
</>) : (<>
{choice("代理", "tcpForward.outboundId", [
{ value: "", label: "请选择代理" },
...p.outbounds.map(o => ({
value: o.config.id,
label: o.config.name,
})),
])}
{field("目的主机", "tcpForward.host")}
</>)}
{field("目的 / 资源端口", "tcpForward.port", "number")}
</>) : !draft.gatewayAccounts.length ? (mapping("")) : (<p>每个网关账号在“账号”页独立配置目标映射。</p>)}
</>)}
</div>
</div>
<div role="tabpanel" id="connection-panel-accounts" aria-labelledby="connection-tab-accounts" hidden={tab !== "accounts"}>
<div className="lp-account-toolbar">
<Choice label={target ? "编辑目标账号" : "编辑网关账号"} value={String(accountIndex)} onChange={v => setAccountIndex(Number(v))} options={accounts.map((a: any, i: number) => ({
value: String(i),
label: `${i + 1}. ${a.remark || a.username || a.id}`,
}))}/>
<Button disabled={accounts.length >= 32 || !!draft.tcpForward} onClick={() => {
mutate(d => (target ? d.accounts : d.gatewayAccounts).push({
id: crypto.randomUUID(),
username: "",
password: "",
...(target ? { remark: "", authMethod: "password", privateKey: "", passphrase: "" } : {
allowCIDRs: [],
authorizedKeys: [],
disabled: false,
accessMode: ["ssh", "sftp", "tcp"].includes(draft.type) ? undefined : "readwrite",
}),
}));
setAccountIndex(accounts.length);
}} variant="filled" color={"default"}>
添加账号
</Button>
<Button disabled={!current} onClick={async () => {
if (await ask("删除账号?", "删除后将在保存配置时移除账号及其凭据。")) {
mutate(d => (target ? d.accounts : d.gatewayAccounts).splice(accountIndex, 1));
setAccountIndex(Math.max(0, accountIndex - 1));
}
}} variant="filled" color={"red"}>
删除账号
</Button>
</div>
<div className="editor-grid">
{current && (<>
{target && accountField("账号备注", "remark")}
{(!target || current.authMethod !== "none") && accountField(target ? "登录用户名" : "用户名", "username")}
{target && choice("认证方式", accountPath("authMethod"), [
{ value: "password", label: "密码" },
...(supportsNoAuth(draft.type) ? [{ value: "none", label: "无认证" }] : []),
...(["ssh", "sftp"].includes(draft.type) ? [{ value: "privatekey", label: "私钥" }] : []),
])}
{(!target || (current.authMethod !== "none" && current.authMethod !== "privatekey")) && (<>
{accountField(target ? "账号密码" : "密码", "password", "password")}
{target && (<Toggle label="清除已保存密码" value={current.clearPassword} onChange={async (v) => {
if (!v || (await ask("清除已保存密码?", "保存后将删除此账号的密码。")))
set(accountPath("clearPassword"), v);
}}/>)}
</>)}
{target && ["ssh", "sftp"].includes(draft.type) && current.authMethod === "privatekey" && (<>
{choice("账号私钥资产", accountPath("privateKeyAssetId"), assetOptions("ssh_private_key"))}
{choice("SSH 用户证书资产", accountPath("certificateAssetId"), assetOptions("ssh_certificate"))}
{accountField("私钥口令(留空保留)", "passphrase", "password")}
<Toggle label="清除已保存口令" value={current.clearPassphrase} onChange={async (v) => {
if (!v || (await ask("清除私钥口令?", "保存后将删除此账号的私钥口令。")))
set(accountPath("clearPassphrase"), v);
}}/>
</>)}
{!target && (<>
{mapping(`gatewayAccounts.${accountIndex}.`)}
<Toggle label="禁用账号" value={current.disabled} onChange={v => set(accountPath("disabled"), v)}/>
{!["ssh", "sftp", "tcp"].includes(draft.type) && choice("账号访问模式", accountPath("accessMode"), accessModeOptions(draft.type))}
{draft.type === "rabbitmq" && current.accessMode === "readonly" && <p>严格只读:仅检查已有队列和交换机,禁止消费、确认、发布及修改配置。</p>}
<Field label="账号来源白名单" value={(current.allowCIDRs || []).join(", ")} onChange={v => set(accountPath("allowCIDRs"), v.split(/[\s,,]+/).filter(Boolean))}/>
{["ssh", "sftp"].includes(draft.type) && choice("客户端公钥资产", accountPath("authorizedKeysAssetId"), assetOptions("ssh_public_key"))}
{["ssh", "sftp"].includes(draft.type) && (<Lines label="授权公钥(每行一条)" value={(current.authorizedKeys || []).join("\n")} onChange={v => set(accountPath("authorizedKeys"), v.split("\n").filter(Boolean))}/>)}
</>)}
{accountField("凭据到期时间(ISO 8601)", "expiresAt")}
{accountField("提前提醒天数", "rotationReminderDays", "number")}
</>)}
</div>
</div>
{target && (<>
<div role="tabpanel" id="connection-panel-outbound" aria-labelledby="connection-tab-outbound" hidden={tab !== "outbound"}>
<div className="editor-grid">
{choice("连接出口", "outboundId", [
{ value: "", label: "直连 / 独立 Kubernetes 配置" },
...p.outbounds.map(o => ({
value: o.config.id,
label: o.config.name,
})),
])}
{!draft.outboundId && (<>
{choice("直连或 Kubernetes", "outbound.mode", [
{ value: "direct", label: "直连" },
{ value: "k8s", label: "Kubernetes" },
])}
{draft.outbound.mode === "k8s" && (<>
{choice("Kubeconfig 资产", "outbound.kubeConfigAssetId", assetOptions("kubeconfig"))}
{field("Context", "outbound.kubeContext")}
{field("命名空间", "outbound.namespace")}
{field("资源(pod/service/deployment/statefulset)", "outbound.resource")}
</>)}
</>)}
</div>
</div>
<div role="tabpanel" id="connection-panel-advanced" aria-labelledby="connection-tab-advanced" hidden={tab !== "advanced"}>
<div className="editor-grid">
<Toggle label="后台持续连接" value={draft.connectionOptions.persistent} onChange={v => set("connectionOptions.persistent", v)}/>
{field("网络连接超时(秒)", "connectionOptions.connectTimeoutSeconds", "number")}
{draft.type === "pgsql" && field("查询超时(秒)", "connectionOptions.queryTimeoutSeconds", "number")}
{draft.type === "mongodb" && field("空闲会话超时(秒)", "connectionOptions.idleTimeoutSeconds", "number")}
{field("TCP 保活间隔(秒,空白使用默认)", "connectionOptions.keepAliveSeconds", "number")}
{draft.type === "mysql" && choice("字符集", "connectionOptions.charset", ["", "utf8mb4", "utf8", "latin1", "ascii"].map(value => ({
value,
label: value || "服务器默认",
})))}
{draft.type === "pgsql" && field("应用名称", "connectionOptions.applicationName")}
</div>
</div>
</>)}
<div role="tabpanel" id="connection-panel-identity" aria-labelledby="connection-tab-identity" hidden={tab !== "identity"}>
<div className="editor-grid">
{field("连接颜色(#RRGGBB)", "color")}
<Lines label="连接备注" value={draft.remark || ""} onChange={v => set("remark", v)}/>
<Toggle label="列表显示备注" value={draft.showRemark} onChange={v => set("showRemark", v)}/>
</div>
</div>
</>
{result && (<div ref={resultPanel} tabIndex={-1} role={result.success === undefined ? "status" : undefined} className="editor-section">
{result.success === undefined ? <pre>{JSON.stringify(result, null, 2)}</pre> : <Diagnostics result={result} />}
<Button onClick={() => void navigator.clipboard.writeText(JSON.stringify({
protocol: draft.type,
stage: result.阶段,
diagnostics: sanitizeConnectionDiagnostics(result.diagnostics),
}, null, 2))} variant="filled" color={"default"}>
复制脱敏诊断
</Button>
</div>)}
</div></DetailPage>
{confirm && (<Modal open aria-labelledby="connection-confirmation-title" onCancel={() => {
confirm.done(false);
setConfirm(undefined);
}} className="lp-dialog" width={1100} maskClosable={false} title={<>
<span id="connection-confirmation-title">{confirm.title}</span>
</>} footer={<div className="lp-actions">
<Button onClick={() => {
confirm.done(false);
setConfirm(undefined);
}} variant="filled" color={"default"}>
取消
</Button>
<Button onClick={() => {
confirm.done(true);
setConfirm(undefined);
}} variant="filled" color={"red"}>
确认
</Button>
</div>}><div className="lp-stack">
<p style={{ whiteSpace: "pre-line" }}>{confirm.message}</p>
</div></Modal>)}
</ValidationContext.Provider>);
}