(() => {
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 FILES_PATH_KEY = "ngs.filesPath";
function readRememberedFilesPath() {
try {
const p = String(localStorage.getItem(FILES_PATH_KEY) || "").trim();
if (p.startsWith("/") && !p.includes("\0")) return p;
} catch (_) {}
return "/";
}
function rememberFilesPath(path) {
const p = String(path || "").trim() || "/";
try {
localStorage.setItem(FILES_PATH_KEY, p);
} catch (_) {}
}
const state = {
status: null,
sites: [],
databases: [],
busy: false,
filesPath: readRememberedFilesPath(),
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
siteGroupFilter: "", // "" = all, "__none__" = ungrouped, else group name
redisConfigModal: null,
nginxConfigModal: null,
redisCfgEditor: null,
nginxCfgEditor: null,
selfInfo: null,
panelName: "NGS",
siteLogKind: "access",
siteLogIp: "",
siteLogQuery: "",
siteLogSearchTimer: null,
siteSslUploadTarget: "", // cert | key
analyticsModal: null,
analyticsSite: "",
analyticsCharts: [],
analyticsData: null,
analyticsTopUriWithArgs: false,
analyticsSlowUriWithArgs: false,
siteModules: [],
siteModulesFilter: "all",
siteModuleOps: {}, // `${site}::${name_en}` -> { act, taskId }
siteProxies: [],
processes: [],
procQuery: "",
procSort: "cpu_percent",
procSortDir: "desc",
procTimer: null,
procLoading: false,
procReloadQueued: false,
shellTerm: null,
shellFit: null,
shellWs: null,
shellConnected: false,
siteNginxEditor: null,
siteNginxPath: "",
scheduleJobs: [],
scheduleSelectedId: "",
scheduleLogs: [],
scheduleTimer: null,
plansTab: "cron",
};
const titles = {
console: { title: "控制台", eyebrow: "Console" },
software: { title: "软件管理", eyebrow: "Software" },
sites: { title: "网站管理", eyebrow: "Websites" },
mysql: { title: "MySQL", eyebrow: "数据库" },
redis: { title: "Redis", eyebrow: "数据库" },
files: { title: "文件管理", eyebrow: "Files" },
processes: { title: "进程管理", eyebrow: "Processes" },
shell: { title: "终端", eyebrow: "Shell" },
plans: { title: "计划", eyebrow: "Plans" },
settings: { title: "设置", eyebrow: "Settings" },
};
const navGroups = {
database: ["mysql", "redis"],
};
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 syncNavGroups(activeView) {
$$(".nav-group").forEach((g) => {
const key = g.dataset.navGroup;
const children = navGroups[key] || [];
const hasActive = children.includes(activeView);
g.classList.toggle("has-active", hasActive);
if (hasActive) g.classList.add("is-open");
const toggle = g.querySelector("[data-nav-toggle]");
if (toggle) toggle.setAttribute("aria-expanded", g.classList.contains("is-open") ? "true" : "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));
syncNavGroups(name);
const t = titles[name] || titles.console;
$("#page-title").textContent = t.title;
$("#page-eyebrow").textContent = t.eyebrow;
document.title = `${state.panelName || "NGS"} · ${t.title}`;
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();
}
if (name === "shell") {
ensureShellTerminal();
connectShell();
} else {
// Keep session alive when switching away; only dispose on logout/unload.
fitShellSoon();
}
if (name === "plans") {
loadScheduleJobs().catch((err) => toast(err.message || String(err), "err"));
startScheduleAutoRefresh();
} else {
stopScheduleAutoRefresh();
}
if (name === "settings") fillSettingsForm();
if (name === "redis") {
loadRedisPage().catch((err) => toast(err.message || String(err), "err"));
}
}
function applyPanelName(name) {
const n = String(name || "").trim() || "NGS";
state.panelName = n;
const mark = $("#brand-mark");
const brand = $("#brand-name");
if (mark) mark.textContent = [...n][0] || "N";
if (brand) {
brand.textContent = n;
brand.title = n;
}
const view = (location.hash || "#console").slice(1);
const t = titles[view] || titles.console;
document.title = `${n} · ${t.title}`;
const input = $("#setting-panel-name");
if (input && document.activeElement !== input) input.value = n;
}
function fillSettingsForm() {
const input = $("#setting-panel-name");
if (input) input.value = state.panelName || "NGS";
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function safeDecodeUri(s) {
let cur = String(s ?? "");
if (!cur) return "";
// Scanners often double/triple-encode; keep decoding until stable.
for (let i = 0; i < 8; i++) {
try {
const next = decodeURIComponent(cur.replace(/\+/g, "%20"));
if (next === cur) break;
cur = next;
} catch {
break;
}
}
return cur;
}
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 setRedisConfigTab(tab) {
const quick = tab !== "file";
$("#redis-tab-quick")?.classList.toggle("active", quick);
$("#redis-tab-file")?.classList.toggle("active", !quick);
$("#redis-pane-quick").hidden = !quick;
$("#redis-pane-file").hidden = quick;
if (!quick) {
requestAnimationFrame(() => layoutSoftCfgEditor(state.redisCfgEditor));
}
}
async function loadRedisQuickConfig() {
const data = await api("/api/redis/config");
$("#redis-cfg-bind").value = data.bind || "127.0.0.1";
$("#redis-cfg-port").value = data.port || 6379;
$("#redis-cfg-pass").value = data.requirepass || "";
$("#redis-cfg-path").textContent = data.conf || "";
return data;
}
function layoutSoftCfgEditor(editor) {
if (editor && typeof editor.layout === "function") {
editor.layout();
}
}
async function ensureSoftCfgEditor(kind) {
const isRedis = kind === "redis";
const hostId = isRedis ? "redis-cfg-monaco" : "nginx-cfg-monaco";
const host = $(`#${hostId}`);
if (!host) throw new Error("编辑器容器不存在");
const existing = isRedis ? state.redisCfgEditor : state.nginxCfgEditor;
if (existing) {
layoutSoftCfgEditor(existing);
return existing;
}
const monaco = await ensureMonaco();
host.innerHTML = "";
const editor = 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",
});
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
withBusy(async () => {
if (isRedis) await saveRedisConfFile();
else await saveNginxConfFile();
});
});
if (isRedis) state.redisCfgEditor = editor;
else state.nginxCfgEditor = editor;
layoutSoftCfgEditor(editor);
return editor;
}
async function loadRedisConfFile() {
const path = $("#redis-cfg-path").textContent.trim();
if (!path) throw new Error("配置文件路径未知");
const data = await api(`/api/files/read?path=${encodeURIComponent(path)}`);
const content = (data && data.content) || "";
const editor = await ensureSoftCfgEditor("redis");
const monaco = await ensureMonaco();
const model = editor.getModel();
const lang = langFromPath(path) || "ini";
if (model && monaco.editor.setModelLanguage) {
monaco.editor.setModelLanguage(model, lang);
}
editor.setValue(content);
requestAnimationFrame(() => layoutSoftCfgEditor(editor));
}
async function saveRedisConfFile() {
const path = $("#redis-cfg-path").textContent.trim();
if (!path) throw new Error("配置文件路径未知");
const editor = await ensureSoftCfgEditor("redis");
await api("/api/files/write", {
method: "POST",
body: { path, content: editor.getValue() || "" },
});
toast("配置文件已保存");
}
async function openRedisConfigModal() {
if (!state.redisConfigModal) {
state.redisConfigModal = new bootstrap.Modal($("#redis-config-modal"));
}
setRedisConfigTab("quick");
await loadRedisQuickConfig();
showStackedModal(state.redisConfigModal, $("#redis-config-modal"));
}
function setNginxConfigTab(tab) {
const quick = tab !== "file";
$("#nginx-tab-quick")?.classList.toggle("active", quick);
$("#nginx-tab-file")?.classList.toggle("active", !quick);
$("#nginx-pane-quick").hidden = !quick;
$("#nginx-pane-file").hidden = quick;
if (!quick) {
requestAnimationFrame(() => layoutSoftCfgEditor(state.nginxCfgEditor));
}
}
async function loadNginxQuickConfig() {
const data = await api("/api/nginx/config");
$("#nginx-cfg-workers").value = data.worker_processes || "auto";
$("#nginx-cfg-connections").value = data.worker_connections || 1024;
$("#nginx-cfg-keepalive").value = data.keepalive_timeout || "65";
$("#nginx-cfg-body").value = data.client_max_body_size || "50m";
$("#nginx-cfg-gzip").checked = !!data.gzip;
$("#nginx-cfg-path").textContent = data.conf || "";
return data;
}
async function loadNginxConfFile() {
const path = $("#nginx-cfg-path").textContent.trim();
if (!path) throw new Error("配置文件路径未知");
const data = await api(`/api/files/read?path=${encodeURIComponent(path)}`);
const content = (data && data.content) || "";
const editor = await ensureSoftCfgEditor("nginx");
const monaco = await ensureMonaco();
const model = editor.getModel();
const lang = langFromPath(path) || "ini";
if (model && monaco.editor.setModelLanguage) {
monaco.editor.setModelLanguage(model, lang);
}
editor.setValue(content);
requestAnimationFrame(() => layoutSoftCfgEditor(editor));
}
async function saveNginxConfFile() {
const path = $("#nginx-cfg-path").textContent.trim();
if (!path) throw new Error("配置文件路径未知");
const editor = await ensureSoftCfgEditor("nginx");
await api("/api/files/write", {
method: "POST",
body: { path, content: editor.getValue() || "" },
});
toast("配置文件已保存");
}
async function openNginxConfigModal() {
if (!state.nginxConfigModal) {
state.nginxConfigModal = new bootstrap.Modal($("#nginx-config-modal"));
}
setNginxConfigTab("quick");
await loadNginxQuickConfig();
showStackedModal(state.nginxConfigModal, $("#nginx-config-modal"));
}
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")),
gpu: $("#chart-gpu") ? makePercentChart($("#chart-gpu"), "GPU 占用", "rgb(124, 58, 237)") : null,
gpuMem: $("#chart-gpu-mem") ? makePercentChart($("#chart-gpu-mem"), "GPU 显存", "rgb(219, 39, 119)") : null,
};
return state.metricCharts;
}
function renderDiskList(disks) {
const host = $("#disk-list");
if (!host) return;
const list = Array.isArray(disks) ? disks : [];
if (!list.length) {
host.innerHTML = "";
return;
}
host.innerHTML = list
.map((d) => {
const pct = Math.max(0, Math.min(100, Number(d.percent) || 0));
const name = escapeHtml(d.path || "/");
const extra = [d.device, d.fstype].filter(Boolean).join(" · ");
return `
${name}${extra ? ` (${escapeHtml(extra)})` : ""}
${formatBytes(d.used)} / ${formatBytes(d.total)} · ${pct.toFixed(1)}%
`;
})
.join("");
}
function gpuMemPercent(used, total) {
const t = Number(total) || 0;
if (t <= 0) return 0;
return Math.max(0, Math.min(100, ((Number(used) || 0) / t) * 100));
}
function renderGpuPanel(gpus, last) {
const wrap = $("#gpu-wrap");
const list = Array.isArray(gpus) ? gpus : [];
const hasGpu = list.length > 0 || (last && Number(last.gpu_mem_total) > 0);
if (wrap) wrap.hidden = !hasGpu;
if (!hasGpu) return;
const charts = state.metricCharts;
for (const c of [charts && charts.gpu, charts && charts.gpuMem]) {
if (!c) continue;
try {
c.resize();
} catch (_) {}
}
const g0 = list[0];
const name = g0 ? g0.name : "";
const util = Number(g0 ? g0.percent : last && last.gpu_percent) || 0;
const memUsed = Number(g0 ? g0.mem_used : last && last.gpu_mem_used) || 0;
const memTotal = Number(g0 ? g0.mem_total : last && last.gpu_mem_total) || 0;
const memPct = Number(g0 ? g0.mem_percent : gpuMemPercent(memUsed, memTotal)) || 0;
const temp = g0 && g0.temperature ? ` · ${Number(g0.temperature).toFixed(0)}°C` : "";
const nameBadge = $("#gpu-name-badge");
const gpuBadge = $("#gpu-badge");
const memBadge = $("#gpu-mem-badge");
if (nameBadge) nameBadge.textContent = name || "—";
if (gpuBadge) gpuBadge.textContent = `${util.toFixed(1)}%${temp}`;
if (memBadge) {
memBadge.textContent =
`${formatBytes(memUsed)} / ${formatBytes(memTotal)} · ${memPct.toFixed(1)}%`;
}
}
function applyHistoryToCharts(items, range, live) {
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 gpu = items.map((p) => Number(p.gpu_percent) || 0);
const gpuMem = items.map((p) => gpuMemPercent(p.gpu_mem_used, p.gpu_mem_total));
const applyOne = (chart, data) => {
if (!chart) return;
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);
applyOne(charts.gpu, gpu);
applyOne(charts.gpuMem, gpuMem);
if (charts.net) {
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 = "—";
renderDiskList([]);
renderGpuPanel([], null);
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)}%`;
const liveDisks = live && Array.isArray(live.disks) && live.disks.length
? live.disks
: null;
const primary = liveDisks
? liveDisks.find((d) => d.path === "/") || liveDisks[0]
: last;
const diskUsed = primary.used ?? primary.disk_used;
const diskTotal = primary.total ?? primary.disk_total;
const diskPct = Number(primary.percent ?? last.disk_percent) || 0;
$("#disk-badge").textContent = liveDisks && liveDisks.length > 1
? `${liveDisks.length} 块磁盘`
: `${formatBytes(diskUsed)} / ${formatBytes(diskTotal)} · ${diskPct.toFixed(1)}%`;
renderDiskList(
liveDisks ||
[{
path: "/",
used: last.disk_used,
total: last.disk_total,
percent: last.disk_percent,
}]
);
const net = (live && live.network) || {};
const rxBytes = Number(net.rx_bytes != null ? net.rx_bytes : last.net_rx_bytes) || 0;
const txBytes = Number(net.tx_bytes != null ? net.tx_bytes : last.net_tx_bytes) || 0;
const rxBps = Number(net.rx_bps != null ? net.rx_bps : last.net_rx_bps) || 0;
const txBps = Number(net.tx_bps != null ? net.tx_bps : last.net_tx_bps) || 0;
$("#net-badge").innerHTML =
`↓ ${formatBytes(rxBytes)} · ${formatRate(rxBps)}` +
`
` +
`↑ ${formatBytes(txBytes)} · ${formatRate(txBps)}`;
renderGpuPanel((live && live.gpus) || [], last);
}
async function pollMetrics() {
try {
const range = state.metricsRange || "10m";
const [hist, live] = await Promise.all([
api(`/api/system/metrics/history?range=${encodeURIComponent(range)}`),
api("/api/system/metrics").catch(() => null),
]);
applyHistoryToCharts((hist && hist.items) || [], range, live);
} 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`;
const ver = state.selfInfo?.commit_short;
$("#top-meta").textContent = ver
? `站点 ${s.sites ?? 0} · ${ver}`
: `站点 ${s.sites ?? 0}`;
}
function renderSelfVersion(info) {
state.selfInfo = info || null;
const el = $("#self-version");
const btn = $("#btn-self-update");
if (!el) return;
if (!info || !info.has_repo) {
el.textContent = "版本 —";
el.classList.remove("is-update");
el.title = (info && info.error) || "无法读取 Git 版本";
btn?.classList.add("d-none");
return;
}
const parts = [];
if (info.branch) parts.push(info.branch);
parts.push(info.commit_short || info.commit || "—");
if (info.update_available) parts.push(`落后${info.behind}`);
el.textContent = parts.join(" · ");
el.classList.toggle("is-update", !!info.update_available);
el.title = [
info.subject || "",
info.commit_date || "",
info.remote ? `${info.remote} ${info.remote_commit_short || ""}`.trim() : "",
info.in_tmux ? `tmux ${info.tmux_pane || ""}`.trim() : "未在 tmux 中运行",
info.dirty ? "工作区有未提交改动" : "",
]
.filter(Boolean)
.join("\n");
btn?.classList.toggle("d-none", !info.update_available);
if ($("#top-meta")) {
const s = state.status || {};
$("#top-meta").textContent = `站点 ${s.sites ?? 0} · ${info.commit_short || ""}`;
}
}
async function loadSelfVersion({ fetchRemote = false } = {}) {
const info = fetchRemote
? await api("/api/self/check", { method: "POST", body: {} })
: await api("/api/self/version");
renderSelfVersion(info);
return info;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function waitForPanelRestart(taskId) {
const t0 = Date.now();
let sawDown = false;
let successAt = 0;
while (Date.now() - t0 < 300000) {
await sleep(1500);
try {
if (taskId && !sawDown) {
const data = await api("/api/tasks");
const tasks = (data && data.tasks) || [];
const t = tasks.find((x) => x.id === taskId);
if (t && t.status === "failed") {
toast(t.message || "更新失败", "err");
return;
}
if (t && t.status === "success") {
if (!successAt) successAt = Date.now();
if (Date.now() - successAt > 8000 && !sawDown) {
toast("已是最新,无需重启");
await loadSelfVersion({ fetchRemote: false });
return;
}
}
}
const res = await fetch("/api/ping", { credentials: "same-origin" });
if (!res.ok) {
sawDown = true;
continue;
}
const json = await res.json().catch(() => null);
if (json && Number(json.code) === 200) {
if (sawDown) {
location.reload();
return;
}
} else {
sawDown = true;
}
} catch (_) {
sawDown = true;
}
}
toast("面板仍在重启,请稍后手动刷新", "err");
}
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 siteLogCell(site) {
const files = Number(site?.log_file_bytes) || 0;
const db = Number(site?.log_db_bytes) || 0;
const title = `文件 ${formatBytes(files)}(access/error)\n数据库 ${formatBytes(db)}`;
return ``;
}
function siteGroupLabel(name) {
return name ? name : "未分组";
}
function siteGroupKey(name) {
return name ? name : "__none__";
}
function uniqueSiteGroups() {
const set = new Set();
(state.sites || []).forEach((s) => {
if (s.group_name) set.add(s.group_name);
});
return [...set].sort((a, b) => a.localeCompare(b, "zh"));
}
function fillSiteGroupDatalist(el) {
if (!el) return;
el.innerHTML = uniqueSiteGroups()
.map((g) => ``)
.join("");
}
function filteredSites() {
const f = state.siteGroupFilter || "";
const list = [...(state.sites || [])];
list.sort((a, b) => {
const ga = a.group_name || "";
const gb = b.group_name || "";
if (!!ga !== !!gb) return ga ? -1 : 1;
if (ga !== gb) return ga.localeCompare(gb, "zh");
const oa = Number(a.sort_order) || 0;
const ob = Number(b.sort_order) || 0;
if (oa !== ob) return oa - ob;
return String(a.name || "").localeCompare(String(b.name || ""), "zh");
});
if (!f) return list;
if (f === "__none__") return list.filter((s) => !s.group_name);
return list.filter((s) => s.group_name === f);
}
function renderSiteGroupBar() {
const host = $("#sites-group-bar");
if (!host) return;
const groups = uniqueSiteGroups();
const counts = { "": state.sites.length, __none__: 0 };
(state.sites || []).forEach((s) => {
if (!s.group_name) counts.__none__ += 1;
else counts[s.group_name] = (counts[s.group_name] || 0) + 1;
});
const chips = [
{ key: "", label: "全部", count: counts[""] },
{ key: "__none__", label: "未分组", count: counts.__none__ },
...groups.map((g) => ({ key: g, label: g, count: counts[g] || 0 })),
];
host.innerHTML = `${chips
.map((c) => {
const active = (state.siteGroupFilter || "") === c.key;
return ``;
})
.join("")}
`;
}
async function persistSiteOrder(sitesInView) {
const items = sitesInView.map((s, idx) => ({
name: s.name,
group_name: s.group_name || "",
sort_order: (idx + 1) * 10,
}));
// Also keep other sites' meta unchanged by including them.
const names = new Set(items.map((i) => i.name));
(state.sites || []).forEach((s) => {
if (names.has(s.name)) return;
items.push({
name: s.name,
group_name: s.group_name || "",
sort_order: Number(s.sort_order) || 0,
});
});
const data = await api("/api/websites/reorder", { method: "POST", body: { items } });
if (Array.isArray(data)) state.sites = data;
else await refresh();
}
async function moveSiteInFilter(name, dir) {
const list = filteredSites();
const idx = list.findIndex((s) => s.name === name);
if (idx < 0) return;
const j = idx + dir;
if (j < 0 || j >= list.length) return;
// Only swap within same group when viewing all.
if (!(state.siteGroupFilter || "") && (list[idx].group_name || "") !== (list[j].group_name || "")) {
toast("请先筛选到同一分组内再排序", "err");
return;
}
const tmp = list[idx];
list[idx] = list[j];
list[j] = tmp;
await withBusy(async () => {
await persistSiteOrder(list);
renderSites();
toast("排序已更新");
});
}
function renderSites() {
renderSiteGroupBar();
const tbody = $("#sites-table tbody");
const sites = filteredSites();
if (!sites.length) {
tbody.innerHTML = `| 暂无网站 |
`;
return;
}
tbody.innerHTML = sites
.map((s, idx) => {
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 || "";
const group = siteGroupLabel(s.group_name || "");
const prev = idx > 0 ? sites[idx - 1] : null;
const next = idx < sites.length - 1 ? sites[idx + 1] : null;
const sameGroup = (a, b) => (a?.group_name || "") === (b?.group_name || "");
const upDis = !prev || !sameGroup(prev, s) ? " disabled" : "";
const downDis = !next || !sameGroup(next, s) ? " disabled" : "";
return `
|
|
${escapeHtml(s.name)} |
${escapeHtml(group)} |
${escapeHtml(s.type)} |
${escapeHtml(domainText)} |
|
${siteSslExpireCell(s)} |
${siteLogCell(s)} |
${st} |
|
`;
})
.join("");
}
function fillSqlDbSelect() {
const sel = $("#sql-db");
if (!sel) return;
const cur = sel.value;
const names = (state.databases || []).map((d) => d.name).filter(Boolean);
sel.innerHTML =
`` +
names.map((n) => ``).join("");
if ([...sel.options].some((o) => o.value === cur)) sel.value = cur;
}
function renderSqlResult(data) {
const meta = $("#sql-meta");
const thead = $("#sql-result thead");
const tbody = $("#sql-result tbody");
if (!meta || !thead || !tbody) return;
const cols = Array.isArray(data?.columns) ? data.columns : [];
const rows = Array.isArray(data?.rows) ? data.rows : [];
const ms = data?.elapsed_ms != null ? ` · ${data.elapsed_ms} ms` : "";
meta.textContent = `${data?.message || "执行成功"}${ms}`;
meta.className = "small text-secondary mt-2";
if (!cols.length) {
thead.innerHTML = "";
tbody.innerHTML = "";
return;
}
thead.innerHTML = `${cols.map((c) => `| ${escapeHtml(c)} | `).join("")}
`;
if (!rows.length) {
tbody.innerHTML = `| 无数据 |
`;
return;
}
tbody.innerHTML = rows
.map(
(row) =>
`${cols
.map((_, i) => `| ${escapeHtml((row && row[i]) ?? "")} | `)
.join("")}
`
)
.join("");
}
async function execSql(btn) {
const sql = ($("#sql-input")?.value || "").trim();
if (!sql) {
toast("请输入 SQL", "err");
return;
}
if (sql.length > 256 * 1024) {
toast("SQL 过长(最大 256KB)", "err");
return;
}
await withBusy(async () => {
const data = await api("/api/mysql/exec", {
method: "POST",
body: { database: $("#sql-db")?.value || "", sql },
});
renderSqlResult(data || {});
}, btn);
}
function renderDatabases() {
fillSqlDbSelect();
const tbody = $("#db-table tbody");
if (!tbody) return;
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 redisInfoCell(label, value) {
return `
${escapeHtml(label)}
${value}
`;
}
function renderRedis() {
const grid = $("#redis-info-grid");
const actions = $("#redis-actions");
const summary = $("#redis-status-summary");
if (!grid || !actions) return;
const info = (state.status && state.status.redis) || {};
const installed = !!info.installed;
const running = info.running;
if (summary) {
summary.textContent = !installed
? "未安装"
: running
? "运行中"
: "已停止";
}
const btns = [];
if (!installed) {
btns.push(``);
} else {
if (running) {
btns.push(``);
} else {
btns.push(``);
}
btns.push(``);
btns.push(``);
btns.push(``);
}
actions.innerHTML = btns.join("");
grid.innerHTML = [
redisInfoCell("状态", statusPill(installed, running)),
redisInfoCell("版本", escapeHtml(info.version || "—")),
redisInfoCell("端口", escapeHtml(info.port != null ? String(info.port) : "—")),
redisInfoCell("配置文件", `${escapeHtml(info.conf || "—")}`),
redisInfoCell("安装目录", `${escapeHtml(info.install_dir || "—")}`),
redisInfoCell("可执行文件", `${escapeHtml(info.bin || "—")}`),
].join("");
}
async function loadRedisPage() {
try {
const data = await api("/api/redis/status");
if (!state.status) state.status = {};
state.status.redis = { ...(state.status.redis || {}), ...(data || {}) };
} catch (_) {
/* keep cached status */
}
renderRedis();
}
async function execRedisCmd(btn) {
const input = $("#redis-cmd-input");
const meta = $("#redis-cmd-meta");
const out = $("#redis-cmd-output");
if (!input) return;
const command = String(input.value || "").trim();
if (!command) {
toast("请输入 Redis 命令", "err");
return;
}
await withBusy(async () => {
const data = await api("/api/redis/exec", {
method: "POST",
body: { command },
});
const elapsed = data && data.elapsed_ms != null ? `${data.elapsed_ms} ms` : "";
if (meta) meta.textContent = [elapsed, data && data.truncated ? "输出已截断" : ""].filter(Boolean).join(" · ");
if (out) {
out.hidden = false;
out.textContent = (data && data.output) || "(无输出)";
}
}, btn);
}
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;
// Skip if a request is already in flight — do not queue extra scans.
if (state.procLoading) 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;
const cwd = p.cwd || "";
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(cwd || "—")} |
${escapeHtml(p.cmdline || "—")} |
|
`;
})
.join("");
}
async function loadProcesses(opts = {}) {
if (state.procLoading) {
if (!opts.silent) state.procReloadQueued = true;
return;
}
state.procLoading = true;
try {
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();
} finally {
state.procLoading = false;
if (state.procReloadQueued) {
state.procReloadQueued = false;
loadProcesses({ silent: true }).catch(() => {});
}
}
}
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 setShellStatus(text, kind) {
const el = $("#shell-status");
if (!el) return;
el.textContent = text;
el.classList.remove("is-online", "is-offline", "is-error");
if (kind) el.classList.add(kind);
}
function shellWsUrl(ticket) {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/api/shell/ws?ticket=${encodeURIComponent(ticket || "")}`;
}
function fitShellSoon() {
requestAnimationFrame(() => {
try {
state.shellFit?.fit();
sendShellResize();
} catch (_) {}
});
}
function sendShellResize() {
const ws = state.shellWs;
const term = state.shellTerm;
if (!ws || ws.readyState !== WebSocket.OPEN || !term) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
function ensureShellTerminal() {
if (state.shellTerm || typeof Terminal === "undefined") return;
const host = $("#shell-terminal");
if (!host) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
theme: {
background: "#0b1220",
foreground: "#e2e8f0",
cursor: "#2dd4bf",
selectionBackground: "rgba(45, 212, 191, 0.28)",
},
allowProposedApi: true,
});
const fit = typeof FitAddon !== "undefined" ? new FitAddon.FitAddon() : null;
if (fit) term.loadAddon(fit);
term.open(host);
state.shellTerm = term;
state.shellFit = fit;
term.onData((data) => {
const ws = state.shellWs;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
});
window.addEventListener("resize", () => {
if ($("#view-shell")?.classList.contains("is-active")) fitShellSoon();
});
fitShellSoon();
}
function disconnectShell() {
const ws = state.shellWs;
state.shellWs = null;
state.shellConnected = false;
if (ws) {
try {
ws.onopen = null;
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
} catch (_) {}
}
setShellStatus("未连接", "is-offline");
}
async function connectShell() {
ensureShellTerminal();
if (!state.shellTerm) {
setShellStatus("终端组件未加载", "is-error");
return;
}
if (state.shellWs &&
(state.shellWs.readyState === WebSocket.OPEN ||
state.shellWs.readyState === WebSocket.CONNECTING)) {
fitShellSoon();
state.shellTerm.focus();
return;
}
disconnectShell();
setShellStatus("连接中…", "is-offline");
let ticket = "";
try {
const data = await api("/api/shell/ticket");
ticket = (data && data.ticket) || "";
} catch (err) {
setShellStatus(err.message || "获取票据失败", "is-error");
return;
}
if (!ticket) {
setShellStatus("票据无效", "is-error");
return;
}
const ws = new WebSocket(shellWsUrl(ticket));
ws.binaryType = "arraybuffer";
state.shellWs = ws;
ws.onopen = () => {
state.shellConnected = true;
setShellStatus("已连接", "is-online");
fitShellSoon();
state.shellTerm?.focus();
};
ws.onmessage = (ev) => {
const term = state.shellTerm;
if (!term) return;
if (typeof ev.data === "string") {
term.write(ev.data);
return;
}
term.write(new Uint8Array(ev.data));
};
ws.onerror = () => {
setShellStatus("连接错误", "is-error");
};
ws.onclose = () => {
state.shellConnected = false;
if (state.shellWs === ws) state.shellWs = null;
setShellStatus("已断开", "is-offline");
};
}
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) {
const want = path || state.filesPath || readRememberedFilesPath() || "/";
try {
const data = await api(`/api/files/list?path=${encodeURIComponent(want)}`);
const nextPath = (data && data.path) || want || "/";
if (nextPath !== state.filesPath) clearFilesSelection();
state.filesPath = nextPath;
rememberFilesPath(nextPath);
state.files = (data && data.entries) || [];
renderFiles();
} catch (err) {
if (want !== "/") {
rememberFilesPath("/");
state.filesPath = "/";
try {
await loadFiles("/");
toast("上次目录不可用,已回到 /", "err");
return;
} catch (_) {}
}
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="site_type"], [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 : [];
if (status && status.panel_name) applyPanelName(status.panel_name);
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();
renderRedis();
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 === "redis-refresh") {
await withBusy(async () => {
await loadRedisPage();
toast("已刷新");
}, btn);
return;
}
if (act === "redis-config") {
await withBusy(async () => openRedisConfigModal(), btn);
return;
}
if (act === "nginx-config") {
await withBusy(async () => openNginxConfigModal(), btn);
return;
}
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
});
}
const UPLOAD_CHUNK_BYTES = 8 * 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;
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 uploadChunk(path, blob, offset, total, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const q =
`path=${encodeURIComponent(path)}` +
`&offset=${offset}` +
`&total=${total}`;
xhr.open("POST", `/api/files/upload?${q}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
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(blob);
});
}
async function uploadChunkWithRetry(path, blob, offset, total, onProgress) {
let last = null;
for (let i = 0; i < 3; i += 1) {
try {
return await uploadChunk(path, blob, offset, total, onProgress);
} catch (err) {
last = err;
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
}
}
throw last || new Error("上传失败");
}
async function uploadFileWithProgress(path, file, onProgress) {
const total = file.size || 0;
if (total === 0) {
await uploadChunkWithRetry(path, file, 0, 0, onProgress);
if (onProgress) onProgress(0, 0);
return;
}
let offset = 0;
while (offset < total) {
const end = Math.min(offset + UPLOAD_CHUNK_BYTES, total);
const blob = file.slice(offset, end);
const start = offset;
await uploadChunkWithRetry(path, blob, start, total, (loaded) => {
if (onProgress) onProgress(start + loaded, total);
});
offset = end;
if (onProgress) onProgress(offset, total);
}
}
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) => {
setUploadProgress(
completedBytes + loaded,
totalBytes,
`正在上传 ${it.relativePath}(${formatBytes(loaded)} / ${formatBytes(total)})`
);
});
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 === "custom") {
loadSiteCustomConf().catch((err) => toast(err.message || String(err), "err"));
}
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 || ""}`;
fillSiteGroupDatalist($("#site-group-datalist"));
$("#site-set-group").value = site.group_name || "";
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 配置已保存并重载");
}
async function loadSiteCustomConf() {
const site = state.siteSettings;
if (!site) return;
const data = await api(`/api/websites/custom?name=${encodeURIComponent(site.name)}`);
$("#site-set-custom").value = data.content || "";
}
async function saveSiteCustomConf() {
const site = state.siteSettings;
if (!site) return;
await api("/api/websites/custom", {
method: "POST",
body: {
name: site.name,
content: $("#site-set-custom")?.value || "",
},
});
toast("自定义配置已保存并重载");
// Refresh full nginx view cache if user switches back.
if (state.siteNginxPath) {
try {
await loadSiteNginxConf();
} catch (_) {}
}
}
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 rawUri = (r.uri || "") + (r.args ? "?" + r.args : "");
const uri = esc(safeDecodeUri(r.uri || "")) + (r.args ? "?" + esc(safeDecodeUri(r.args)) : "");
const body = String(r.body || "");
const bodyCell = body
? `${esc(body)} | `
: `— | `;
const rt = r.request_time != null ? Number(r.request_time).toFixed(3) : "";
return `
| ${esc(r.time)} |
${esc(r.ip)} |
${esc(r.host || "—")} |
${esc(r.method)} |
${esc(r.status)} |
${uri} |
${bodyCell}
${esc(rt)} |
${esc(formatBytes(Number(r.bytes_recv) || 0))} |
${esc(formatBytes(Number(r.bytes_sent) || 0))} |
`;
})
.join("");
return `
| 时间 | IP | 域名 | 方法 | 状态 | URI | Body |
耗时(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 group_name = String($("#site-set-group")?.value || "").trim();
const updated = await api("/api/websites/update", {
method: "POST",
body: { name: site.name, domains, group_name },
});
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 analyticsUriRowsHtml(kind, list) {
const esc = escapeHtml;
const rows = list || [];
if (!rows.length) {
return `| 暂无数据 |
`;
}
if (kind === "top") {
return rows
.map(
(r) => `
| ${esc(safeDecodeUri(r.uri))} |
${esc(r.count)} |
${esc(formatBytes(r.bytes_sent))} |
${Number(r.avg_request_time || 0).toFixed(3)}s |
`
)
.join("");
}
return rows
.map(
(r) => `
| ${esc(safeDecodeUri(r.uri))} |
${esc(r.count)} |
${Number(r.avg_request_time || 0).toFixed(3)}s |
${Number(r.max_request_time || 0).toFixed(3)}s |
`
)
.join("");
}
function refreshAnalyticsUriTables() {
const data = state.analyticsData || {};
const topList = state.analyticsTopUriWithArgs
? data.top_uris_with_args || data.top_uris || []
: data.top_uris || [];
const slowList = state.analyticsSlowUriWithArgs
? data.slow_uris_with_args || data.slow_uris || []
: data.slow_uris || [];
const topBody = $("#an-top-uri-tbody");
const slowBody = $("#an-slow-uri-tbody");
if (topBody) topBody.innerHTML = analyticsUriRowsHtml("top", topList);
if (slowBody) slowBody.innerHTML = analyticsUriRowsHtml("slow", slowList);
}
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 selfIp = String(data.self_ip || "").trim();
const isSelfIp = (ip) => {
const v = String(ip || "").trim();
return !!selfIp && !!v && selfIp === v;
};
const ipLink = (ip) => {
const v = String(ip || "").trim();
if (!v) return "—";
return `${esc(v)}`;
};
const ipCell = (ip) => {
const mark = isSelfIp(ip)
? ` 自己`
: "";
return `${ipLink(ip)}${mark}`;
};
const suspectHtml = suspects.length
? `
| IP | 请求 | 错误 | 占比 | 原因 |
${suspects
.map(
(r) => `
| ${ipCell(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) => `
| ${ipCell(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 topUriList = state.analyticsTopUriWithArgs
? data.top_uris_with_args || data.top_uris || []
: data.top_uris || [];
const topUriRows = analyticsUriRowsHtml("top", topUriList);
const slowUriList = state.analyticsSlowUriWithArgs
? data.slow_uris_with_args || data.slow_uris || []
: data.slow_uris || [];
const slowRows = analyticsUriRowsHtml("slow", slowUriList);
const uriArgsSwitch = (id, checked) =>
`
`;
body.innerHTML = `
${cards}
CC / 异常 IP 分析
嫌疑 ${esc(cc.suspect_count ?? 0)} · 峰值桶请求 ${esc(cc.peak_bucket_requests ?? 0)}
${suspectHtml}
Top IP
| IP | 请求 | 错误 | 接收 | 发送 | 占比 |
${topIpRows || `| 暂无数据 |
`}
热门 URI
${uriArgsSwitch("an-top-uri-args", !!state.analyticsTopUriWithArgs)}
最慢 URI
${uriArgsSwitch("an-slow-uri-args", !!state.analyticsSlowUriWithArgs)}
数据源:${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 } },
},
tooltip: {
callbacks: {
label: (ctx) => {
const v = ctx.parsed.y;
if (v == null || Number.isNaN(v)) return `${ctx.dataset.label}: —`;
const text = typeof yFmt === "function" ? yFmt(v) : v;
return `${ctx.dataset.label}: ${text}`;
},
},
},
},
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, hoverRadius: 3 } },
},
});
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)}`
);
state.analyticsData = data || {};
renderAnalytics(state.analyticsData);
}
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-set-custom-save")?.addEventListener("click", (e) => {
withBusy(async () => saveSiteCustomConf(), e.currentTarget);
});
$("#site-set-custom-reload")?.addEventListener("click", (e) => {
withBusy(async () => {
await loadSiteCustomConf();
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 bindRedisConfigModal() {
if (!$("#redis-config-modal")) return;
state.redisConfigModal = new bootstrap.Modal($("#redis-config-modal"));
$("#redis-config-close")?.addEventListener("click", () => state.redisConfigModal.hide());
$("#redis-tab-quick")?.addEventListener("click", () => setRedisConfigTab("quick"));
$("#redis-tab-file")?.addEventListener("click", () => {
setRedisConfigTab("file");
withBusy(async () => loadRedisConfFile());
});
$("#redis-cfg-save")?.addEventListener("click", (e) => {
withBusy(async () => {
const bind = String($("#redis-cfg-bind").value || "").trim() || "127.0.0.1";
const port = Number($("#redis-cfg-port").value || 0);
const requirepass = String($("#redis-cfg-pass").value || "");
if (!port || port < 1 || port > 65535) throw new Error("端口无效");
await api("/api/redis/config", {
method: "POST",
body: { bind, port, requirepass },
});
toast("Redis 配置已应用");
await refresh();
}, e.currentTarget);
});
$("#redis-cfg-reload")?.addEventListener("click", (e) => {
withBusy(async () => {
await loadRedisQuickConfig();
await loadRedisConfFile();
toast("已重新加载");
}, e.currentTarget);
});
$("#redis-cfg-file-save")?.addEventListener("click", (e) => {
withBusy(async () => saveRedisConfFile(), e.currentTarget);
});
$("#redis-config-modal")?.addEventListener("shown.bs.modal", () => {
layoutSoftCfgEditor(state.redisCfgEditor);
});
}
function bindNginxConfigModal() {
if (!$("#nginx-config-modal")) return;
state.nginxConfigModal = new bootstrap.Modal($("#nginx-config-modal"));
$("#nginx-config-close")?.addEventListener("click", () => state.nginxConfigModal.hide());
$("#nginx-tab-quick")?.addEventListener("click", () => setNginxConfigTab("quick"));
$("#nginx-tab-file")?.addEventListener("click", () => {
setNginxConfigTab("file");
withBusy(async () => loadNginxConfFile());
});
$("#nginx-cfg-save")?.addEventListener("click", (e) => {
withBusy(async () => {
const worker_processes = String($("#nginx-cfg-workers").value || "").trim() || "auto";
const worker_connections = Number($("#nginx-cfg-connections").value || 0);
const keepalive_timeout = String($("#nginx-cfg-keepalive").value || "").trim() || "65";
const client_max_body_size = String($("#nginx-cfg-body").value || "").trim() || "50m";
const gzip = !!$("#nginx-cfg-gzip").checked;
if (!worker_connections || worker_connections < 1) {
throw new Error("worker_connections 无效");
}
await api("/api/nginx/config", {
method: "POST",
body: {
worker_processes,
worker_connections,
keepalive_timeout,
client_max_body_size,
gzip,
},
});
toast("Nginx 配置已应用");
await refresh();
}, e.currentTarget);
});
$("#nginx-cfg-reload")?.addEventListener("click", (e) => {
withBusy(async () => {
await loadNginxQuickConfig();
await loadNginxConfFile();
toast("已重新加载");
}, e.currentTarget);
});
$("#nginx-cfg-file-save")?.addEventListener("click", (e) => {
withBusy(async () => saveNginxConfFile(), e.currentTarget);
});
$("#nginx-config-modal")?.addEventListener("shown.bs.modal", () => {
layoutSoftCfgEditor(state.nginxCfgEditor);
});
}
function formatMs(ms) {
if (!ms) return "—";
const d = new Date(Number(ms));
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString();
}
function scheduleUnitLabel(unit) {
return ({ sec: "秒", min: "分", hour: "时", day: "天" }[unit] || unit);
}
function stopScheduleAutoRefresh() {
if (state.scheduleTimer) {
clearInterval(state.scheduleTimer);
state.scheduleTimer = null;
}
}
function startScheduleAutoRefresh() {
stopScheduleAutoRefresh();
state.scheduleTimer = setInterval(() => {
if (!$("#view-plans")?.classList.contains("is-active")) return;
loadScheduleJobs({ silent: true }).catch(() => {});
if (state.scheduleSelectedId) {
loadScheduleLogs({ silent: true }).catch(() => {});
}
}, 3000);
}
function renderScheduleJobs() {
const tbody = $("#schedule-table tbody");
if (!tbody) return;
const jobs = state.scheduleJobs || [];
if (!jobs.length) {
tbody.innerHTML = `| 暂无定时任务,点击右上角新建 |
`;
return;
}
tbody.innerHTML = jobs
.map((j) => {
const selected = j.id === state.scheduleSelectedId ? "is-selected" : "";
const st = j.enabled
? j.inflight > 0
? `执行中×${j.inflight}`
: `启用`
: `停用`;
const last = j.last_run_ms
? `${j.last_ok ? "OK" : "FAIL"} ${j.last_status || ""}`.trim()
: "未执行";
return `
|
${escapeHtml(j.name)}
${escapeHtml(j.method)} · ${escapeHtml(last)}
|
${j.interval}${scheduleUnitLabel(j.unit)} |
${escapeHtml(j.url)} |
${st} |
|
`;
})
.join("");
}
function renderScheduleLogs() {
const view = $("#schedule-log-view");
const meta = $("#schedule-log-meta");
if (!view || !meta) return;
const job = (state.scheduleJobs || []).find((j) => j.id === state.scheduleSelectedId);
if (!state.scheduleSelectedId || !job) {
meta.textContent = "选择左侧任务查看日志";
view.textContent = "";
return;
}
meta.textContent = `${job.name} · 最近 ${(state.scheduleLogs || []).length} 条`;
const lines = (state.scheduleLogs || []).map((e) => {
const flag = e.ok ? "OK" : "ERR";
const err = e.error ? ` ${e.error}` : "";
const body = e.response_preview
? `\n └ ${String(e.response_preview).replace(/\s+/g, " ").slice(0, 180)}`
: "";
return `[${formatMs(e.time_ms)}] ${flag} HTTP ${e.status_code || "-"} ${e.duration_ms}ms${err}${body}`;
});
view.textContent = lines.length ? lines.join("\n") : "暂无日志";
}
async function loadScheduleJobs(opts = {}) {
const data = await api("/api/schedule/jobs");
state.scheduleJobs = data.jobs || [];
if (
state.scheduleSelectedId &&
!state.scheduleJobs.some((j) => j.id === state.scheduleSelectedId)
) {
state.scheduleSelectedId = "";
state.scheduleLogs = [];
}
renderScheduleJobs();
if (!opts.silent) renderScheduleLogs();
}
async function loadScheduleLogs(opts = {}) {
if (!state.scheduleSelectedId) {
state.scheduleLogs = [];
renderScheduleLogs();
return;
}
const data = await api(
`/api/schedule/logs?job_id=${encodeURIComponent(state.scheduleSelectedId)}&limit=100`
);
state.scheduleLogs = data.logs || [];
renderScheduleLogs();
if (!opts.silent) {
/* keep quiet on auto refresh */
}
}
function openScheduleEditor(job) {
const isEdit = !!(job && job.id);
openModal({
title: isEdit ? "编辑定时任务" : "新建定时任务",
okText: "保存",
bodyHtml: `
`,
onSubmit: async (fd) => {
const body = {
id: job?.id || "",
name: String(fd.get("name") || "").trim(),
method: String(fd.get("method") || "GET"),
url: String(fd.get("url") || "").trim(),
interval: Number(fd.get("interval") || 60),
unit: String(fd.get("unit") || "sec"),
timeout_sec: Number(fd.get("timeout_sec") || 30),
enabled: !!fd.get("enabled"),
continue_when_busy: !!fd.get("continue_when_busy"),
};
const saved = await api("/api/schedule/jobs", { method: "POST", body });
toast(isEdit ? "已保存" : "已创建");
await loadScheduleJobs();
if (saved?.id) {
state.scheduleSelectedId = saved.id;
renderScheduleJobs();
await loadScheduleLogs();
}
},
});
}
function bindEvents() {
state.bsModal = new bootstrap.Modal($("#modal"));
bindUploadModal();
bindSiteSettingsModal();
bindRedisConfigModal();
bindNginxConfigModal();
$("#sites-group-bar")?.addEventListener("click", (e) => {
const chip = e.target.closest(".sites-group-chip");
if (!chip) return;
state.siteGroupFilter = chip.dataset.group || "";
renderSites();
});
$("#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("change", (e) => {
const t = e.target;
if (!(t instanceof HTMLInputElement)) return;
if (t.id === "an-top-uri-args") {
state.analyticsTopUriWithArgs = t.checked;
refreshAnalyticsUriTables();
return;
}
if (t.id === "an-slow-uri-args") {
state.analyticsSlowUriWithArgs = t.checked;
refreshAnalyticsUriTables();
}
});
$("#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));
});
$("#settings-form")?.addEventListener("submit", (e) => {
e.preventDefault();
const btn = $("#btn-settings-save");
withBusy(async () => {
const name = String($("#setting-panel-name")?.value || "").trim();
if (!name) throw new Error("请填写网站名称");
const data = await api("/api/settings", {
method: "POST",
body: { panel_name: name },
});
applyPanelName((data && data.panel_name) || name);
toast("已保存");
}, btn);
});
$("#btn-self-check")?.addEventListener("click", (e) => {
withBusy(async () => {
const info = await loadSelfVersion({ fetchRemote: true });
if (info?.update_available) {
toast(`发现 ${info.behind} 个新提交`);
} else if (info?.error) {
toast(info.error, "err");
} else {
toast("已是最新");
}
}, e.currentTarget);
});
$("#btn-self-update")?.addEventListener("click", (e) => {
withBusy(async () => {
const ok = await confirmDialog({
title: "更新面板",
message:
"将从 Git 拉取最新代码、编译并重启面板。更新期间页面会短暂中断;若当前在 tmux 中运行,会在同一窗口里拉起。",
okText: "开始更新",
});
if (!ok) return;
const data = await api("/api/self/update", { method: "POST", body: {} });
toast("已开始更新,完成后会自动重启");
if (data && data.task_id) {
await refreshTasks();
openTaskPanel(data.task_id);
await waitForPanelRestart(data.task_id);
}
}, e.currentTarget);
});
$("#btn-logout")?.addEventListener("click", async () => {
const ok = await confirmDialog({
title: "退出登录",
message: "确认退出当前账号?",
okText: "退出",
danger: true,
});
if (!ok) return;
withBusy(async () => {
disconnectShell();
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));
});
$$("[data-nav-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.dataset.navToggle;
const group = $(`.nav-group[data-nav-group="${key}"]`);
if (!group) return;
group.classList.toggle("is-open");
btn.setAttribute("aria-expanded", group.classList.contains("is-open") ? "true" : "false");
});
});
$("#redis-actions")?.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-act]");
if (!btn) return;
softAction(btn.dataset.act, btn);
});
$("#btn-redis-exec")?.addEventListener("click", (e) => {
execRedisCmd(e.currentTarget);
});
$("#redis-cmd-input")?.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
execRedisCmd($("#btn-redis-exec"));
}
});
$("#btn-schedule-refresh")?.addEventListener("click", (e) => {
withBusy(async () => {
await loadScheduleJobs();
if (state.scheduleSelectedId) await loadScheduleLogs();
toast("已刷新");
}, e.currentTarget);
});
$("#btn-schedule-add")?.addEventListener("click", () => openScheduleEditor(null));
$("#btn-schedule-log-refresh")?.addEventListener("click", (e) => {
withBusy(async () => {
await loadScheduleLogs();
toast("日志已刷新");
}, e.currentTarget);
});
$("#btn-schedule-log-clear")?.addEventListener("click", async (e) => {
if (!state.scheduleSelectedId) {
toast("请先选择任务", "err");
return;
}
const ok = await confirmDialog({
title: "清空日志",
message: "确认清空该任务的执行日志?",
okText: "清空",
danger: true,
});
if (!ok) return;
withBusy(async () => {
await api("/api/schedule/logs", {
method: "POST",
body: { job_id: state.scheduleSelectedId },
});
await loadScheduleLogs();
toast("日志已清空");
}, e.currentTarget);
});
$("#schedule-table")?.addEventListener("click", (e) => {
const actBtn = e.target.closest("button[data-sch-act]");
if (actBtn) {
const id = actBtn.dataset.id;
const act = actBtn.dataset.schAct;
const job = (state.scheduleJobs || []).find((j) => j.id === id);
withBusy(async () => {
if (act === "edit") {
if (!job) throw new Error("任务不存在");
openScheduleEditor(job);
return;
}
if (act === "run") {
await api("/api/schedule/jobs/run", { method: "POST", body: { id } });
toast("已触发执行");
await loadScheduleJobs({ silent: true });
state.scheduleSelectedId = id;
renderScheduleJobs();
setTimeout(() => loadScheduleLogs().catch(() => {}), 800);
return;
}
if (act === "toggle") {
await api("/api/schedule/jobs/enable", {
method: "POST",
body: { id, enabled: !(job && job.enabled) },
});
toast(job && job.enabled ? "已停用" : "已启用");
await loadScheduleJobs();
return;
}
if (act === "del") {
const ok = await confirmDialog({
title: "删除定时任务",
message: `确认删除「${job?.name || id}」?日志将一并删除。`,
okText: "删除",
danger: true,
});
if (!ok) return;
await api("/api/schedule/jobs/delete", { method: "POST", body: { id } });
if (state.scheduleSelectedId === id) {
state.scheduleSelectedId = "";
state.scheduleLogs = [];
}
toast("已删除");
await loadScheduleJobs();
renderScheduleLogs();
}
}, actBtn);
return;
}
const row = e.target.closest("tr[data-schedule-id]");
if (!row) return;
state.scheduleSelectedId = row.dataset.scheduleId || "";
renderScheduleJobs();
withBusy(async () => loadScheduleLogs());
});
$("#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);
});
$("#btn-shell-reconnect")?.addEventListener("click", () => {
disconnectShell();
connectShell();
});
$("#btn-shell-clear")?.addEventListener("click", () => {
state.shellTerm?.clear();
state.shellTerm?.focus();
});
window.addEventListener("beforeunload", () => disconnectShell());
$("#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-up") {
moveSiteInFilter(name, -1);
return;
}
if (act === "site-down") {
moveSiteInFilter(name, 1);
return;
}
if (act === "site-config") {
withBusy(async () => openSiteSettings(name), btn);
return;
}
if (act === "site-analyze") {
withBusy(async () => openSiteAnalytics(name), btn);
return;
}
if (act === "site-logs") {
const site = (state.sites || []).find((s) => s.name === name) || {};
const files = Number(site.log_file_bytes) || 0;
const db = Number(site.log_db_bytes) || 0;
const access = Number(site.log_access_bytes) || 0;
const error = Number(site.log_error_bytes) || 0;
openModal({
title: `日志占用 · ${name}`,
bodyHtml: `
日志文件${escapeHtml(formatBytes(files))}
access.log ${escapeHtml(formatBytes(access))} · error.log ${escapeHtml(formatBytes(error))}
日志数据库${escapeHtml(formatBytes(db))}
用于访问日志查询与统计分析
合计${escapeHtml(formatBytes(files + db))}
清理后不可恢复。截断文件后会通知 Nginx 重新打开日志。
`,
onSubmit: async (fd) => {
const filesOn = fd.get("files") === "1";
const dbOn = fd.get("db") === "1";
if (!filesOn && !dbOn) {
throw new Error("请至少选择一项");
}
await api("/api/websites/logs/purge", {
method: "POST",
body: { name, files: filesOn, db: dbOn },
});
toast("日志已清理");
},
});
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-exec") {
const sel = $("#sql-db");
if (sel) {
fillSqlDbSelect();
sel.value = name;
}
const input = $("#sql-input");
if (input) {
input.focus();
input.scrollIntoView({ behavior: "smooth", block: "center" });
}
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 typeRaw = String(fd.get("site_type") || "static").trim();
const type = typeRaw === "fastweb" ? "fastweb" : "static";
const domain = String(fd.get("site_domain") || "").trim();
if (!domain) throw new Error("请填写域名");
if (/[\s,;]/.test(domain)) throw new Error("仅支持填写一个域名");
const root = String(fd.get("site_root") || "").trim();
if (!root) throw new Error("请填写目录");
const body = {
name: String(fd.get("site_name") || "").trim(),
type,
domain,
domains: [domain],
root,
group_name: String(fd.get("site_group") || "").trim(),
};
await api("/api/websites", { method: "POST", body });
toast("网站已创建");
},
});
fillSiteGroupDatalist($("#create-site-group-datalist"));
const form = $("#modal-form");
form.setAttribute("autocomplete", "off");
const body = $("#modal-body");
const typeEl = body.querySelector('[name="site_type"]');
const domainEl = body.querySelector('[name="site_domain"]');
const rootEl = body.querySelector('[name="site_root"]');
// Unlock readonly only after focus — blocks Chrome autofill on open.
body.querySelectorAll("input[readonly]").forEach((el) => {
el.addEventListener("focus", () => el.removeAttribute("readonly"), { once: true });
});
typeEl.value = "static";
let typeTouched = false;
typeEl.addEventListener("pointerdown", () => { typeTouched = true; });
typeEl.addEventListener("keydown", () => { typeTouched = true; });
typeEl.addEventListener("change", () => {
typeTouched = true;
syncSiteFormFields(body);
});
// Autofill often mutates selects without a change event; re-assert default
// until the user explicitly picks a type.
const guardAutofillType = () => {
if (!typeTouched && typeEl.value !== "static") {
typeEl.value = "static";
}
syncSiteFormFields(body);
};
syncSiteFormFields(body);
const ac = new AbortController();
form.addEventListener("input", guardAutofillType, { signal: ac.signal });
form.addEventListener("change", guardAutofillType, { signal: ac.signal });
$("#modal").addEventListener("hidden.bs.modal", () => ac.abort(), { once: true });
setTimeout(guardAutofillType, 50);
setTimeout(guardAutofillType, 300);
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);
});
$("#btn-sql-exec")?.addEventListener("click", (e) => {
execSql(e.currentTarget);
});
$("#sql-input")?.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
execSql($("#btn-sql-exec"));
}
});
$("#btn-db-create").addEventListener("click", () => {
openModal({
title: "新建数据库",
bodyHtml: `
库名和用户名仅允许字母、数字、下划线,例如 blog、wp_user。不能使用中文、连字符或点号。
`,
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();
loadSelfVersion().catch(() => {});
setTimeout(() => {
loadSelfVersion({ fetchRemote: true }).catch(() => {});
}, 1500);
setInterval(() => {
loadSelfVersion({ fetchRemote: true }).catch(() => {});
}, 15 * 60 * 1000);
setInterval(refresh, 15000);
setInterval(refreshTasks, 2000);
}
boot();
})();