|
|
@@ -46,6 +46,13 @@
|
|
|
siteModulesFilter: "all",
|
|
|
siteModuleOps: {}, // `${site}::${name_en}` -> { act, taskId }
|
|
|
siteProxies: [],
|
|
|
+ processes: [],
|
|
|
+ procQuery: "",
|
|
|
+ procSort: "cpu_percent",
|
|
|
+ procSortDir: "desc",
|
|
|
+ procTimer: null,
|
|
|
+ siteNginxEditor: null,
|
|
|
+ siteNginxPath: "",
|
|
|
};
|
|
|
|
|
|
const titles = {
|
|
|
@@ -54,6 +61,7 @@
|
|
|
sites: { title: "网站管理", eyebrow: "Websites" },
|
|
|
mysql: { title: "数据库", eyebrow: "MySQL" },
|
|
|
files: { title: "文件管理", eyebrow: "Files" },
|
|
|
+ processes: { title: "进程管理", eyebrow: "Processes" },
|
|
|
};
|
|
|
|
|
|
function toast(msg, type = "ok") {
|
|
|
@@ -121,6 +129,12 @@
|
|
|
if (name === "console") startMetrics();
|
|
|
else stopMetrics();
|
|
|
if (name === "files") loadFiles(state.filesPath);
|
|
|
+ if (name === "processes") {
|
|
|
+ loadProcesses().catch((err) => toast(err.message || String(err), "err"));
|
|
|
+ startProcAutoRefresh();
|
|
|
+ } else {
|
|
|
+ stopProcAutoRefresh();
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
function escapeHtml(s) {
|
|
|
@@ -910,6 +924,108 @@
|
|
|
.join("");
|
|
|
}
|
|
|
|
|
|
+ function stopProcAutoRefresh() {
|
|
|
+ if (state.procTimer) {
|
|
|
+ clearInterval(state.procTimer);
|
|
|
+ state.procTimer = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function startProcAutoRefresh() {
|
|
|
+ stopProcAutoRefresh();
|
|
|
+ const on = $("#proc-auto")?.checked;
|
|
|
+ if (!on) return;
|
|
|
+ state.procTimer = setInterval(() => {
|
|
|
+ const panel = $("#view-processes");
|
|
|
+ if (!panel || !panel.classList.contains("is-active")) return;
|
|
|
+ loadProcesses({ silent: true }).catch(() => {});
|
|
|
+ }, 3000);
|
|
|
+ }
|
|
|
+
|
|
|
+ function sortedProcesses(rows) {
|
|
|
+ const key = state.procSort || "cpu_percent";
|
|
|
+ const dir = state.procSortDir === "asc" ? 1 : -1;
|
|
|
+ const list = rows.slice();
|
|
|
+ list.sort((a, b) => {
|
|
|
+ const va = a[key];
|
|
|
+ const vb = b[key];
|
|
|
+ if (typeof va === "string" || typeof vb === "string") {
|
|
|
+ return String(va || "").localeCompare(String(vb || ""), "zh") * dir;
|
|
|
+ }
|
|
|
+ return ((Number(va) || 0) - (Number(vb) || 0)) * dir;
|
|
|
+ });
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderProcesses() {
|
|
|
+ const tbody = $("#proc-table tbody");
|
|
|
+ if (!tbody) return;
|
|
|
+ $$("#proc-table th.proc-sort").forEach((th) => {
|
|
|
+ const active = th.dataset.procSort === state.procSort;
|
|
|
+ th.classList.toggle("is-active", active);
|
|
|
+ if (active) th.dataset.sortDir = state.procSortDir || "desc";
|
|
|
+ else th.removeAttribute("data-sort-dir");
|
|
|
+ });
|
|
|
+ const rows = sortedProcesses(state.processes || []);
|
|
|
+ $("#proc-count").textContent = `${rows.length} 个进程`;
|
|
|
+ if (!rows.length) {
|
|
|
+ tbody.innerHTML = `<tr><td colspan="9" class="text-secondary text-center py-4">暂无进程</td></tr>`;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ tbody.innerHTML = rows
|
|
|
+ .map((p) => {
|
|
|
+ const cpu = Number(p.cpu_percent) || 0;
|
|
|
+ const mem = Number(p.mem_percent) || 0;
|
|
|
+ const protect = Number(p.pid) <= 1;
|
|
|
+ return `<tr>
|
|
|
+ <td><code>${escapeHtml(p.pid)}</code></td>
|
|
|
+ <td>${escapeHtml(p.user || "—")}</td>
|
|
|
+ <td>${cpu.toFixed(1)}</td>
|
|
|
+ <td>${mem.toFixed(1)}</td>
|
|
|
+ <td>${escapeHtml(formatBytes(p.rss || 0))}</td>
|
|
|
+ <td><span class="badge text-bg-light">${escapeHtml(p.state || "—")}</span></td>
|
|
|
+ <td class="proc-name" title="${escapeHtml(p.name || "")}">${escapeHtml(p.name || "—")}</td>
|
|
|
+ <td class="proc-cmd" title="${escapeHtml(p.cmdline || "")}">${escapeHtml(p.cmdline || "—")}</td>
|
|
|
+ <td class="text-end">
|
|
|
+ <div class="btn-group btn-group-sm">
|
|
|
+ <button type="button" class="btn btn-outline-secondary" data-proc-act="term" data-pid="${escapeHtml(p.pid)}"${protect ? " disabled" : ""}>结束</button>
|
|
|
+ <button type="button" class="btn btn-outline-danger" data-proc-act="kill" data-pid="${escapeHtml(p.pid)}"${protect ? " disabled" : ""}>强杀</button>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>`;
|
|
|
+ })
|
|
|
+ .join("");
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadProcesses(opts = {}) {
|
|
|
+ const q = String(state.procQuery || "").trim();
|
|
|
+ const qs = q ? `?q=${encodeURIComponent(q)}` : "";
|
|
|
+ const tbody = $("#proc-table tbody");
|
|
|
+ if (!opts.silent && tbody) {
|
|
|
+ tbody.innerHTML = `<tr><td colspan="9" class="text-secondary text-center py-4">加载中…</td></tr>`;
|
|
|
+ }
|
|
|
+ const data = await api(`/api/system/processes${qs}`);
|
|
|
+ state.processes = (data && data.items) || [];
|
|
|
+ renderProcesses();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function killProcess(pid, signalName) {
|
|
|
+ const label = signalName === "KILL" ? "强制结束 (SIGKILL)" : "结束 (SIGTERM)";
|
|
|
+ const ok = await confirmDialog({
|
|
|
+ title: label,
|
|
|
+ message: `确认向进程 PID ${pid} 发送 ${signalName}?`,
|
|
|
+ okText: signalName === "KILL" ? "强杀" : "结束",
|
|
|
+ danger: true,
|
|
|
+ });
|
|
|
+ if (!ok) return;
|
|
|
+ await api("/api/system/processes/kill", {
|
|
|
+ method: "POST",
|
|
|
+ body: { pid: Number(pid), signal_name: signalName },
|
|
|
+ });
|
|
|
+ toast(`已发送 ${signalName} → PID ${pid}`);
|
|
|
+ await loadProcesses({ silent: true });
|
|
|
+ }
|
|
|
+
|
|
|
function renderCrumbs(path) {
|
|
|
const parts = path === "/" ? [] : path.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
|
let cur = "";
|
|
|
@@ -1214,16 +1330,12 @@
|
|
|
|
|
|
function syncSiteFormFields(root) {
|
|
|
const type = root.querySelector('[name="type"]')?.value || "static";
|
|
|
- const up = root.querySelector(".field-upstream");
|
|
|
const tip = root.querySelector(".field-upstream-tip");
|
|
|
- if (up) up.classList.toggle("d-none", type !== "proxy");
|
|
|
if (tip) {
|
|
|
tip.textContent =
|
|
|
- type === "proxy"
|
|
|
- ? "反向代理需填写上游地址,例如 127.0.0.1:9000"
|
|
|
- : type === "fastweb"
|
|
|
- ? "Fastweb 站点会自动分配后端端口"
|
|
|
- : "静态站仅需名称/域名/端口";
|
|
|
+ type === "fastweb"
|
|
|
+ ? "Fastweb 站点会自动探测并分配后端端口,反代可在站点设置「反向代理」中查看"
|
|
|
+ : "静态站仅需名称/域名;如需反代请创建后在「反向代理」中添加";
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -1654,7 +1766,13 @@
|
|
|
|
|
|
const modalSpecs = [
|
|
|
{ id: "modal", key: "generic", minW: 360, minH: 280 },
|
|
|
- { id: "site-settings-modal", key: "siteSettings", minW: 640, minH: 420 },
|
|
|
+ {
|
|
|
+ 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 },
|
|
|
];
|
|
|
@@ -1670,8 +1788,15 @@
|
|
|
minH: spec.minH,
|
|
|
handleHost: content,
|
|
|
showEventEl: root,
|
|
|
+ onResize: spec.onResize,
|
|
|
});
|
|
|
});
|
|
|
+ $("#site-settings-modal")?.addEventListener("shown.bs.modal", () => {
|
|
|
+ requestAnimationFrame(() => layoutSiteNginxEditor());
|
|
|
+ });
|
|
|
+ $("#site-settings-modal")?.addEventListener("hidden.bs.modal", () => {
|
|
|
+ // keep editor instance; just clear dirty content path tracking
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
function fileToBase64(file) {
|
|
|
@@ -2030,7 +2155,10 @@
|
|
|
loadSiteSslPem().catch(() => {});
|
|
|
renderSslDomainChecklist(selectedSslHosts());
|
|
|
}
|
|
|
- if (tab === "nginx") loadSiteNginxConf().catch((err) => toast(err.message || String(err), "err"));
|
|
|
+ if (tab === "nginx") {
|
|
|
+ loadSiteNginxConf().catch((err) => toast(err.message || String(err), "err"));
|
|
|
+ requestAnimationFrame(() => layoutSiteNginxEditor());
|
|
|
+ }
|
|
|
if (tab === "logs") loadSiteLog().catch((err) => toast(err.message || String(err), "err"));
|
|
|
if (tab === "modules") loadSiteModules().catch((err) => toast(err.message || String(err), "err"));
|
|
|
if (tab === "proxy") loadSiteProxies().catch((err) => toast(err.message || String(err), "err"));
|
|
|
@@ -2049,7 +2177,7 @@
|
|
|
function syncSiteProxyTab(site) {
|
|
|
const tab = $("#site-tab-proxy");
|
|
|
if (!tab) return;
|
|
|
- const show = !!(site && (site.type === "fastweb" || site.type === "proxy"));
|
|
|
+ const show = !!site;
|
|
|
tab.classList.toggle("d-none", !show);
|
|
|
if (!show && tab.classList.contains("is-active")) {
|
|
|
setSiteSettingsTab("domain");
|
|
|
@@ -2087,7 +2215,7 @@
|
|
|
async function loadSiteProxies() {
|
|
|
const site = state.siteSettings;
|
|
|
const tbody = $("#site-proxy-tbody");
|
|
|
- if (!site || (site.type !== "fastweb" && site.type !== "proxy")) {
|
|
|
+ if (!site) {
|
|
|
state.siteProxies = [];
|
|
|
if (tbody) tbody.innerHTML = "";
|
|
|
return;
|
|
|
@@ -2140,9 +2268,9 @@
|
|
|
placeholder="请输入备注,可为空" />
|
|
|
</div>
|
|
|
<ul class="small text-secondary mb-0 ps-3">
|
|
|
- <li>目标可填写 URL(如 <code>http://127.0.0.1:15700</code>)或 UNIX Socket(如 <code>/tmp/panel.sock</code>)。</li>
|
|
|
- <li>发送域名会写入请求头 Host,默认 <code>$http_host</code>;代理外站域名时可改为目标域名。</li>
|
|
|
- <li>代理目录填 <code>/</code> 将覆盖站点默认上游;子目录如 <code>/web</code> 仅匹配该前缀。</li>
|
|
|
+ <li>目标可填写 URL(如 <code>http://127.0.0.1:15700</code> 或 <code>https://bucket.cos...myqcloud.com</code>)或 UNIX Socket(如 <code>/tmp/panel.sock</code>)。</li>
|
|
|
+ <li>发送域名会写入请求头 Host,默认 <code>$http_host</code>;代理外站(如 COS)时请改为目标域名。</li>
|
|
|
+ <li>代理目录填 <code>/</code> 将接管整站;子目录如 <code>/web</code> 仅匹配该前缀。静态站无 <code>/</code> 规则时仍提供本地静态文件。</li>
|
|
|
</ul>`,
|
|
|
onSubmit: async (fd) => {
|
|
|
const site = state.siteSettings;
|
|
|
@@ -2487,7 +2615,10 @@
|
|
|
expireEl.classList.toggle("text-secondary", !(Number(site.ssl_days_left) < 14));
|
|
|
}
|
|
|
$("#site-set-conf-path").textContent = site.conf || "";
|
|
|
- $("#site-set-nginx-conf").value = "";
|
|
|
+ state.siteNginxPath = site.conf || "";
|
|
|
+ if (state.siteNginxEditor) {
|
|
|
+ state.siteNginxEditor.setValue("");
|
|
|
+ }
|
|
|
state.siteProxies = [];
|
|
|
state.siteLogKind = "access";
|
|
|
$("#site-log-tab-access").classList.add("active");
|
|
|
@@ -2512,19 +2643,66 @@
|
|
|
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)}`);
|
|
|
- $("#site-set-nginx-conf").value = (data && data.content) || "";
|
|
|
+ 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: $("#site-set-nginx-conf").value },
|
|
|
+ body: { path: site.conf, content },
|
|
|
});
|
|
|
await api("/api/nginx/reload", { method: "POST", body: {} });
|
|
|
toast("Nginx 配置已保存并重载");
|
|
|
@@ -2637,11 +2815,30 @@
|
|
|
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);
|
|
|
- const pem = ($("#site-set-ssl-pem")?.value || "").trim();
|
|
|
- const key = ($("#site-set-ssl-key-pem")?.value || "").trim();
|
|
|
+ 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) {
|
|
|
@@ -3123,6 +3320,45 @@
|
|
|
btn.addEventListener("click", () => setView(btn.dataset.view));
|
|
|
});
|
|
|
|
|
|
+ $("#btn-proc-refresh")?.addEventListener("click", () => {
|
|
|
+ withBusy(async () => {
|
|
|
+ await loadProcesses();
|
|
|
+ toast("已刷新");
|
|
|
+ });
|
|
|
+ });
|
|
|
+ let procSearchTimer = null;
|
|
|
+ $("#proc-search")?.addEventListener("input", (e) => {
|
|
|
+ state.procQuery = e.target.value || "";
|
|
|
+ clearTimeout(procSearchTimer);
|
|
|
+ procSearchTimer = setTimeout(() => {
|
|
|
+ loadProcesses({ silent: true }).catch((err) => toast(err.message || String(err), "err"));
|
|
|
+ }, 250);
|
|
|
+ });
|
|
|
+ $("#proc-auto")?.addEventListener("change", () => {
|
|
|
+ if ($("#view-processes")?.classList.contains("is-active")) startProcAutoRefresh();
|
|
|
+ });
|
|
|
+ $("#proc-table")?.addEventListener("click", (e) => {
|
|
|
+ const sortTh = e.target.closest("th.proc-sort");
|
|
|
+ if (sortTh) {
|
|
|
+ const key = sortTh.dataset.procSort;
|
|
|
+ if (state.procSort === key) {
|
|
|
+ state.procSortDir = state.procSortDir === "desc" ? "asc" : "desc";
|
|
|
+ } else {
|
|
|
+ state.procSort = key;
|
|
|
+ state.procSortDir = key === "user" || key === "name" ? "asc" : "desc";
|
|
|
+ }
|
|
|
+ renderProcesses();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const btn = e.target.closest("button[data-proc-act]");
|
|
|
+ if (!btn) return;
|
|
|
+ const pid = btn.dataset.pid;
|
|
|
+ const act = btn.dataset.procAct;
|
|
|
+ withBusy(async () => {
|
|
|
+ await killProcess(pid, act === "kill" ? "KILL" : "TERM");
|
|
|
+ }, btn);
|
|
|
+ });
|
|
|
+
|
|
|
$("#task-badge").addEventListener("click", () => openTaskPanel());
|
|
|
$("#task-panel-close").addEventListener("click", closeTaskPanel);
|
|
|
$("#task-panel-backdrop").addEventListener("click", closeTaskPanel);
|
|
|
@@ -3270,10 +3506,9 @@
|
|
|
<label class="form-label">类型</label>
|
|
|
<select class="form-select form-select-sm" name="type" id="site-type">
|
|
|
<option value="static">静态</option>
|
|
|
- <option value="proxy">反向代理</option>
|
|
|
<option value="fastweb">Fastweb</option>
|
|
|
</select>
|
|
|
- <div class="form-text field-upstream-tip">静态站仅需名称/域名</div>
|
|
|
+ <div class="form-text field-upstream-tip">静态站仅需名称/域名;反代请创建后在「反向代理」添加</div>
|
|
|
</div>
|
|
|
<div class="mb-2">
|
|
|
<label class="form-label">域名</label>
|
|
|
@@ -3284,10 +3519,6 @@
|
|
|
<label class="form-label">目录</label>
|
|
|
<input class="form-control form-control-sm" name="root" required placeholder="/ngs/wwwroot/www.example.com" />
|
|
|
<div class="form-text">根据域名自动生成,可手动修改</div>
|
|
|
- </div>
|
|
|
- <div class="mb-2 field-upstream d-none">
|
|
|
- <label class="form-label">上游(proxy 必填)</label>
|
|
|
- <input class="form-control form-control-sm" name="upstream" placeholder="127.0.0.1:9000" />
|
|
|
</div>`,
|
|
|
onSubmit: async (fd) => {
|
|
|
const type = fd.get("type");
|
|
|
@@ -3302,9 +3533,7 @@
|
|
|
domain,
|
|
|
domains: [domain],
|
|
|
root,
|
|
|
- upstream: type === "proxy" ? String(fd.get("upstream") || "").trim() : "",
|
|
|
};
|
|
|
- if (type === "proxy" && !body.upstream) throw new Error("反向代理需填写上游");
|
|
|
await api("/api/websites", { method: "POST", body });
|
|
|
toast("网站已创建");
|
|
|
},
|