Compare commits

...

2 Commits

Author SHA1 Message Date
dev
6dd4f3d640 Merge pull request 'GAZ-8: 实现 Teamserver 连接中心与诊断界面' (#7) from agent/docker/04f5be09 into main
Reviewed-on: #7
2026-08-03 19:27:12 +00:00
Ubuntu
a74cfa990f 实现 Teamserver 连接中心与安全诊断
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 19:25:30 +00:00
9 changed files with 351 additions and 2 deletions

View File

@ -1,4 +1,5 @@
import { useReducer, useState } from "react"; import { useReducer, useState } from "react";
import { ConnectionCenter, MemoryMockConnectionAdapter } from "./features/connections";
import { SessionQueryIntent, SessionTable } from "./SessionTable"; import { SessionQueryIntent, SessionTable } from "./SessionTable";
import { demoSessions } from "./protocol/fixtures"; import { demoSessions } from "./protocol/fixtures";
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState"; import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
@ -12,11 +13,13 @@ import {
const fixtureNow = new Date("2026-08-03T19:12:02Z"); const fixtureNow = new Date("2026-08-03T19:12:02Z");
const sessions = demoSessions.map((session) => toSessionRow(session, fixtureNow)); const sessions = demoSessions.map((session) => toSessionRow(session, fixtureNow));
const connectionAdapter = new MemoryMockConnectionAdapter();
export function App() { export function App() {
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState); const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder)); const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder));
const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({}); const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({});
const [view, setView] = useState<"sessions" | "connections">("sessions");
const server = selectActiveWorkspace(workspace); const server = selectActiveWorkspace(workspace);
const activeTab = selectActiveTab(server); const activeTab = selectActiveTab(server);
const tableState = { ...tableStates[server.id], filter: server.filter }; const tableState = { ...tableStates[server.id], filter: server.filter };
@ -54,9 +57,10 @@ export function App() {
</label> </label>
<nav aria-label="项目导航"> <nav aria-label="项目导航">
<a> <strong>{server.unread}</strong></a> <a> <strong>{server.unread}</strong></a>
<a className={view === "connections" ? "selected" : ""} onClick={() => setView("connections")}></a>
<p className="nav-heading"> · {server.activeProject}</p> <p className="nav-heading"> · {server.activeProject}</p>
{['Overview', 'Sessions', 'Tasks', 'Events', 'Artifacts', 'Audit'].map((item) => ( {['Overview', 'Sessions', 'Tasks', 'Events', 'Artifacts', 'Audit'].map((item) => (
<a className={item === activeTab.title ? "selected" : ""} key={item}>{item}</a> <a className={view === "sessions" && item === activeTab.title ? "selected" : ""} key={item} onClick={() => setView("sessions")}>{item}</a>
))} ))}
</nav> </nav>
<p className="memory-note"> · 退</p> <p className="memory-note"> · 退</p>
@ -81,6 +85,7 @@ export function App() {
<span className="context-lock"> LOCKED</span>{server.name} <i>/</i> {activeTab.context.project} <i>/</i> <b>{activeTab.context.session}</b> <i>/</i> {activeTab.title} <span className="context-lock"> LOCKED</span>{server.name} <i>/</i> {activeTab.context.project} <i>/</i> <b>{activeTab.context.session}</b> <i>/</i> {activeTab.title}
</div> </div>
<article className="content"> <article className="content">
{view === "connections" ? <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> : <>
<div className="content-heading"> <div className="content-heading">
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 248 Session · 4 </p></div> <div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 248 Session · 4 </p></div>
<div className="heading-actions"><button className="secondary"></button><button className="primary"> </button></div> <div className="heading-actions"><button className="secondary"></button><button className="primary"> </button></div>
@ -90,6 +95,7 @@ export function App() {
setTableStates((current) => updateSessionTableState(current, server.id, state)); setTableStates((current) => updateSessionTableState(current, server.id, state));
}} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} /> }} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} />
<output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output> <output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output>
</>}
</article> </article>
<footer className="statusbar"><span> {server.connection}</span><span> operator@example</span><span> 38ms</span><span> v1.0</span><span> </span><span className="status-spacer" /></footer> <footer className="statusbar"><span> {server.connection}</span><span> operator@example</span><span> 38ms</span><span> v1.0</span><span> </span><span className="status-spacer" /></footer>
</section> </section>

View File

@ -0,0 +1,23 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ConnectionCenter, ConnectionEditor } from "./components";
import { MemoryMockConnectionAdapter } from "./mockAdapter";
describe("连接中心组件", () => {
it("表单拒绝无效配置,秘密字段使用 password 类型", () => {
render(<ConnectionEditor onSave={() => undefined} onCancel={() => undefined} />);
expect(screen.getByLabelText("认证秘密")).toHaveAttribute("type", "password");
fireEvent.click(screen.getByRole("button", { name: "保存到内存" }));
expect(screen.getByRole("alert")).toHaveTextContent("配置错误");
});
it("通过 mock adapter 完成创建并明确未建立网络连接", async () => {
render(<ConnectionCenter adapter={new MemoryMockConnectionAdapter()} />);
fireEvent.click(await screen.findByRole("button", { name: " 新建连接" }));
fireEvent.change(screen.getByLabelText("名称"), { target: { value: "Atlas" } });
fireEvent.change(screen.getByLabelText("地址"), { target: { value: "https://atlas.example" } });
fireEvent.click(screen.getByRole("button", { name: "保存到内存" }));
await waitFor(() => expect(screen.getByText("Atlas")).toBeInTheDocument());
expect(screen.getByText(/尚未建立网络连接/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,109 @@
import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
import { ConnectionAdapter } from "./mockAdapter";
import { blankConnection, ConnectionConfig, ConnectionRecord, connectionStates, DiagnosticCategory, DiagnosticResult, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
const field = (config: ConnectionConfig, onChange: (next: ConnectionConfig) => void) =>
<K extends keyof ConnectionConfig>(key: K, value: ConnectionConfig[K]) => onChange({ ...config, [key]: value });
function Section({ title, children }: { title: string; children: ReactNode }) {
return <fieldset className="connection-section"><legend>{title}</legend>{children}</fieldset>;
}
export function TlsSettings({ config, onChange, developmentMode }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void; developmentMode: boolean }) {
const set = (value: Partial<ConnectionConfig["tls"]>) => onChange({ ...config, tls: { ...config.tls, ...value } });
return <Section title="TLS / mTLS">
<label className="check"><input type="checkbox" checked={config.tls.enabled} onChange={(e) => set({ enabled: e.target.checked })} /> TLS</label>
<label> CA<textarea value={config.tls.customCa} onChange={(e) => set({ customCa: e.target.value })} placeholder="PEM 内容(仅保存在内存)" /></label>
<label><textarea value={config.tls.clientCertificate} onChange={(e) => set({ clientCertificate: e.target.value })} /></label>
<label><input type="password" autoComplete="off" value={config.tls.clientKey} onChange={(e) => set({ clientKey: e.target.value })} /></label>
<label><input value={config.tls.fingerprint} onChange={(e) => set({ fingerprint: e.target.value })} placeholder="sha256:…" /></label>
<label>SNI<input value={config.tls.sni} onChange={(e) => set({ sni: e.target.value })} /></label>
{developmentMode && <label className="check danger"><input type="checkbox" checked={config.tls.skipVerification} onChange={(e) => set({ skipVerification: e.target.checked })} /></label>}
</Section>;
}
export function AuthSettings({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
const set = (value: Partial<ConnectionConfig["auth"]>) => onChange({ ...config, auth: { ...config.auth, ...value } });
return <Section title="认证">
<label><select value={config.auth.method} onChange={(e) => set({ method: e.target.value as ConnectionConfig["auth"]["method"] })}><option value="token">Token</option><option value="password"> / </option><option value="mtls"> mTLS</option></select></label>
{config.auth.method === "password" && <label><input value={config.auth.username} onChange={(e) => set({ username: e.target.value })} /></label>}
{config.auth.method !== "mtls" && <label><input aria-label="认证秘密" type="password" autoComplete="new-password" value={config.auth.secret} onChange={(e) => set({ secret: e.target.value })} placeholder={maskedSecret(config.auth.secret)} /></label>}
</Section>;
}
export function ProxySettings({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
const set = (value: Partial<ConnectionConfig["proxy"]>) => onChange({ ...config, proxy: { ...config.proxy, ...value } });
return <Section title="代理">
<label className="check"><input type="checkbox" checked={config.proxy.enabled} onChange={(e) => set({ enabled: e.target.checked })} />使</label>
{config.proxy.enabled && <><label> URL<input value={config.proxy.url} onChange={(e) => set({ url: e.target.value })} placeholder="http://proxy.local:8080" /></label><label><input value={config.proxy.username} onChange={(e) => set({ username: e.target.value })} /></label><label><input type="password" autoComplete="new-password" value={config.proxy.password} onChange={(e) => set({ password: e.target.value })} /></label></>}
</Section>;
}
export function ReconnectPolicy({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
const set = (value: Partial<ConnectionConfig["reconnect"]>) => onChange({ ...config, reconnect: { ...config.reconnect, ...value } });
return <Section title="重连策略">
<label className="check"><input type="checkbox" checked={config.reconnect.enabled} onChange={(e) => set({ enabled: e.target.checked })} />退</label>
<label>退<input type="number" value={config.reconnect.initialSeconds} onChange={(e) => set({ initialSeconds: Number(e.target.value) })} /></label>
<label>退<input type="number" value={config.reconnect.maxSeconds} onChange={(e) => set({ maxSeconds: Number(e.target.value) })} /></label>
<label><input type="number" step="0.1" value={config.reconnect.multiplier} onChange={(e) => set({ multiplier: Number(e.target.value) })} /></label>
<label>01<input type="number" step="0.1" value={config.reconnect.jitter} onChange={(e) => set({ jitter: Number(e.target.value) })} /></label>
</Section>;
}
export function ConnectionEditor({ initial, developmentMode = false, onSave, onCancel }: { initial?: ConnectionConfig; developmentMode?: boolean; onSave: (config: ConnectionConfig) => void; onCancel: () => void }) {
const [config, setConfig] = useState(() => structuredClone(initial ?? blankConnection()));
const [submitted, setSubmitted] = useState(false);
const errors = useMemo(() => validateConnection(config, developmentMode), [config, developmentMode]);
const set = field(config, setConfig);
return <form className="connection-editor" onSubmit={(e) => { e.preventDefault(); setSubmitted(true); if (!Object.keys(errors).length) onSave(config); }}>
<div className="editor-grid"><Section title="基本设置">
<label><input value={config.name} onChange={(e) => set("name", e.target.value)} />{submitted && errors.name && <small className="field-error">{errors.name}</small>}</label>
<label><select value={config.transport} onChange={(e) => set("transport", e.target.value as ConnectionConfig["transport"])}><option value="grpc">gRPC</option><option value="websocket">WebSocket</option></select></label>
<label><input value={config.address} onChange={(e) => set("address", e.target.value)} placeholder={config.transport === "grpc" ? "https://teamserver.example" : "wss://teamserver.example/events"} />{submitted && errors.address && <small className="field-error">{errors.address}</small>}</label>
<label><input type="number" value={config.timeoutSeconds} onChange={(e) => set("timeoutSeconds", Number(e.target.value))} /></label>
<label><input type="number" value={config.heartbeatSeconds} onChange={(e) => set("heartbeatSeconds", Number(e.target.value))} /></label>
</Section><TlsSettings config={config} onChange={setConfig} developmentMode={developmentMode} /><AuthSettings config={config} onChange={setConfig} /><ProxySettings config={config} onChange={setConfig} /><ReconnectPolicy config={config} onChange={setConfig} /></div>
{submitted && Object.keys(errors).length > 0 && <p role="alert" className="form-error"> {Object.keys(errors).length} </p>}
<footer><button type="button" className="secondary" onClick={onCancel}></button><button className="primary" type="submit"></button></footer>
</form>;
}
export function ConnectionList({ records, selectedId, onSelect, onCreate, onCopy, onExport, onDelete }: { records: ConnectionRecord[]; selectedId?: string; onSelect: (id: string) => void; onCreate: () => void; onCopy: (record: ConnectionRecord) => void; onExport: (record: ConnectionRecord) => void; onDelete: (record: ConnectionRecord) => void }) {
return <section className="connection-list" aria-label="连接配置列表"><header><div><h2></h2><p>{records.length} · Mock adapter</p></div><button className="primary" onClick={onCreate}> </button></header>
<div className="state-legend" aria-label="连接状态机">{connectionStates.map((state) => <span key={state}>{state}</span>)}</div>
{records.length === 0 ? <div className="empty-state"></div> : records.map((record) => <article key={record.config.id} className={selectedId === record.config.id ? "selected" : ""} onClick={() => onSelect(record.config.id)}>
<div><strong>{record.config.name}</strong><code>{record.config.address}</code></div><span className={`connection-state ${record.state.toLowerCase()}`}>{record.state}</span>
<div className="record-actions"><button onClick={(e) => { e.stopPropagation(); onCopy(record); }}></button><button onClick={(e) => { e.stopPropagation(); onExport(record); }}></button><button className="danger-button" onClick={(e) => { e.stopPropagation(); onDelete(record); }}></button></div>
</article>)}</section>;
}
const categories: DiagnosticCategory[] = ["network", "tls", "authentication", "version", "permission", "rate_limit", "timeout", "message_format", "server"];
export function ConnectionDiagnostics({ record, adapter }: { record: ConnectionRecord; adapter: ConnectionAdapter }) {
const [results, setResults] = useState<DiagnosticResult[]>([]);
return <section className="diagnostics"><h3></h3><p> mock </p><button className="secondary" onClick={async () => setResults(await Promise.all(categories.map((category) => adapter.diagnose(record.config.id, category))))}></button><div>{results.map((result) => <output key={result.category}><b>{result.category}</b><span>{result.summary}</span></output>)}</div></section>;
}
export function DeleteConfirmation({ record, onConfirm, onCancel }: { record: ConnectionRecord; onConfirm: () => void; onCancel: () => void }) {
return <div className="modal-backdrop" role="presentation"><section role="dialog" aria-modal="true" aria-labelledby="delete-title" className="connection-dialog"><h3 id="delete-title">{record.config.name}</h3><p> mock Teamserver</p><footer><button className="secondary" onClick={onCancel}></button><button className="danger-button" onClick={onConfirm}></button></footer></section></div>;
}
export function ConnectionCenter({ adapter, developmentMode = false }: { adapter: ConnectionAdapter; developmentMode?: boolean }) {
const [records, setRecords] = useState<ConnectionRecord[]>([]);
const [editing, setEditing] = useState<ConnectionConfig>();
const [selected, setSelected] = useState<string>();
const [deleting, setDeleting] = useState<ConnectionRecord>();
const [importText, setImportText] = useState("");
const [notice, setNotice] = useState("");
useEffect(() => { void adapter.list().then(setRecords); }, [adapter]);
const refresh = () => adapter.list().then(setRecords);
const selectedRecord = records.find((item) => item.config.id === selected);
const importPreview = () => { try { setEditing(parseImportPreview(importText)); setNotice("导入预览已加载;秘密字段已清空,保存前请确认。"); } catch (error) { setNotice(error instanceof Error ? error.message : "导入失败"); } };
const exportRecord = (record: ConnectionRecord) => { const safe = JSON.stringify(toSafeExport(record.config), null, 2); setNotice(`安全导出预览(不含 Token、密码和客户端私钥\n${safe}`); };
if (editing) return <ConnectionEditor initial={editing} developmentMode={developmentMode} onCancel={() => setEditing(undefined)} onSave={async (config) => { await adapter.save(config); await refresh(); setSelected(config.id); setEditing(undefined); setNotice("配置已保存到内存 mock adapter尚未建立网络连接。"); }} />;
return <div className="connection-center"><ConnectionList records={records} selectedId={selected} onSelect={setSelected} onCreate={() => setEditing(blankConnection())} onCopy={(record) => setEditing({ ...structuredClone(record.config), id: crypto.randomUUID(), name: `${record.config.name} 副本`, auth: { ...record.config.auth, secret: "" }, proxy: { ...record.config.proxy, password: "" }, tls: { ...record.config.tls, clientKey: "" } })} onExport={exportRecord} onDelete={setDeleting} />
<section className="import-panel"><h3></h3><textarea aria-label="导入 JSON" value={importText} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setImportText(e.target.value)} placeholder="粘贴由本客户端安全导出的 JSON" /><button className="secondary" onClick={importPreview}></button></section>
{selectedRecord && <ConnectionDiagnostics record={selectedRecord} adapter={adapter} />}
{notice && <pre className="connection-notice" aria-live="polite">{notice}</pre>}
{deleting && <DeleteConfirmation record={deleting} onCancel={() => setDeleting(undefined)} onConfirm={async () => { await adapter.remove(deleting.config.id); await refresh(); setDeleting(undefined); if (selected === deleting.config.id) setSelected(undefined); }} />}
</div>;
}

View File

@ -0,0 +1,3 @@
export * from "./model";
export * from "./mockAdapter";
export * from "./components";

View File

@ -0,0 +1,29 @@
import { ConnectionConfig, ConnectionRecord, DiagnosticCategory, DiagnosticResult } from "./model";
export interface ConnectionAdapter {
readonly kind: "mock";
list(): Promise<ConnectionRecord[]>;
save(config: ConnectionConfig): Promise<ConnectionRecord>;
remove(id: string): Promise<void>;
diagnose(id: string, category: DiagnosticCategory): Promise<DiagnosticResult>;
}
/** 仅用于安全的内存交互演示;不会发起网络请求,也不会持久化秘密。 */
export class MemoryMockConnectionAdapter implements ConnectionAdapter {
readonly kind = "mock" as const;
private records: ConnectionRecord[];
constructor(seed: ConnectionRecord[] = []) { this.records = structuredClone(seed); }
async list() { return structuredClone(this.records); }
async save(config: ConnectionConfig) {
const record = { config: structuredClone(config), state: "DISCONNECTED" as const };
const index = this.records.findIndex((item) => item.config.id === config.id);
if (index === -1) this.records.push(record); else this.records[index] = record;
return structuredClone(record);
}
async remove(id: string) { this.records = this.records.filter((item) => item.config.id !== id); }
async diagnose(id: string, category: DiagnosticCategory) {
if (!this.records.some((item) => item.config.id === id)) throw new Error("连接不存在");
return { category, ok: true, summary: `Mock 检查:${category} 配置结构有效(未连接真实服务器)` };
}
}

View File

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { blankConnection, connectionStates, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
function validConfig() {
const config = blankConnection();
config.name = "Atlas";
config.address = "https://atlas.example";
return config;
}
describe("连接配置安全边界", () => {
it("验证地址、URL 凭据及开发模式 TLS 约束", () => {
const config = validConfig();
config.address = "https://user:password@atlas.example";
config.tls.skipVerification = true;
expect(validateConnection(config)).toMatchObject({ address: "地址中不得包含凭据", skipVerification: "仅开发模式允许跳过证书校验" });
expect(validateConnection({ ...config, address: "https://atlas.example" }, true)).toEqual({});
});
it("安全导出排除全部秘密并在导入时生成新 ID", () => {
const config = validConfig();
config.auth.secret = "token-value";
config.proxy.password = "proxy-password";
config.tls.clientKey = "private-key";
const json = JSON.stringify(toSafeExport(config));
expect(json).not.toContain("token-value");
expect(json).not.toContain("proxy-password");
expect(json).not.toContain("private-key");
const imported = parseImportPreview(json);
expect(imported.id).not.toBe(config.id);
expect(imported.auth.secret).toBe("");
expect(imported.proxy.password).toBe("");
expect(imported.tls.clientKey).toBe("");
});
it("覆盖完整状态机且脱敏函数不回显原秘密", () => {
expect(connectionStates).toHaveLength(11);
expect(connectionStates).toContain("AUTH_EXPIRED");
expect(maskedSecret("sensitive")).toBe("••••••••");
});
});

View File

@ -0,0 +1,99 @@
export const connectionStates = [
"DISCONNECTED", "CONNECTING", "TLS_HANDSHAKE", "AUTHENTICATING", "NEGOTIATING",
"SYNCHRONIZING", "CONNECTED", "DEGRADED", "RECONNECTING", "AUTH_EXPIRED", "FAILED",
] as const;
export type ConnectionState = (typeof connectionStates)[number];
export type Transport = "grpc" | "websocket";
export type AuthMethod = "token" | "password" | "mtls";
export interface ConnectionConfig {
id: string;
name: string;
address: string;
transport: Transport;
tls: {
enabled: boolean;
customCa: string;
clientCertificate: string;
clientKey: string;
fingerprint: string;
sni: string;
skipVerification: boolean;
};
auth: { method: AuthMethod; username: string; secret: string };
proxy: { enabled: boolean; url: string; username: string; password: string };
timeoutSeconds: number;
heartbeatSeconds: number;
reconnect: { enabled: boolean; initialSeconds: number; maxSeconds: number; multiplier: number; jitter: number };
}
export interface ConnectionRecord { config: ConnectionConfig; state: ConnectionState; }
export type DiagnosticCategory = "network" | "tls" | "authentication" | "version" | "permission" | "rate_limit" | "timeout" | "message_format" | "server";
export interface DiagnosticResult { category: DiagnosticCategory; ok: boolean; summary: string; }
export const blankConnection = (): ConnectionConfig => ({
id: crypto.randomUUID(), name: "", address: "", transport: "grpc",
tls: { enabled: true, customCa: "", clientCertificate: "", clientKey: "", fingerprint: "", sni: "", skipVerification: false },
auth: { method: "token", username: "", secret: "" },
proxy: { enabled: false, url: "", username: "", password: "" },
timeoutSeconds: 15, heartbeatSeconds: 30,
reconnect: { enabled: true, initialSeconds: 1, maxSeconds: 30, multiplier: 2, jitter: 0.2 },
});
export function validateConnection(config: ConnectionConfig, developmentMode = false): Record<string, string> {
const errors: Record<string, string> = {};
if (!config.name.trim()) errors.name = "请输入连接名称";
try {
const url = new URL(config.address);
const allowed = config.transport === "grpc" ? ["https:", "http:"] : ["wss:", "ws:"];
if (!allowed.includes(url.protocol)) errors.address = `传输 ${config.transport} 不支持 ${url.protocol}`;
if (url.username || url.password) errors.address = "地址中不得包含凭据";
} catch { errors.address = "请输入包含协议的有效地址"; }
if (config.tls.skipVerification && !developmentMode) errors.skipVerification = "仅开发模式允许跳过证书校验";
if (config.tls.fingerprint && !/^sha256:[a-f0-9]{64}$/i.test(config.tls.fingerprint)) errors.fingerprint = "指纹格式应为 sha256: 后接 64 位十六进制字符";
if (config.timeoutSeconds < 1 || config.timeoutSeconds > 300) errors.timeoutSeconds = "超时范围为 1300 秒";
if (config.heartbeatSeconds < 5 || config.heartbeatSeconds > 3600) errors.heartbeatSeconds = "心跳范围为 53600 秒";
if (config.reconnect.initialSeconds > config.reconnect.maxSeconds) errors.reconnect = "初始退避不能大于最大退避";
if (config.reconnect.jitter < 0 || config.reconnect.jitter > 1) errors.jitter = "抖动范围为 01";
if (config.proxy.enabled) {
try {
const proxy = new URL(config.proxy.url);
if (proxy.username || proxy.password) errors.proxy = "代理 URL 中不得包含凭据";
} catch { errors.proxy = "请输入有效代理 URL"; }
}
return errors;
}
export type SafeConnectionExport = Omit<ConnectionConfig, "auth" | "proxy" | "tls"> & {
auth: Omit<ConnectionConfig["auth"], "secret">;
proxy: Omit<ConnectionConfig["proxy"], "password">;
tls: Omit<ConnectionConfig["tls"], "clientKey">;
exportVersion: 1;
};
export function toSafeExport(config: ConnectionConfig): SafeConnectionExport {
const { secret: _secret, ...auth } = config.auth;
const { password: _password, ...proxy } = config.proxy;
const { clientKey: _clientKey, ...tls } = config.tls;
return { ...config, auth, proxy, tls, exportVersion: 1 };
}
export function parseImportPreview(raw: string): ConnectionConfig {
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("导入内容必须是 JSON 对象");
const input = parsed as Partial<SafeConnectionExport>;
if (input.exportVersion !== 1) throw new Error("不支持的导出版本");
const base = blankConnection();
const imported: ConnectionConfig = {
...base, ...input, id: crypto.randomUUID(),
tls: { ...base.tls, ...input.tls, clientKey: "" },
auth: { ...base.auth, ...input.auth, secret: "" },
proxy: { ...base.proxy, ...input.proxy, password: "" },
};
if (Object.keys(validateConnection(imported)).length) throw new Error("导入配置未通过安全校验");
return imported;
}
export function maskedSecret(secret: string): string { return secret ? "••••••••" : "未设置"; }

View File

@ -7,7 +7,7 @@
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; min-width: 900px; min-height: 100vh; background: var(--app); } body { margin: 0; min-width: 900px; min-height: 100vh; background: var(--app); }
button, input { font: inherit; color: inherit; } button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; } button { cursor: pointer; }
button:focus-visible, input:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--info); outline-offset: -2px; } button:focus-visible, input:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--info); outline-offset: -2px; }
.shell { display: grid; grid-template-columns: 52px 260px 1fr; min-height: 100vh; background: var(--app); } .shell { display: grid; grid-template-columns: 52px 260px 1fr; min-height: 100vh; background: var(--app); }
@ -35,6 +35,7 @@ h1 { font-size: 14px; line-height: 20px; margin: 0 0 8px; font-weight: 600; }
kbd { border: 1px solid #343b45; border-radius: 3px; padding: 1px 4px; font-size: 9px; white-space: nowrap; } kbd { border: 1px solid #343b45; border-radius: 3px; padding: 1px 4px; font-size: 9px; white-space: nowrap; }
nav { display: flex; flex-direction: column; gap: 2px; } nav { display: flex; flex-direction: column; gap: 2px; }
nav a { display: flex; justify-content: space-between; padding: 6px 9px; border-radius: 4px; color: #aab1bd; font-size: 12px; transition: background 100ms ease; } nav a { display: flex; justify-content: space-between; padding: 6px 9px; border-radius: 4px; color: #aab1bd; font-size: 12px; transition: background 100ms ease; }
nav a { cursor: pointer; }
nav a:hover { background: var(--hover); } nav a:hover { background: var(--hover); }
nav a.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); } nav a.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); }
nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; } nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
@ -98,3 +99,40 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; } input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } } @media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } }
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } } @media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }
.connection-center { max-width: 1120px; display: grid; grid-template-columns: minmax(520px, 1fr) 330px; gap: 14px; }
.connection-list { grid-row: span 3; }
.connection-list > header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.connection-list h2, .connection-list p, .diagnostics h3, .diagnostics p, .import-panel h3 { margin: 0; }
.connection-list p, .diagnostics p { color: var(--muted); margin-top: 4px; }
.state-legend { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 12px; }
.state-legend span { padding: 2px 5px; border: 1px solid var(--border); border-radius: 3px; color: #7f8998; font: 8px ui-monospace, monospace; }
.connection-list article { display: grid; grid-template-columns: 1fr auto; gap: 10px; padding: 13px; border: 1px solid var(--border); background: #15191f; margin-bottom: 7px; border-radius: 5px; }
.connection-list article.selected { border-color: #4777ac; background: #1c2631; }
.connection-list article strong, .connection-list article code { display: block; }
.connection-list article code { color: var(--muted); margin-top: 4px; }
.connection-state { align-self: start; padding: 3px 6px; border: 1px solid #48515e; border-radius: 3px; font: 9px ui-monospace, monospace; }
.connection-state.connected { color: #82d5aa; border-color: #28523e; }
.record-actions { grid-column: 1 / -1; display: flex; gap: 6px; justify-content: flex-end; }
.record-actions button, .danger-button { border: 1px solid var(--border); border-radius: 4px; padding: 5px 8px; background: #1b2027; }
.danger-button { color: #f18b92; border-color: #6a353a; background: #301d21; }
.empty-state, .import-panel, .diagnostics, .connection-notice { padding: 16px; border: 1px solid var(--border); background: #15191f; border-radius: 5px; color: var(--muted); }
.import-panel textarea { width: 100%; min-height: 130px; margin: 10px 0; }
.diagnostics > button { margin: 10px 0; }
.diagnostics output { display: grid; grid-template-columns: 110px 1fr; padding: 6px 0; border-top: 1px solid var(--border); font-size: 10px; }
.diagnostics output b { color: #87b5e8; font-family: ui-monospace, monospace; }
.connection-notice { white-space: pre-wrap; max-height: 260px; overflow: auto; font-size: 10px; }
.connection-editor { max-width: 1100px; }
.editor-grid { display: grid; grid-template-columns: repeat(2, minmax(300px, 1fr)); gap: 12px; align-items: start; }
.connection-section { border: 1px solid var(--border); background: #15191f; border-radius: 5px; padding: 14px; display: grid; gap: 10px; }
.connection-section legend { padding: 0 6px; color: #cbd3de; font-weight: 600; }
.connection-section label { display: grid; gap: 5px; color: #9da6b4; font-size: 11px; }
.connection-section input, .connection-section select, .connection-section textarea, .import-panel textarea { border: 1px solid #353d48; border-radius: 4px; background: #101318; padding: 7px 8px; outline: 0; }
.connection-section textarea { min-height: 65px; resize: vertical; }
.connection-section .check { display: flex; align-items: center; gap: 7px; }
.connection-section .danger { color: #e8b36e; padding: 8px; border: 1px solid #65512f; background: #2b251a; }
.field-error, .form-error { color: #f1888f; }
.connection-editor > footer, .connection-dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; background: #080a0dcc; }
.connection-dialog { width: 410px; padding: 20px; border: 1px solid #454e5a; border-radius: 6px; background: #171b21; box-shadow: 0 15px 50px #000a; }
@media (max-width: 1150px) { .connection-center, .editor-grid { grid-template-columns: 1fr; } .connection-list { grid-row: auto; } }

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />