(() => { const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const METRICS_INTERVAL_MS = 2000; const MAX_EDIT_BYTES = 10 * 1024 * 1024; const state = { status: null, sites: [], databases: [], busy: false, filesPath: "/", files: [], filesSelected: new Set(), // absolute paths in current dir filesLastClicked: "", // for shift-range select metricsTimer: null, metricsRange: "10m", metricCharts: null, editor: null, editorTabs: [], // { path, model, readonly, dirty, savedValue } editorActive: "", editorTreePath: "/", editorTreeEntries: [], editorBound: false, monacoReady: null, tasks: [], taskActive: 0, selectedTaskId: "", taskLogOffset: 0, taskPanelOpen: false, taskTab: "active", // active | history taskStatusMap: {}, bsModal: null, uploadModal: null, uploadQueue: [], // { id, relativePath, file, status, error } uploadBusy: false, siteSettingsModal: null, siteSettings: null, // current site object siteLogKind: "access", siteLogIp: "", siteLogQuery: "", siteLogSearchTimer: null, siteSslUploadTarget: "", // cert | key analyticsModal: null, analyticsSite: "", analyticsCharts: [], siteModules: [], siteModulesFilter: "all", siteModuleOps: {}, // `${site}::${name_en}` -> { act, taskId } siteProxies: [], processes: [], procQuery: "", procSort: "cpu_percent", procSortDir: "desc", procTimer: null, siteNginxEditor: null, siteNginxPath: "", }; const titles = { console: { title: "控制台", eyebrow: "Console" }, software: { title: "软件管理", eyebrow: "Software" }, sites: { title: "网站管理", eyebrow: "Websites" }, mysql: { title: "数据库", eyebrow: "MySQL" }, files: { title: "文件管理", eyebrow: "Files" }, processes: { title: "进程管理", eyebrow: "Processes" }, }; function toast(msg, type = "ok") { const el = document.createElement("div"); el.className = `toast-item ${type === "err" ? "err" : "ok"}`; el.textContent = msg; $("#toast-host").appendChild(el); setTimeout(() => { el.style.opacity = "0"; el.style.transition = "opacity 0.25s ease"; setTimeout(() => el.remove(), 260); }, type === "ok" ? 1600 : 3200); } async function api(path, options = {}) { const opts = { method: options.method || "GET", credentials: "same-origin", headers: { Accept: "application/json" }, ...options, }; if (opts.body && typeof opts.body === "object") { opts.headers = { ...(opts.headers || {}), "Content-Type": "application/json", Accept: "application/json" }; opts.body = JSON.stringify(opts.body); } const res = await fetch(path, opts); let json = null; try { json = await res.json(); } catch (_) { throw new Error(`无效响应 (${res.status})`); } if (json && Number(json.code) === 401) { location.replace("/login.html"); throw new Error(json.msg || "未登录"); } if (!json || json.code !== 200) { throw new Error((json && json.msg) || `请求失败 (${res.status})`); } return json.data; } async function withBusy(fn, btn) { if (state.busy) return; state.busy = true; if (btn) btn.disabled = true; try { await fn(); } catch (err) { toast(err.message || String(err), "err"); } finally { state.busy = false; if (btn) btn.disabled = false; } } function setView(name) { if (name === "overview" || name === "monitor") name = "console"; $$(".nav-item[data-view]").forEach((b) => b.classList.toggle("is-active", b.dataset.view === name)); $$(".view").forEach((v) => v.classList.toggle("is-active", v.dataset.viewPanel === name)); const t = titles[name] || titles.console; $("#page-title").textContent = t.title; $("#page-eyebrow").textContent = t.eyebrow; history.replaceState(null, "", `#${name}`); if (name === "console") startMetrics(); else stopMetrics(); if (name === "files") loadFiles(state.filesPath); if (name === "processes") { loadProcesses().catch((err) => toast(err.message || String(err), "err")); startProcAutoRefresh(); } else { stopProcAutoRefresh(); } } function escapeHtml(s) { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function formatBytes(n) { const v = Number(n) || 0; if (v < 1024) return `${v} B`; const units = ["KB", "MB", "GB", "TB"]; let x = v / 1024; let i = 0; while (x >= 1024 && i < units.length - 1) { x /= 1024; i += 1; } return `${x.toFixed(x >= 10 ? 0 : 1)} ${units[i]}`; } function formatTime(sec) { if (!sec) return "—"; const d = new Date(Number(sec) * 1000); if (Number.isNaN(d.getTime())) return "—"; return d.toLocaleString(); } function joinPath(base, name) { if (base === "/") return `/${name}`; return `${base.replace(/\/+$/, "")}/${name}`; } function parentPath(p) { if (!p || p === "/") return "/"; const parts = p.replace(/\/+$/, "").split("/"); parts.pop(); return parts.length <= 1 ? "/" : parts.join("/"); } function fileIcon(name, isDir) { if (isDir) return ``; const ext = (String(name).includes(".") ? String(name).split(".").pop() : "").toLowerCase(); const map = { js: ["bi-filetype-js", "code"], mjs: ["bi-filetype-js", "code"], ts: ["bi-filetype-tsx", "code"], tsx: ["bi-filetype-tsx", "code"], jsx: ["bi-filetype-jsx", "code"], py: ["bi-filetype-py", "code"], go: ["bi-filetype-jsx", "code"], rs: ["bi-filetype-jsx", "code"], java: ["bi-filetype-java", "code"], c: ["bi-filetype-c", "code"], h: ["bi-filetype-h", "code"], cpp: ["bi-filetype-cpp", "code"], hpp: ["bi-filetype-hpp", "code"], php: ["bi-filetype-php", "code"], rb: ["bi-filetype-rb", "code"], sh: ["bi-terminal", "code"], bash: ["bi-terminal", "code"], html: ["bi-filetype-html", "web"], htm: ["bi-filetype-html", "web"], css: ["bi-filetype-css", "web"], scss: ["bi-filetype-scss", "web"], json: ["bi-filetype-json", "code"], xml: ["bi-filetype-xml", "code"], yaml: ["bi-filetype-yml", "cfg"], yml: ["bi-filetype-yml", "cfg"], md: ["bi-markdown", "doc"], txt: ["bi-filetype-txt", "doc"], log: ["bi-journal-text", "doc"], conf: ["bi-gear", "cfg"], cfg: ["bi-gear", "cfg"], ini: ["bi-gear", "cfg"], sql: ["bi-filetype-sql", "code"], png: ["bi-filetype-png", "img"], jpg: ["bi-filetype-jpg", "img"], jpeg: ["bi-filetype-jpg", "img"], gif: ["bi-filetype-gif", "img"], svg: ["bi-filetype-svg", "img"], webp: ["bi-file-earmark-image", "img"], zip: ["bi-file-earmark-zip", "doc"], gz: ["bi-file-earmark-zip", "doc"], tar: ["bi-file-earmark-zip", "doc"], pdf: ["bi-filetype-pdf", "doc"], }; const [icon, cls] = map[ext] || ["bi-file-earmark", "doc"]; return ``; } function langFromPath(path) { const name = String(path || "").split("/").pop() || ""; const ext = (name.includes(".") ? name.split(".").pop() : "").toLowerCase(); const map = { js: "javascript", mjs: "javascript", cjs: "javascript", ts: "typescript", tsx: "typescript", jsx: "javascript", json: "json", html: "html", htm: "html", css: "css", scss: "scss", md: "markdown", py: "python", go: "go", rs: "rust", java: "java", c: "c", h: "c", cpp: "cpp", cc: "cpp", hpp: "cpp", php: "php", sh: "shell", bash: "shell", sql: "sql", xml: "xml", yaml: "yaml", yml: "yaml", toml: "ini", ini: "ini", conf: "ini", cfg: "ini", txt: "plaintext", log: "plaintext", }; return map[ext] || "plaintext"; } async function ensureMonaco() { if (window.monaco) return Promise.resolve(window.monaco); if (state.monacoReady) return state.monacoReady; state.monacoReady = new Promise((resolve, reject) => { if (typeof require === "undefined") { reject(new Error("Monaco loader 未加载")); return; } // Workers resolve modules via fetch(); relative "/assets/..." paths are // invalid inside WorkerGlobalScope — force absolute URLs via baseUrl proxy. const monacoBase = `${window.location.origin}/assets/monaco`; window.MonacoEnvironment = { getWorkerUrl() { const code = `self.MonacoEnvironment={baseUrl:${JSON.stringify(monacoBase + "/")}};` + `importScripts(${JSON.stringify(monacoBase + "/vs/base/worker/workerMain.js")});`; return URL.createObjectURL(new Blob([code], { type: "text/javascript" })); }, }; window.require.config({ paths: { vs: "/assets/monaco/vs" } }); window.require(["vs/editor/editor.main"], () => { if (window.monaco) resolve(window.monaco); else reject(new Error("Monaco 初始化失败")); }, reject); }); return state.monacoReady; } function fileBaseName(path) { const parts = String(path || "").split("/").filter(Boolean); return parts.length ? parts[parts.length - 1] : path || "/"; } function findTab(path) { return state.editorTabs.find((t) => t.path === path); } function activeTab() { return findTab(state.editorActive); } function syncEditorChrome() { const tab = activeTab(); $("#code-editor-path").textContent = tab ? tab.path : ""; $("#code-editor-mode").textContent = tab ? tab.readonly ? "预览" : tab.dirty ? "编辑 *" : "编辑" : "编辑器"; } function renderEditorTabs() { const host = $("#editor-tabs"); if (!state.editorTabs.length) { host.innerHTML = `
未打开文件
`; return; } host.innerHTML = state.editorTabs .map((t) => { const active = t.path === state.editorActive ? " is-active" : ""; const dirty = t.dirty ? " is-dirty" : ""; return `
${escapeHtml(fileBaseName(t.path))}
`; }) .join(""); } function renderEditorTree() { $("#editor-tree-path").textContent = state.editorTreePath || "/"; const host = $("#editor-tree"); const entries = state.editorTreeEntries || []; if (!entries.length) { host.innerHTML = `
空目录
`; return; } host.innerHTML = entries .map((e) => { const full = joinPath(state.editorTreePath, e.name); const open = full === state.editorActive ? " is-open" : ""; return ``; }) .join(""); } async function loadEditorTree(path) { const data = await api(`/api/files/list?path=${encodeURIComponent(path || "/")}`); state.editorTreePath = (data && data.path) || path || "/"; state.editorTreeEntries = (data && data.entries) || []; renderEditorTree(); } async function ensureEditorInstance() { const monaco = await ensureMonaco(); if (state.editor) return monaco; const host = $("#monaco-host"); host.innerHTML = ""; state.editor = monaco.editor.create(host, { theme: "vs-dark", automaticLayout: true, minimap: { enabled: true }, fontSize: 14, wordWrap: "on", scrollBeyondLastLine: false, tabSize: 2, }); state.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { withBusy(async () => { await saveActiveTab(); }); }); return monaco; } async function switchEditorTab(path) { const tab = findTab(path); if (!tab || !state.editor) return; const prev = activeTab(); if (prev && prev.model && state.editor) { prev.viewState = state.editor.saveViewState(); } state.editorActive = path; state.editor.setModel(tab.model); state.editor.updateOptions({ readOnly: !!tab.readonly }); if (tab.viewState) state.editor.restoreViewState(tab.viewState); state.editor.focus(); syncEditorChrome(); renderEditorTabs(); renderEditorTree(); } async function closeEditorTab(path) { const idx = state.editorTabs.findIndex((t) => t.path === path); if (idx < 0) return; const tab = state.editorTabs[idx]; if ( tab.dirty && !(await confirmDialog({ title: "未保存", message: `「${fileBaseName(path)}」未保存,确认关闭?`, danger: true, })) ) { return; } if (tab.model) tab.model.dispose(); state.editorTabs.splice(idx, 1); if (state.editorActive === path) { const next = state.editorTabs[idx] || state.editorTabs[idx - 1] || null; if (next) await switchEditorTab(next.path); else { state.editorActive = ""; if (state.editor) state.editor.setModel(null); syncEditorChrome(); renderEditorTabs(); renderEditorTree(); } } else { renderEditorTabs(); renderEditorTree(); } } async function openCodeEditor({ path, content, readonly }) { $("#code-editor").hidden = false; applyDialogSize(document.querySelector(".code-editor-window"), "ngs.dialogSize.codeEditor", { minW: 560, minH: 360, }); const monaco = await ensureEditorInstance(); let tab = findTab(path); if (!tab) { const model = monaco.editor.createModel(content || "", langFromPath(path)); tab = { path, model, readonly: !!readonly, dirty: false, savedValue: content || "", viewState: null, }; model.onDidChangeContent(() => { tab.dirty = model.getValue() !== tab.savedValue; syncEditorChrome(); renderEditorTabs(); }); state.editorTabs.push(tab); } else if (!readonly && content != null && !tab.dirty) { tab.model.setValue(content); tab.savedValue = content; tab.dirty = false; } if (!state.editorTreePath || state.editorTreePath === "/") { await loadEditorTree(parentPath(path) || "/").catch(() => loadEditorTree("/")); } else { renderEditorTree(); } await switchEditorTab(path); } async function closeCodeEditor() { const dirty = state.editorTabs.some((t) => t.dirty); if ( dirty && !(await confirmDialog({ title: "未保存", message: "有未保存的文件,确认关闭编辑器?", danger: true, })) ) { return; } state.editorTabs.forEach((t) => { if (t.model) t.model.dispose(); }); state.editorTabs = []; state.editorActive = ""; if (state.editor) { state.editor.dispose(); state.editor = null; } $("#monaco-host").innerHTML = ""; $("#editor-tabs").innerHTML = ""; $("#editor-tree").innerHTML = ""; $("#code-editor").hidden = true; } async function saveActiveTab() { const tab = activeTab(); if (!tab || tab.readonly || !tab.model) return; const content = tab.model.getValue(); if (new TextEncoder().encode(content).length > MAX_EDIT_BYTES) { throw new Error("内容超过 10MB,无法保存"); } await api("/api/files/write", { method: "POST", body: { path: tab.path, content }, }); tab.savedValue = content; tab.dirty = false; syncEditorChrome(); renderEditorTabs(); toast("保存成功"); if ((location.hash || "").slice(1) === "files") { await loadFiles(state.filesPath).catch(() => {}); } } async function openFileInEditor(path, size, readonly) { if (Number(size) > MAX_EDIT_BYTES) { throw new Error("文件超过 10MB,不支持在线编辑/预览"); } const existing = findTab(path); if (existing) { $("#code-editor").hidden = false; await ensureEditorInstance(); await switchEditorTab(path); await loadEditorTree(parentPath(path) || state.editorTreePath || "/").catch(() => {}); return; } const data = await api(`/api/files/read?path=${encodeURIComponent(path)}`); await openCodeEditor({ path, content: (data && data.content) || "", readonly: !!readonly, }); await loadEditorTree(parentPath(path) || "/").catch(() => loadEditorTree("/")); } async function openConfigFile(path) { if (!path) throw new Error("配置文件路径未知"); await openFileInEditor(path, 0, false); } function formatRate(bps) { const v = Math.max(0, Number(bps) || 0); if (v < 1024) return `${Math.round(v)} B/s`; let x = v / 1024; const units = ["KB/s", "MB/s", "GB/s", "TB/s"]; let i = 0; while (x >= 1024 && i < units.length - 1) { x /= 1024; i += 1; } const digits = x >= 100 ? 0 : x >= 10 ? 1 : 2; return `${x.toFixed(digits)} ${units[i]}`; } function formatMetricLabel(timeMs, range) { const d = new Date(Number(timeMs) || 0); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); const ss = String(d.getSeconds()).padStart(2, "0"); if (range === "1m" || range === "10m" || range === "30m") { return `${hh}:${mm}:${ss}`; } if (range === "1h" || range === "6h") { return `${hh}:${mm}`; } const mo = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${mo}-${day} ${hh}:${mm}`; } function chartDefaults() { return { responsive: true, maintainAspectRatio: false, animation: { duration: 280 }, interaction: { mode: "index", intersect: false }, plugins: { legend: { display: false }, tooltip: { backgroundColor: "rgba(15,23,42,0.92)", titleFont: { size: 11 }, bodyFont: { size: 12 }, padding: 10, cornerRadius: 8, }, }, scales: { x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 6, color: "#94a3b8", font: { size: 10 }, }, border: { display: false }, }, y: { beginAtZero: true, grid: { color: "rgba(148,163,184,0.18)" }, ticks: { color: "#94a3b8", font: { size: 10 } }, border: { display: false }, }, }, elements: { line: { tension: 0.35, borderWidth: 2 }, point: { radius: 0, hoverRadius: 3 }, }, }; } function makePercentChart(canvas, label, color) { const fill = color.replace(")", ", 0.14)").replace("rgb", "rgba"); return new Chart(canvas, { type: "line", data: { labels: [], datasets: [{ label, data: [], borderColor: color, backgroundColor: fill, fill: true, }], }, options: { ...chartDefaults(), scales: { ...chartDefaults().scales, y: { ...chartDefaults().scales.y, suggestedMax: 100, ticks: { ...chartDefaults().scales.y.ticks, callback: (v) => `${Number(v).toFixed(0)}%`, }, }, }, plugins: { ...chartDefaults().plugins, tooltip: { ...chartDefaults().plugins.tooltip, callbacks: { label: (ctx) => `${ctx.dataset.label}: ${Number(ctx.parsed.y).toFixed(1)}%`, }, }, }, }, }); } function makeNetChart(canvas) { return new Chart(canvas, { type: "line", data: { labels: [], datasets: [ { label: "下行", data: [], borderColor: "#0d9488", backgroundColor: "rgba(13, 148, 136, 0.14)", fill: true, }, { label: "上行", data: [], borderColor: "#ea580c", backgroundColor: "rgba(234, 88, 12, 0.12)", fill: true, }, ], }, options: { ...chartDefaults(), plugins: { ...chartDefaults().plugins, legend: { display: true, position: "top", align: "end", labels: { boxWidth: 10, boxHeight: 10, font: { size: 11 }, color: "#64748b" }, }, tooltip: { ...chartDefaults().plugins.tooltip, callbacks: { label: (ctx) => `${ctx.dataset.label}: ${formatRate(ctx.parsed.y)}`, }, }, }, scales: { ...chartDefaults().scales, y: { ...chartDefaults().scales.y, ticks: { ...chartDefaults().scales.y.ticks, callback: (v) => formatRate(v), }, }, }, }, }); } function ensureMetricCharts() { if (state.metricCharts || typeof Chart === "undefined") return state.metricCharts; state.metricCharts = { cpu: makePercentChart($("#chart-cpu"), "CPU", "rgb(15, 118, 110)"), mem: makePercentChart($("#chart-mem"), "内存", "rgb(3, 105, 161)"), disk: makePercentChart($("#chart-disk"), "磁盘", "rgb(180, 83, 9)"), net: makeNetChart($("#chart-net")), }; return state.metricCharts; } function applyHistoryToCharts(items, range) { const charts = ensureMetricCharts(); if (!charts) return; const labels = items.map((p) => formatMetricLabel(p.time_ms, range)); const cpu = items.map((p) => Number(p.cpu_percent) || 0); const mem = items.map((p) => Number(p.mem_percent) || 0); const disk = items.map((p) => Number(p.disk_percent) || 0); const rx = items.map((p) => Number(p.net_rx_bps) || 0); const tx = items.map((p) => Number(p.net_tx_bps) || 0); const applyOne = (chart, data) => { chart.data.labels = labels; chart.data.datasets[0].data = data; chart.update("none"); }; applyOne(charts.cpu, cpu); applyOne(charts.mem, mem); applyOne(charts.disk, disk); charts.net.data.labels = labels; charts.net.data.datasets[0].data = rx; charts.net.data.datasets[1].data = tx; charts.net.update("none"); const last = items.length ? items[items.length - 1] : null; if (!last) { $("#cpu-badge").textContent = "—"; $("#mem-badge").textContent = "—"; $("#disk-badge").textContent = "—"; $("#net-badge").textContent = "—"; return; } $("#cpu-badge").textContent = `${(Number(last.cpu_percent) || 0).toFixed(1)}% · ${last.cpu_cores ?? "—"} 核`; $("#mem-badge").textContent = `${formatBytes(last.mem_used)} / ${formatBytes(last.mem_total)} · ${(Number(last.mem_percent) || 0).toFixed(1)}%`; $("#disk-badge").textContent = `${formatBytes(last.disk_used)} / ${formatBytes(last.disk_total)} · ${(Number(last.disk_percent) || 0).toFixed(1)}%`; $("#net-badge").innerHTML = `↓ ${formatRate(last.net_rx_bps)}` + ` · ` + `↑ ${formatRate(last.net_tx_bps)}`; } async function pollMetrics() { try { const range = state.metricsRange || "10m"; const data = await api(`/api/system/metrics/history?range=${encodeURIComponent(range)}`); applyHistoryToCharts((data && data.items) || [], range); } catch (_) {} } function startMetrics() { if (state.metricsTimer) return; const sel = $("#metrics-range"); if (sel) { state.metricsRange = sel.value || "10m"; sel.onchange = () => { state.metricsRange = sel.value || "10m"; pollMetrics(); }; } ensureMetricCharts(); pollMetrics(); state.metricsTimer = setInterval(pollMetrics, METRICS_INTERVAL_MS); } function stopMetrics() { if (state.metricsTimer) { clearInterval(state.metricsTimer); state.metricsTimer = null; } } function statusPill(installed, running) { if (!installed) return `未安装`; if (running === true) return `运行中`; if (running === false) return `已停止`; return `已安装`; } function softCard(name, info, withActions) { const key = name.toLowerCase(); const installed = !!info.installed; const running = info.running; const bits = [`版本 ${escapeHtml(info.version || "—")}`]; if (info.port) bits.push(`端口 ${escapeHtml(info.port)}`); let actions = ""; if (withActions) { const btns = []; if (!installed) { btns.push(``); } else { if (key === "nginx" || key === "mysql" || key === "redis") { if (running) { btns.push(``); if (key === "nginx") btns.push(``); } else { btns.push(``); } if (info.conf) { btns.push(``); } } btns.push(``); } actions = `
${btns.join("")}
`; } return `
${name}
${installed ? (running === true ? "服务可用" : running === false ? "已安装未运行" : "已就绪") : "尚未安装"}
${statusPill(installed, running)}
${bits.map((b) => escapeHtml(b)).join(" · ")}
${actions}
`; } function renderConsole() { const s = state.status || {}; $("#overview-sites").textContent = `网站 ${s.sites ?? 0} · API 15665`; $("#top-meta").textContent = `站点 ${s.sites ?? 0}`; } function renderSoftware() { const s = state.status || {}; $("#software-grid").innerHTML = [ softCard("Nginx", s.nginx || {}, true), softCard("Fastweb", s.fastweb || {}, true), softCard("MySQL", s.mysql || {}, true), softCard("Redis", s.redis || {}, true), ].join(""); } function siteSslExpireCell(site) { if (!site || !site.ssl_enable) { return ``; } const days = Number(site.ssl_days_left); if (!Number.isFinite(days) || site.ssl_days_left === -1) { return ``; } if (days < 0) { return `已过期`; } const cls = days <= 30 ? "text-danger" : "text-success"; return `${days}天`; } function renderSites() { const tbody = $("#sites-table tbody"); if (!state.sites.length) { tbody.innerHTML = `暂无网站`; return; } tbody.innerHTML = state.sites .map((s) => { const running = !!s.running; const st = running ? `运行中` : `已停止`; const startDis = running ? " disabled" : ""; const stopDis = running ? "" : " disabled"; const domains = Array.isArray(s.domains) && s.domains.length ? s.domains : String(s.domain || "").split(/[\s,;]+/).filter(Boolean); const primaryTok = domains[0] || ""; const primary = parseDomainToken(primaryTok); const domainText = primaryTok ? primaryTok + (domains.length > 1 ? ` +${domains.length - 1}` : "") : "—"; const openUrl = s.ssl_enable && Number(s.ssl_port) ? `https://${primary.host}:${s.ssl_port}` : `http://${primary.host}:${primary.port || 80}`; const root = s.root || ""; return ` ${escapeHtml(s.name)} ${escapeHtml(s.type)} ${escapeHtml(domainText)} ${siteSslExpireCell(s)} ${st}
`; }) .join(""); } function renderDatabases() { const tbody = $("#db-table tbody"); if (!state.databases.length) { tbody.innerHTML = `暂无数据库记录`; return; } tbody.innerHTML = state.databases .map((d) => ` ${escapeHtml(d.name)} ${escapeHtml(d.user)} ${escapeHtml(d.password)} ${escapeHtml(d.access)} ${escapeHtml(d.host || "—")}
`) .join(""); } function stopProcAutoRefresh() { if (state.procTimer) { clearInterval(state.procTimer); state.procTimer = null; } } function startProcAutoRefresh() { stopProcAutoRefresh(); const on = $("#proc-auto")?.checked; if (!on) return; state.procTimer = setInterval(() => { const panel = $("#view-processes"); if (!panel || !panel.classList.contains("is-active")) return; loadProcesses({ silent: true }).catch(() => {}); }, 3000); } function sortedProcesses(rows) { const key = state.procSort || "cpu_percent"; const dir = state.procSortDir === "asc" ? 1 : -1; const list = rows.slice(); list.sort((a, b) => { const va = a[key]; const vb = b[key]; if (typeof va === "string" || typeof vb === "string") { return String(va || "").localeCompare(String(vb || ""), "zh") * dir; } return ((Number(va) || 0) - (Number(vb) || 0)) * dir; }); return list; } function renderProcesses() { const tbody = $("#proc-table tbody"); if (!tbody) return; $$("#proc-table th.proc-sort").forEach((th) => { const active = th.dataset.procSort === state.procSort; th.classList.toggle("is-active", active); if (active) th.dataset.sortDir = state.procSortDir || "desc"; else th.removeAttribute("data-sort-dir"); }); const rows = sortedProcesses(state.processes || []); $("#proc-count").textContent = `${rows.length} 个进程`; if (!rows.length) { tbody.innerHTML = `暂无进程`; return; } tbody.innerHTML = rows .map((p) => { const cpu = Number(p.cpu_percent) || 0; const mem = Number(p.mem_percent) || 0; const protect = Number(p.pid) <= 1; return ` ${escapeHtml(p.pid)} ${escapeHtml(p.user || "—")} ${cpu.toFixed(1)} ${mem.toFixed(1)} ${escapeHtml(formatBytes(p.rss || 0))} ${escapeHtml(p.state || "—")} ${escapeHtml(p.name || "—")} ${escapeHtml(p.cmdline || "—")}
`; }) .join(""); } async function loadProcesses(opts = {}) { const q = String(state.procQuery || "").trim(); const qs = q ? `?q=${encodeURIComponent(q)}` : ""; const tbody = $("#proc-table tbody"); if (!opts.silent && tbody) { tbody.innerHTML = `加载中…`; } const data = await api(`/api/system/processes${qs}`); state.processes = (data && data.items) || []; renderProcesses(); } async function killProcess(pid, signalName) { const label = signalName === "KILL" ? "强制结束 (SIGKILL)" : "结束 (SIGTERM)"; const ok = await confirmDialog({ title: label, message: `确认向进程 PID ${pid} 发送 ${signalName}?`, okText: signalName === "KILL" ? "强杀" : "结束", danger: true, }); if (!ok) return; await api("/api/system/processes/kill", { method: "POST", body: { pid: Number(pid), signal_name: signalName }, }); toast(`已发送 ${signalName} → PID ${pid}`); await loadProcesses({ silent: true }); } function renderCrumbs(path) { const parts = path === "/" ? [] : path.replace(/^\/+/, "").split("/").filter(Boolean); let cur = ""; const bits = [``]; parts.forEach((p, i) => { cur += `/${p}`; // Root button already shows "/", so only add "/" separators between later segments. if (i > 0) bits.push(`/`); bits.push(``); }); $("#files-crumbs").innerHTML = bits.join(""); } function clearFilesSelection() { state.filesSelected = new Set(); state.filesLastClicked = ""; } function visibleFilePaths() { return (state.files || []).map((f) => joinPath(state.filesPath, f.name)); } function updateFilesSelectionUi() { const n = state.filesSelected.size; const bulk = $("#files-bulk-actions"); const countEl = $("#files-selected-count"); if (bulk) bulk.hidden = n === 0; if (countEl) countEl.textContent = `已选 ${n}`; const paths = visibleFilePaths(); const all = $("#files-select-all"); if (all) { const selectedVisible = paths.filter((p) => state.filesSelected.has(p)).length; all.checked = paths.length > 0 && selectedVisible === paths.length; all.indeterminate = selectedVisible > 0 && selectedVisible < paths.length; all.disabled = paths.length === 0; } } function setFileSelected(path, selected) { if (!path) return; if (selected) state.filesSelected.add(path); else state.filesSelected.delete(path); } function toggleFileRange(fromPath, toPath, selected) { const paths = visibleFilePaths(); const a = paths.indexOf(fromPath); const b = paths.indexOf(toPath); if (a < 0 || b < 0) { setFileSelected(toPath, selected); return; } const lo = Math.min(a, b); const hi = Math.max(a, b); for (let i = lo; i <= hi; i++) setFileSelected(paths[i], selected); } function renderFiles() { renderCrumbs(state.filesPath); const tbody = $("#files-table tbody"); const valid = new Set(visibleFilePaths()); state.filesSelected = new Set([...state.filesSelected].filter((p) => valid.has(p))); if (!state.files.length) { tbody.innerHTML = `空目录`; updateFilesSelectionUi(); return; } tbody.innerHTML = state.files .map((f) => { const isDir = !!f.is_dir; const fullPath = joinPath(state.filesPath, f.name); const full = escapeHtml(fullPath); const size = Number(f.size) || 0; const tooBig = !isDir && size > MAX_EDIT_BYTES; const checked = state.filesSelected.has(fullPath); const editBtn = isDir ? "" : tooBig ? `` : ``; return ` ${fileIcon(f.name, isDir)}${escapeHtml(f.name)} ${isDir ? "目录" : "文件"} ${isDir ? "—" : formatBytes(size)} ${formatTime(f.mtime)}
${editBtn} ${isDir ? "" : `下载`}
`; }) .join(""); updateFilesSelectionUi(); } async function loadFiles(path) { try { const data = await api(`/api/files/list?path=${encodeURIComponent(path || "/")}`); const nextPath = (data && data.path) || path || "/"; if (nextPath !== state.filesPath) clearFilesSelection(); state.filesPath = nextPath; state.files = (data && data.entries) || []; renderFiles(); } catch (err) { toast(err.message || String(err), "err"); } } async function deleteSelectedFiles() { const items = [...state.filesSelected]; if (!items.length) return; const meta = new Map( (state.files || []).map((f) => [joinPath(state.filesPath, f.name), !!f.is_dir]) ); const hasDir = items.some((p) => meta.get(p)); const ok = await confirmDialog({ title: "删除选中项", message: hasDir ? `确认删除选中的 ${items.length} 项?目录将递归删除,且不可恢复。` : `确认删除选中的 ${items.length} 个文件?此操作不可恢复。`, okText: "删除", danger: true, }); if (!ok) return; await withBusy(async () => { let failed = 0; for (const path of items) { const isDir = !!meta.get(path); try { await api("/api/files/delete", { method: "POST", body: { path, recursive: isDir }, }); } catch (_) { failed += 1; } } clearFilesSelection(); await loadFiles(state.filesPath); if (failed) toast(`删除完成,${failed} 项失败`, "err"); else toast("已删除选中项"); }); } function showStackedModal(modalInstance, modalEl) { if (!modalInstance || !modalEl) return; const nested = $$(".modal.show").some((el) => el !== modalEl); const raiseBackdrop = () => { if (!nested) return; modalEl.style.zIndex = "2010"; const backs = [...document.querySelectorAll(".modal-backdrop")]; if (backs.length) { const last = backs[backs.length - 1]; last.style.zIndex = "2000"; last.classList.add("modal-backdrop-nested"); } }; modalEl.addEventListener("shown.bs.modal", raiseBackdrop, { once: true }); modalEl.addEventListener( "hidden.bs.modal", () => { modalEl.style.zIndex = ""; }, { once: true } ); modalInstance.show(); // Backdrop is inserted synchronously on show(); raise it ASAP. if (nested) { raiseBackdrop(); requestAnimationFrame(raiseBackdrop); } } function openModal({ title, bodyHtml, onSubmit }) { $("#modal-title").textContent = title; $("#modal-body").innerHTML = bodyHtml; const okBtn = $("#modal-ok"); const cancelBtn = $("#modal-cancel"); okBtn.textContent = "确定"; okBtn.className = "btn btn-sm btn-primary"; cancelBtn.hidden = false; cancelBtn.textContent = "取消"; const form = $("#modal-form"); const cleanup = () => { form.onsubmit = null; state.bsModal.hide(); }; $("#modal-close").onclick = cleanup; cancelBtn.onclick = cleanup; form.onsubmit = async (e) => { e.preventDefault(); await withBusy(async () => { await onSubmit(new FormData(form)); cleanup(); // Avoid full refresh when editing inside site settings. if (!(state.siteSettingsModal && $("#site-settings-modal")?.classList.contains("show"))) { await refresh(); } }, okBtn); }; showStackedModal(state.bsModal, $("#modal")); } function confirmDialog({ title = "确认", message = "", okText = "确定", cancelText = "取消", danger = false, } = {}) { return new Promise((resolve) => { const form = $("#modal-form"); const okBtn = $("#modal-ok"); const cancelBtn = $("#modal-cancel"); const modalEl = $("#modal"); $("#modal-title").textContent = title; $("#modal-body").innerHTML = `

${escapeHtml(message)}

`; okBtn.textContent = okText; okBtn.className = danger ? "btn btn-sm btn-danger" : "btn btn-sm btn-primary"; cancelBtn.hidden = false; cancelBtn.textContent = cancelText; let settled = false; const finish = (ok) => { if (settled) return; settled = true; form.onsubmit = null; modalEl.removeEventListener("hidden.bs.modal", onHidden); okBtn.textContent = "确定"; okBtn.className = "btn btn-sm btn-primary"; cancelBtn.hidden = false; cancelBtn.textContent = "取消"; state.bsModal.hide(); resolve(!!ok); }; const onHidden = () => finish(false); $("#modal-close").onclick = () => finish(false); cancelBtn.onclick = () => finish(false); form.onsubmit = (e) => { e.preventDefault(); finish(true); }; modalEl.addEventListener("hidden.bs.modal", onHidden); showStackedModal(state.bsModal, modalEl); }); } function alertDialog({ title = "提示", message = "", okText = "知道了", } = {}) { return new Promise((resolve) => { const form = $("#modal-form"); const okBtn = $("#modal-ok"); const cancelBtn = $("#modal-cancel"); const modalEl = $("#modal"); $("#modal-title").textContent = title; $("#modal-body").innerHTML = `
${escapeHtml(message || "未知错误")}
`; okBtn.textContent = okText; okBtn.className = "btn btn-sm btn-primary"; cancelBtn.hidden = true; let settled = false; const finish = () => { if (settled) return; settled = true; form.onsubmit = null; modalEl.removeEventListener("hidden.bs.modal", onHidden); okBtn.textContent = "确定"; okBtn.className = "btn btn-sm btn-primary"; cancelBtn.hidden = false; cancelBtn.textContent = "取消"; state.bsModal.hide(); resolve(); }; const onHidden = () => finish(); $("#modal-close").onclick = () => finish(); cancelBtn.onclick = () => finish(); form.onsubmit = (e) => { e.preventDefault(); finish(); }; modalEl.addEventListener("hidden.bs.modal", onHidden); showStackedModal(state.bsModal, modalEl); }); } function syncSiteFormFields(root) { const type = root.querySelector('[name="type"]')?.value || "static"; const tip = root.querySelector(".field-upstream-tip"); if (tip) { tip.textContent = type === "fastweb" ? "Fastweb 站点会自动探测并分配后端端口,反代可在站点设置「反向代理」中查看" : "静态站仅需名称/域名;如需反代请创建后在「反向代理」中添加"; } } async function refresh() { try { const [status, sites] = await Promise.all([api("/api/status"), api("/api/websites")]); state.status = status; state.sites = Array.isArray(sites) ? sites : []; try { state.databases = (await api("/api/mysql/databases")) || []; } catch (_) { state.databases = []; } $("#live-dot").className = "live is-ok small d-flex align-items-center gap-2"; $("#live-label").textContent = "在线"; renderConsole(); renderSoftware(); renderSites(); renderDatabases(); const hash = (location.hash || "#console").slice(1); if (hash === "files") await loadFiles(state.filesPath); } catch (err) { $("#live-dot").className = "live is-bad small d-flex align-items-center gap-2"; $("#live-label").textContent = "离线"; toast(err.message || String(err), "err"); } } async function softAction(act, btn) { if (act.endsWith("-config")) { const conf = btn.dataset.conf; await withBusy(async () => openConfigFile(conf), btn); return; } const map = { "nginx-install": ["/api/nginx/install", {}], "nginx-uninstall": ["/api/nginx/uninstall", {}], "nginx-start": ["/api/nginx/start", {}], "nginx-stop": ["/api/nginx/stop", {}], "nginx-reload": ["/api/nginx/reload", {}], "fastweb-install": ["/api/fastweb/install", { debug: false }], "fastweb-uninstall": ["/api/fastweb/uninstall", {}], "mysql-install": ["/api/mysql/install", {}], "mysql-uninstall": ["/api/mysql/uninstall", {}], "mysql-start": ["/api/mysql/start", {}], "mysql-stop": ["/api/mysql/stop", {}], "redis-install": ["/api/redis/install", {}], "redis-uninstall": ["/api/redis/uninstall", {}], "redis-start": ["/api/redis/start", {}], "redis-stop": ["/api/redis/stop", {}], }; const item = map[act]; if (!item) return; if (act.endsWith("-uninstall")) { const ok = await confirmDialog({ title: "确认卸载", message: "确认卸载该软件?此操作不可恢复。", okText: "卸载", danger: true, }); if (!ok) return; } const queued = act.endsWith("-install") || act.endsWith("-uninstall"); await withBusy(async () => { if (!queued) toast("执行中…"); const data = await api(item[0], { method: "POST", body: item[1] }); if (queued && data && data.task_id) { toast("已加入任务队列"); await refreshTasks(); } else { toast("完成"); await refresh(); } }, btn); } function updateTaskBadge() { const n = state.taskActive || 0; $("#task-badge-count").textContent = String(n); $("#task-badge").classList.toggle("has-active", n > 0); } function isActiveTask(t) { return t && (t.status === "running" || t.status === "pending"); } function visibleTasks() { if (state.taskTab === "history") { return state.tasks.filter((t) => !isActiveTask(t)); } return state.tasks.filter(isActiveTask); } function pickDefaultTaskId(preferId) { const list = visibleTasks(); if (preferId && list.some((t) => t.id === preferId)) return preferId; const running = list.find((t) => t.status === "running"); if (running) return running.id; if (list.length) return list[0].id; return ""; } function syncTaskTabs() { const activeBtn = $("#task-tab-active"); const histBtn = $("#task-tab-history"); const clearBtn = $("#task-clear-finished"); const onActive = state.taskTab === "active"; activeBtn.className = onActive ? "btn btn-warning" : "btn btn-outline-light"; histBtn.className = !onActive ? "btn btn-light" : "btn btn-outline-light"; clearBtn.classList.toggle("d-none", onActive); const nActive = state.tasks.filter(isActiveTask).length; const nHist = state.tasks.length - nActive; activeBtn.textContent = nActive ? `进行中 (${nActive})` : "进行中"; histBtn.textContent = nHist ? `历史任务 (${nHist})` : "历史任务"; } function renderTaskList() { const host = $("#task-list"); syncTaskTabs(); const list = visibleTasks(); if (!list.length) { host.innerHTML = `
${ state.taskTab === "history" ? "暂无历史任务" : "当前没有进行中的任务" }
`; return; } host.innerHTML = list .map((t) => { const selected = t.id === state.selectedTaskId ? " is-active" : ""; const running = t.status === "running" ? " is-running" : ""; const st = t.status === "running" ? "运行中" : t.status === "pending" ? "排队" : t.status === "success" ? "成功" : t.status === "failed" ? "失败" : (t.status || "—"); const dot = t.status === "running" ? `` : ""; return ``; }) .join(""); } function selectTask(id, resetLog) { if (!id) { state.selectedTaskId = ""; $("#task-log").textContent = ""; $("#task-log-title").textContent = "日志"; $("#task-log-status").textContent = "—"; $("#task-log-status").className = "badge text-bg-secondary"; return; } if (resetLog || id !== state.selectedTaskId) { state.selectedTaskId = id; state.taskLogOffset = 0; $("#task-log").textContent = ""; } else { state.selectedTaskId = id; } } async function refreshTasks() { try { const data = await api("/api/tasks"); const tasks = (data && data.tasks) || []; let softChanged = false; for (const t of tasks) { const prev = state.taskStatusMap[t.id]; const now = t.status || ""; if ( prev && (prev === "running" || prev === "pending") && (now === "success" || now === "failed") && /\.(install|uninstall)$/.test(t.type || "") && !(t.type || "").startsWith("fastweb.module.") ) { softChanged = true; } if ( prev && (prev === "running" || prev === "pending") && now === "success" && (t.type || "") === "ssl.letsencrypt" ) { softChanged = true; if (state.siteSettings && state.siteSettingsModal) { const name = state.siteSettings.name; refresh().then(() => { const latest = state.sites.find((s) => s.name === name); if (latest) { Object.assign(state.siteSettings, latest); fillSiteSettingsForm(state.siteSettings); $("#site-set-ssl-enable").checked = true; setSiteCertSubTab("pem"); loadSiteSslPem().catch(() => {}); toast("证书已申请并写入 /ngs/conf/ssl/<域名>/"); } }).catch(() => {}); } } state.taskStatusMap[t.id] = now; } state.tasks = tasks; state.taskActive = Number((data && data.active) || 0); updateTaskBadge(); syncModuleOpsFromTasks(tasks); if (state.taskPanelOpen) { const nextId = pickDefaultTaskId(state.selectedTaskId); if (nextId !== state.selectedTaskId) selectTask(nextId, true); else if (!nextId) selectTask("", true); renderTaskList(); if (state.selectedTaskId) await pollTaskLog(false); } if (softChanged) await refresh(); } catch (_) {} } async function pollTaskLog(forceBottom) { if (!state.selectedTaskId) return; const id = state.selectedTaskId; try { const data = await api(`/api/tasks/log?id=${encodeURIComponent(id)}&offset=${state.taskLogOffset}`); const pre = $("#task-log"); if (!pre) return; const lines = (data && data.lines) || []; if (data && data.truncated) { pre.textContent = ""; state.taskLogOffset = Number(data.log_base) || 0; } if (lines.length) { const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 48; pre.textContent += (pre.textContent ? "\n" : "") + lines.join("\n"); if (forceBottom || atBottom) pre.scrollTop = pre.scrollHeight; } if (data && data.next_offset != null && data.next_offset !== "") { state.taskLogOffset = Number(data.next_offset); } const meta = state.tasks.find((t) => t.id === id) || {}; $("#task-log-title").textContent = meta.title || id; const st = $("#task-log-status"); const status = (data && data.status) || meta.status || "—"; st.className = "badge " + ( status === "running" || status === "success" ? "text-bg-success" : status === "pending" ? "text-bg-warning" : status === "failed" ? "text-bg-danger" : "text-bg-secondary" ); st.textContent = status === "running" ? "运行中" : status === "pending" ? "排队" : status === "success" ? "成功" : status === "failed" ? "失败" : status; } catch (_) {} } function setTaskTab(tab) { state.taskTab = tab === "history" ? "history" : "active"; const nextId = pickDefaultTaskId(""); selectTask(nextId, true); renderTaskList(); if (state.selectedTaskId) pollTaskLog(true); } function openTaskPanel(taskId) { state.taskPanelOpen = true; $("#task-panel").hidden = false; applyDialogSize(document.querySelector(".task-panel-dialog"), "ngs.dialogSize.task", { minW: 480, minH: 320, }); if (taskId) { const t = state.tasks.find((x) => x.id === taskId); state.taskTab = t && !isActiveTask(t) ? "history" : "active"; selectTask(taskId, true); } else { state.taskTab = "active"; } refreshTasks(); } function closeTaskPanel() { state.taskPanelOpen = false; $("#task-panel").hidden = true; } function clampDialogSize(w, h, minW, minH) { const maxW = Math.max(minW, window.innerWidth - 40); const maxH = Math.max(minH, window.innerHeight - 40); return { w: Math.min(Math.max(minW, Math.round(w)), maxW), h: Math.min(Math.max(minH, Math.round(h)), maxH), }; } function readDialogSize(storageKey) { if (!storageKey) return null; try { const v = JSON.parse(localStorage.getItem(storageKey) || "null"); if (v && v.w && v.h) return v; } catch (_) {} // migrate old task panel key if (storageKey === "ngs.dialogSize.task") { try { const legacy = JSON.parse(localStorage.getItem("ngs.taskPanelSize") || "null"); if (legacy && legacy.w && legacy.h) return legacy; } catch (_) {} } return null; } function saveDialogSize(storageKey, w, h, minW, minH) { const c = clampDialogSize(w, h, minW, minH); if (storageKey) { try { localStorage.setItem(storageKey, JSON.stringify(c)); } catch (_) {} } return c; } function applyDialogSize(el, storageKey, opts = {}) { if (!el) return; const minW = opts.minW || 360; const minH = opts.minH || 240; const size = opts.size || readDialogSize(storageKey); if (!size) return; const c = clampDialogSize(size.w, size.h, minW, minH); el.style.width = c.w + "px"; el.style.height = c.h + "px"; el.classList.add("has-custom-size"); } function enableDialogResize(target, opts = {}) { if (!target || target.dataset.resizeBound === "1") return; const minW = opts.minW || 360; const minH = opts.minH || 240; const storageKey = opts.storageKey || ""; const handleHost = opts.handleHost || target; const showEventEl = opts.showEventEl || null; target.dataset.resizeBound = "1"; target.classList.add("is-resizable-dialog"); let handle = handleHost.querySelector("[data-dialog-resize]"); if (!handle) { handle = document.createElement("div"); handle.className = "dialog-resize"; handle.dataset.dialogResize = ""; handle.title = "拖动调整大小"; handle.setAttribute("aria-hidden", "true"); handleHost.appendChild(handle); } if (getComputedStyle(handleHost).position === "static") { handleHost.style.position = "relative"; } applyDialogSize(target, storageKey, { minW, minH }); let startX = 0; let startY = 0; let startW = 0; let startH = 0; const onMove = (e) => { const dx = e.clientX - startX; const dy = e.clientY - startY; const c = clampDialogSize(startW + dx, startH + dy, minW, minH); target.style.width = c.w + "px"; target.style.height = c.h + "px"; target.classList.add("has-custom-size"); if (typeof opts.onResize === "function") opts.onResize(c); }; const onUp = () => { target.classList.remove("is-resizing"); document.body.style.cursor = ""; document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onUp); const saved = saveDialogSize(storageKey, target.offsetWidth, target.offsetHeight, minW, minH); if (typeof opts.onResize === "function") opts.onResize(saved); }; handle.addEventListener("pointerdown", (e) => { e.preventDefault(); e.stopPropagation(); startX = e.clientX; startY = e.clientY; startW = target.offsetWidth; startH = target.offsetHeight; target.classList.add("is-resizing"); document.body.style.cursor = "nwse-resize"; document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onUp); }); if (showEventEl) { showEventEl.addEventListener("shown.bs.modal", () => { applyDialogSize(target, storageKey, { minW, minH }); }); } window.addEventListener("resize", () => { if (!target.classList.contains("has-custom-size")) return; applyDialogSize(target, storageKey, { minW, minH, size: { w: target.offsetWidth, h: target.offsetHeight }, }); }); } function bindAllDialogResize() { enableDialogResize(document.querySelector(".task-panel-dialog"), { storageKey: "ngs.dialogSize.task", minW: 480, minH: 320, }); enableDialogResize(document.querySelector(".code-editor-window"), { storageKey: "ngs.dialogSize.codeEditor", minW: 560, minH: 360, onResize: () => { if (state.editor && typeof state.editor.layout === "function") { state.editor.layout(); } }, }); const modalSpecs = [ { id: "modal", key: "generic", minW: 360, minH: 280 }, { id: "site-settings-modal", key: "siteSettings", minW: 640, minH: 420, onResize: () => layoutSiteNginxEditor(), }, { id: "upload-modal", key: "upload", minW: 520, minH: 360 }, { id: "site-analytics-modal", key: "analytics", minW: 720, minH: 420 }, ]; modalSpecs.forEach((spec) => { const root = document.getElementById(spec.id); if (!root) return; const dialog = root.querySelector(".modal-dialog"); const content = root.querySelector(".modal-content"); if (!dialog || !content) return; enableDialogResize(dialog, { storageKey: "ngs.dialogSize." + spec.key, minW: spec.minW, minH: spec.minH, handleHost: content, showEventEl: root, onResize: spec.onResize, }); }); $("#site-settings-modal")?.addEventListener("shown.bs.modal", () => { requestAnimationFrame(() => layoutSiteNginxEditor()); }); $("#site-settings-modal")?.addEventListener("hidden.bs.modal", () => { // keep editor instance; just clear dirty content path tracking }); } function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const s = String(reader.result || ""); const i = s.indexOf(","); resolve(i >= 0 ? s.slice(i + 1) : s); }; reader.onerror = () => reject(new Error("读取文件失败")); reader.readAsDataURL(file); }); } const MAX_UPLOAD_BYTES = 48 * 1024 * 1024; function normalizeRelPath(p) { return String(p || "") .replace(/\\/g, "/") .replace(/^\/+/, "") .replace(/\/{2,}/g, "/"); } function renderUploadQueue() { const host = $("#upload-queue"); const summary = $("#upload-queue-summary"); const clearBtn = $("#upload-clear-queue"); const startBtn = $("#upload-start"); const q = state.uploadQueue; if (!q.length) { host.innerHTML = ""; summary.textContent = "未选择文件"; clearBtn.hidden = true; startBtn.disabled = true; return; } const total = q.reduce((n, it) => n + (it.file ? it.file.size : 0), 0); summary.textContent = `${q.length} 项 · ${formatBytes(total)}`; clearBtn.hidden = state.uploadBusy; startBtn.disabled = state.uploadBusy || !q.some((it) => it.status === "pending"); host.innerHTML = q .map((it) => { const st = it.status === "ok" ? "完成" : it.status === "err" ? "失败" : it.status === "uploading" ? "上传中" : it.status === "skip" ? "跳过" : "待传"; const cls = it.status === "ok" ? "is-ok" : it.status === "err" ? "is-err" : it.status === "uploading" ? "is-run" : ""; return `
${escapeHtml(it.relativePath)} ${it.file ? formatBytes(it.file.size) : "—"} ${st}
`; }) .join(""); } function addUploadItems(items) { const seen = new Set(state.uploadQueue.map((it) => it.relativePath)); let added = 0; for (const it of items) { const relativePath = normalizeRelPath(it.relativePath); if (!relativePath || seen.has(relativePath)) continue; if (relativePath.split("/").includes("..")) continue; if (it.file && it.file.size > MAX_UPLOAD_BYTES) { state.uploadQueue.push({ id: `${Date.now()}-${added}-${relativePath}`, relativePath, file: it.file, status: "err", error: "单文件超过 48MB", }); } else { state.uploadQueue.push({ id: `${Date.now()}-${added}-${relativePath}`, relativePath, file: it.file || null, isDir: !!it.isDir, status: "pending", error: "", }); } seen.add(relativePath); added += 1; } renderUploadQueue(); } function itemsFromFileList(fileList, fromDirPicker) { const out = []; for (const file of fileList || []) { let rel = fromDirPicker ? file.webkitRelativePath || file.name : file.webkitRelativePath || file.name; rel = normalizeRelPath(rel); if (!rel) continue; out.push({ relativePath: rel, file }); } return out; } function readAllDirectoryEntries(reader) { return new Promise((resolve, reject) => { const all = []; const readBatch = () => { reader.readEntries((entries) => { if (!entries.length) { resolve(all); return; } all.push(...entries); readBatch(); }, reject); }; readBatch(); }); } async function traverseFileSystemEntry(entry, prefix = "") { const name = entry.name; const base = prefix ? `${prefix}${name}` : name; if (entry.isFile) { const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); return [{ relativePath: base, file }]; } if (entry.isDirectory) { const reader = entry.createReader(); const children = await readAllDirectoryEntries(reader); if (!children.length) { return [{ relativePath: base, file: null, isDir: true }]; } const out = []; for (const child of children) { out.push(...(await traverseFileSystemEntry(child, `${base}/`))); } return out; } return []; } async function itemsFromDataTransfer(dt) { const items = dt && dt.items ? [...dt.items] : []; if (items.some((it) => typeof it.webkitGetAsEntry === "function" && it.webkitGetAsEntry())) { const out = []; for (const it of items) { if (it.kind !== "file") continue; const entry = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (!entry) continue; out.push(...(await traverseFileSystemEntry(entry))); } return out; } return itemsFromFileList(dt.files, false); } function setUploadProgress(doneBytes, totalBytes, label) { const wrap = $("#upload-progress"); wrap.hidden = false; const pct = totalBytes > 0 ? Math.min(100, Math.round((doneBytes / totalBytes) * 100)) : 0; $("#upload-progress-bar").style.width = `${pct}%`; $("#upload-progress-pct").textContent = `${pct}%`; $("#upload-progress-label").textContent = label || ""; } function uploadFileWithProgress(path, file, onProgress) { return fileToBase64(file).then( (content_base64) => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/files/upload"); xhr.setRequestHeader("Content-Type", "application/json"); xhr.setRequestHeader("Accept", "application/json"); xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); }; xhr.onload = () => { let json = null; try { json = JSON.parse(xhr.responseText || "{}"); } catch (_) { reject(new Error(`无效响应 (${xhr.status})`)); return; } if (json && Number(json.code) === 401) { location.replace("/login.html"); reject(new Error(json.msg || "未登录")); return; } if (!json || json.code !== 200) { reject(new Error((json && json.msg) || `上传失败 (${xhr.status})`)); return; } resolve(json.data); }; xhr.onerror = () => reject(new Error("网络错误")); xhr.send(JSON.stringify({ path, content_base64 })); }) ); } function openUploadModal() { state.uploadQueue = []; state.uploadBusy = false; $("#upload-target-path").textContent = state.filesPath || "/"; $("#upload-progress").hidden = true; $("#upload-progress-bar").style.width = "0%"; $("#upload-progress-pct").textContent = "0%"; $("#upload-progress-label").textContent = ""; renderUploadQueue(); state.uploadModal.show(); } function closeUploadModal() { if (state.uploadBusy) return; state.uploadModal.hide(); } async function startUploadQueue() { if (state.uploadBusy) return; const pending = state.uploadQueue.filter((it) => it.status === "pending"); if (!pending.length) return; state.uploadBusy = true; renderUploadQueue(); $("#upload-start").disabled = true; $("#upload-modal-cancel").disabled = true; $("#upload-modal-close").disabled = true; $("#upload-clear-queue").hidden = true; $("#upload-pick-files").disabled = true; $("#upload-pick-dir").disabled = true; const totalBytes = pending.reduce((n, it) => n + (it.file ? it.file.size : 0), 0) || 1; let completedBytes = 0; let ok = 0; let fail = 0; try { for (const it of pending) { it.status = "uploading"; renderUploadQueue(); const dest = joinPath(state.filesPath || "/", it.relativePath); setUploadProgress( completedBytes, totalBytes, `正在上传 ${it.relativePath}` ); try { if (it.isDir && !it.file) { await api("/api/files/mkdir", { method: "POST", body: { path: dest } }).catch((err) => { const msg = err.message || String(err); if (!/已存在|exists/i.test(msg)) throw err; }); } else { setUploadProgress(completedBytes, totalBytes, `读取 ${it.relativePath}`); await uploadFileWithProgress(dest, it.file, (loaded, total) => { const filePct = total > 0 ? loaded / total : 1; setUploadProgress( completedBytes + (it.file.size * filePct), totalBytes, `正在上传 ${it.relativePath}` ); }); completedBytes += it.file.size; } it.status = "ok"; ok += 1; } catch (err) { it.status = "err"; it.error = err.message || String(err); fail += 1; if (it.file) completedBytes += it.file.size; } renderUploadQueue(); } setUploadProgress(totalBytes, totalBytes, fail ? `完成:成功 ${ok},失败 ${fail}` : `全部上传成功(${ok})`); if (ok) toast(fail ? `上传完成(成功 ${ok},失败 ${fail})` : "上传完成"); else if (fail) toast(`上传失败(${fail})`, "err"); await loadFiles(state.filesPath); } finally { state.uploadBusy = false; $("#upload-modal-cancel").disabled = false; $("#upload-modal-close").disabled = false; $("#upload-pick-files").disabled = false; $("#upload-pick-dir").disabled = false; renderUploadQueue(); } } function bindUploadModal() { state.uploadModal = new bootstrap.Modal($("#upload-modal"), { backdrop: "static" }); const zone = $("#upload-dropzone"); $("#btn-files-upload").addEventListener("click", () => openUploadModal()); $("#upload-modal-close").addEventListener("click", () => closeUploadModal()); $("#upload-modal-cancel").addEventListener("click", () => closeUploadModal()); $("#upload-clear-queue").addEventListener("click", () => { if (state.uploadBusy) return; state.uploadQueue = []; renderUploadQueue(); }); $("#upload-start").addEventListener("click", () => startUploadQueue()); $("#upload-pick-files").addEventListener("click", () => $("#upload-input-files").click()); $("#upload-pick-dir").addEventListener("click", () => $("#upload-input-dir").click()); $("#upload-input-files").addEventListener("change", (e) => { addUploadItems(itemsFromFileList(e.target.files, false)); e.target.value = ""; }); $("#upload-input-dir").addEventListener("change", (e) => { addUploadItems(itemsFromFileList(e.target.files, true)); e.target.value = ""; }); ["dragenter", "dragover"].forEach((ev) => { zone.addEventListener(ev, (e) => { e.preventDefault(); e.stopPropagation(); zone.classList.add("is-dragover"); }); }); ["dragleave", "drop"].forEach((ev) => { zone.addEventListener(ev, (e) => { e.preventDefault(); e.stopPropagation(); if (ev === "dragleave") zone.classList.remove("is-dragover"); }); }); zone.addEventListener("drop", async (e) => { zone.classList.remove("is-dragover"); if (state.uploadBusy) return; try { const items = await itemsFromDataTransfer(e.dataTransfer); addUploadItems(items); } catch (err) { toast(err.message || String(err), "err"); } }); } function setSiteSettingsTab(tab) { $$(".site-settings-tab").forEach((b) => b.classList.toggle("is-active", b.dataset.siteTab === tab)); $$(".site-settings-panel").forEach((p) => p.classList.toggle("is-active", p.dataset.sitePanel === tab)); if (tab === "cert") { setSiteCertSubTab(state.siteCertTab || "pem"); loadSiteSslPem().catch(() => {}); renderSslDomainChecklist(selectedSslHosts()); } if (tab === "nginx") { loadSiteNginxConf().catch((err) => toast(err.message || String(err), "err")); requestAnimationFrame(() => layoutSiteNginxEditor()); } if (tab === "logs") loadSiteLog().catch((err) => toast(err.message || String(err), "err")); if (tab === "modules") loadSiteModules().catch((err) => toast(err.message || String(err), "err")); if (tab === "proxy") loadSiteProxies().catch((err) => toast(err.message || String(err), "err")); } function syncSiteModulesTab(site) { const tab = $("#site-tab-modules"); if (!tab) return; const show = !!(site && site.type === "fastweb"); tab.classList.toggle("d-none", !show); if (!show && tab.classList.contains("is-active")) { setSiteSettingsTab("domain"); } } function syncSiteProxyTab(site) { const tab = $("#site-tab-proxy"); if (!tab) return; const show = !!site; tab.classList.toggle("d-none", !show); if (!show && tab.classList.contains("is-active")) { setSiteSettingsTab("domain"); } } function renderSiteProxies() { const tbody = $("#site-proxy-tbody"); if (!tbody) return; const rows = state.siteProxies || []; if (!rows.length) { tbody.innerHTML = `暂无 URL 代理,点击右上角添加`; return; } tbody.innerHTML = rows .map((r) => { const targetLabel = (r.target_type === "unix" ? "unix:" : "") + (r.target || ""); return ` ${escapeHtml(r.path || "")} ${escapeHtml(targetLabel)} ${escapeHtml(r.host || "$http_host")} ${escapeHtml(r.remark || "—")}
`; }) .join(""); } async function loadSiteProxies() { const site = state.siteSettings; const tbody = $("#site-proxy-tbody"); if (!site) { state.siteProxies = []; if (tbody) tbody.innerHTML = ""; return; } if (tbody) tbody.innerHTML = `加载中…`; const data = await api(`/api/websites/proxies?name=${encodeURIComponent(site.name)}`); state.siteProxies = (data && data.proxies) || []; renderSiteProxies(); } function openUrlProxyDialog(existing) { const isEdit = !!(existing && existing.id); const targetType = (existing && existing.target_type) || "url"; let targetVal = (existing && existing.target) || ""; if (targetType === "url" && targetVal.startsWith("http://")) { targetVal = targetVal.slice(7); } else if (targetType === "url" && targetVal.startsWith("https://")) { // keep full https URL in input } openModal({ title: isEdit ? "编辑 URL 代理" : "添加 URL 代理", bodyHtml: `
`, onSubmit: async (fd) => { const site = state.siteSettings; if (!site) throw new Error("未找到网站"); let target = String(fd.get("target") || "").trim(); const target_type = String(fd.get("target_type") || "url"); if (target_type === "url" && target && !/^https?:\/\//i.test(target)) { target = "http://" + target; } const body = { name: site.name, path: String(fd.get("path") || "").trim(), target_type, target, host: String(fd.get("host") || "").trim() || "$http_host", remark: String(fd.get("remark") || "").trim(), }; if (isEdit) { body.id = existing.id; await api("/api/websites/proxies/update", { method: "POST", body }); toast("已更新"); } else { await api("/api/websites/proxies", { method: "POST", body }); toast("已添加"); } await loadSiteProxies(); }, }); } function moduleOpKey(site, nameEn) { return `${site || ""}::${nameEn || ""}`; } function getModuleOp(nameEn) { const site = state.siteSettings && state.siteSettings.name; if (!site || !nameEn) return null; return state.siteModuleOps[moduleOpKey(site, nameEn)] || null; } function setModuleOp(site, nameEn, op) { const key = moduleOpKey(site, nameEn); if (!op) delete state.siteModuleOps[key]; else state.siteModuleOps[key] = op; } function renderSiteModules() { const host = $("#site-modules-list"); if (!host) return; const filter = state.siteModulesFilter || "all"; const rows = (state.siteModules || []).filter( (m) => filter === "all" || m.type === filter ); if (!rows.length) { host.innerHTML = `
暂无模块
`; return; } host.innerHTML = rows .map((m) => { const installed = !!m.installed; const op = getModuleOp(m.name_en); let statusBadge; let btn; if (op && (op.act === "install" || op.act === "uninstall")) { const busyText = op.act === "uninstall" ? "卸载中" : "安装中"; statusBadge = `${busyText}`; btn = ``; } else if (installed) { statusBadge = `已安装`; btn = ``; } else { statusBadge = `未安装`; btn = ``; } const doc = m.doc ? `文档` : ""; return `
${escapeHtml(m.name || m.name_en)} ${statusBadge} ${escapeHtml(m.type || "")}
${escapeHtml(m.name_en || "")}
${escapeHtml(m.desc || "")}
${doc}
${btn}
`; }) .join(""); } async function loadSiteModules(opts = {}) { const silent = !!opts.silent; const site = state.siteSettings; const host = $("#site-modules-list"); if (!site || site.type !== "fastweb") { if (host) host.innerHTML = `
仅 Fastweb 站点支持模块管理
`; return; } if (!silent && host) host.innerHTML = `
加载中…
`; const data = await api(`/api/fastweb/modules?site=${encodeURIComponent(site.name)}`); state.siteModules = (data && data.modules) || []; renderSiteModules(); } async function siteModuleAction(act, nameEn) { const site = state.siteSettings; if (!site || !nameEn) return; if (getModuleOp(nameEn)) { toast("该模块正在处理中", "err"); return; } if (act === "uninstall") { const ok = await confirmDialog({ title: "卸载模块", message: `确认卸载模块「${nameEn}」?`, okText: "卸载", danger: true, }); if (!ok) return; } const path = act === "uninstall" ? "/api/fastweb/modules/uninstall" : "/api/fastweb/modules/install"; try { const data = await api(path, { method: "POST", body: { site: site.name, name_en: nameEn }, }); if (data && data.task_id) { setModuleOp(site.name, nameEn, { act, taskId: data.task_id }); renderSiteModules(); toast(act === "uninstall" ? "已加入卸载任务" : "已加入安装任务"); refreshTasks(); } else { toast("完成"); await loadSiteModules({ silent: true }); } } catch (err) { toast(err.message || String(err), "err"); } } function syncModuleOpsFromTasks(tasks) { let changed = false; const finished = []; for (const [key, op] of Object.entries(state.siteModuleOps || {})) { if (!op || !op.taskId) continue; const t = (tasks || []).find((x) => x.id === op.taskId); if (!t) continue; const st = t.status || ""; if (st === "success" || st === "failed") { finished.push({ key, op, status: st }); } } for (const item of finished) { delete state.siteModuleOps[item.key]; changed = true; if (item.status === "success") { toast(item.op.act === "uninstall" ? "模块已卸载" : "模块已安装"); } else { toast(item.op.act === "uninstall" ? "模块卸载失败" : "模块安装失败", "err"); } } if (changed) { renderSiteModules(); const site = state.siteSettings; const modulesTab = document.querySelector('.site-settings-tab[data-site-tab="modules"]'); if ( site && site.type === "fastweb" && modulesTab && modulesTab.classList.contains("is-active") ) { loadSiteModules({ silent: true }).catch(() => {}); } } return changed; } function setSiteCertSubTab(tab) { const which = tab === "apply" ? "apply" : "pem"; state.siteCertTab = which; $("#site-cert-tab-pem")?.classList.toggle("active", which === "pem"); $("#site-cert-tab-apply")?.classList.toggle("active", which === "apply"); const pem = $("#site-cert-pane-pem"); const apply = $("#site-cert-pane-apply"); if (pem) pem.hidden = which !== "pem"; if (apply) apply.hidden = which !== "apply"; if (which === "apply") renderSslDomainChecklist(selectedSslHosts()); } function siteSslPrimaryDomain(site) { const domains = domainsFromText( ($("#site-set-domain") && $("#site-set-domain").value) || site.domain || site.name || "" ); if (!domains.length) return site.name || ""; return parseDomainToken(domains[0]).host; } function siteSslPaths(site) { const domain = siteSslPrimaryDomain(site); const sslDir = joinPath("/ngs/conf/ssl", domain); return { dir: sslDir, cert: joinPath(sslDir, "fullchain.pem"), key: joinPath(sslDir, "privkey.pem"), readCert: site.ssl_cert || joinPath(sslDir, "fullchain.pem"), readKey: site.ssl_key || joinPath(sslDir, "privkey.pem"), }; } async function readFileText(path) { if (!path) return ""; try { const data = await api(`/api/files/read?path=${encodeURIComponent(path)}`); return (data && data.content) || ""; } catch (_) { return ""; } } async function loadSiteSslPem() { const site = state.siteSettings; if (!site) return; const paths = siteSslPaths(site); const [pem, key] = await Promise.all([ readFileText(paths.readCert), readFileText(paths.readKey), ]); const pemEl = $("#site-set-ssl-pem"); const keyEl = $("#site-set-ssl-key-pem"); if (pemEl) pemEl.value = pem; if (keyEl) keyEl.value = key; } function domainsFromText(text) { return String(text || "") .split(/[\s,;]+/) .map((s) => s.trim()) .filter(Boolean); } function parseDomainToken(tok) { const s = String(tok || "").trim(); const m = s.match(/^(.*):(\d{1,5})$/); if (m) { const port = Number(m[2]); if (port >= 1 && port <= 65535) return { host: m[1], port }; } return { host: s, port: 80 }; } function formatDomainToken(host, port) { const p = Number(port) || 80; return p === 80 ? host : `${host}:${p}`; } function renderSslDomainChecklist(selectedHosts) { const host = $("#site-ssl-domain-list"); if (!host) return; const tokens = domainsFromText($("#site-set-domain")?.value || "") .concat(Array.isArray(state.siteSettings?.domains) ? state.siteSettings.domains : []); const uniq = []; const seen = new Set(); for (const tok of tokens) { const { host: h, port } = parseDomainToken(tok); if (!h || seen.has(h)) continue; seen.add(h); uniq.push({ host: h, port }); } // Prefer domain textarea order if present const fromText = domainsFromText($("#site-set-domain")?.value || ""); const ordered = []; const orderedSeen = new Set(); for (const tok of fromText) { const { host: h, port } = parseDomainToken(tok); if (!h || orderedSeen.has(h)) continue; orderedSeen.add(h); ordered.push({ host: h, port }); } if (!ordered.length) ordered.push(...uniq); const selected = new Set( (selectedHosts || ordered.map((x) => x.host)).map((h) => parseDomainToken(h).host) ); if (!ordered.length) { host.innerHTML = `
请先在「域名」中配置域名
`; return; } host.innerHTML = ordered .map((item, i) => { const id = `ssl-dom-${i}`; const checked = selected.has(item.host) ? " checked" : ""; const hint = item.port && item.port !== 80 ? `:${item.port}` : ""; return `
`; }) .join(""); } function selectedSslHosts() { return $$("#site-ssl-domain-list input[type=checkbox]:checked").map((el) => el.value).filter(Boolean); } function formatSslExpire(site) { if (!site || !site.ssl_not_after) return "证书有效期:—"; const days = site.ssl_days_left; let extra = ""; if (days !== null && days !== undefined && days !== "") { const n = Number(days); if (!Number.isNaN(n)) { if (n < 0) extra = `(已过期 ${Math.abs(n)} 天)`; else if (n === 0) extra = "(今天到期)"; else extra = `(剩余 ${n} 天)`; } } return `证书有效期:${site.ssl_not_before || "?"} ~ ${site.ssl_not_after}${extra}`; } function fillSiteSettingsForm(site) { state.siteSettings = site; $("#site-settings-name").textContent = site.name || ""; $("#site-settings-sub").textContent = `${site.type || ""} · ${site.root || ""}`; const domains = Array.isArray(site.domains) && site.domains.length ? site.domains : domainsFromText(site.domain); $("#site-set-domain").value = domains.join("\n"); $("#site-set-ssl-enable").checked = !!site.ssl_enable; $("#site-set-ssl-port").value = site.ssl_port || 443; const expireEl = $("#site-set-ssl-expire"); if (expireEl) { expireEl.textContent = formatSslExpire(site); expireEl.classList.toggle("text-danger", Number(site.ssl_days_left) < 14); expireEl.classList.toggle("text-secondary", !(Number(site.ssl_days_left) < 14)); } $("#site-set-conf-path").textContent = site.conf || ""; state.siteNginxPath = site.conf || ""; if (state.siteNginxEditor) { state.siteNginxEditor.setValue(""); } state.siteProxies = []; state.siteLogKind = "access"; state.siteLogIp = ""; state.siteLogQuery = ""; if (state.siteLogSearchTimer) { clearTimeout(state.siteLogSearchTimer); state.siteLogSearchTimer = null; } const searchEl = $("#site-log-search"); if (searchEl) searchEl.value = ""; syncSiteLogIpFilter(); $("#site-log-tab-access").classList.add("active"); $("#site-log-tab-error").classList.remove("active"); $("#site-log-view").textContent = "加载中…"; state.siteModules = []; state.siteModulesFilter = "all"; $$("#site-modules-filter [data-mod-filter]").forEach((b) => b.classList.toggle("active", b.dataset.modFilter === "all") ); syncSiteModulesTab(site); syncSiteProxyTab(site); renderSslDomainChecklist(); } async function openSiteSettings(name) { const site = state.sites.find((s) => s.name === name); if (!site) throw new Error("未找到网站"); fillSiteSettingsForm(site); state.siteCertTab = "pem"; setSiteSettingsTab("domain"); state.siteSettingsModal.show(); } function layoutSiteNginxEditor() { if (state.siteNginxEditor && typeof state.siteNginxEditor.layout === "function") { state.siteNginxEditor.layout(); } } async function ensureSiteNginxEditor() { const monaco = await ensureMonaco(); const host = $("#site-nginx-monaco"); if (!host) throw new Error("编辑器容器不存在"); if (state.siteNginxEditor) { layoutSiteNginxEditor(); return state.siteNginxEditor; } host.innerHTML = ""; state.siteNginxEditor = monaco.editor.create(host, { value: "", language: "ini", theme: "vs-dark", automaticLayout: true, minimap: { enabled: false }, fontSize: 13, lineNumbers: "on", scrollBeyondLastLine: false, wordWrap: "off", tabSize: 4, renderWhitespace: "selection", }); state.siteNginxEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { withBusy(async () => saveSiteNginxConf()); }); layoutSiteNginxEditor(); return state.siteNginxEditor; } async function loadSiteNginxConf() { const site = state.siteSettings; if (!site || !site.conf) throw new Error("配置路径未知"); const data = await api(`/api/files/read?path=${encodeURIComponent(site.conf)}`); const content = (data && data.content) || ""; const editor = await ensureSiteNginxEditor(); const monaco = await ensureMonaco(); const model = editor.getModel(); const lang = langFromPath(site.conf) || "ini"; if (model && monaco.editor.setModelLanguage) { monaco.editor.setModelLanguage(model, lang); } editor.setValue(content); state.siteNginxPath = site.conf; requestAnimationFrame(() => layoutSiteNginxEditor()); } async function saveSiteNginxConf() { const site = state.siteSettings; if (!site || !site.conf) throw new Error("配置路径未知"); const editor = await ensureSiteNginxEditor(); const content = editor.getValue(); await api("/api/files/write", { method: "POST", body: { path: site.conf, content }, }); await api("/api/nginx/reload", { method: "POST", body: {} }); toast("Nginx 配置已保存并重载"); } function syncSiteLogIpFilter() { const wrap = $("#site-log-ip-filter"); const val = $("#site-log-ip-value"); if (!wrap) return; const ip = String(state.siteLogIp || "").trim(); wrap.hidden = !ip; if (val) val.textContent = ip; } function accessStatusClass(code) { const n = Number(code); if (n >= 500) return "status-server"; if (n >= 400) return "status-client"; if (n >= 300) return "status-redir"; return "status-ok"; } function renderAccessLogTable(items, emptyText) { const esc = escapeHtml; if (!items.length) { return `
${esc(emptyText || "暂无访问日志")}
`; } const rows = items .map((r) => { const uri = esc(r.uri || "") + (r.args ? "?" + esc(r.args) : ""); const rt = r.request_time != null ? Number(r.request_time).toFixed(3) : ""; return ` ${esc(r.time)} ${esc(r.ip)} ${esc(r.method)} ${esc(r.status)} ${uri} ${esc(rt)} ${esc(formatBytes(Number(r.bytes_recv) || 0))} ${esc(formatBytes(Number(r.bytes_sent) || 0))} `; }) .join(""); return `${rows}
时间IP方法状态URI 耗时(s)接收发送
`; } async function loadSiteLog() { const site = state.siteSettings; if (!site) return; const view = $("#site-log-view"); if (!view) return; syncSiteLogIpFilter(); const q = String(state.siteLogQuery || "").trim(); if (state.siteLogKind === "error") { view.classList.add("table-mode"); try { const qs = new URLSearchParams({ name: site.name, kind: "error", limit: "200" }); if (q) qs.set("q", q); const data = await api(`/api/websites/logs?${qs.toString()}`); const items = (data && data.items) || []; if (!items.length) { view.innerHTML = `
${ q ? "没有匹配的错误日志" : "暂无错误日志" }
`; return; } const esc = escapeHtml; const rows = items .map((r) => ` ${esc(r.time)} ${esc(r.level)} ${esc(r.message || r.raw)} `) .join(""); view.innerHTML = `${rows}
时间级别内容
`; view.scrollTop = 0; } catch (err) { view.classList.remove("table-mode"); view.textContent = err.message || String(err); } return; } view.classList.add("table-mode"); const ip = String(state.siteLogIp || "").trim(); const qs = new URLSearchParams({ name: site.name, kind: "access", limit: "200" }); if (ip) qs.set("ip", ip); if (q) qs.set("q", q); try { const data = await api(`/api/websites/logs?${qs.toString()}`); const items = (data && data.items) || []; let empty = "暂无访问日志(写入 access.log 后会自动入库)"; if (ip && q) empty = `暂无匹配记录(IP ${ip} · 关键词 ${q})`; else if (ip) empty = `暂无该 IP 的访问记录:${ip}`; else if (q) empty = `没有匹配的访问日志:${q}`; view.innerHTML = renderAccessLogTable(items, empty); view.scrollTop = 0; } catch (err) { view.classList.remove("table-mode"); view.textContent = err.message || String(err); } } async function saveSiteDomain() { const site = state.siteSettings; if (!site) return; const domains = domainsFromText($("#site-set-domain").value); if (!domains.length) throw new Error("至少填写一个域名"); const updated = await api("/api/websites/update", { method: "POST", body: { name: site.name, domains }, }); Object.assign(site, updated || {}); fillSiteSettingsForm(site); toast("域名已保存"); await refresh(); } async function saveSiteCert() { const site = state.siteSettings; if (!site) return; const domains = domainsFromText($("#site-set-domain").value); const ssl_enable = $("#site-set-ssl-enable").checked; const ssl_port = Number($("#site-set-ssl-port").value || 443); let pem = ($("#site-set-ssl-pem")?.value || "").trim(); let key = ($("#site-set-ssl-key-pem")?.value || "").trim(); if (ssl_enable && (!pem || !key)) { throw new Error("启用 HTTPS 时请填写证书 PEM 与私钥 KEY"); } const looksCert = (t) => /BEGIN CERTIFICATE/.test(t); const looksKey = (t) => /BEGIN (RSA )?PRIVATE KEY|BEGIN EC PRIVATE KEY/.test(t); if (pem && key && looksKey(pem) && looksCert(key)) { // Common paste swap: auto-correct instead of writing broken nginx ssl. const tmp = pem; pem = key; key = tmp; const pemEl = $("#site-set-ssl-pem"); const keyEl = $("#site-set-ssl-key-pem"); if (pemEl) pemEl.value = pem; if (keyEl) keyEl.value = key; toast("检测到证书与私钥粘贴反了,已自动对调", "ok"); } if (pem && !looksCert(pem)) { throw new Error("证书 PEM 内容无效:应以 -----BEGIN CERTIFICATE----- 开头"); } if (key && !looksKey(key)) { throw new Error("私钥 KEY 内容无效:应以 -----BEGIN PRIVATE KEY----- 开头"); } const paths = siteSslPaths(site); await api("/api/files/mkdir", { method: "POST", body: { path: paths.dir } }).catch(() => {}); if (pem) { await api("/api/files/write", { method: "POST", body: { path: paths.cert, content: pem.endsWith("\n") ? pem : pem + "\n" }, }); } if (key) { await api("/api/files/write", { method: "POST", body: { path: paths.key, content: key.endsWith("\n") ? key : key + "\n" }, }); } const updated = await api("/api/websites/update", { method: "POST", body: { name: site.name, domains: domains.length ? domains : domainsFromText(site.domain), ssl_enable, ssl_port, ssl_cert: paths.cert, ssl_key: paths.key, }, }); Object.assign(site, updated || {}); fillSiteSettingsForm(site); await loadSiteSslPem(); toast("证书已保存并重载"); await refresh(); } async function applyLetsEncrypt() { const site = state.siteSettings; if (!site) return; await saveSiteDomain(); renderSslDomainChecklist(selectedSslHosts()); const hosts = selectedSslHosts(); if (!hosts.length) throw new Error("请至少勾选一个要申请证书的域名"); const data = await api("/api/websites/ssl/apply", { method: "POST", body: { name: site.name, domains: hosts }, }); toast(`已加入任务队列(${hosts.length} 个域名,一张证书)`); if (data && data.task_id) { await refreshTasks(); } } function destroyAnalyticsCharts() { (state.analyticsCharts || []).forEach((c) => { try { c.destroy(); } catch (_) {} }); state.analyticsCharts = []; } function analyticsLabel(ms) { const d = new Date(Number(ms) || 0); const mo = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${mo}-${day} ${hh}:${mm}`; } function analyticsHourLabel(offsetMs) { const totalMin = Math.floor(Math.max(0, Number(offsetMs) || 0) / 60000); const hh = String(Math.floor(totalMin / 60) % 24).padStart(2, "0"); const mm = String(totalMin % 60).padStart(2, "0"); return `${hh}:${mm}`; } /** Align today/yesterday bucket rows onto a shared clock axis. */ function alignTrafficCompare(compare) { const bucketMs = Number(compare?.bucket_ms) || 3600000; const slots = Math.max(1, Math.round(86400000 / bucketMs)); const nowOff = Number(compare?.now_offset_ms); const truncateAt = Number.isFinite(nowOff) ? nowOff : null; const toMap = (rows) => { const m = new Map(); (rows || []).forEach((r) => { const off = Number(r.offset_ms); if (!Number.isFinite(off) || off < 0) return; m.set(Math.round(off / bucketMs) * bucketMs, r); }); return m; }; const todayMap = toMap(compare?.today); const yestMap = toMap(compare?.yesterday); const labels = []; const today = { requests: [], bytes_recv: [], bytes_sent: [] }; const yesterday = { requests: [], bytes_recv: [], bytes_sent: [] }; for (let i = 0; i < slots; i++) { const off = i * bucketMs; labels.push(analyticsHourLabel(off)); const y = yestMap.get(off); yesterday.requests.push(y ? Number(y.requests) || 0 : 0); yesterday.bytes_recv.push(y ? Number(y.bytes_recv) || 0 : 0); yesterday.bytes_sent.push(y ? Number(y.bytes_sent) || 0 : 0); if (truncateAt != null && off > truncateAt) { today.requests.push(null); today.bytes_recv.push(null); today.bytes_sent.push(null); } else { const t = todayMap.get(off); today.requests.push(t ? Number(t.requests) || 0 : 0); today.bytes_recv.push(t ? Number(t.bytes_recv) || 0 : 0); today.bytes_sent.push(t ? Number(t.bytes_sent) || 0 : 0); } } return { labels, today, yesterday, bucketMs }; } function renderAnalytics(data) { const body = $("#analytics-body"); if (!body) return; destroyAnalyticsCharts(); const s = data.summary || {}; const cc = data.cc || {}; const esc = escapeHtml; const cards = [ ["请求数", s.requests ?? 0], ["独立 IP", s.unique_ips ?? 0], ["接收", formatBytes(s.bytes_recv)], ["发送", formatBytes(s.bytes_sent)], ["平均耗时", `${Number(s.avg_request_time || 0).toFixed(3)} s`], ["错误率", `${Number(s.error_rate || 0).toFixed(2)}%`], ["4xx", s.status_4xx ?? 0], ["5xx", s.status_5xx ?? 0], ] .map( ([k, v]) => `
${esc(k)}
${esc(v)}
` ) .join(""); const suspects = Array.isArray(cc.suspects) ? cc.suspects : []; const ipLink = (ip) => { const v = String(ip || "").trim(); if (!v) return "—"; return `${esc(v)}`; }; const suspectHtml = suspects.length ? `
${suspects .map( (r) => `` ) .join("")}
IP请求错误占比原因
${ipLink(r.ip)} ${esc(r.count)} ${esc(r.errors)} ${Number(r.share || 0).toFixed(1)}% ${esc(r.reason || "")}
` : `
未发现明显异常 IP
`; const topIpRows = (data.top_ips || []) .map( (r) => ` ${ipLink(r.ip)} ${esc(r.count)} ${esc(r.errors)} ${esc(formatBytes(r.bytes_recv))} ${esc(formatBytes(r.bytes_sent))} ${Number(r.share || 0).toFixed(1)}% ` ) .join(""); const topUriRows = (data.top_uris || []) .map( (r) => ` ${esc(r.uri)} ${esc(r.count)} ${esc(formatBytes(r.bytes_sent))} ${Number(r.avg_request_time || 0).toFixed(3)}s ` ) .join(""); const slowRows = (data.slow_uris || []) .map( (r) => ` ${esc(r.uri)} ${esc(r.count)} ${Number(r.avg_request_time || 0).toFixed(3)}s ${Number(r.max_request_time || 0).toFixed(3)}s ` ) .join(""); body.innerHTML = `
${cards}
请求趋势 今日 vs 昨日
状态码分布
流量趋势 今日 vs 昨日
CC / 异常 IP 分析 嫌疑 ${esc(cc.suspect_count ?? 0)} · 峰值桶请求 ${esc(cc.peak_bucket_requests ?? 0)}
${suspectHtml}
Top IP
${topIpRows || ``}
IP请求错误接收发送占比
暂无数据
热门 URI
${topUriRows || ``}
URI次数发送均耗时
暂无数据
最慢 URI
${slowRows || ``}
URI次数平均最大
暂无数据
数据源:${esc(data.log_db || "")}
`; if (typeof Chart === "undefined") return; const cmp = alignTrafficCompare(data.traffic_compare || {}); const labels = cmp.labels; const mkLine = (el, datasets, yFmt) => { if (!el) return null; const chart = new Chart(el, { type: "line", data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, animation: false, spanGaps: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { display: datasets.length > 1, position: "top", align: "end", labels: { boxWidth: 10, font: { size: 11 } } } }, scales: { x: { ticks: { maxTicksLimit: 12, color: "#94a3b8", font: { size: 10 } }, grid: { display: false } }, y: { beginAtZero: true, ticks: { color: "#94a3b8", font: { size: 10 }, callback: yFmt }, grid: { color: "rgba(148,163,184,.15)" } }, }, elements: { line: { tension: 0.3, borderWidth: 2 }, point: { radius: 0 } }, }, }); state.analyticsCharts.push(chart); return chart; }; mkLine( $("#an-chart-traffic"), [ { label: "今日", data: cmp.today.requests, borderColor: "#0d9488", backgroundColor: "rgba(13,148,136,.12)", fill: true, }, { label: "昨日", data: cmp.yesterday.requests, borderColor: "#94a3b8", backgroundColor: "transparent", borderDash: [6, 4], fill: false, }, ], (v) => v ); mkLine( $("#an-chart-bytes"), [ { label: "今日接收", data: cmp.today.bytes_recv, borderColor: "#0d9488", backgroundColor: "rgba(13,148,136,.08)", fill: false, }, { label: "今日发送", data: cmp.today.bytes_sent, borderColor: "#ea580c", backgroundColor: "rgba(234,88,12,.08)", fill: false, }, { label: "昨日接收", data: cmp.yesterday.bytes_recv, borderColor: "#5eead4", backgroundColor: "transparent", borderDash: [6, 4], fill: false, }, { label: "昨日发送", data: cmp.yesterday.bytes_sent, borderColor: "#fdba74", backgroundColor: "transparent", borderDash: [6, 4], fill: false, }, ], (v) => formatBytes(v) ); const status = data.status || []; const stEl = $("#an-chart-status"); if (stEl && status.length) { const chart = new Chart(stEl, { type: "doughnut", data: { labels: status.map((x) => String(x.status)), datasets: [{ data: status.map((x) => Number(x.count) || 0), backgroundColor: ["#0d9488", "#0369a1", "#ca8a04", "#ea580c", "#dc2626", "#7c3aed", "#64748b"], }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom", labels: { boxWidth: 10, font: { size: 11 } } } }, }, }); state.analyticsCharts.push(chart); } } async function loadSiteAnalytics() { const name = state.analyticsSite; if (!name) return; const range = $("#analytics-range")?.value || "24h"; $("#analytics-body").innerHTML = `
分析中…
`; const data = await api( `/api/websites/analytics?name=${encodeURIComponent(name)}&range=${encodeURIComponent(range)}` ); renderAnalytics(data || {}); } async function openSiteAnalytics(name) { state.analyticsSite = name; $("#analytics-site-name").textContent = name; if (!state.analyticsModal) { state.analyticsModal = new bootstrap.Modal($("#site-analytics-modal")); } state.analyticsModal.show(); await loadSiteAnalytics(); } async function openIpAccessDetail(siteName, ip) { const name = String(siteName || "").trim(); const addr = String(ip || "").trim(); if (!name || !addr) return; const site = state.sites.find((s) => s.name === name); if (!site) throw new Error("未找到网站"); fillSiteSettingsForm(site); state.siteLogKind = "access"; state.siteLogIp = addr; $("#site-log-tab-access")?.classList.add("active"); $("#site-log-tab-error")?.classList.remove("active"); syncSiteLogIpFilter(); if (!state.siteSettingsModal) { state.siteSettingsModal = new bootstrap.Modal($("#site-settings-modal")); } showStackedModal(state.siteSettingsModal, $("#site-settings-modal")); setSiteSettingsTab("logs"); } function bindSiteSettingsModal() { state.siteSettingsModal = new bootstrap.Modal($("#site-settings-modal")); $("#site-settings-close").addEventListener("click", () => state.siteSettingsModal.hide()); $$(".site-settings-tab").forEach((btn) => { btn.addEventListener("click", () => setSiteSettingsTab(btn.dataset.siteTab)); }); $("#site-set-domain-save").addEventListener("click", (e) => { withBusy(async () => saveSiteDomain(), e.currentTarget); }); $("#site-proxy-add")?.addEventListener("click", () => openUrlProxyDialog(null)); $("#site-proxy-tbody")?.addEventListener("click", (e) => { const btn = e.target.closest("button[data-proxy-act]"); if (!btn) return; const id = btn.dataset.proxyId; const row = (state.siteProxies || []).find((x) => x.id === id); if (btn.dataset.proxyAct === "edit") { if (!row) return toast("规则不存在", "err"); openUrlProxyDialog(row); return; } if (btn.dataset.proxyAct === "del") { withBusy(async () => { const ok = await confirmDialog({ title: "删除 URL 代理", message: `确认删除代理「${row?.path || id}」?`, okText: "删除", danger: true, }); if (!ok) return; const site = state.siteSettings; await api("/api/websites/proxies/delete", { method: "POST", body: { name: site.name, id }, }); toast("已删除"); await loadSiteProxies(); }, btn); } }); $("#site-set-cert-save").addEventListener("click", (e) => { withBusy(async () => saveSiteCert(), e.currentTarget); }); $("#site-cert-tab-pem").addEventListener("click", () => { setSiteCertSubTab("pem"); loadSiteSslPem().catch(() => {}); }); $("#site-cert-tab-apply").addEventListener("click", () => setSiteCertSubTab("apply")); $("#site-set-ssl-apply").addEventListener("click", (e) => { withBusy(async () => applyLetsEncrypt(), e.currentTarget); }); $("#site-ssl-domains-all").addEventListener("click", () => { $$("#site-ssl-domain-list input[type=checkbox]").forEach((el) => { el.checked = true; }); }); $("#site-ssl-domains-none").addEventListener("click", () => { $$("#site-ssl-domain-list input[type=checkbox]").forEach((el) => { el.checked = false; }); }); $("#site-set-domain").addEventListener("change", () => renderSslDomainChecklist(selectedSslHosts())); $("#site-set-nginx-save").addEventListener("click", (e) => { withBusy(async () => saveSiteNginxConf(), e.currentTarget); }); $("#site-set-nginx-reload").addEventListener("click", (e) => { withBusy(async () => { await loadSiteNginxConf(); toast("已重新加载"); }, e.currentTarget); }); $("#site-log-tab-access").addEventListener("click", () => { state.siteLogKind = "access"; $("#site-log-tab-access").classList.add("active"); $("#site-log-tab-error").classList.remove("active"); loadSiteLog(); }); $("#site-log-tab-error").addEventListener("click", () => { state.siteLogKind = "error"; $("#site-log-tab-error").classList.add("active"); $("#site-log-tab-access").classList.remove("active"); loadSiteLog(); }); $("#site-log-refresh").addEventListener("click", () => loadSiteLog()); $("#site-log-ip-clear")?.addEventListener("click", () => { state.siteLogIp = ""; syncSiteLogIpFilter(); loadSiteLog(); }); const scheduleSiteLogSearch = () => { if (state.siteLogSearchTimer) clearTimeout(state.siteLogSearchTimer); state.siteLogSearchTimer = setTimeout(() => { state.siteLogSearchTimer = null; const input = $("#site-log-search"); state.siteLogQuery = (input?.value || "").trim(); loadSiteLog().catch((err) => toast(err.message || String(err), "err")); }, 280); }; $("#site-log-search")?.addEventListener("input", scheduleSiteLogSearch); $("#site-log-search")?.addEventListener("keydown", (e) => { if (e.key !== "Enter") return; e.preventDefault(); if (state.siteLogSearchTimer) { clearTimeout(state.siteLogSearchTimer); state.siteLogSearchTimer = null; } state.siteLogQuery = (e.currentTarget.value || "").trim(); loadSiteLog().catch((err) => toast(err.message || String(err), "err")); }); $("#site-modules-refresh")?.addEventListener("click", () => { withBusy(async () => loadSiteModules()); }); $("#site-modules-filter")?.addEventListener("click", (e) => { const btn = e.target.closest("[data-mod-filter]"); if (!btn) return; state.siteModulesFilter = btn.dataset.modFilter || "all"; $$("#site-modules-filter [data-mod-filter]").forEach((b) => b.classList.toggle("active", b === btn) ); renderSiteModules(); }); $("#site-modules-list")?.addEventListener("click", (e) => { const btn = e.target.closest("[data-mod-act]"); if (!btn) return; siteModuleAction(btn.dataset.modAct, btn.dataset.modName); }); } function bindEvents() { state.bsModal = new bootstrap.Modal($("#modal")); bindUploadModal(); bindSiteSettingsModal(); $("#analytics-close")?.addEventListener("click", () => state.analyticsModal?.hide()); $("#analytics-range")?.addEventListener("change", () => { withBusy(async () => loadSiteAnalytics()); }); $("#site-analytics-modal")?.addEventListener("hidden.bs.modal", () => destroyAnalyticsCharts()); $("#analytics-body")?.addEventListener("click", (e) => { const link = e.target.closest("[data-analytics-ip]"); if (!link) return; e.preventDefault(); const ip = link.getAttribute("data-analytics-ip") || ""; withBusy(async () => openIpAccessDetail(state.analyticsSite, ip)); }); $("#btn-logout")?.addEventListener("click", async () => { const ok = await confirmDialog({ title: "退出登录", message: "确认退出当前账号?", okText: "退出", danger: true, }); if (!ok) return; withBusy(async () => { await api("/api/auth/logout", { method: "POST", body: {} }); location.replace("/login.html"); }); }); $$(".nav-item[data-view]").forEach((btn) => { btn.addEventListener("click", () => setView(btn.dataset.view)); }); $("#btn-proc-refresh")?.addEventListener("click", () => { withBusy(async () => { await loadProcesses(); toast("已刷新"); }); }); let procSearchTimer = null; $("#proc-search")?.addEventListener("input", (e) => { state.procQuery = e.target.value || ""; clearTimeout(procSearchTimer); procSearchTimer = setTimeout(() => { loadProcesses({ silent: true }).catch((err) => toast(err.message || String(err), "err")); }, 250); }); $("#proc-auto")?.addEventListener("change", () => { if ($("#view-processes")?.classList.contains("is-active")) startProcAutoRefresh(); }); $("#proc-table")?.addEventListener("click", (e) => { const sortTh = e.target.closest("th.proc-sort"); if (sortTh) { const key = sortTh.dataset.procSort; if (state.procSort === key) { state.procSortDir = state.procSortDir === "desc" ? "asc" : "desc"; } else { state.procSort = key; state.procSortDir = key === "user" || key === "name" ? "asc" : "desc"; } renderProcesses(); return; } const btn = e.target.closest("button[data-proc-act]"); if (!btn) return; const pid = btn.dataset.pid; const act = btn.dataset.procAct; withBusy(async () => { await killProcess(pid, act === "kill" ? "KILL" : "TERM"); }, btn); }); $("#task-badge").addEventListener("click", () => openTaskPanel()); $("#task-panel-close").addEventListener("click", closeTaskPanel); $("#task-panel-backdrop").addEventListener("click", closeTaskPanel); bindAllDialogResize(); $("#task-tab-active").addEventListener("click", () => setTaskTab("active")); $("#task-tab-history").addEventListener("click", () => setTaskTab("history")); $("#task-clear-finished").addEventListener("click", () => { withBusy(async () => { await api("/api/tasks/clear", { method: "POST", body: {} }); selectTask("", true); await refreshTasks(); toast("已清理历史任务"); }); }); $("#task-list").addEventListener("click", (e) => { const btn = e.target.closest("[data-task-id]"); if (!btn) return; selectTask(btn.dataset.taskId, true); renderTaskList(); pollTaskLog(true); }); $("#software-grid").addEventListener("click", (e) => { const btn = e.target.closest("button[data-act]"); if (!btn) return; softAction(btn.dataset.act, btn); }); $("#sites-table").addEventListener("click", (e) => { const pathBtn = e.target.closest("[data-site-path]"); if (pathBtn) { const path = pathBtn.dataset.sitePath; if (!path) return; state.filesPath = path; setView("files"); return; } const btn = e.target.closest("button[data-act]"); if (!btn) return; const name = btn.dataset.site; const act = btn.dataset.act; if (act === "site-config") { withBusy(async () => openSiteSettings(name), btn); return; } if (act === "site-analyze") { withBusy(async () => openSiteAnalytics(name), btn); return; } if (act === "site-delete") { openModal({ title: `删除网站 · ${name}`, bodyHtml: `

将删除配置记录与 Nginx 虚拟主机。

`, onSubmit: async (fd) => { await api("/api/websites/delete", { method: "POST", body: { name, delete_files: fd.get("delete_files") === "1" }, }); toast("已删除"); }, }); return; } withBusy(async () => { try { if (act === "site-start") await api("/api/websites/start", { method: "POST", body: { name } }); else if (act === "site-stop") await api("/api/websites/stop", { method: "POST", body: { name } }); else if (act === "site-restart") await api("/api/websites/restart", { method: "POST", body: { name } }); toast("已完成"); } catch (err) { if (act === "site-start" || act === "site-restart") { await alertDialog({ title: act === "site-restart" ? "重启失败" : "启动失败", message: err.message || String(err), }); } else { throw err; } } await refresh(); }, btn); }); $("#db-table").addEventListener("click", (e) => { const btn = e.target.closest("button[data-act]"); if (!btn) return; const name = btn.dataset.db; if (btn.dataset.act === "db-drop") { withBusy(async () => { const ok = await confirmDialog({ title: "删除数据库", message: `确认删除数据库「${name}」?此操作不可恢复。`, okText: "删除", danger: true, }); if (!ok) return; await api("/api/mysql/databases/drop", { method: "POST", body: { name } }); toast("已删除"); await refresh(); }, btn); return; } if (btn.dataset.act === "db-access") { openModal({ title: `权限 · ${name}`, bodyHtml: `
`, onSubmit: async (fd) => { await api("/api/mysql/databases/access", { method: "POST", body: { name, mode: fd.get("mode"), host: fd.get("host") || "" }, }); toast("权限已更新"); }, }); } }); $("#btn-site-create").addEventListener("click", () => { openModal({ title: "新建网站", bodyHtml: `
可用中文
静态站仅需名称/域名;反代请创建后在「反向代理」添加
仅支持 1 个域名;不写端口默认 80
根据域名自动生成,可手动修改
`, onSubmit: async (fd) => { const type = fd.get("type"); const domain = String(fd.get("domain") || "").trim(); if (!domain) throw new Error("请填写域名"); if (/[\s,;]/.test(domain)) throw new Error("仅支持填写一个域名"); const root = String(fd.get("root") || "").trim(); if (!root) throw new Error("请填写目录"); const body = { name: String(fd.get("name") || "").trim(), type, domain, domains: [domain], root, }; await api("/api/websites", { method: "POST", body }); toast("网站已创建"); }, }); const body = $("#modal-body"); syncSiteFormFields(body); const domainEl = body.querySelector('[name="domain"]'); const rootEl = body.querySelector('[name="root"]'); let rootTouched = false; rootEl.addEventListener("input", () => { rootTouched = true; }); const syncRootFromDomain = () => { if (rootTouched) return; const host = parseDomainToken(domainEl.value.trim()).host || ""; rootEl.value = host ? `/ngs/wwwroot/${host}` : ""; }; domainEl.addEventListener("input", syncRootFromDomain); domainEl.addEventListener("change", syncRootFromDomain); body.querySelector('[name="type"]').addEventListener("change", () => syncSiteFormFields(body)); }); $("#btn-db-create").addEventListener("click", () => { openModal({ title: "新建数据库", bodyHtml: `
`, onSubmit: async (fd) => { await api("/api/mysql/databases", { method: "POST", body: { name: String(fd.get("name") || "").trim(), user: String(fd.get("user") || "").trim(), password: String(fd.get("password") || ""), }, }); toast("数据库已创建"); }, }); }); $("#btn-mysql-password").addEventListener("click", () => { openModal({ title: "修改 root 密码", bodyHtml: `
`, onSubmit: async (fd) => { await api("/api/mysql/root/password", { method: "POST", body: { password: String(fd.get("password") || "") }, }); toast("root 密码已更新"); }, }); }); $("#files-crumbs").addEventListener("click", (e) => { const btn = e.target.closest("button[data-path]"); if (!btn) return; loadFiles(btn.dataset.path); }); $("#btn-files-up").addEventListener("click", () => loadFiles(parentPath(state.filesPath))); $("#btn-files-delete-selected")?.addEventListener("click", () => { deleteSelectedFiles().catch((err) => toast(err.message || String(err), "err")); }); $("#btn-files-clear-selection")?.addEventListener("click", () => { clearFilesSelection(); renderFiles(); }); $("#files-select-all")?.addEventListener("change", (e) => { const on = !!e.target.checked; for (const p of visibleFilePaths()) setFileSelected(p, on); state.filesLastClicked = ""; renderFiles(); }); $("#btn-files-mkdir").addEventListener("click", () => { openModal({ title: "新建目录", bodyHtml: `
`, onSubmit: async (fd) => { const name = String(fd.get("name") || "").trim(); if (!name) throw new Error("目录名不能为空"); await api("/api/files/mkdir", { method: "POST", body: { path: joinPath(state.filesPath, name) } }); toast("目录已创建"); await loadFiles(state.filesPath); }, }); }); $("#btn-files-newfile").addEventListener("click", () => { openModal({ title: "新建文件", bodyHtml: `
`, onSubmit: async (fd) => { const name = String(fd.get("name") || "").trim(); if (!name) throw new Error("文件名不能为空"); await openCodeEditor({ path: joinPath(state.filesPath, name), content: "", readonly: false }); }, }); }); $("#files-table").addEventListener("click", (e) => { const check = e.target.closest(".files-row-check"); if (check) { e.stopPropagation(); const path = check.dataset.path; const on = !!check.checked; if (e.shiftKey && state.filesLastClicked) { toggleFileRange(state.filesLastClicked, path, on); } else { setFileSelected(path, on); } state.filesLastClicked = path; renderFiles(); return; } const open = e.target.closest("[data-open]"); if (open) { if (open.dataset.dir === "1") loadFiles(open.dataset.open); return; } const btn = e.target.closest("button[data-act]"); if (!btn) return; const path = btn.dataset.path; const act = btn.dataset.act; if (act === "file-rename") { openModal({ title: "重命名", bodyHtml: `
`, onSubmit: async (fd) => { const name = String(fd.get("name") || "").trim(); if (!name) throw new Error("名称不能为空"); await api("/api/files/rename", { method: "POST", body: { from: path, to: joinPath(parentPath(path), name) } }); toast("已重命名"); await loadFiles(state.filesPath); }, }); return; } if (act === "file-delete") { const isDir = btn.dataset.dir === "1"; openModal({ title: "删除确认", bodyHtml: `

将删除:${escapeHtml(path)}

${isDir ? `
` : ""}`, onSubmit: async (fd) => { await api("/api/files/delete", { method: "POST", body: { path, recursive: fd.get("recursive") === "1" }, }); toast("已删除"); await loadFiles(state.filesPath); }, }); return; } if (act === "file-edit") { withBusy(async () => { await openFileInEditor(path, Number(btn.dataset.size) || 0, false); }, btn); } }); $("#files-table").addEventListener("dblclick", (e) => { const open = e.target.closest("[data-open]"); if (!open || open.dataset.dir === "1") return; e.preventDefault(); withBusy(async () => { if (open.dataset.tooBig === "1") throw new Error("文件超过 10MB,不支持在线编辑/预览"); await openFileInEditor(open.dataset.open, Number(open.dataset.size) || 0, false); }); }); $("#code-editor-cancel").addEventListener("click", () => { closeCodeEditor(); }); $("#code-editor-backdrop").addEventListener("click", () => { closeCodeEditor(); }); $("#editor-tree-up").addEventListener("click", () => { withBusy(async () => { await loadEditorTree(parentPath(state.editorTreePath)); }); }); $("#editor-tree-refresh").addEventListener("click", () => { withBusy(async () => { await loadEditorTree(state.editorTreePath || "/"); }); }); $("#editor-tree").addEventListener("click", (e) => { const item = e.target.closest("[data-tree-path]"); if (!item) return; const path = item.dataset.treePath; if (item.dataset.treeDir === "1") { withBusy(async () => loadEditorTree(path)); return; } withBusy(async () => { await openFileInEditor(path, Number(item.dataset.treeSize) || 0, false); }); }); $("#editor-tabs").addEventListener("click", (e) => { const closeBtn = e.target.closest("[data-close-tab]"); if (closeBtn) { e.stopPropagation(); withBusy(async () => closeEditorTab(closeBtn.dataset.closeTab)); return; } const tab = e.target.closest("[data-tab-path]"); if (!tab) return; withBusy(async () => switchEditorTab(tab.dataset.tabPath)); }); document.addEventListener("keydown", (e) => { if ($("#code-editor").hidden) return; if (e.key === "Escape") { // don't close whole IDE on Esc; only if no tabs? skip for safety return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { e.preventDefault(); e.stopPropagation(); withBusy(async () => { await saveActiveTab(); }); } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "w") { const tab = activeTab(); if (!tab) return; e.preventDefault(); withBusy(async () => closeEditorTab(tab.path)); } }, true); } async function boot() { try { await api("/api/auth/me"); } catch (_) { location.replace("/login.html"); return; } bindEvents(); let hash = (location.hash || "#console").slice(1); if (hash === "overview" || hash === "monitor") hash = "console"; setView(titles[hash] ? hash : "console"); refresh(); refreshTasks(); setInterval(refresh, 15000); setInterval(refreshTasks, 2000); } boot(); })();