733 lines
39 KiB
TypeScript
733 lines
39 KiB
TypeScript
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>);
|
||
}
|