你的用户名 hace 1 mes
padre
commit
4c9414b238
Se han modificado 11 ficheros con 1072 adiciones y 138 borrados
  1. 44 5
      API.md
  2. 1 0
      CMakeLists.txt
  3. 125 7
      data/www/assets/app.css
  4. 256 27
      data/www/assets/app.js
  5. 45 5
      data/www/index.html
  6. 74 2
      src/api/api_server.cpp
  7. 317 0
      src/system/process.cpp
  8. 35 0
      src/system/process.h
  9. 173 89
      src/website/website.cpp
  10. 2 3
      src/website/website.h
  11. 0 0
      tmux.sh

+ 44 - 5
API.md

@@ -100,6 +100,46 @@ password=ngsadmin
 |------|------|
 | `range` | `1m` / `10m` / `30m` / `1h` / `6h` / `1d` / `7d` / `30d` |
 
+### `GET /api/system/processes`
+
+列出当前系统进程(读 `/proc`)。
+
+| 参数 | 说明 |
+|------|------|
+| `q` | 可选,按 PID / 用户 / 名称 / 命令行过滤 |
+
+```json
+{
+  "total": 120,
+  "items": [
+    {
+      "pid": 1,
+      "ppid": 0,
+      "user": "root",
+      "state": "S",
+      "name": "systemd",
+      "cmdline": "/sbin/init",
+      "threads": 1,
+      "rss": 10485760,
+      "vms": 167772160,
+      "mem_percent": 0.5,
+      "cpu_percent": 0.1
+    }
+  ]
+}
+```
+
+### `POST /api/system/processes/kill`
+
+结束进程(默认 `SIGTERM`)。
+
+```json
+{ "pid": 12345, "signal": 15, "signal_name": "TERM" }
+```
+
+- `signal` / `signal_name`:`TERM`(15) / `KILL`(9) / `INT`(2) / `HUP`(1)
+- 拒绝 `PID <= 1`
+
 ```json
 {
   "range": "10m",
@@ -243,18 +283,17 @@ password=ngsadmin
 ```json
 {
   "name": "demo",
-  "type": "static|proxy|fastweb",
+  "type": "static|fastweb",
   "domain": "demo.local www.demo.local",
   "domains": ["demo.local", "www.demo.local"],
-  "listen_port": 8080,
-  "upstream": "127.0.0.1:9000"
+  "listen_port": 8080
 }
 ```
 
 - `domain` / `domains` 可省略,默认等于 `name`;支持多个,格式为 `域名` 或 `域名:端口`(默认 80)
 - `listen_port` 可省略,由域名条目解析;未写端口时默认 80
-- `type=proxy` 时 `upstream` 必填
-- `type=fastweb` 时会自动分配后端端口并生成站点配置
+- 网站类型仅 `static` / `fastweb`;反向代理请用站点设置中的 URL 代理(`/api/websites/proxies`)
+- `type=fastweb` 时会自动探测空闲端口并生成站点配置与 `/` URL 代理规则
 - SSL:`POST /api/websites/ssl/apply` 使用 Let's Encrypt(HTTP-01),域名需解析到本机且 **80 端口可达**
 
 列表示例字段:`name` / `type` / `domain` / `domains` / `listen_port` / `upstream` / `running` / `root` / `log_dir` / `access_log` / `error_log` / `log_db` / `conf` / `ssl_*`

+ 1 - 0
CMakeLists.txt

@@ -14,6 +14,7 @@ add_executable(ngs
     src/store/store.cpp
     src/system/metrics.cpp
     src/system/metrics_history.cpp
+    src/system/process.cpp
     src/files/files.cpp
     src/tasks/tasks.cpp
     src/software/software.cpp

+ 125 - 7
data/www/assets/app.css

@@ -149,6 +149,78 @@ html, body {
   margin-right: 0;
 }
 
+#view-processes.is-active {
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+#view-processes.is-active > .card {
+  flex: 1 1 auto;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+#view-processes .proc-table-wrap {
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow: auto;
+}
+#view-processes .proc-toolbar {
+  min-width: 0;
+}
+#view-processes .proc-toolbar-actions {
+  min-width: 0;
+  flex: 1 1 auto;
+  justify-content: flex-end;
+}
+#view-processes #proc-search {
+  width: 16rem;
+  max-width: 100%;
+  min-width: 8rem;
+  flex: 1 1 12rem;
+}
+#view-processes .proc-toolbar-actions .form-check,
+#view-processes #btn-proc-refresh {
+  flex: 0 0 auto;
+}
+#proc-table th.proc-sort {
+  cursor: pointer;
+  user-select: none;
+  white-space: nowrap;
+}
+#proc-table th.proc-sort.is-active {
+  color: #0d6efd;
+}
+#proc-table th.proc-sort.is-active::after {
+  content: "";
+  display: inline-block;
+  margin-left: 0.25rem;
+  border: 4px solid transparent;
+}
+#proc-table th.proc-sort.is-active[data-sort-dir="asc"]::after {
+  border-bottom-color: currentColor;
+  transform: translateY(-2px);
+}
+#proc-table th.proc-sort.is-active[data-sort-dir="desc"]::after {
+  border-top-color: currentColor;
+  transform: translateY(2px);
+}
+#proc-table td.proc-cmd {
+  max-width: 28rem;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 0.78rem;
+}
+#proc-table td.proc-name {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 0.8rem;
+  white-space: nowrap;
+}
+
 .metrics-range {
   width: auto;
   min-width: 5.5rem;
@@ -426,10 +498,51 @@ html, body {
   min-height: 0;
   display: flex;
   flex-direction: column;
+  overflow: hidden;
 }
 .modal-dialog.is-resizable-dialog.has-custom-size .site-settings-layout {
   flex: 1;
   min-height: 0;
+  height: 100%;
+  overflow: hidden;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-panels {
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-panel.is-active {
+  flex: 1 1 auto;
+  min-height: 0;
+  overflow: auto;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-panel.is-active[data-site-panel="logs"] {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-panel.is-active[data-site-panel="logs"] .site-log-toolbar {
+  flex: 0 0 auto;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-panel.is-active[data-site-panel="logs"] .site-log-view {
+  flex: 1 1 auto;
+  min-height: 0;
+  max-height: none;
+  height: auto;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-panel.is-active[data-site-panel="nginx"] {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-panel.is-active[data-site-panel="nginx"] .site-nginx-toolbar,
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-panel.is-active[data-site-panel="nginx"] .site-nginx-actions {
+  flex: 0 0 auto;
+}
+.modal-dialog.is-resizable-dialog.has-custom-size .site-settings-panel.is-active[data-site-panel="nginx"] .site-nginx-monaco {
+  flex: 1 1 auto;
+  min-height: 0;
   height: auto;
 }
 .task-panel-head {
@@ -694,6 +807,8 @@ html, body {
   padding: 0.75rem 0.5rem;
   background: #f8fafc;
   border-right: 1px solid #e2e8f0;
+  min-height: 0;
+  overflow: auto;
 }
 .site-settings-tab {
   appearance: none; border: 0; background: transparent;
@@ -706,18 +821,20 @@ html, body {
   color: #0f766e;
   font-weight: 600;
 }
-.site-settings-panels { padding: 1rem 1.15rem; min-width: 0; }
+.site-settings-panels { padding: 1rem 1.15rem; min-width: 0; min-height: 0; }
 .site-settings-panel { display: none; }
 .site-settings-panel.is-active { display: block; }
-.site-conf-editor {
-  font-size: 0.8rem;
-  line-height: 1.45;
-  resize: vertical;
+.site-nginx-monaco {
   min-height: 280px;
+  height: min(420px, 50vh);
+  border: 1px solid #1e293b;
+  border-radius: 8px;
+  overflow: hidden;
+  background: #1e1e1e;
 }
 .site-log-view {
   margin: 0;
-  max-height: 360px;
+  max-height: min(360px, 50vh);
   overflow: auto;
   padding: 0.75rem;
   background: #0f172a;
@@ -738,6 +855,7 @@ html, body {
   color: #e2e8f0;
   font-size: 0.75rem;
   border-collapse: collapse;
+  table-layout: auto;
 }
 .site-log-view.table-mode th,
 .site-log-view.table-mode td {
@@ -757,7 +875,7 @@ html, body {
 .site-log-view.table-mode td.uri {
   white-space: normal;
   word-break: break-all;
-  max-width: 220px;
+  max-width: clamp(12rem, 32vw, 40rem);
 }
 .site-log-view.table-mode .status-ok { color: #4ade80; }
 .site-log-view.table-mode .status-redir { color: #38bdf8; }

+ 256 - 27
data/www/assets/app.js

@@ -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("网站已创建");
         },

+ 45 - 5
data/www/index.html

@@ -28,6 +28,7 @@
         <button type="button" class="nav-link text-start nav-item" data-view="sites"><i class="bi bi-globe2 me-2"></i>网站</button>
         <button type="button" class="nav-link text-start nav-item" data-view="files"><i class="bi bi-folder2-open me-2"></i>文件</button>
         <button type="button" class="nav-link text-start nav-item" data-view="mysql"><i class="bi bi-database me-2"></i>数据库</button>
+        <button type="button" class="nav-link text-start nav-item" data-view="processes"><i class="bi bi-cpu me-2"></i>进程</button>
         <button type="button" class="nav-link text-start nav-item" data-view="software"><i class="bi bi-box-seam me-2"></i>软件</button>
       </nav>
       <div class="rail-foot px-2 pb-2">
@@ -212,6 +213,45 @@
         </div>
       </section>
 
+      <section class="view" id="view-processes" data-view-panel="processes">
+        <div class="card">
+          <div class="card-header py-2">
+            <div class="d-flex justify-content-between align-items-center gap-2 proc-toolbar">
+              <div class="proc-toolbar-title text-nowrap">
+                <strong>进程</strong>
+                <span class="small text-secondary ms-2" id="proc-count">—</span>
+              </div>
+              <div class="d-flex flex-nowrap align-items-center gap-2 proc-toolbar-actions">
+                <input class="form-control form-control-sm" id="proc-search" type="search" placeholder="搜索 PID / 用户 / 名称 / 命令" />
+                <div class="form-check form-switch mb-0 text-nowrap">
+                  <input class="form-check-input" type="checkbox" id="proc-auto" />
+                  <label class="form-check-label small" for="proc-auto">自动刷新</label>
+                </div>
+                <button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" id="btn-proc-refresh">刷新</button>
+              </div>
+            </div>
+          </div>
+          <div class="table-responsive proc-table-wrap">
+            <table class="table table-sm table-hover align-middle mb-0" id="proc-table">
+              <thead>
+                <tr>
+                  <th data-proc-sort="pid" class="proc-sort">PID</th>
+                  <th data-proc-sort="user" class="proc-sort">用户</th>
+                  <th data-proc-sort="cpu_percent" class="proc-sort is-active">CPU%</th>
+                  <th data-proc-sort="mem_percent" class="proc-sort">内存%</th>
+                  <th data-proc-sort="rss" class="proc-sort">RSS</th>
+                  <th>状态</th>
+                  <th data-proc-sort="name" class="proc-sort">名称</th>
+                  <th>命令行</th>
+                  <th class="text-end">操作</th>
+                </tr>
+              </thead>
+              <tbody></tbody>
+            </table>
+          </div>
+        </div>
+      </section>
+
       <section class="view" id="view-software" data-view-panel="software">
         <div class="row g-2" id="software-grid"></div>
       </section>
@@ -367,18 +407,18 @@
                 </div>
               </div>
               <div class="site-settings-panel" data-site-panel="nginx">
-                <div class="d-flex justify-content-between align-items-center mb-2">
+                <div class="d-flex justify-content-between align-items-center mb-2 site-nginx-toolbar">
                   <h6 class="mb-0">Nginx 站点配置</h6>
-                  <code class="small text-secondary" id="site-set-conf-path"></code>
+                  <code class="small text-secondary text-truncate" id="site-set-conf-path"></code>
                 </div>
-                <textarea class="form-control font-monospace site-conf-editor" id="site-set-nginx-conf" rows="16" spellcheck="false"></textarea>
-                <div class="mt-2 d-flex gap-2">
+                <div id="site-nginx-monaco" class="site-nginx-monaco" aria-label="Nginx 配置编辑器"></div>
+                <div class="mt-2 d-flex gap-2 site-nginx-actions">
                   <button type="button" class="btn btn-sm btn-primary" id="site-set-nginx-save">保存并重载</button>
                   <button type="button" class="btn btn-sm btn-outline-secondary" id="site-set-nginx-reload">重新加载文件</button>
                 </div>
               </div>
               <div class="site-settings-panel" data-site-panel="logs">
-                <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
+                <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2 site-log-toolbar">
                   <div class="btn-group btn-group-sm" role="group">
                     <button type="button" class="btn btn-outline-primary active" id="site-log-tab-access" data-log-kind="access">访问日志</button>
                     <button type="button" class="btn btn-outline-primary" id="site-log-tab-error" data-log-kind="error">error.log</button>

+ 74 - 2
src/api/api_server.cpp

@@ -10,6 +10,7 @@
 #include "../store/store.h"
 #include "../system/metrics.h"
 #include "../system/metrics_history.h"
+#include "../system/process.h"
 #include "../tasks/tasks.h"
 #include "../utils.h"
 #include "../website/website.h"
@@ -890,9 +891,13 @@ void h_websites(request* req, response* resp) {
         creq.name = json_str(body, "name");
         std::string type = json_str(body, "type", "static");
         if (!website::parse_type_key(type, creq.type)) {
-            reply_err(resp, "invalid type, expect static|proxy|fastweb");
+            reply_err(resp, "invalid type, expect static|fastweb");
             return;
         }
+        // "proxy" is accepted as alias of static for older clients.
+        if (type == "proxy") {
+            creq.type = website::SiteType::Static;
+        }
         creq.domain = json_str(body, "domain");
         if (body.exist("domains") && body["domains"].is_array()) {
             std::string joined;
@@ -912,7 +917,7 @@ void h_websites(request* req, response* resp) {
             }
         }
         creq.listen_port = json_int(body, "listen_port", 0);
-        creq.upstream = json_str(body, "upstream");
+        creq.upstream.clear();
         creq.root = json_str(body, "root");
         if (creq.name.empty()) {
             reply_err(resp, "name required");
@@ -1408,6 +1413,71 @@ void h_system_metrics(request* req, response* resp) {
     reply_ok(resp, data);
 }
 
+void h_system_processes(request* req, response* resp) {
+    if (!require_method(req, resp, "GET")) {
+        return;
+    }
+    const std::string q = url_param(req, "q", "");
+    const auto list = system::list_processes(q);
+    ylib::json arr;
+    for (const auto& p : list) {
+        ylib::json item;
+        item["pid"] = p.pid;
+        item["ppid"] = p.ppid;
+        item["user"] = p.user;
+        item["state"] = p.state;
+        item["name"] = p.name;
+        item["cmdline"] = p.cmdline;
+        item["threads"] = p.threads;
+        item["rss"] = static_cast<int64>(p.rss_bytes);
+        item["vms"] = static_cast<int64>(p.vms_bytes);
+        item["mem_percent"] = p.mem_percent;
+        item["cpu_percent"] = p.cpu_percent;
+        arr.push_back(item);
+    }
+    ylib::json data;
+    data["total"] = static_cast<int>(list.size());
+    data["items"] = arr;
+    reply_ok(resp, data);
+}
+
+void h_system_processes_kill(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    auto body = parse_body(req);
+    const int pid = json_int(body, "pid", 0);
+    int signal = json_int(body, "signal", 15);
+    const std::string sig_name = json_str(body, "signal_name");
+    if (!sig_name.empty()) {
+        if (sig_name == "KILL" || sig_name == "SIGKILL" || sig_name == "9") {
+            signal = 9;
+        } else if (sig_name == "TERM" || sig_name == "SIGTERM" ||
+                   sig_name == "15") {
+            signal = 15;
+        } else if (sig_name == "INT" || sig_name == "SIGINT" ||
+                   sig_name == "2") {
+            signal = 2;
+        } else if (sig_name == "HUP" || sig_name == "SIGHUP" ||
+                   sig_name == "1") {
+            signal = 1;
+        }
+    }
+    if (pid <= 0) {
+        reply_err(resp, "pid required");
+        return;
+    }
+    std::string err;
+    if (!system::kill_process(pid, signal, err)) {
+        reply_err(resp, err.empty() ? "kill failed" : err);
+        return;
+    }
+    ylib::json data;
+    data["pid"] = pid;
+    data["signal"] = signal;
+    reply_ok(resp, data, "process signaled");
+}
+
 void h_system_metrics_history(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
@@ -1721,6 +1791,8 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/status", h_status);
     reg(router, "/api/system/metrics", h_system_metrics);
     reg(router, "/api/system/metrics/history", h_system_metrics_history);
+    reg(router, "/api/system/processes", h_system_processes);
+    reg(router, "/api/system/processes/kill", h_system_processes_kill);
     reg(router, "/api/tasks", h_tasks_list);
     reg(router, "/api/tasks/log", h_tasks_log);
     reg(router, "/api/tasks/clear", h_tasks_clear);

+ 317 - 0
src/system/process.cpp

@@ -0,0 +1,317 @@
+#include "process.h"
+
+#include <algorithm>
+#include <cctype>
+#include <cstdlib>
+#include <cstring>
+#include <dirent.h>
+#include <fstream>
+#include <pwd.h>
+#include <signal.h>
+#include <sstream>
+#include <sys/sysinfo.h>
+#include <unistd.h>
+#include <unordered_map>
+
+namespace ngs {
+namespace system {
+namespace {
+
+long clk_tck() {
+    static long v = 0;
+    if (v <= 0) {
+        v = sysconf(_SC_CLK_TCK);
+        if (v <= 0) {
+            v = 100;
+        }
+    }
+    return v;
+}
+
+int cpu_cores() {
+    static int v = 0;
+    if (v <= 0) {
+        v = static_cast<int>(sysconf(_SC_NPROCESSORS_ONLN));
+        if (v <= 0) {
+            v = 1;
+        }
+    }
+    return v;
+}
+
+uint64_t mem_total_bytes() {
+    struct sysinfo info {};
+    if (sysinfo(&info) != 0) {
+        return 0;
+    }
+    return static_cast<uint64_t>(info.totalram) *
+           static_cast<uint64_t>(info.mem_unit);
+}
+
+double system_uptime_sec() {
+    std::ifstream in("/proc/uptime");
+    double up = 0;
+    if (in) {
+        in >> up;
+    }
+    return up > 0 ? up : 1.0;
+}
+
+std::string read_file(const std::string& path) {
+    std::ifstream in(path);
+    if (!in) {
+        return "";
+    }
+    std::ostringstream ss;
+    ss << in.rdbuf();
+    return ss.str();
+}
+
+std::string uid_to_user(uid_t uid) {
+    static std::unordered_map<uid_t, std::string> cache;
+    auto it = cache.find(uid);
+    if (it != cache.end()) {
+        return it->second;
+    }
+    passwd* pw = getpwuid(uid);
+    std::string name = pw && pw->pw_name ? pw->pw_name : std::to_string(uid);
+    cache.emplace(uid, name);
+    return name;
+}
+
+std::string read_cmdline(int pid) {
+    const std::string path = "/proc/" + std::to_string(pid) + "/cmdline";
+    std::string raw = read_file(path);
+    if (raw.empty()) {
+        return "";
+    }
+    for (char& c : raw) {
+        if (c == '\0') {
+            c = ' ';
+        }
+    }
+    while (!raw.empty() && (raw.back() == ' ' || raw.back() == '\0')) {
+        raw.pop_back();
+    }
+    if (raw.size() > 512) {
+        raw.resize(512);
+        raw += "…";
+    }
+    return raw;
+}
+
+bool parse_stat_full(const std::string& content, ProcessInfo& out) {
+    auto lpar = content.find('(');
+    auto rpar = content.rfind(')');
+    if (lpar == std::string::npos || rpar == std::string::npos || rpar <= lpar) {
+        return false;
+    }
+    try {
+        out.pid = std::stoi(content.substr(0, lpar));
+    } catch (...) {
+        return false;
+    }
+    out.name = content.substr(lpar + 1, rpar - lpar - 1);
+    std::string after = content.substr(rpar + 2);
+    std::istringstream rest(after);
+    std::string state;
+    rest >> state;
+    if (state.empty()) {
+        return false;
+    }
+    out.state = state.substr(0, 1);
+
+    // Fields after state: ppid pgrp session tty_nr tpgid flags
+    // minflt cminflt majflt cmajflt utime stime cutime cstime priority nice
+    // num_threads itrealvalue starttime vsize rss
+    int ppid = 0, pgrp = 0, session = 0, tty_nr = 0, tpgid = 0;
+    unsigned long flags = 0;
+    unsigned long minflt = 0, cminflt = 0, majflt = 0, cmajflt = 0;
+    unsigned long utime = 0, stime = 0, cutime = 0, cstime = 0;
+    long priority = 0, nice = 0;
+    long num_threads = 0;
+    long itrealvalue = 0;
+    unsigned long long starttime = 0;
+    unsigned long vsize = 0;
+    long rss = 0;
+
+    rest >> ppid >> pgrp >> session >> tty_nr >> tpgid >> flags >> minflt >>
+        cminflt >> majflt >> cmajflt >> utime >> stime >> cutime >> cstime >>
+        priority >> nice >> num_threads >> itrealvalue >> starttime >> vsize >>
+        rss;
+
+    out.ppid = ppid;
+    out.cpu_ticks = utime + stime;
+    out.threads = num_threads > 0 ? static_cast<int>(num_threads) : 1;
+    out.vms_bytes = vsize;
+    const long page = sysconf(_SC_PAGESIZE);
+    const uint64_t page_sz = page > 0 ? static_cast<uint64_t>(page) : 4096ULL;
+    out.rss_bytes = rss > 0 ? static_cast<uint64_t>(rss) * page_sz : 0;
+
+    const double up = system_uptime_sec();
+    const double start_sec = static_cast<double>(starttime) /
+                             static_cast<double>(clk_tck());
+    const double age = up > start_sec ? (up - start_sec) : up;
+    const double cpu_sec =
+        static_cast<double>(out.cpu_ticks) / static_cast<double>(clk_tck());
+    if (age > 0.01) {
+        out.cpu_percent =
+            (cpu_sec / age) * 100.0 / static_cast<double>(cpu_cores());
+        if (out.cpu_percent < 0) {
+            out.cpu_percent = 0;
+        }
+        if (out.cpu_percent > 100.0 * cpu_cores()) {
+            out.cpu_percent = 100.0 * cpu_cores();
+        }
+    }
+    return true;
+}
+
+uid_t read_uid(int pid) {
+    std::ifstream in("/proc/" + std::to_string(pid) + "/status");
+    std::string line;
+    while (std::getline(in, line)) {
+        if (line.rfind("Uid:", 0) == 0) {
+            std::istringstream ss(line.substr(4));
+            uid_t uid = 0;
+            ss >> uid;
+            return uid;
+        }
+    }
+    return 0;
+}
+
+bool contains_ci(const std::string& hay, const std::string& needle) {
+    if (needle.empty()) {
+        return true;
+    }
+    auto lower = [](unsigned char c) {
+        return static_cast<char>(std::tolower(c));
+    };
+    if (needle.size() > hay.size()) {
+        return false;
+    }
+    for (size_t i = 0; i + needle.size() <= hay.size(); ++i) {
+        bool ok = true;
+        for (size_t j = 0; j < needle.size(); ++j) {
+            if (lower(static_cast<unsigned char>(hay[i + j])) !=
+                lower(static_cast<unsigned char>(needle[j]))) {
+                ok = false;
+                break;
+            }
+        }
+        if (ok) {
+            return true;
+        }
+    }
+    return false;
+}
+
+}  // namespace
+
+std::vector<ProcessInfo> list_processes(const std::string& q) {
+    std::vector<ProcessInfo> out;
+    DIR* dir = opendir("/proc");
+    if (!dir) {
+        return out;
+    }
+    const uint64_t mem_total = mem_total_bytes();
+    dirent* ent = nullptr;
+    while ((ent = readdir(dir)) != nullptr) {
+        if (ent->d_type != DT_DIR && ent->d_type != DT_UNKNOWN) {
+            continue;
+        }
+        const char* name = ent->d_name;
+        if (!name || !*name) {
+            continue;
+        }
+        bool digits = true;
+        for (const char* p = name; *p; ++p) {
+            if (*p < '0' || *p > '9') {
+                digits = false;
+                break;
+            }
+        }
+        if (!digits) {
+            continue;
+        }
+        const int pid = std::atoi(name);
+        if (pid <= 0) {
+            continue;
+        }
+        const std::string stat_path = std::string("/proc/") + name + "/stat";
+        const std::string stat = read_file(stat_path);
+        if (stat.empty()) {
+            continue;
+        }
+        ProcessInfo info;
+        if (!parse_stat_full(stat, info)) {
+            continue;
+        }
+        info.user = uid_to_user(read_uid(pid));
+        info.cmdline = read_cmdline(pid);
+        if (info.cmdline.empty()) {
+            info.cmdline = "[" + info.name + "]";
+        }
+        if (mem_total > 0) {
+            info.mem_percent =
+                100.0 * static_cast<double>(info.rss_bytes) /
+                static_cast<double>(mem_total);
+        }
+        if (!q.empty()) {
+            const std::string pid_s = std::to_string(info.pid);
+            if (!contains_ci(pid_s, q) && !contains_ci(info.name, q) &&
+                !contains_ci(info.cmdline, q) && !contains_ci(info.user, q)) {
+                continue;
+            }
+        }
+        out.push_back(std::move(info));
+    }
+    closedir(dir);
+
+    std::sort(out.begin(), out.end(),
+              [](const ProcessInfo& a, const ProcessInfo& b) {
+                  if (a.cpu_percent != b.cpu_percent) {
+                      return a.cpu_percent > b.cpu_percent;
+                  }
+                  return a.rss_bytes > b.rss_bytes;
+              });
+    return out;
+}
+
+bool kill_process(int pid, int signal, std::string& err) {
+    err.clear();
+    if (pid <= 1) {
+        err = "不能结束系统关键进程 (PID <= 1)";
+        return false;
+    }
+    if (signal != SIGTERM && signal != SIGKILL && signal != SIGINT &&
+        signal != SIGHUP) {
+        err = "不支持的信号";
+        return false;
+    }
+    if (::kill(pid, 0) != 0) {
+        if (errno == ESRCH) {
+            err = "进程不存在: " + std::to_string(pid);
+        } else if (errno == EPERM) {
+            err = "无权限操作该进程";
+        } else {
+            err = "无法访问进程: " + std::string(strerror(errno));
+        }
+        return false;
+    }
+    if (::kill(pid, signal) != 0) {
+        if (errno == ESRCH) {
+            err = "进程不存在: " + std::to_string(pid);
+        } else if (errno == EPERM) {
+            err = "无权限结束该进程";
+        } else {
+            err = "结束进程失败: " + std::string(strerror(errno));
+        }
+        return false;
+    }
+    return true;
+}
+
+}  // namespace system
+}  // namespace ngs

+ 35 - 0
src/system/process.h

@@ -0,0 +1,35 @@
+#ifndef NGS_SYSTEM_PROCESS_H
+#define NGS_SYSTEM_PROCESS_H
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace ngs {
+namespace system {
+
+struct ProcessInfo {
+    int pid = 0;
+    int ppid = 0;
+    std::string user;
+    std::string state;   // R/S/D/Z/...
+    std::string name;    // comm
+    std::string cmdline;
+    int threads = 0;
+    uint64_t rss_bytes = 0;
+    uint64_t vms_bytes = 0;
+    double mem_percent = 0;
+    double cpu_percent = 0;  // lifetime average vs cores (approx)
+    uint64_t cpu_ticks = 0;
+};
+
+// Enumerate /proc processes. Optional q filters name/cmdline/user/pid.
+std::vector<ProcessInfo> list_processes(const std::string& q = "");
+
+// Send signal to pid. signal: 15 (TERM) or 9 (KILL). Rejects pid <= 1.
+bool kill_process(int pid, int signal, std::string& err);
+
+}  // namespace system
+}  // namespace ngs
+
+#endif

+ 173 - 89
src/website/website.cpp

@@ -42,6 +42,14 @@ bool start_fastweb_process(const SiteInfo& s, std::string& err);
 bool stop_fastweb_process(const SiteInfo& s);
 bool load_url_proxy_rules(const SiteInfo& s, std::vector<UrlProxyRule>& out,
                           std::string& err);
+bool save_url_proxy_rules(const SiteInfo& s,
+                          const std::vector<UrlProxyRule>& rules,
+                          std::string& err);
+bool normalize_url_proxy_rule(UrlProxyRule& rule, std::string& err);
+bool seed_url_proxy_root(const SiteInfo& s, const std::string& target,
+                         const std::string& remark);
+bool save_sites(const std::vector<SiteInfo>& list);
+std::string make_proxy_id();
 std::string nginx_escape_comment(std::string s);
 
 std::string vhost_dir() {
@@ -560,10 +568,17 @@ std::vector<int> unique_listen_ports(const std::vector<DomainBind>& binds) {
 
 std::vector<SiteInfo> load_sites() {
     std::vector<SiteInfo> list;
+    bool migrated = false;
     for (const auto& r : store::list_sites()) {
         SiteInfo s;
         s.name = r.name;
-        s.type = parse_type(r.type);
+        // Legacy "proxy" sites become static; reverse proxy lives in URL rules.
+        if (r.type == "proxy") {
+            s.type = SiteType::Static;
+            migrated = true;
+        } else {
+            s.type = parse_type(r.type);
+        }
         s.domain = r.domain;
         s.listen_port = r.listen_port;
         s.upstream = r.upstream;
@@ -572,8 +587,44 @@ std::vector<SiteInfo> load_sites() {
         s.ssl_cert = r.ssl_cert;
         s.ssl_key = r.ssl_key;
         s.ssl_port = r.ssl_port > 0 ? r.ssl_port : 443;
+
+        if (r.type == "proxy" && !s.upstream.empty()) {
+            std::vector<UrlProxyRule> rules;
+            std::string perr;
+            if (load_url_proxy_rules(s, rules, perr) && rules.empty()) {
+                UrlProxyRule rule;
+                rule.id = make_proxy_id();
+                rule.path = "/";
+                rule.target_type = "url";
+                rule.target = s.upstream;
+                rule.host = "$http_host";
+                rule.remark = "由旧反向代理站点迁移";
+                if (normalize_url_proxy_rule(rule, perr)) {
+                    rules.push_back(rule);
+                    save_url_proxy_rules(s, rules, perr);
+                }
+            }
+            // Upstream is only meaningful for Fastweb backends now.
+            s.upstream.clear();
+        }
+
+        if (s.type == SiteType::Fastweb && !s.upstream.empty()) {
+            std::string target = s.upstream;
+            if (target.rfind("http://", 0) != 0 &&
+                target.rfind("https://", 0) != 0) {
+                target = "http://" + target;
+            }
+            seed_url_proxy_root(s, target, "Fastweb 后端");
+        }
+
         list.push_back(s);
     }
+    if (migrated) {
+        save_sites(list);
+        for (const auto& s : list) {
+            write_vhost(s);
+        }
+    }
     return list;
 }
 
@@ -615,8 +666,7 @@ bool port_used_by_sites(int port, const std::string& except_name = "") {
         if (s.listen_port == port) {
             return true;
         }
-        if ((s.type == SiteType::Fastweb || s.type == SiteType::Proxy) &&
-            !s.upstream.empty()) {
+        if (s.type == SiteType::Fastweb && !s.upstream.empty()) {
             auto pos = s.upstream.rfind(':');
             if (pos != std::string::npos) {
                 try {
@@ -742,20 +792,12 @@ std::string nginx_server_names(const SiteInfo& s) {
     return out.str();
 }
 
-std::string nginx_static_conf(const SiteInfo& s) {
-    std::ostringstream out;
-    out << "# ngs site: " << s.name << " (static)\n"
-        << "server {\n"
-        << nginx_listen_and_ssl(s)
-        << "    server_name  " << nginx_server_names(s) << ";\n"
-        << "    root         " << nginx_quote_path(site_dir(s)) << ";\n"
-        << "    index        index.html index.htm;\n"
-        << nginx_acme_location()
-        << "    location / {\n"
-        << "        try_files $uri $uri/ =404;\n"
-        << "    }\n"
-        << nginx_log_block(s) << "}\n";
-    return out.str();
+std::string nginx_proxy_pass_from_upstream(const std::string& upstream) {
+    if (upstream.rfind("http://", 0) == 0 ||
+        upstream.rfind("https://", 0) == 0) {
+        return upstream;
+    }
+    return "http://" + upstream;
 }
 
 std::string nginx_proxy_location_block(const std::string& path,
@@ -786,6 +828,58 @@ std::string nginx_url_proxy_pass_target(const UrlProxyRule& r) {
     return r.target;
 }
 
+std::string nginx_static_conf(const SiteInfo& s) {
+    std::vector<UrlProxyRule> rules;
+    std::string load_err;
+    load_url_proxy_rules(s, rules, load_err);
+
+    const UrlProxyRule* root_rule = nullptr;
+    for (const auto& r : rules) {
+        if (r.path == "/") {
+            root_rule = &r;
+            break;
+        }
+    }
+
+    std::ostringstream out;
+    out << "# ngs site: " << s.name << " (static)\n"
+        << "server {\n"
+        << nginx_listen_and_ssl(s)
+        << "    server_name  " << nginx_server_names(s) << ";\n"
+        << "    root         " << nginx_quote_path(site_dir(s)) << ";\n"
+        << "    index        index.html index.htm;\n"
+        << nginx_acme_location();
+
+    for (const auto& r : rules) {
+        if (r.path == "/") {
+            continue;
+        }
+        const std::string comment =
+            r.remark.empty() ? ("url-proxy " + r.id)
+                             : ("url-proxy " + nginx_escape_comment(r.remark));
+        out << nginx_proxy_location_block(r.path, nginx_url_proxy_pass_target(r),
+                                          r.host.empty() ? "$http_host" : r.host,
+                                          comment);
+    }
+
+    if (root_rule) {
+        const std::string comment =
+            root_rule->remark.empty()
+                ? ("url-proxy " + root_rule->id)
+                : ("url-proxy " + nginx_escape_comment(root_rule->remark));
+        out << nginx_proxy_location_block(
+            "/", nginx_url_proxy_pass_target(*root_rule),
+            root_rule->host.empty() ? "$http_host" : root_rule->host, comment);
+    } else {
+        out << "    location / {\n"
+            << "        try_files $uri $uri/ =404;\n"
+            << "    }\n";
+    }
+
+    out << nginx_log_block(s) << "}\n";
+    return out.str();
+}
+
 std::string nginx_proxy_conf(const SiteInfo& s) {
     std::vector<UrlProxyRule> rules;
     std::string load_err;
@@ -800,7 +894,8 @@ std::string nginx_proxy_conf(const SiteInfo& s) {
     }
 
     std::ostringstream out;
-    out << "# ngs site: " << s.name << " (proxy)\n"
+    out << "# ngs site: " << s.name
+        << (s.type == SiteType::Fastweb ? " (fastweb)\n" : " (proxy)\n")
         << "server {\n"
         << nginx_listen_and_ssl(s)
         << "    server_name  " << nginx_server_names(s) << ";\n"
@@ -827,9 +922,13 @@ std::string nginx_proxy_conf(const SiteInfo& s) {
         out << nginx_proxy_location_block(
             "/", nginx_url_proxy_pass_target(*root_rule),
             root_rule->host.empty() ? "$http_host" : root_rule->host, comment);
+    } else if (!s.upstream.empty()) {
+        out << nginx_proxy_location_block(
+            "/", nginx_proxy_pass_from_upstream(s.upstream), "$host", "");
     } else {
-        out << nginx_proxy_location_block("/", "http://" + s.upstream, "$host",
-                                          "");
+        out << "    location / {\n"
+            << "        return 502;\n"
+            << "    }\n";
     }
 
     out << nginx_log_block(s) << "}\n";
@@ -1027,11 +1126,10 @@ bool write_vhost(const SiteInfo& s) {
         return false;
     }
     std::string body;
-    if (s.type == SiteType::Static) {
-        body = nginx_static_conf(s);
-    } else {
-        // proxy + fastweb both use reverse proxy block
+    if (s.type == SiteType::Fastweb) {
         body = nginx_proxy_conf(s);
+    } else {
+        body = nginx_static_conf(s);
     }
     std::string path = vhost_file(s);
     if (!path_exists(path) && path_exists(vhost_disabled_file(s))) {
@@ -1078,19 +1176,31 @@ bool create_static_site(const SiteInfo& s) {
     return true;
 }
 
-bool create_proxy_site(const SiteInfo& s) {
-    const std::string root = site_dir(s);
-    if (!ensure_dir(root)) {
-        std::cerr << "无法创建网站目录: " << root << "\n";
+bool seed_url_proxy_root(const SiteInfo& s, const std::string& target,
+                         const std::string& remark) {
+    std::vector<UrlProxyRule> rules;
+    std::string err;
+    if (!load_url_proxy_rules(s, rules, err)) {
         return false;
     }
-    write_text_file(join_path(root, ".ngs_proxy"),
-                    "upstream=" + s.upstream + "\n");
-    if (!write_vhost(s)) {
-        std::cerr << "写入 nginx vhost 失败。\n";
+    for (const auto& r : rules) {
+        if (r.path == "/") {
+            return true;
+        }
+    }
+    UrlProxyRule rule;
+    rule.id = make_proxy_id();
+    rule.path = "/";
+    rule.target_type = "url";
+    rule.target = target;
+    rule.host = "$http_host";
+    rule.remark = remark;
+    if (!normalize_url_proxy_rule(rule, err)) {
+        std::cerr << "初始化 URL 代理失败: " << err << "\n";
         return false;
     }
-    return true;
+    rules.push_back(rule);
+    return save_url_proxy_rules(s, rules, err);
 }
 
 bool create_fastweb_site(SiteInfo& s) {
@@ -1175,6 +1285,12 @@ bool create_fastweb_site(SiteInfo& s) {
     ensure_dir(join_path(root, "module"));
     ensure_dir(join_path(root, "log"));
 
+    // Expose Fastweb backend in the URL-proxy panel (path=/).
+    if (!seed_url_proxy_root(s, "http://" + s.upstream, "Fastweb 后端")) {
+        std::cerr << "写入 Fastweb URL 代理规则失败。\n";
+        return false;
+    }
+
     if (!write_vhost(s)) {
         std::cerr << "写入 nginx vhost 失败。\n";
         return false;
@@ -1328,28 +1444,22 @@ bool create_site_impl(const CreateSiteRequest& req, SiteInfo& created,
     s.type = req.type;
     s.domain = domain;
     s.listen_port = listen;
-    s.upstream = req.upstream;
+    s.upstream.clear();
     s.root = root;
 
-    if (req.type == SiteType::Proxy) {
-        if (s.upstream.empty()) {
-            err = "反代目标不能为空";
-            return false;
-        }
-        if (!create_proxy_site(s)) {
-            err = "创建反向代理网站失败";
-            return false;
-        }
-    } else if (req.type == SiteType::Fastweb) {
+    if (req.type == SiteType::Fastweb) {
         if (!create_fastweb_site(s)) {
             err = "创建 Fastweb 网站失败";
             return false;
         }
-    } else {
+    } else if (req.type == SiteType::Static) {
         if (!create_static_site(s)) {
             err = "创建静态网站失败";
             return false;
         }
+    } else {
+        err = "无效的网站类型";
+        return false;
     }
 
     if (!register_site(s)) {
@@ -1403,9 +1513,6 @@ bool create_site_interactive(SiteType type) {
             return false;
         }
     }
-    if (type == SiteType::Proxy) {
-        req.upstream = read_line("反代目标 (如 127.0.0.1:9000): ");
-    }
 
     SiteInfo created;
     std::string err;
@@ -1422,8 +1529,9 @@ bool create_site_interactive(SiteType type) {
               << "  (或 http://127.0.0.1:" << created.listen_port << ")\n";
     if (created.type == SiteType::Fastweb) {
         std::cout << "  Fastweb 后端: " << created.upstream << "\n";
-    } else if (created.type == SiteType::Proxy) {
-        std::cout << "  上游: " << created.upstream << "\n";
+        std::cout << "  提示: 反向代理请在网页「反向代理」中管理。\n";
+    } else {
+        std::cout << "  提示: 如需反代,请在网页「反向代理」中添加 URL 代理。\n";
     }
     return true;
 }
@@ -1585,8 +1693,6 @@ std::string site_key_by_name(const std::string& name) {
 
 std::string type_key(SiteType t) {
     switch (t) {
-        case SiteType::Proxy:
-            return "proxy";
         case SiteType::Fastweb:
             return "fastweb";
         case SiteType::Static:
@@ -1597,8 +1703,6 @@ std::string type_key(SiteType t) {
 
 std::string type_label(SiteType t) {
     switch (t) {
-        case SiteType::Proxy:
-            return "反向代理";
         case SiteType::Fastweb:
             return "Fastweb";
         case SiteType::Static:
@@ -1608,18 +1712,15 @@ std::string type_label(SiteType t) {
 }
 
 bool parse_type_key(const std::string& key, SiteType& out) {
-    if (key == "proxy") {
-        out = SiteType::Proxy;
+    // Legacy create type "proxy" maps to static; use URL proxies instead.
+    if (key == "proxy" || key == "static" || key.empty()) {
+        out = SiteType::Static;
         return true;
     }
     if (key == "fastweb") {
         out = SiteType::Fastweb;
         return true;
     }
-    if (key == "static" || key.empty()) {
-        out = SiteType::Static;
-        return true;
-    }
     return false;
 }
 
@@ -1793,10 +1894,6 @@ std::vector<UrlProxyRule> list_url_proxies(const std::string& site_name,
         err = "未找到网站: " + site_name;
         return {};
     }
-    if (s.type != SiteType::Proxy && s.type != SiteType::Fastweb) {
-        err = "仅反向代理 / Fastweb 站点支持 URL 代理";
-        return {};
-    }
     std::vector<UrlProxyRule> rules;
     if (!load_url_proxy_rules(s, rules, err)) {
         return {};
@@ -1812,10 +1909,6 @@ bool add_url_proxy(const std::string& site_name, UrlProxyRule rule,
         err = "未找到网站: " + site_name;
         return false;
     }
-    if (s.type != SiteType::Proxy && s.type != SiteType::Fastweb) {
-        err = "仅反向代理 / Fastweb 站点支持 URL 代理";
-        return false;
-    }
     if (!normalize_url_proxy_rule(rule, err)) {
         return false;
     }
@@ -1847,10 +1940,6 @@ bool update_url_proxy(const std::string& site_name, const UrlProxyRule& in,
         err = "未找到网站: " + site_name;
         return false;
     }
-    if (s.type != SiteType::Proxy && s.type != SiteType::Fastweb) {
-        err = "仅反向代理 / Fastweb 站点支持 URL 代理";
-        return false;
-    }
     if (in.id.empty()) {
         err = "缺少代理规则 id";
         return false;
@@ -2487,17 +2576,16 @@ void menu() {
         }
         std::cout << "\n";
         std::cout << "  1) 创建静态网站\n";
-        std::cout << "  2) 创建反向代理网站\n";
-        std::cout << "  3) 创建 Fastweb 网站\n";
-        std::cout << "  4) 启动网站\n";
-        std::cout << "  5) 停止网站\n";
-        std::cout << "  6) 重启网站\n";
-        std::cout << "  7) 删除网站\n";
+        std::cout << "  2) 创建 Fastweb 网站\n";
+        std::cout << "  3) 启动网站\n";
+        std::cout << "  4) 停止网站\n";
+        std::cout << "  5) 重启网站\n";
+        std::cout << "  6) 删除网站\n";
         if (nginx::is_installed()) {
             if (nginx::is_running()) {
-                std::cout << "  8) 重载 Nginx\n";
+                std::cout << "  7) 重载 Nginx\n";
             } else {
-                std::cout << "  8) 启动 Nginx\n";
+                std::cout << "  7) 启动 Nginx\n";
             }
         }
         std::cout << "  0) 返回主菜单\n\n";
@@ -2509,14 +2597,10 @@ void menu() {
                 pause();
                 break;
             case 2:
-                create_site_interactive(SiteType::Proxy);
-                pause();
-                break;
-            case 3:
                 create_site_interactive(SiteType::Fastweb);
                 pause();
                 break;
-            case 4: {
+            case 3: {
                 std::string name;
                 if (pick_site_name(name)) {
                     std::string e;
@@ -2525,7 +2609,7 @@ void menu() {
                 pause();
                 break;
             }
-            case 5: {
+            case 4: {
                 std::string name;
                 if (pick_site_name(name)) {
                     stop_site_by_name(name);
@@ -2533,7 +2617,7 @@ void menu() {
                 pause();
                 break;
             }
-            case 6: {
+            case 5: {
                 std::string name;
                 if (pick_site_name(name)) {
                     std::string e;
@@ -2542,11 +2626,11 @@ void menu() {
                 pause();
                 break;
             }
-            case 7:
+            case 6:
                 delete_site_interactive();
                 pause();
                 break;
-            case 8:
+            case 7:
                 if (!nginx::is_installed()) {
                     std::cout << "无效选项。\n";
                 } else if (nginx::is_running()) {

+ 2 - 3
src/website/website.h

@@ -9,7 +9,6 @@ namespace website {
 
 enum class SiteType {
     Static,
-    Proxy,
     Fastweb,
 };
 
@@ -18,7 +17,7 @@ struct SiteInfo {
     SiteType type = SiteType::Static;
     std::string domain;
     int listen_port = 0;
-    std::string upstream;  // proxy target or fastweb backend host:port
+    std::string upstream;  // fastweb backend host:port (internal)
     std::string root;      // site files dir; empty -> /ngs/wwwroot/<name>
     bool running = false;
     bool ssl_enable = false;
@@ -32,7 +31,7 @@ struct CreateSiteRequest {
     SiteType type = SiteType::Static;
     std::string domain;      // single host or host:port
     int listen_port = 0;     // 0 -> derive from domain token (default 80)
-    std::string upstream;    // required for Proxy
+    std::string upstream;    // unused on create; Fastweb allocates backend port
     std::string root;        // optional; default /ngs/wwwroot/<domain-host>
 };
 

+ 0 - 0
tmux.sh