(() => {
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: `
- 目标可填写 URL(如
http://127.0.0.1:15700 或 https://bucket.cos...myqcloud.com)或 UNIX Socket(如 /tmp/panel.sock)。
- 发送域名会写入请求头 Host,默认
$http_host;代理外站(如 COS)时请改为目标域名。
- 代理目录填
/ 将接管整站;子目录如 /web 仅匹配该前缀。静态站无 / 规则时仍提供本地静态文件。
`,
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 ``;
})
.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 `
| 时间 | IP | 方法 | 状态 | URI |
耗时(s) | 接收 | 发送 |
${rows}
`;
}
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 = ``;
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]) => ``
)
.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
? `
| IP | 请求 | 错误 | 占比 | 原因 |
${suspects
.map(
(r) => `
${ipLink(r.ip)} |
${esc(r.count)} |
${esc(r.errors)} |
${Number(r.share || 0).toFixed(1)}% |
${esc(r.reason || "")} |
`
)
.join("")}
`
: `未发现明显异常 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}
CC / 异常 IP 分析
嫌疑 ${esc(cc.suspect_count ?? 0)} · 峰值桶请求 ${esc(cc.peak_bucket_requests ?? 0)}
${suspectHtml}
Top IP
| IP | 请求 | 错误 | 接收 | 发送 | 占比 |
${topIpRows || `| 暂无数据 |
`}
热门 URI
| URI | 次数 | 发送 | 均耗时 |
${topUriRows || `| 暂无数据 |
`}
最慢 URI
| URI | 次数 | 平均 | 最大 |
${slowRows || `| 暂无数据 |
`}
数据源:${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: `
静态站仅需名称/域名;反代请创建后在「反向代理」添加
`,
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();
})();