Files
ssh-e2e/ui/app.js
T
leefer 27a3b4c6a8 SSH 隧道管理器:pywebview+paramiko 桌面应用
- 本地端口转发隧道管理(密码/私钥认证,自动重连,随应用启动)
- 主机与隧道可视化配置,DPAPI 加密存储机密,known_hosts 校验
- 自定义无边框窗口,深色界面,托盘驻留(最小化/关闭到托盘)
- 内置本地测试 SSH 服务端与端到端自测脚本
2026-09-24 17:14:59 +08:00

710 lines
32 KiB
JavaScript

/* SSH 隧道管理器 — 前端逻辑
运行于 pywebview(调用 Python API)或浏览器(Mock,便于调试界面) */
"use strict";
/* ---------- 工具 ---------- */
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const isPort = (v) => /^\d+$/.test(String(v).trim()) && +v >= 1 && +v <= 65535;
const withTimeout = (p, ms, msg) => Promise.race([
p,
new Promise((_, rej) => setTimeout(() => rej(new Error(msg)), ms)),
]);
function fmtUptime(s) {
s = Math.max(0, Math.floor(s));
const h = String(Math.floor(s / 3600)).padStart(2, "0");
const m = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const sec = String(s % 60).padStart(2, "0");
return `${h}:${m}:${sec}`;
}
/* ---------- 状态 ---------- */
const S = {
hosts: [],
tunnels: [], // 每项含 cfg 字段 + runtime + logs + host_label
feed: [],
view: "tunnels",
openLogs: new Set(),
};
/* ---------- API 桥 ---------- */
function nativeApi() {
return (typeof window.pywebview === "object" && window.pywebview && window.pywebview.api) || null;
}
async function call(name, ...args) {
const a = nativeApi();
if (a) {
const r = await a[name](...args);
if (r && r.ok === false) throw new Error(r.error || "操作失败");
return r;
}
return MockApi[name](...args);
}
/* Python 事件入口 */
window.__bus = (ev) => {
try { handleEvent(ev); } catch (e) { console.error(e); }
};
function handleEvent(ev) {
if (ev.type === "state") { applyState(ev.state); return; }
if (ev.type === "tunnel") {
const t = S.tunnels.find((x) => x.id === ev.id);
if (t) { t.runtime = { status: ev.status, error: ev.error, started_at: ev.started_at, conn_count: ev.conn_count }; renderTunnels(); renderSummary(); }
return;
}
if (ev.type === "log") {
const t = S.tunnels.find((x) => x.id === ev.tunnel_id);
if (t) {
t.logs.push(ev.entry);
if (S.openLogs.has(t.id)) appendLogBox(t);
}
appendFeedRow({ tunnel_name: t ? t.name : "?", ...ev.entry });
if (S.view === "logs") maybeScrollFeed();
}
}
function applyState(state) {
S.hosts = state.hosts || [];
S.tunnels = state.tunnels || [];
S.feed = state.feed || [];
renderAll();
}
/* ---------- Mock(浏览器调试用) ---------- */
const MockApi = (() => {
let hosts = [
{ id: "h1", name: "开发服务器", hostname: "192.168.8.20", port: 22, username: "deploy", auth: "password", key_path: "", note: "", has_password: true, has_passphrase: false },
{ id: "h2", name: "跳板机", hostname: "gw.example.com", port: 2222, username: "root", auth: "key", key_path: "C:\\Users\\me\\.ssh\\id_ed25519", note: "", has_password: false, has_passphrase: true },
];
let tunnels = [
{ id: "t1", name: "grafana", host_id: "h1", local_port: 3000, remote_host: "127.0.0.1", remote_port: 3000, autostart: false, autoreconnect: true, host_label: "deploy@192.168.8.20:22", host_auth: "password", runtime: { id: "t1", status: "connected", error: "", started_at: Date.now() / 1000 - 4372, conn_count: 12 }, logs: [
{ t: "10:21:07", level: "info", msg: "正在连接 deploy@192.168.8.20:22 ..." },
{ t: "10:21:08", level: "success", msg: "隧道已建立: 127.0.0.1:3000 → 127.0.0.1:3000" },
{ t: "10:24:11", level: "info", msg: "接受连接 → 127.0.0.1:3000" },
] },
{ id: "t2", name: "mysql-debug", host_id: "h2", local_port: 3307, remote_host: "127.0.0.1", remote_port: 3306, autostart: true, autoreconnect: true, host_label: "root@gw.example.com:2222", host_auth: "key", runtime: { id: "t2", status: "stopped", error: "", started_at: null, conn_count: 0 }, logs: [] },
];
let feed = tunnels.flatMap((t) => t.logs.map((e) => ({ tunnel_name: t.name, ...e })));
const snap = () => ({ hosts: structuredClone(hosts), tunnels: structuredClone(tunnels), feed: feed.slice(-300) });
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
function pushLog(id, level, msg) {
const t = tunnels.find((x) => x.id === id);
if (!t) return;
const e = { t: new Date().toTimeString().slice(0, 8), level, msg };
t.logs.push(e);
feed.push({ tunnel_name: t.name, ...e });
window.__bus({ type: "log", tunnel_id: id, entry: e });
}
return {
async get_state() { return { ok: true, state: snap() }; },
async save_host(p) {
const rec = { id: p.id || "h" + Math.random().toString(36).slice(2, 6), name: p.name || p.hostname, hostname: p.hostname, port: +p.port || 22, username: p.username, auth: p.auth, key_path: p.key_path || "", note: p.note || "", has_password: !!p.password || (p.id && hosts.find((h) => h.id === p.id)?.has_password && !p.password === false), has_passphrase: false };
if (!p.id && p.auth === "password") rec.has_password = !!p.password;
if (p.id && !p.password) rec.has_password = hosts.find((h) => h.id === p.id).has_password;
const i = hosts.findIndex((h) => h.id === rec.id);
if (i >= 0) hosts[i] = rec; else hosts.push(rec);
tunnels.forEach((t) => { if (t.host_id === rec.id) t.host_label = `${rec.username}@${rec.hostname}:${rec.port}`; });
return { ok: true, id: rec.id, state: snap() };
},
async delete_host(id) { hosts = hosts.filter((h) => h.id !== id); return { ok: true, state: snap() }; },
async save_tunnel(p) {
const host = hosts.find((h) => h.id === p.host_id);
const rec = { id: p.id || "t" + Math.random().toString(36).slice(2, 6), name: p.name || `${p.remote_host}:${p.remote_port}`, host_id: p.host_id, local_port: +p.local_port, remote_host: p.remote_host || "127.0.0.1", remote_port: +p.remote_port, autostart: !!p.autostart, autoreconnect: p.autoreconnect !== false, host_label: host ? `${host.username}@${host.hostname}:${host.port}` : "?", host_auth: host?.auth, runtime: { id: p.id || "t0", status: "stopped", error: "", started_at: null, conn_count: 0 } };
const i = tunnels.findIndex((t) => t.id === rec.id);
if (i >= 0) { rec.runtime = tunnels[i].runtime; tunnels[i] = rec; } else tunnels.push(rec);
return { ok: true, id: rec.id, state: snap() };
},
async delete_tunnel(id) { tunnels = tunnels.filter((t) => t.id !== id); feed = feed.filter((f) => f.tunnel_id !== id); return { ok: true, state: snap() }; },
async start_tunnel(id) {
const t = tunnels.find((x) => x.id === id);
t.runtime = { ...t.runtime, status: "connecting", error: "" };
window.__bus({ type: "tunnel", ...t.runtime });
pushLog(id, "info", `正在连接 ${t.host_label} ...`);
await wait(1200);
if (t.runtime.status !== "connecting") return { ok: true };
t.runtime = { ...t.runtime, status: "connected", started_at: Date.now() / 1000 };
window.__bus({ type: "tunnel", ...t.runtime });
pushLog(id, "success", `隧道已建立: 127.0.0.1:${t.local_port} → ${t.remote_host}:${t.remote_port}`);
return { ok: true };
},
async stop_tunnel(id) {
const t = tunnels.find((x) => x.id === id);
t.runtime = { ...t.runtime, status: "stopped", started_at: null };
window.__bus({ type: "tunnel", ...t.runtime });
pushLog(id, "info", "隧道已断开");
return { ok: true };
},
async probe_host(p) { await wait(900); return p.hostname && p.username ? { ok: true, message: "认证成功 · 服务端 SSH-2.0-OpenSSH_9.6" } : { ok: false, message: "请先填写主机地址" }; },
async open_url(url) { window.open(url, "_blank"); return { ok: true }; },
async copy_text() { return { ok: true }; },
async pick_key_file() { return { ok: true, path: "C:\\Users\\me\\.ssh\\id_ed25519" }; },
async win_minimize() { return { ok: true }; },
async win_maximize() { return { ok: true }; },
async win_close() { return { ok: true }; },
};
})();
/* ---------- 图标 ---------- */
const I = {
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6M14 11v6"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
external: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
play: '<path d="m7 4 13 8-13 8Z"/>',
stop: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
chevron: '<path d="m6 9 6 6 6-6"/>',
key: '<circle cx="8" cy="16" r="4"/><path d="m10.8 13.2 9.7-9.7"/><path d="M17 7l3 3"/>',
lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
alert: '<circle cx="12" cy="12" r="9"/><path d="M12 8v4.5"/><path d="M12 16h.01"/>',
folder: '<path d="M3 7V5a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
x: '<path d="M6 6l12 12M18 6 6 18"/>',
};
const icon = (n, s = 14, w = 1.8) =>
`<svg class="ic" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round">${I[n]}</svg>`;
/* ---------- 渲染 ---------- */
const STATUS_META = {
stopped: { text: "已停止", cls: "", dot: "" },
connecting: { text: "连接中…", cls: "amber", dot: "amber pulse" },
connected: { text: "已连接", cls: "green", dot: "green" },
error: { text: "连接失败", cls: "red", dot: "red" },
};
function renderAll() {
renderTunnels();
renderHosts();
renderFeed(true);
renderSummary();
$("#badgeHosts").textContent = S.hosts.length || "";
$("#logCount").textContent = S.feed.length ? `${S.feed.length} 条` : "";
}
function renderTunnels() {
const grid = $("#tunnelGrid");
const running = S.tunnels.filter((t) => t.runtime.status === "connected").length;
$("#tunnelCount").textContent = S.tunnels.length ? `${S.tunnels.length} 条 · ${running} 条运行中` : "";
$("#tunnelsEmpty").style.display = S.tunnels.length ? "none" : "";
grid.style.display = S.tunnels.length ? "" : "none";
const b = $("#badgeTunnels");
b.textContent = running || "";
b.classList.toggle("live", running > 0);
grid.innerHTML = S.tunnels.map((t) => {
const rt = t.runtime;
const meta = STATUS_META[rt.status] || STATUS_META.stopped;
const live = rt.status === "connected";
const conn = rt.status === "connecting";
const up = live && rt.started_at ? ` · ${fmtUptime(Date.now() / 1000 - rt.started_at)}` : "";
const authIc = t.host_auth === "key" ? "key" : "lock";
const open = S.openLogs.has(t.id);
return `
<article class="tcard ${live ? "live" : ""}" data-id="${t.id}">
<div class="head">
<span class="dot ${meta.dot}"></span>
<span class="grow name" title="${esc(t.name)}">${esc(t.name)}</span>
<span class="status-text ${meta.cls}" data-status>${meta.text}${up}</span>
<button class="ibtn" data-act="edit" title="编辑">${icon("edit")}</button>
<button class="ibtn red" data-act="del" title="删除">${icon("trash")}</button>
</div>
${rt.status === "error" && rt.error ? `<div class="err-line" title="${esc(rt.error)}">${esc(rt.error)}</div>` : ""}
<div class="mapline">
<span class="addr">127.0.0.1:${t.local_port}</span>
<svg class="ic arrow" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
<span class="addr">${esc(t.remote_host)}:${t.remote_port}</span>
<span class="grow"></span>
<button class="ibtn" data-act="copy" title="复制本地地址">${icon("copy", 13)}</button>
<button class="ibtn" data-act="open" title="在浏览器打开 http://127.0.0.1:${t.local_port}">${icon("external", 13)}</button>
</div>
<div class="meta-row">
${icon(authIc, 12)}
<span>${esc(t.host_label)}</span>
<span class="sep">|</span>
<span>累计连接 ${rt.conn_count || 0}</span>
<span class="grow"></span>
<button data-act="log" class="${open ? "open" : ""}">${icon("chevron", 12)} 日志</button>
</div>
<div class="acts">
${live || conn
? `<button class="btn sm subtle" data-act="stop">${icon("stop", 12, 2)} 断开</button>`
: `<button class="btn sm primary" data-act="start">${icon("play", 12, 2)} 启动</button>`}
</div>
${open ? `<div class="logbox" data-logbox>${(t.logs || []).map(logLine).join("")}</div>` : ""}
</article>`;
}).join("");
}
const logLine = (e) =>
`<div><span class="lt">${esc(e.t)}</span><span class="l-${esc(e.level)}">${esc(e.msg)}</span></div>`;
function appendLogBox(t) {
const box = $(`#tunnelGrid .tcard[data-id="${t.id}"] [data-logbox]`);
if (!box) return;
box.innerHTML = (t.logs || []).map(logLine).join("");
box.scrollTop = box.scrollHeight;
}
function renderHosts() {
const list = $("#hostList");
$("#hostCount").textContent = S.hosts.length ? `${S.hosts.length} 台` : "";
$("#hostsEmpty").style.display = S.hosts.length ? "none" : "";
list.style.display = S.hosts.length ? "" : "none";
list.innerHTML = S.hosts.map((h) => {
const used = S.tunnels.filter((t) => t.host_id === h.id);
return `
<div class="hcard" data-id="${h.id}">
<span class="icon-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>
</span>
<div class="info">
<div class="hname">${esc(h.name)}</div>
<div class="haddr">${esc(h.username)}@${esc(h.hostname)}:${h.port}</div>
<div class="tags">
<span class="tag">${icon(h.auth === "key" ? "key" : "lock", 11)} ${h.auth === "key" ? "私钥" : "密码"}</span>
${h.note ? `<span class="tag">${esc(h.note)}</span>` : ""}
</div>
${used.length ? `<div class="used-by">被 ${used.length} 条隧道使用:${esc(used.map((t) => t.name).join("、"))}</div>` : ""}
</div>
<button class="ibtn" data-act="edit" title="编辑">${icon("edit")}</button>
<button class="ibtn red" data-act="del" title="删除">${icon("trash")}</button>
</div>`;
}).join("");
}
/* ---------- 日志视图 ---------- */
const feedRow = (e) =>
`<div class="row l-${esc(e.level)}"><span class="t">${esc(e.t)}</span><span class="src" title="${esc(e.tunnel_name)}">${esc(e.tunnel_name)}</span><span class="m">${esc(e.msg)}</span></div>`;
function renderFeed(clear) {
const el = $("#logFeed");
el.innerHTML = S.feed.length
? S.feed.map(feedRow).join("")
: `<div class="empty-feed">暂无日志 —— 启动一条隧道后,这里会记录连接与转发事件</div>`;
maybeScrollFeed(true);
}
function appendFeedRow(e) {
S.feed.push(e);
if (S.feed.length > 400) S.feed.shift();
const el = $("#logFeed");
const empty = $(".empty-feed", el);
if (empty) empty.remove();
el.insertAdjacentHTML("beforeend", feedRow(e));
while (el.children.length > 400) el.firstChild.remove();
if (S.view === "logs") maybeScrollFeed();
}
function maybeScrollFeed(force) {
if (!$("#autoScroll").checked && !force) return;
const el = $("#logFeed");
el.scrollTop = el.scrollHeight;
}
/* ---------- 顶部/侧栏摘要 ---------- */
function renderSummary() {
const running = S.tunnels.filter((t) => t.runtime.status === "connected").length;
const connecting = S.tunnels.filter((t) => t.runtime.status === "connecting").length;
const dot = $("#connDot"), text = $("#connText");
dot.className = "dot" + (running ? " green" : connecting ? " amber pulse" : "");
text.textContent = running
? `${running} 条隧道运行中`
: connecting ? "正在连接…" : "没有运行中的隧道";
}
/* ---------- Toast ---------- */
function toast(msg, type = "ok") {
const el = document.createElement("div");
el.className = `toast ${type}`;
el.innerHTML = `<span class="t-ic">${icon(type === "error" ? "alert" : "check", 14, 2)}</span><span class="t-msg">${esc(msg)}</span>`;
$("#toastRoot").appendChild(el);
setTimeout(() => { el.classList.add("out"); setTimeout(() => el.remove(), 300); }, 3600);
}
/* ---------- 弹窗框架(支持叠加) ---------- */
const modalStack = [];
function openModal({ title, body, footer }) {
const overlay = document.createElement("div");
overlay.className = "overlay";
overlay.innerHTML = `
<div class="modal" role="dialog">
<div class="m-head"><h2>${esc(title)}</h2>
<button class="ibtn" data-close>${icon("x", 14)}</button></div>
<form novalidate>
<div class="m-body">${body}</div>
<div class="m-foot">${footer}</div>
</form>
</div>`;
$("#modalRoot").appendChild(overlay);
const ctx = { overlay, onOk: null, onClose: null };
modalStack.push(ctx);
const close = () => closeModal(ctx);
overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); });
$("[data-close]", overlay).addEventListener("click", close);
$$("[data-cancel]", overlay).forEach((b) => b.addEventListener("click", close));
$("form", overlay).addEventListener("submit", (e) => {
e.preventDefault();
if (ctx.onOk) ctx.onOk();
});
const first = $("input, select", overlay);
if (first) first.focus();
ctx.close = close;
return ctx;
}
function closeModal(ctx) {
const c = ctx || modalStack[modalStack.length - 1];
if (!c) return;
const i = modalStack.indexOf(c);
if (i >= 0) modalStack.splice(i, 1);
if (c.onClose) c.onClose();
c.overlay.remove();
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modalStack.length) closeModal();
});
const footBtns = (okText, opts = {}) => `
${opts.dangerSide ? `<button type="button" class="btn danger sm" data-side>${opts.dangerSide}</button><span class="grow"></span>` : `<span class="grow"></span>`}
<button type="button" class="btn sm subtle" data-cancel>取消</button>
<button type="submit" class="btn sm ${opts.okClass || "primary"}">${esc(okText)}</button>`;
/* ---------- 主机编辑弹窗 ---------- */
function openHostModal(existing, opts = {}) {
const h = existing || {};
const ctx = openModal({
title: existing ? "编辑主机" : "新建主机",
body: `
<div class="field"><label>备注名称</label>
<input class="ipt" name="name" placeholder="例如:公司跳板机" value="${esc(h.name || "")}"></div>
<div class="row2-uneven">
<div class="field"><label>主机地址</label>
<input class="ipt mono" name="hostname" placeholder="例如 203.0.113.10 或 gw.example.com" value="${esc(h.hostname || "")}"></div>
<div class="field"><label>SSH 端口</label>
<input class="ipt mono" name="port" value="${esc(h.port || 22)}"></div>
</div>
<div class="field"><label>用户名</label>
<input class="ipt" name="username" placeholder="SSH 登录用户" value="${esc(h.username || "")}"></div>
<div class="field"><label>认证方式</label>
<div class="seg" name="auth">
<button type="button" data-v="password" class="${(h.auth || "password") === "password" ? "on" : ""}">${icon("lock", 13)} 密码</button>
<button type="button" data-v="key" class="${h.auth === "key" ? "on" : ""}">${icon("key", 13)} 私钥</button>
</div></div>
<div data-auth="password" style="${h.auth === "key" ? "display:none" : ""}">
<div class="field"><label>密码</label>
<input class="ipt" type="password" name="password" placeholder="${h.has_password ? "已保存 — 留空则不修改" : "登录密码"}"
autocomplete="new-password"></div>
</div>
<div data-auth="key" style="${h.auth === "key" ? "" : "display:none"}">
<div class="field"><label>私钥文件</label>
<div class="row2-uneven">
<input class="ipt mono" name="key_path" placeholder="例如 C:\\Users\\你\\.ssh\\id_ed25519" value="${esc(h.key_path || "")}">
<button type="button" class="btn" data-pick style="width:100%">浏览…</button>
</div>
<div class="hint">支持 OpenSSH 格式的私钥(id_ed25519 / id_rsa 等)</div></div>
<div class="field"><label>私钥口令(可选)</label>
<input class="ipt" type="password" name="key_passphrase" placeholder="${h.has_passphrase ? "已保存 — 留空则不修改" : "私钥若有口令"}"
autocomplete="new-password"></div>
</div>
<div class="field"><label>备注(可选)</label>
<input class="ipt" name="note" placeholder="" value="${esc(h.note || "")}"></div>
<div class="probe-result" data-probe style="display:none"></div>
`,
footer: `<button type="button" class="btn sm subtle" data-probe-btn style="margin-right:auto">${icon("play", 12, 2)} 测试连接</button>
${footBtns(existing ? "保存" : "添加")}`,
});
const ov = ctx.overlay;
let auth = h.auth || "password";
const segBtns = $$("[name=auth] button", ov);
segBtns.forEach((b) => b.addEventListener("click", () => {
auth = b.dataset.v;
segBtns.forEach((x) => x.classList.toggle("on", x === b));
$("[data-auth=password]", ov).style.display = auth === "password" ? "" : "none";
$("[data-auth=key]", ov).style.display = auth === "key" ? "" : "none";
}));
$("[data-pick]", ov).addEventListener("click", async () => {
try {
const r = await call("pick_key_file");
if (r && r.path) $("input[name=key_path]", ov).value = r.path;
} catch (e) { toast(e.message, "error"); }
});
const collect = () => ({
id: h.id, name: v("name"), hostname: v("hostname"), port: v("port"),
username: v("username"), auth,
password: $("input[name=password]", ov).value,
key_path: v("key_path"), key_passphrase: $("input[name=key_passphrase]", ov).value,
note: v("note"),
has_password: !!h.has_password, has_passphrase: !!h.has_passphrase,
});
function v(name) { return $(`input[name=${name}]`, ov).value.trim(); }
const showProbe = (ok, msg) => {
const el = $("[data-probe]", ov);
el.style.display = "";
el.className = `probe-result ${ok ? "ok" : "bad"}`;
el.innerHTML = `${icon(ok ? "check" : "alert", 14, 2)}<span>${esc(msg)}</span>`;
};
$("[data-probe-btn]", ov).addEventListener("click", async () => {
const btn = $("[data-probe-btn]", ov);
btn.disabled = true;
btn.textContent = "测试中…";
try { const r = await call("probe_host", collect()); showProbe(r.ok, r.message || r.error || ""); }
catch (e) { showProbe(false, e.message); }
btn.disabled = false;
btn.innerHTML = `${icon("play", 12, 2)} 测试连接`;
});
ctx.onOk = async () => {
const p = collect();
let bad = false;
if (!p.hostname) { markErr("hostname"), bad = true; }
if (!p.username) { markErr("username"), bad = true; }
if (!isPort(p.port)) { markErr("port"), bad = true; }
if (bad) return;
try {
const r = await call("save_host", p);
applyState(r.state);
toast(existing ? "主机已更新" : "主机已添加");
closeModal(ctx);
if (opts.onSaved) opts.onSaved(r.id);
} catch (e) { toast(e.message, "error"); }
};
function markErr(name) { $(`input[name=${name}]`, ov).classList.add("err"); }
return ctx;
}
/* ---------- 隧道编辑弹窗 ---------- */
function openTunnelModal(existing) {
const t = existing || {};
const hostOpts = () => S.hosts.map((h) =>
`<option value="${h.id}" ${t.host_id === h.id ? "selected" : ""}>${esc(h.name)}(${esc(h.username)}@${esc(h.hostname)})</option>`).join("");
const ctx = openModal({
title: existing ? "编辑隧道" : "新建隧道",
body: `
<div class="field"><label>名称</label>
<input class="ipt" name="name" placeholder="${esc(t.name || "例如 dev-web")}" value="${esc(existing ? t.name : "")}"></div>
<div class="field"><label>SSH 主机</label>
<div class="row2-uneven">
<div class="sel-wrap"><select class="ipt" name="host_id">${hostOpts()}</select></div>
<button type="button" class="btn" data-newhost style="width:100%">新主机…</button>
</div></div>
<div class="row2">
<div class="field"><label>远程地址</label>
<input class="ipt mono" name="remote_host" value="${esc(t.remote_host || "127.0.0.1")}">
<div class="hint">服务在远程主机上监听的地址,只对本机开放的服务填 127.0.0.1</div></div>
<div class="field"><label>远程端口</label>
<input class="ipt mono" name="remote_port" placeholder="例如 3000" value="${esc(t.remote_port || "")}"></div>
</div>
<div class="field"><label>本地端口</label>
<input class="ipt mono" name="local_port" placeholder="本机访问用的端口" value="${esc(t.local_port || "")}">
<div class="hint">建立后,在本机打开 <b>127.0.0.1:本地端口</b>,等同于直接访问远程的这个服务</div></div>
<div class="switch-row">
<span class="sw-text"><span class="sw-title">自动重连</span><br>
<span class="sw-desc">连接断开后自动重试,重试间隔逐渐拉长</span></span>
<input type="checkbox" class="switch" name="autoreconnect" ${t.autoreconnect !== false && !("autoreconnect" in t) || t.autoreconnect ? "checked" : ""}>
</div>
<div class="switch-row">
<span class="sw-text"><span class="sw-title">随应用启动</span><br>
<span class="sw-desc">打开管理器时自动建立这条隧道</span></span>
<input type="checkbox" class="switch" name="autostart" ${t.autostart ? "checked" : ""}>
</div>
`,
footer: footBtns(existing ? "保存" : "创建隧道"),
});
const ov = ctx.overlay;
if (!S.hosts.length) $("select[name=host_id]", ov).innerHTML = `<option value="">—— 请先添加主机 ——</option>`;
// 远程端口 → 本地端口跟随
const rp = $("input[name=remote_port]", ov), lp = $("input[name=local_port]", ov);
rp.addEventListener("input", () => { if (!lp.dataset.touched && isPort(rp.value)) lp.value = rp.value; });
lp.addEventListener("input", () => { lp.dataset.touched = "1"; });
$("[data-newhost]", ov).addEventListener("click", () => {
openHostModal(null, {
onSaved: (id) => {
const sel = $("select[name=host_id]", ov);
sel.innerHTML = S.hosts.map((h) =>
`<option value="${h.id}" ${h.id === id ? "selected" : ""}>${esc(h.name)}(${esc(h.username)}@${esc(h.hostname)})</option>`).join("");
},
});
});
ctx.onOk = async () => {
const v = (n) => $(`[name=${n}]`, ov).value.trim();
let bad = false;
if (!isPort(v("remote_port"))) { markErr("remote_port"); bad = true; }
if (!isPort(v("local_port"))) { markErr("local_port"); bad = true; }
if (!v("host_id")) { toast("请选择主机(或先新建一台)", "error"); bad = true; }
if (bad) return;
try {
const r = await call("save_tunnel", {
id: t.id, name: v("name"), host_id: v("host_id"),
remote_host: v("remote_host") || "127.0.0.1", remote_port: v("remote_port"),
local_port: v("local_port"),
autostart: $("[name=autostart]", ov).checked,
autoreconnect: $("[name=autoreconnect]", ov).checked,
});
applyState(r.state);
toast(existing ? "隧道已更新" : "隧道已创建,点「启动」建立连接");
closeModal(ctx);
} catch (e) { toast(e.message, "error"); }
};
function markErr(n) { $(`[name=${n}]`, ov).classList.add("err"); }
}
/* ---------- 删除确认 ---------- */
function confirmDanger(title, html) {
return new Promise((resolve) => {
const ctx = openModal({
title,
body: `<div class="confirm-text">${html}</div>`,
footer: `<span class="grow"></span>
<button type="button" class="btn sm subtle" data-cancel>取消</button>
<button type="button" class="btn sm danger" data-ok>删除</button>`,
});
ctx.onClose = () => resolve(false);
$("[data-ok]", ctx.overlay).addEventListener("click", () => {
ctx.onClose = null; resolve(true); closeModal(ctx);
});
});
}
/* ---------- 事件绑定 ---------- */
function switchView(name) {
S.view = name;
$$(".nav-item").forEach((b) => b.classList.toggle("on", b.dataset.view === name));
$$(".view").forEach((v) => v.classList.toggle("on", v.id === `view-${name}`));
if (name === "logs") maybeScrollFeed(true);
}
function bindOnce() {
$$(".nav-item").forEach((b) => b.addEventListener("click", () => switchView(b.dataset.view)));
$("#btnNewTunnel").addEventListener("click", () => openTunnelModal());
$("#btnEmptyNewTunnel").addEventListener("click", () => openTunnelModal());
$("#btnNewHost").addEventListener("click", () => openHostModal());
$("#btnEmptyNewHost").addEventListener("click", () => openHostModal());
$("#btnClearLogs").addEventListener("click", () => { S.feed = []; renderFeed(); });
// 隧道卡片(事件委托)
$("#tunnelGrid").addEventListener("click", async (e) => {
const card = e.target.closest(".tcard");
if (!card) return;
const t = S.tunnels.find((x) => x.id === card.dataset.id);
if (!t) return;
const act = e.target.closest("[data-act]")?.dataset.act;
if (!act) return;
try {
if (act === "start") await call("start_tunnel", t.id);
else if (act === "stop") await call("stop_tunnel", t.id);
else if (act === "edit") openTunnelModal(t);
else if (act === "del") {
const ok = await confirmDanger("删除隧道",
`确定删除隧道 <b>${esc(t.name)}</b>?<br>映射关系 127.0.0.1:${t.local_port} → ${esc(t.remote_host)}:${t.remote_port} 将被移除。`);
if (!ok) return;
const r = await call("delete_tunnel", t.id);
applyState(r.state);
toast("隧道已删除");
} else if (act === "copy") {
await call("copy_text", `http://127.0.0.1:${t.local_port}`);
toast("已复制 " + `http://127.0.0.1:${t.local_port}`);
} else if (act === "open") {
await call("open_url", `http://127.0.0.1:${t.local_port}`);
} else if (act === "log") {
S.openLogs.has(t.id) ? S.openLogs.delete(t.id) : S.openLogs.add(t.id);
renderTunnels();
}
} catch (err) { toast(err.message, "error"); }
});
// 主机卡片
$("#hostList").addEventListener("click", async (e) => {
const card = e.target.closest(".hcard");
if (!card) return;
const h = S.hosts.find((x) => x.id === card.dataset.id);
if (!h) return;
const act = e.target.closest("[data-act]")?.dataset.act;
if (!act) return;
try {
if (act === "edit") openHostModal(h);
else if (act === "del") {
const used = S.tunnels.filter((t) => t.host_id === h.id);
if (used.length) { toast(`该主机正被「${used.map((t) => t.name).join("、")}」使用,请先删除相关隧道`, "error"); return; }
const ok = await confirmDanger("删除主机",
`确定删除主机 <b>${esc(h.name)}</b>(${esc(h.username)}@${esc(h.hostname)})?`);
if (!ok) return;
const r = await call("delete_host", h.id);
applyState(r.state);
toast("主机已删除");
}
} catch (err) { toast(err.message, "error"); }
});
// 窗口控制
$("#btnMin").addEventListener("click", () => call("win_minimize").catch(() => {}));
$("#btnMax").addEventListener("click", () => call("win_maximize").catch(() => {}));
$("#btnClose").addEventListener("click", () => call("win_close").catch(() => {}));
$("#dragZone").addEventListener("dblclick", () => call("win_maximize").catch(() => {}));
// 运行时长刷新
setInterval(() => {
$$("#tunnelGrid .tcard").forEach((card) => {
const t = S.tunnels.find((x) => x.id === card.dataset.id);
if (!t || t.runtime.status !== "connected" || !t.runtime.started_at) return;
const st = $("[data-status]", card);
st.textContent = `已连接 · ${fmtUptime(Date.now() / 1000 - t.runtime.started_at)}`;
});
}, 1000);
}
/* ---------- 启动 ---------- */
function whenReady() {
return new Promise((resolve) => {
if (nativeApi()) return resolve();
window.addEventListener("pywebviewready", () => resolve(), { once: true });
setTimeout(resolve, 1500); // 浏览器 Mock 兜底
});
}
(async function init() {
bindOnce();
renderAll();
await whenReady();
try {
const r = await withTimeout(call("get_state"), 5000, "连接应用后端超时");
applyState(r.state);
} catch (e) {
toast("加载配置失败:" + e.message, "error");
}
})();