你的用户名 1 month ago
parent
commit
f268582470

+ 1 - 0
.gitignore

@@ -10,3 +10,4 @@ data/database.db
 data/metrics.db
 data/log
 
+data/schedule.db

+ 1 - 0
CMakeLists.txt

@@ -17,6 +17,7 @@ add_executable(ngs
     src/system/process.cpp
     src/files/files.cpp
     src/tasks/tasks.cpp
+    src/schedule/schedule.cpp
     src/software/software.cpp
     src/software/nginx/nginx.cpp
     src/software/fastweb/fastweb.cpp

+ 68 - 2
data/www/assets/app.css

@@ -207,8 +207,9 @@ html, body {
   border-top-color: currentColor;
   transform: translateY(2px);
 }
-#proc-table td.proc-cmd {
-  max-width: 28rem;
+#proc-table td.proc-cmd,
+#proc-table td.proc-cwd {
+  max-width: 20rem;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -251,12 +252,29 @@ html, body {
   border-radius: 12px;
   padding: 0.85rem 1rem;
   background: #fff;
+}
+/* Only stretch panels that share a row, so full-width blocks don't leave tall empty gaps */
+.row > [class*="col"] > .analytics-panel {
   height: 100%;
 }
 .analytics-panel-title {
   font-weight: 600;
   font-size: 0.9rem;
   margin-bottom: 0.65rem;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 0.5rem;
+  flex-wrap: wrap;
+}
+.analytics-panel-title .form-check {
+  font-weight: 400;
+  min-height: 0;
+}
+.analytics-panel-title .form-check-label {
+  color: #64748b;
+  font-size: 0.78rem;
+  user-select: none;
 }
 .analytics-chart {
   position: relative;
@@ -894,6 +912,21 @@ html, body {
 .analytics-ip-link:hover {
   color: #0f766e;
 }
+.analytics-self-ip {
+  display: inline-block;
+  margin-left: 0.35rem;
+  padding: 0.05rem 0.4rem;
+  border-radius: 4px;
+  font-size: 0.7rem;
+  font-weight: 600;
+  line-height: 1.3;
+  color: #0f766e;
+  background: #ccfbf1;
+  vertical-align: middle;
+}
+.analytics-self-row td {
+  background: rgba(13, 148, 136, 0.06);
+}
 .site-log-ip-filter {
   display: inline-flex;
   align-items: center;
@@ -920,12 +953,23 @@ html, body {
   cursor: pointer;
 }
 .sites-link:hover { text-decoration: underline; }
+.sites-group-chips .sites-group-chip {
+  --bs-btn-padding-y: 0.15rem;
+  --bs-btn-padding-x: 0.55rem;
+  --bs-btn-font-size: 0.78rem;
+}
 .site-ssl-pem {
   font-size: 0.78rem;
   line-height: 1.4;
   resize: vertical;
   min-height: 140px;
 }
+.site-custom-nginx {
+  font-size: 0.8rem;
+  line-height: 1.45;
+  resize: vertical;
+  min-height: 220px;
+}
 .site-ssl-domain-list {
   max-height: 180px;
   overflow: auto;
@@ -1023,3 +1067,25 @@ html, body {
   align-items: stretch;
   min-width: 5.5rem;
 }
+
+/* Plans / schedule */
+.plans-cron-layout { min-height: 420px; }
+.schedule-log-view {
+  max-height: 520px;
+  overflow: auto;
+  padding: 0.75rem 1rem;
+  font: 0.75rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  white-space: pre-wrap;
+  word-break: break-word;
+  background: #0f172a;
+  color: #cbd5e1;
+}
+.schedule-table tr.is-selected { background: rgba(13, 110, 253, 0.08); }
+.schedule-url {
+  max-width: 220px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 0.78rem;
+}

File diff suppressed because it is too large
+ 713 - 41
data/www/assets/app.js


+ 138 - 4
data/www/index.html

@@ -30,6 +30,7 @@
         <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>
+        <button type="button" class="nav-link text-start nav-item" data-view="plans"><i class="bi bi-calendar2-check me-2"></i>计划</button>
       </nav>
       <div class="rail-foot px-2 pb-2">
         <button type="button" class="nav-link text-start nav-item nav-logout w-100" id="btn-logout">
@@ -114,18 +115,21 @@
 
       <section class="view" id="view-sites" data-view-panel="sites">
         <div class="card">
-          <div class="card-header py-2 d-flex justify-content-between align-items-center">
+          <div class="card-header py-2 d-flex flex-wrap justify-content-between align-items-center gap-2">
             <div>
               <strong>网站</strong>
               <span class="small text-secondary ms-2">创建 / 启停 / 配置 / 删除</span>
             </div>
             <button type="button" class="btn btn-sm btn-primary" id="btn-site-create">新建网站</button>
           </div>
+          <div class="px-3 pt-2" id="sites-group-bar"></div>
           <div class="table-responsive">
             <table class="table table-sm table-hover align-middle mb-0" id="sites-table">
               <thead>
                 <tr>
+                  <th style="width:4.5rem">排序</th>
                   <th>名称</th>
+                  <th>分组</th>
                   <th>类型</th>
                   <th>域名</th>
                   <th>目录</th>
@@ -222,7 +226,7 @@
                 <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 / 用户 / 名称 / 命令" />
+                <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>
@@ -242,6 +246,7 @@
                   <th data-proc-sort="rss" class="proc-sort">RSS</th>
                   <th>状态</th>
                   <th data-proc-sort="name" class="proc-sort">名称</th>
+                  <th data-proc-sort="cwd" class="proc-sort">运行目录</th>
                   <th>命令行</th>
                   <th class="text-end">操作</th>
                 </tr>
@@ -255,6 +260,59 @@
       <section class="view" id="view-software" data-view-panel="software">
         <div class="row g-2" id="software-grid"></div>
       </section>
+
+      <section class="view" id="view-plans" data-view-panel="plans">
+        <div class="card">
+          <div class="card-header py-2">
+            <div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
+              <div class="btn-group btn-group-sm" role="group" id="plans-tabs">
+                <button type="button" class="btn btn-primary" data-plans-tab="cron">定时任务</button>
+                <button type="button" class="btn btn-outline-primary" data-plans-tab="more" disabled title="即将推出">更多</button>
+              </div>
+              <div class="d-flex gap-2" id="plans-cron-actions">
+                <button type="button" class="btn btn-sm btn-outline-secondary" id="btn-schedule-refresh">刷新</button>
+                <button type="button" class="btn btn-sm btn-primary" id="btn-schedule-add">新建任务</button>
+              </div>
+            </div>
+          </div>
+          <div class="card-body p-0">
+            <div class="plans-pane is-active" data-plans-pane="cron">
+              <div class="row g-0 plans-cron-layout">
+                <div class="col-lg-7 border-end">
+                  <div class="table-responsive">
+                    <table class="table table-sm table-hover align-middle mb-0" id="schedule-table">
+                      <thead>
+                        <tr>
+                          <th>名称</th>
+                          <th>间隔</th>
+                          <th>URL</th>
+                          <th>状态</th>
+                          <th class="text-end">操作</th>
+                        </tr>
+                      </thead>
+                      <tbody></tbody>
+                    </table>
+                  </div>
+                </div>
+                <div class="col-lg-5">
+                  <div class="p-2 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
+                    <strong class="small mb-0">执行日志</strong>
+                    <div class="d-flex gap-2">
+                      <button type="button" class="btn btn-sm btn-outline-secondary" id="btn-schedule-log-refresh">刷新日志</button>
+                      <button type="button" class="btn btn-sm btn-outline-danger" id="btn-schedule-log-clear">清空</button>
+                    </div>
+                  </div>
+                  <div class="schedule-log-meta small text-secondary px-2 py-1" id="schedule-log-meta">选择左侧任务查看日志</div>
+                  <pre class="schedule-log-view mb-0" id="schedule-log-view"></pre>
+                </div>
+              </div>
+            </div>
+            <div class="plans-pane p-4 text-secondary" data-plans-pane="more" hidden>
+              更多计划能力即将推出。
+            </div>
+          </div>
+        </div>
+      </section>
     </main>
   </div>
 
@@ -310,6 +368,51 @@
     </div>
   </div>
 
+  <div class="modal fade" id="redis-config-modal" tabindex="-1" aria-hidden="true">
+    <div class="modal-dialog modal-lg modal-dialog-scrollable">
+      <div class="modal-content">
+        <div class="modal-header py-2">
+          <h5 class="modal-title mb-0">Redis 配置</h5>
+          <button type="button" class="btn-close" id="redis-config-close" aria-label="关闭"></button>
+        </div>
+        <div class="modal-body">
+          <div class="btn-group btn-group-sm mb-3" role="group">
+            <button type="button" class="btn btn-outline-primary active" id="redis-tab-quick" data-redis-tab="quick">基础配置</button>
+            <button type="button" class="btn btn-outline-primary" id="redis-tab-file" data-redis-tab="file">配置文件</button>
+          </div>
+          <div id="redis-pane-quick">
+            <p class="small text-secondary mb-3">快捷修改常用项;保存后若 Redis 正在运行会自动重启生效。</p>
+            <div class="mb-2">
+              <label class="form-label">监听地址</label>
+              <input class="form-control form-control-sm" id="redis-cfg-bind" placeholder="127.0.0.1" autocomplete="off" />
+              <div class="form-text">多个地址用空格分隔,例如 <code>127.0.0.1 0.0.0.0</code></div>
+            </div>
+            <div class="mb-2">
+              <label class="form-label">端口</label>
+              <input class="form-control form-control-sm" id="redis-cfg-port" type="number" min="1" max="65535" />
+            </div>
+            <div class="mb-3">
+              <label class="form-label">密码</label>
+              <input class="form-control form-control-sm" id="redis-cfg-pass" type="password" placeholder="留空表示不设密码" autocomplete="new-password" />
+            </div>
+            <button type="button" class="btn btn-sm btn-primary" id="redis-cfg-save">保存并应用</button>
+          </div>
+          <div id="redis-pane-file" hidden>
+            <div class="d-flex justify-content-between align-items-center mb-2 gap-2">
+              <code class="small text-secondary text-truncate" id="redis-cfg-path"></code>
+              <button type="button" class="btn btn-sm btn-outline-secondary" id="redis-cfg-reload">重新加载</button>
+            </div>
+            <textarea class="form-control font-monospace" id="redis-cfg-text" rows="18" spellcheck="false"></textarea>
+            <div class="mt-2 d-flex gap-2">
+              <button type="button" class="btn btn-sm btn-primary" id="redis-cfg-file-save">保存文件</button>
+              <span class="small text-secondary align-self-center">仅写入文件;如需生效请自行重启 Redis。</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
   <div class="modal fade" id="site-settings-modal" tabindex="-1" aria-hidden="true">
     <div class="modal-dialog modal-xl modal-dialog-scrollable">
       <div class="modal-content">
@@ -323,16 +426,23 @@
         <div class="modal-body site-settings-body p-0">
           <div class="site-settings-layout">
             <aside class="site-settings-nav">
-              <button type="button" class="site-settings-tab is-active" data-site-tab="domain">域名</button>
+              <button type="button" class="site-settings-tab is-active" data-site-tab="domain">域名/分组</button>
               <button type="button" class="site-settings-tab d-none" data-site-tab="proxy" id="site-tab-proxy">反向代理</button>
               <button type="button" class="site-settings-tab" data-site-tab="cert">证书</button>
               <button type="button" class="site-settings-tab" data-site-tab="nginx">配置 (NGINX)</button>
+              <button type="button" class="site-settings-tab" data-site-tab="custom">自定义配置</button>
               <button type="button" class="site-settings-tab" data-site-tab="logs">日志 (NGINX)</button>
               <button type="button" class="site-settings-tab d-none" data-site-tab="modules" id="site-tab-modules">模块</button>
             </aside>
             <div class="site-settings-panels">
               <div class="site-settings-panel is-active" data-site-panel="domain">
-                <h6 class="mb-3">域名</h6>
+                <h6 class="mb-3">域名 / 分组</h6>
+                <div class="mb-3">
+                  <label class="form-label">分组</label>
+                  <input class="form-control form-control-sm" id="site-set-group" list="site-group-datalist" placeholder="留空为未分组" autocomplete="off" />
+                  <datalist id="site-group-datalist"></datalist>
+                  <div class="form-text">同名分组合并显示;可在网站列表中筛选与排序。</div>
+                </div>
                 <div class="mb-3">
                   <label class="form-label">域名(支持多个,每行一个)</label>
                   <textarea class="form-control form-control-sm" id="site-set-domain" rows="4" placeholder="example.com&#10;www.example.com:8080"></textarea>
@@ -417,6 +527,30 @@
                   <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="custom">
+                <h6 class="mb-2">自定义配置</h6>
+                <p class="small text-secondary mb-2">
+                  手动添加的 Nginx 指令会写入站点 vhost,并在系统重新生成配置时保留。
+                  保存后会自动检测并重载 Nginx。若在此填写
+                  <code>location /</code>(如 SPA 回退 / 伪静态),系统将<strong>不再生成</strong>默认的
+                  <code>location /</code>,避免 duplicate location。
+                  若站点已配置「/」反向代理,则不要再写 <code>location /</code>。
+                </p>
+                <pre class="small bg-light border rounded p-2 mb-2 text-secondary"># SPA / 伪静态(会替换系统默认 location /)
+location / {
+    try_files $uri $uri/ /index.html;
+}
+# 静态资源类型
+location ~ \.mjs$ {
+    default_type application/javascript;
+}</pre>
+                <textarea class="form-control font-monospace site-custom-nginx" id="site-set-custom"
+                          rows="14" spellcheck="false" placeholder="# 在此输入 server 块内的配置项"></textarea>
+                <div class="mt-2 d-flex gap-2">
+                  <button type="button" class="btn btn-sm btn-primary" id="site-set-custom-save">保存并重载</button>
+                  <button type="button" class="btn btn-sm btn-outline-secondary" id="site-set-custom-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 site-log-toolbar">
                   <div class="d-flex flex-wrap align-items-center gap-2 flex-grow-1">

+ 373 - 11
src/api/api_server.cpp

@@ -11,6 +11,7 @@
 #include "../system/metrics.h"
 #include "../system/metrics_history.h"
 #include "../system/process.h"
+#include "../schedule/schedule.h"
 #include "../tasks/tasks.h"
 #include "../utils.h"
 #include "../website/website.h"
@@ -27,6 +28,7 @@
 #include <atomic>
 #include <chrono>
 #include <cstdint>
+#include <cstdlib>
 #include <csignal>
 #include <filesystem>
 #include <iostream>
@@ -46,10 +48,24 @@ using ylib::network::http::response;
 using ylib::network::http::websocket_message;
 
 std::atomic<bool> g_running{true};
+std::atomic<int> g_signal_count{0};
 ylib::network::http::center* g_center = nullptr;
 
+void stop_background_services() {
+    schedule::stop();
+    weblog::stop();
+    system::history_stop();
+    tasks::stop();
+}
+
 void on_signal(int) {
+    // First signal: request graceful shutdown. Second: force-exit in case
+    // close()/join is stuck (e.g. listen failed then center->close hangs).
+    const int n = g_signal_count.fetch_add(1, std::memory_order_relaxed) + 1;
     g_running.store(false, std::memory_order_relaxed);
+    if (n >= 2) {
+        _Exit(1);
+    }
 }
 
 void install_signal_handlers() {
@@ -147,6 +163,45 @@ std::string url_param(request* req, const std::string& key,
     return def;
 }
 
+std::string trim_ascii(const std::string& s) {
+    size_t b = 0;
+    while (b < s.size() && (s[b] == ' ' || s[b] == '\t')) {
+        ++b;
+    }
+    size_t e = s.size();
+    while (e > b && (s[e - 1] == ' ' || s[e - 1] == '\t')) {
+        --e;
+    }
+    return s.substr(b, e - b);
+}
+
+// Prefer proxy headers when present; otherwise TCP peer address.
+std::string request_client_ip(request* req) {
+    if (!req) {
+        return "";
+    }
+    std::string v;
+    if (req->header("X-Real-IP", v) || req->header("x-real-ip", v)) {
+        v = trim_ascii(v);
+        if (!v.empty()) {
+            return v;
+        }
+    }
+    if (req->header("X-Forwarded-For", v) ||
+        req->header("x-forwarded-for", v)) {
+        const auto comma = v.find(',');
+        v = trim_ascii(comma == std::string::npos ? v : v.substr(0, comma));
+        if (!v.empty()) {
+            return v;
+        }
+    }
+    try {
+        return trim_ascii(req->remote().address);
+    } catch (...) {
+        return "";
+    }
+}
+
 std::string b64_decode(const std::string& in) {
     static const int8_t kTable[256] = {
         -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
@@ -450,6 +505,161 @@ void h_tasks_clear(request* req, response* resp) {
     reply_ok(resp, data, "cleared");
 }
 
+ylib::json schedule_job_to_json(const schedule::Job& j) {
+    ylib::json item;
+    item["id"] = j.id;
+    item["name"] = j.name;
+    item["enabled"] = j.enabled;
+    item["url"] = j.url;
+    item["method"] = j.method;
+    item["interval"] = j.interval;
+    item["unit"] = j.unit;
+    item["timeout_sec"] = j.timeout_sec;
+    item["continue_when_busy"] = j.continue_when_busy;
+    item["created_ms"] = static_cast<double>(j.created_ms);
+    item["next_run_ms"] = static_cast<double>(j.next_run_ms);
+    item["last_run_ms"] = static_cast<double>(j.last_run_ms);
+    item["last_ok"] = j.last_ok;
+    item["last_status"] = j.last_status;
+    item["last_error"] = j.last_error;
+    item["inflight"] = j.inflight;
+    item["interval_ms"] =
+        static_cast<double>(schedule::interval_to_ms(j.interval, j.unit));
+    return item;
+}
+
+ylib::json schedule_log_to_json(const schedule::LogEntry& e) {
+    ylib::json item;
+    item["id"] = static_cast<double>(e.id);
+    item["job_id"] = e.job_id;
+    item["time_ms"] = static_cast<double>(e.time_ms);
+    item["ok"] = e.ok;
+    item["status_code"] = e.status_code;
+    item["duration_ms"] = static_cast<double>(e.duration_ms);
+    item["error"] = e.error;
+    item["response_preview"] = e.response_preview;
+    return item;
+}
+
+void h_schedule_jobs(request* req, response* resp) {
+    if (req->method() == "GET") {
+        ylib::json arr;
+        for (const auto& j : schedule::list_jobs()) {
+            arr.push_back(schedule_job_to_json(j));
+        }
+        ylib::json data;
+        data["jobs"] = arr;
+        reply_ok(resp, data);
+        return;
+    }
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    const ylib::json body = parse_body(req);
+    schedule::Job job;
+    job.id = json_str(body, "id");
+    job.name = json_str(body, "name");
+    job.enabled = json_bool(body, "enabled", true);
+    job.url = json_str(body, "url");
+    job.method = json_str(body, "method", "GET");
+    job.interval = json_int(body, "interval", 60);
+    job.unit = json_str(body, "unit", "sec");
+    job.timeout_sec = json_int(body, "timeout_sec", 30);
+    job.continue_when_busy = json_bool(body, "continue_when_busy", false);
+    std::string err;
+    if (!schedule::upsert_job(job, err)) {
+        reply_err(resp, err, 400);
+        return;
+    }
+    reply_ok(resp, schedule_job_to_json(job), "saved");
+}
+
+void h_schedule_jobs_delete(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    const ylib::json body = parse_body(req);
+    const std::string id = json_str(body, "id");
+    std::string err;
+    if (!schedule::delete_job(id, err)) {
+        reply_err(resp, err, 400);
+        return;
+    }
+    reply_ok(resp, ylib::json(), "deleted");
+}
+
+void h_schedule_jobs_enable(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    const ylib::json body = parse_body(req);
+    const std::string id = json_str(body, "id");
+    const bool enabled = json_bool(body, "enabled", true);
+    std::string err;
+    if (!schedule::set_enabled(id, enabled, err)) {
+        reply_err(resp, err, 400);
+        return;
+    }
+    schedule::Job job;
+    if (!schedule::get_job(id, job, err)) {
+        reply_err(resp, err, 400);
+        return;
+    }
+    reply_ok(resp, schedule_job_to_json(job), enabled ? "enabled" : "disabled");
+}
+
+void h_schedule_jobs_run(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    const ylib::json body = parse_body(req);
+    const std::string id = json_str(body, "id");
+    std::string err;
+    if (!schedule::run_now(id, err)) {
+        reply_err(resp, err, 400);
+        return;
+    }
+    reply_ok(resp, ylib::json(), "started");
+}
+
+void h_schedule_logs(request* req, response* resp) {
+    if (req->method() == "GET") {
+        const std::string job_id = url_param(req, "job_id");
+        int limit = 80;
+        try {
+            limit = std::stoi(url_param(req, "limit", "80"));
+        } catch (...) {
+            limit = 80;
+        }
+        std::vector<schedule::LogEntry> logs;
+        std::string err;
+        if (!schedule::list_logs(job_id, limit, logs, err)) {
+            reply_err(resp, err);
+            return;
+        }
+        ylib::json arr;
+        for (const auto& e : logs) {
+            arr.push_back(schedule_log_to_json(e));
+        }
+        ylib::json data;
+        data["logs"] = arr;
+        reply_ok(resp, data);
+        return;
+    }
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    // clear logs
+    const ylib::json body = parse_body(req);
+    const std::string job_id = json_str(body, "job_id");
+    std::string err;
+    if (!schedule::clear_logs(job_id, err)) {
+        reply_err(resp, err, 400);
+        return;
+    }
+    reply_ok(resp, ylib::json(), "cleared");
+}
+
 void h_status(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
@@ -879,6 +1089,8 @@ ylib::json site_to_json(const website::SiteInfo& s) {
     item["ssl_cert"] = s.ssl_cert;
     item["ssl_key"] = s.ssl_key;
     item["ssl_port"] = s.ssl_port;
+    item["group_name"] = s.group_name;
+    item["sort_order"] = s.sort_order;
     item["ssl_not_before"] = "";
     item["ssl_not_after"] = "";
     item["ssl_days_left"] = -1;
@@ -937,6 +1149,7 @@ void h_websites(request* req, response* resp) {
         creq.listen_port = json_int(body, "listen_port", 0);
         creq.upstream.clear();
         creq.root = json_str(body, "root");
+        creq.group_name = json_str(body, "group_name");
         if (creq.name.empty()) {
             reply_err(resp, "name required");
             return;
@@ -1067,6 +1280,8 @@ void h_websites_update(request* req, response* resp) {
     ureq.ssl_cert = json_str(body, "ssl_cert");
     ureq.ssl_key = json_str(body, "ssl_key");
     ureq.ssl_port = json_int(body, "ssl_port", 443);
+    ureq.update_group = body.exist("group_name");
+    ureq.group_name = json_str(body, "group_name");
     std::string err;
     if (!website::update_site(ureq, err)) {
         reply_err(resp, err.empty() ? "update site failed" : err);
@@ -1077,6 +1292,47 @@ void h_websites_update(request* req, response* resp) {
     reply_ok(resp, site_to_json(updated), "site updated");
 }
 
+void h_websites_reorder(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    auto body = parse_body(req);
+    if (!body.exist("items") || !body["items"].is_array()) {
+        reply_err(resp, "items array required");
+        return;
+    }
+    std::vector<website::SiteOrderItem> items;
+    const auto& arr = body["items"];
+    for (uint32 i = 0; i < arr.size(); ++i) {
+        const auto& j = arr[i];
+        website::SiteOrderItem it;
+        it.name = j.exist("name") ? j["name"].to<std::string>() : "";
+        it.group_name =
+            j.exist("group_name") ? j["group_name"].to<std::string>() : "";
+        try {
+            it.sort_order =
+                j.exist("sort_order") ? j["sort_order"].to<int>() : 0;
+        } catch (...) {
+            it.sort_order = 0;
+        }
+        if (it.name.empty()) {
+            reply_err(resp, "item.name required");
+            return;
+        }
+        items.push_back(it);
+    }
+    std::string err;
+    if (!website::reorder_sites(items, err)) {
+        reply_err(resp, err.empty() ? "reorder failed" : err);
+        return;
+    }
+    ylib::json out;
+    for (const auto& s : website::list_sites()) {
+        out.push_back(site_to_json(s));
+    }
+    reply_ok(resp, out, "sites reordered");
+}
+
 ylib::json url_proxy_to_json(const website::UrlProxyRule& r) {
     ylib::json item;
     item["id"] = r.id;
@@ -1186,6 +1442,45 @@ void h_websites_proxies_delete(request* req, response* resp) {
     reply_ok(resp, ylib::json(), "url proxy deleted");
 }
 
+void h_websites_custom(request* req, response* resp) {
+    const std::string method = req->method();
+    if (method == "GET") {
+        const std::string name = url_param(req, "name");
+        if (name.empty()) {
+            reply_err(resp, "name required");
+            return;
+        }
+        std::string content;
+        std::string err;
+        if (!website::get_custom_nginx(name, content, err)) {
+            reply_err(resp, err.empty() ? "get custom config failed" : err);
+            return;
+        }
+        ylib::json data;
+        data["name"] = name;
+        data["content"] = content;
+        reply_ok(resp, data);
+        return;
+    }
+    if (method == "POST") {
+        auto body = parse_body(req);
+        const std::string name = json_str(body, "name");
+        if (name.empty()) {
+            reply_err(resp, "name required");
+            return;
+        }
+        const std::string content = json_str(body, "content");
+        std::string err;
+        if (!website::set_custom_nginx(name, content, err)) {
+            reply_err(resp, err.empty() ? "save custom config failed" : err);
+            return;
+        }
+        reply_ok(resp, ylib::json(), "custom config saved");
+        return;
+    }
+    reply_err(resp, "method not allowed", 405);
+}
+
 void h_websites_logs(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
@@ -1319,6 +1614,7 @@ void h_websites_analytics(request* req, response* resp) {
         reply_err(resp, "invalid analytics payload");
         return;
     }
+    data["self_ip"] = request_client_ip(req);
     reply_ok(resp, data);
 }
 
@@ -1448,6 +1744,7 @@ void h_system_processes(request* req, response* resp) {
         item["state"] = p.state;
         item["name"] = p.name;
         item["cmdline"] = p.cmdline;
+        item["cwd"] = p.cwd;
         item["threads"] = p.threads;
         item["rss"] = static_cast<int64>(p.rss_bytes);
         item["vms"] = static_cast<int64>(p.vms_bytes);
@@ -1603,6 +1900,57 @@ void h_redis_stop(request* req, response* resp) {
     reply_ok(resp, ylib::json(), "redis stopped");
 }
 
+void h_redis_config(request* req, response* resp) {
+    const std::string method = req->method();
+    if (method == "GET") {
+        redis::QuickConfig cfg;
+        std::string err;
+        if (!redis::read_quick_config(cfg, err)) {
+            reply_err(resp, err.empty() ? "read redis config failed" : err);
+            return;
+        }
+        ylib::json data;
+        data["bind"] = cfg.bind;
+        data["port"] = cfg.port;
+        data["requirepass"] = cfg.requirepass;
+        data["conf"] = redis::conf_file();
+        data["running"] = redis::is_running();
+        reply_ok(resp, data);
+        return;
+    }
+    if (method == "POST") {
+        auto body = parse_body(req);
+        redis::QuickConfig cfg;
+        std::string err;
+        if (!redis::read_quick_config(cfg, err)) {
+            reply_err(resp, err.empty() ? "read redis config failed" : err);
+            return;
+        }
+        if (body.exist("bind")) {
+            cfg.bind = json_str(body, "bind", cfg.bind);
+        }
+        if (body.exist("port")) {
+            cfg.port = json_int(body, "port", cfg.port);
+        }
+        if (body.exist("requirepass")) {
+            cfg.requirepass = json_str(body, "requirepass");
+        }
+        if (!redis::apply_quick_config(cfg, err)) {
+            reply_err(resp, err.empty() ? "apply redis config failed" : err);
+            return;
+        }
+        ylib::json data;
+        data["bind"] = cfg.bind;
+        data["port"] = cfg.port;
+        data["requirepass"] = cfg.requirepass;
+        data["conf"] = redis::conf_file();
+        data["running"] = redis::is_running();
+        reply_ok(resp, data, "redis config updated");
+        return;
+    }
+    reply_err(resp, "method not allowed", 405);
+}
+
 void h_files_list(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
@@ -1817,6 +2165,12 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/tasks/log", h_tasks_log);
     reg(router, "/api/tasks/clear", h_tasks_clear);
 
+    reg(router, "/api/schedule/jobs", h_schedule_jobs);
+    reg(router, "/api/schedule/jobs/delete", h_schedule_jobs_delete);
+    reg(router, "/api/schedule/jobs/enable", h_schedule_jobs_enable);
+    reg(router, "/api/schedule/jobs/run", h_schedule_jobs_run);
+    reg(router, "/api/schedule/logs", h_schedule_logs);
+
     reg(router, "/api/nginx/status", h_nginx_status);
     reg(router, "/api/nginx/install", h_nginx_install);
     reg(router, "/api/nginx/uninstall", h_nginx_uninstall);
@@ -1846,6 +2200,7 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/redis/uninstall", h_redis_uninstall);
     reg(router, "/api/redis/start", h_redis_start);
     reg(router, "/api/redis/stop", h_redis_stop);
+    reg(router, "/api/redis/config", h_redis_config);
 
     reg(router, "/api/websites", h_websites);
     reg(router, "/api/websites/start", h_websites_start);
@@ -1853,9 +2208,11 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/websites/restart", h_websites_restart);
     reg(router, "/api/websites/delete", h_websites_delete);
     reg(router, "/api/websites/update", h_websites_update);
+    reg(router, "/api/websites/reorder", h_websites_reorder);
     reg(router, "/api/websites/proxies", h_websites_proxies);
     reg(router, "/api/websites/proxies/update", h_websites_proxies_update);
     reg(router, "/api/websites/proxies/delete", h_websites_proxies_delete);
+    reg(router, "/api/websites/custom", h_websites_custom);
     reg(router, "/api/websites/logs", h_websites_logs);
     reg(router, "/api/websites/analytics", h_websites_analytics);
     reg(router, "/api/websites/ssl/apply", h_websites_ssl_apply);
@@ -1947,6 +2304,7 @@ bool run(const std::string& listen_addr, uint16_t listen_port) {
     tasks::start();
     weblog::start();
     system::history_start();
+    schedule::start();
     log_info("ngs apiserver starting listen=" + listen_addr + ":" +
              std::to_string(listen_port));
 
@@ -1955,6 +2313,17 @@ bool run(const std::string& listen_addr, uint16_t listen_port) {
     auto* center = new ylib::network::http::center();
     g_center = center;
 
+    auto fail_cleanup = [&]() {
+        // Stop workers first so metrics/tasks do not keep running if close hangs.
+        stop_background_services();
+        if (center) {
+            center->close();
+            delete center;
+            center = nullptr;
+        }
+        g_center = nullptr;
+    };
+
     ylib::network::http::start_config config;
     ylib::network::http::website_config ws_config;
     ws_config.name = "ngs-api";
@@ -1973,8 +2342,7 @@ bool run(const std::string& listen_addr, uint16_t listen_port) {
     if (!center->create(config)) {
         std::cerr << "apiserver create failed: " << center->last_error() << "\n";
         log_error("apiserver create failed: " + center->last_error());
-        delete center;
-        g_center = nullptr;
+        fail_cleanup();
         return false;
     }
 
@@ -1982,9 +2350,7 @@ bool run(const std::string& listen_addr, uint16_t listen_port) {
     if (!website || !website->router()) {
         std::cerr << "apiserver website/router missing\n";
         log_error("apiserver website/router missing");
-        center->close();
-        delete center;
-        g_center = nullptr;
+        fail_cleanup();
         return false;
     }
     register_routes(website->router());
@@ -1992,9 +2358,7 @@ bool run(const std::string& listen_addr, uint16_t listen_port) {
     if (!center->start()) {
         std::cerr << "apiserver start failed: " << center->last_error() << "\n";
         log_error("apiserver start failed: " + center->last_error());
-        center->close();
-        delete center;
-        g_center = nullptr;
+        fail_cleanup();
         return false;
     }
 
@@ -2016,9 +2380,7 @@ bool run(const std::string& listen_addr, uint16_t listen_port) {
 
     std::cout << "Shutting down...\n";
     log_info("ngs apiserver stopping");
-    weblog::stop();
-    system::history_stop();
-    tasks::stop();
+    stop_background_services();
     center->close();
     delete center;
     g_center = nullptr;

+ 704 - 0
src/schedule/schedule.cpp

@@ -0,0 +1,704 @@
+#include "schedule.h"
+
+#include "../utils.h"
+
+#include "net/http_client_plus.h"
+#include "util/sqlite3.h"
+
+#include <atomic>
+#include <chrono>
+#include <condition_variable>
+#include <cstdlib>
+#include <iostream>
+#include <map>
+#include <mutex>
+#include <sstream>
+#include <thread>
+#include <vector>
+
+namespace ngs {
+namespace schedule {
+
+namespace {
+
+constexpr int64_t kMaxLogRows = 5000;
+constexpr size_t kResponsePreviewMax = 2048;
+constexpr auto kTickInterval = std::chrono::milliseconds(500);
+
+std::mutex& mu() {
+    static std::mutex m;
+    return m;
+}
+std::condition_variable& cv() {
+    static std::condition_variable c;
+    return c;
+}
+std::atomic<bool>& running_flag() {
+    static std::atomic<bool> v{false};
+    return v;
+}
+std::thread*& worker() {
+    static std::thread* t = nullptr;
+    return t;
+}
+std::atomic<int>& inflight_total() {
+    static std::atomic<int> v{0};
+    return v;
+}
+
+ylib::sqlite3& db() {
+    static ylib::sqlite3 d;
+    return d;
+}
+
+// job_id -> current in-flight count
+std::map<std::string, int>& inflight_map() {
+    static std::map<std::string, int> m;
+    return m;
+}
+
+int64_t now_ms() {
+    using clock = std::chrono::system_clock;
+    return std::chrono::duration_cast<std::chrono::milliseconds>(
+               clock::now().time_since_epoch())
+        .count();
+}
+
+std::string make_id() {
+    return "job" + std::to_string(now_ms()) + "-" +
+           std::to_string(static_cast<unsigned>(std::rand() % 10000));
+}
+
+std::string sql_escape(std::string s) {
+    std::string out;
+    out.reserve(s.size() + 8);
+    for (char c : s) {
+        if (c == '\'') {
+            out += "''";
+        } else {
+            out.push_back(c);
+        }
+    }
+    return out;
+}
+
+bool exec_db(const std::string& sql, std::string* err = nullptr) {
+    if (!db().exec(sql)) {
+        if (err) {
+            *err = db().last_error();
+        }
+        log_error(std::string("schedule sqlite: ") + db().last_error());
+        return false;
+    }
+    return true;
+}
+
+bool ensure_schema(std::string& err) {
+    const char* ddl = R"SQL(
+CREATE TABLE IF NOT EXISTS jobs (
+  id TEXT PRIMARY KEY,
+  name TEXT NOT NULL,
+  enabled INTEGER NOT NULL DEFAULT 1,
+  url TEXT NOT NULL,
+  method TEXT NOT NULL DEFAULT 'GET',
+  interval_value INTEGER NOT NULL,
+  interval_unit TEXT NOT NULL,
+  timeout_sec INTEGER NOT NULL DEFAULT 30,
+  continue_when_busy INTEGER NOT NULL DEFAULT 0,
+  next_run_ms INTEGER NOT NULL DEFAULT 0,
+  last_run_ms INTEGER NOT NULL DEFAULT 0,
+  last_ok INTEGER NOT NULL DEFAULT 0,
+  last_status INTEGER NOT NULL DEFAULT 0,
+  last_error TEXT NOT NULL DEFAULT '',
+  created_ms INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS logs (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  job_id TEXT NOT NULL,
+  time_ms INTEGER NOT NULL,
+  ok INTEGER NOT NULL,
+  status_code INTEGER NOT NULL,
+  duration_ms INTEGER NOT NULL,
+  error TEXT NOT NULL DEFAULT '',
+  response_preview TEXT NOT NULL DEFAULT ''
+);
+CREATE INDEX IF NOT EXISTS idx_schedule_logs_job_time ON logs(job_id, time_ms DESC);
+)SQL";
+    if (!exec_db(ddl, &err)) {
+        err = "初始化 schedule 表失败: " + err;
+        return false;
+    }
+    return true;
+}
+
+bool open_db(std::string& err) {
+#ifdef NGS_SOURCE_DIR
+    const std::string dir = join_path(NGS_SOURCE_DIR, "data");
+#else
+    const std::string dir = "data";
+#endif
+    if (!ensure_dir(dir)) {
+        err = "无法创建 data 目录";
+        return false;
+    }
+    if (!db().is_open() && !db().open(db_path())) {
+        err = std::string("打开 schedule.db 失败: ") + db().last_error();
+        return false;
+    }
+    return ensure_schema(err);
+}
+
+std::string map_get(const std::map<std::string, std::string>& row,
+                    const std::string& k) {
+    auto it = row.find(k);
+    return it == row.end() ? "" : it->second;
+}
+
+int64_t to_i64(const std::string& s) {
+    try {
+        return s.empty() ? 0 : std::stoll(s);
+    } catch (...) {
+        return 0;
+    }
+}
+
+int to_i(const std::string& s) {
+    try {
+        return s.empty() ? 0 : std::stoi(s);
+    } catch (...) {
+        return 0;
+    }
+}
+
+Job row_to_job(const std::map<std::string, std::string>& row) {
+    Job j;
+    j.id = map_get(row, "id");
+    j.name = map_get(row, "name");
+    j.enabled = to_i(map_get(row, "enabled")) != 0;
+    j.url = map_get(row, "url");
+    j.method = map_get(row, "method");
+    if (j.method.empty()) {
+        j.method = "GET";
+    }
+    j.interval = to_i(map_get(row, "interval_value"));
+    j.unit = map_get(row, "interval_unit");
+    j.timeout_sec = to_i(map_get(row, "timeout_sec"));
+    j.continue_when_busy = to_i(map_get(row, "continue_when_busy")) != 0;
+    j.next_run_ms = to_i64(map_get(row, "next_run_ms"));
+    j.last_run_ms = to_i64(map_get(row, "last_run_ms"));
+    j.last_ok = to_i(map_get(row, "last_ok")) != 0;
+    j.last_status = to_i(map_get(row, "last_status"));
+    j.last_error = map_get(row, "last_error");
+    j.created_ms = to_i64(map_get(row, "created_ms"));
+    return j;
+}
+
+bool normalize_job(Job& job, std::string& err) {
+    auto trim = [](std::string& s) {
+        while (!s.empty() &&
+               (s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
+                s.front() == '\r')) {
+            s.erase(s.begin());
+        }
+        while (!s.empty() &&
+               (s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
+                s.back() == '\r')) {
+            s.pop_back();
+        }
+    };
+    trim(job.name);
+    trim(job.url);
+    trim(job.method);
+    trim(job.unit);
+    if (job.name.empty()) {
+        err = "请填写任务名称";
+        return false;
+    }
+    if (job.url.empty()) {
+        err = "请填写 URL";
+        return false;
+    }
+    if (job.url.rfind("http://", 0) != 0 && job.url.rfind("https://", 0) != 0) {
+        err = "URL 需以 http:// 或 https:// 开头";
+        return false;
+    }
+    for (char& c : job.method) {
+        if (c >= 'a' && c <= 'z') {
+            c = static_cast<char>(c - 'a' + 'A');
+        }
+    }
+    if (job.method != "GET" && job.method != "POST" && job.method != "HEAD") {
+        err = "方法仅支持 GET / POST / HEAD";
+        return false;
+    }
+    if (job.unit != "sec" && job.unit != "min" && job.unit != "hour" &&
+        job.unit != "day") {
+        err = "间隔单位仅支持 sec / min / hour / day";
+        return false;
+    }
+    if (job.interval < 1) {
+        err = "间隔必须 ≥ 1";
+        return false;
+    }
+    if (job.unit == "sec" && job.interval < 1) {
+        err = "秒级间隔至少为 1";
+        return false;
+    }
+    if (job.timeout_sec < 1) {
+        job.timeout_sec = 1;
+    }
+    if (job.timeout_sec > 3600) {
+        job.timeout_sec = 3600;
+    }
+    return true;
+}
+
+bool save_job_locked(const Job& job, std::string& err) {
+    std::ostringstream sql;
+    sql << "INSERT OR REPLACE INTO jobs("
+           "id,name,enabled,url,method,interval_value,interval_unit,"
+           "timeout_sec,continue_when_busy,next_run_ms,last_run_ms,"
+           "last_ok,last_status,last_error,created_ms) VALUES("
+        << "'" << sql_escape(job.id) << "',"
+        << "'" << sql_escape(job.name) << "'," << (job.enabled ? 1 : 0) << ","
+        << "'" << sql_escape(job.url) << "',"
+        << "'" << sql_escape(job.method) << "'," << job.interval << ","
+        << "'" << sql_escape(job.unit) << "'," << job.timeout_sec << ","
+        << (job.continue_when_busy ? 1 : 0) << "," << job.next_run_ms << ","
+        << job.last_run_ms << "," << (job.last_ok ? 1 : 0) << ","
+        << job.last_status << ","
+        << "'" << sql_escape(job.last_error) << "'," << job.created_ms << ")";
+    return exec_db(sql.str(), &err);
+}
+
+bool load_job_locked(const std::string& id, Job& out, std::string& err) {
+    SQLITE_RESULT rows;
+    const std::string sql =
+        "SELECT * FROM jobs WHERE id='" + sql_escape(id) + "' LIMIT 1";
+    if (!db().query(sql, rows)) {
+        err = std::string("查询失败: ") + db().last_error();
+        return false;
+    }
+    if (rows.empty()) {
+        err = "任务不存在";
+        return false;
+    }
+    out = row_to_job(rows[0]);
+    return true;
+}
+
+void prune_logs_locked() {
+    exec_db("DELETE FROM logs WHERE id NOT IN ("
+            "SELECT id FROM logs ORDER BY id DESC LIMIT " +
+            std::to_string(kMaxLogRows) + ")");
+}
+
+void append_log_locked(const LogEntry& e) {
+    std::ostringstream sql;
+    sql << "INSERT INTO logs(job_id,time_ms,ok,status_code,duration_ms,error,"
+           "response_preview) VALUES("
+        << "'" << sql_escape(e.job_id) << "'," << e.time_ms << ","
+        << (e.ok ? 1 : 0) << "," << e.status_code << "," << e.duration_ms << ","
+        << "'" << sql_escape(e.error) << "',"
+        << "'" << sql_escape(e.response_preview) << "')";
+    exec_db(sql.str());
+    prune_logs_locked();
+}
+
+std::string truncate_preview(std::string s) {
+    if (s.size() > kResponsePreviewMax) {
+        s.resize(kResponsePreviewMax);
+        s += "…";
+    }
+    for (char& c : s) {
+        if (c == '\0') {
+            c = ' ';
+        }
+    }
+    return s;
+}
+
+void execute_http(Job job) {
+    const int64_t started = now_ms();
+    LogEntry log;
+    log.job_id = job.id;
+    log.time_ms = started;
+
+    bool ok = false;
+    int status = 0;
+    std::string err;
+    std::string preview;
+
+    try {
+        ylib::network::http::client_plus client;
+        const uint32_t timeout_ms =
+            static_cast<uint32_t>(job.timeout_sec) * 1000u;
+        client.set_timeout(timeout_ms, timeout_ms);
+
+        bool sent = false;
+        if (job.method == "POST") {
+            ylib::buffer empty;
+            sent = client.post(job.url, empty);
+        } else if (job.method == "HEAD") {
+            sent = client.head(job.url);
+        } else {
+            sent = client.get(job.url);
+        }
+
+        status = static_cast<int>(client.status());
+        preview = truncate_preview(client.response().to_string());
+        // Prefer HTTP status when the client populated one; some ylib paths
+        // return false from get()/post() even after a completed response.
+        if (status >= 200 && status < 400) {
+            ok = true;
+        } else if (!sent) {
+            err = client.last_error();
+            if (err.empty()) {
+                err = status > 0 ? ("HTTP " + std::to_string(status))
+                                 : "请求失败";
+            }
+            ok = false;
+        } else {
+            ok = false;
+            err = "HTTP " + std::to_string(status);
+        }
+    } catch (const std::exception& ex) {
+        err = ex.what();
+        ok = false;
+    } catch (...) {
+        err = "未知错误";
+        ok = false;
+    }
+
+    const int64_t finished = now_ms();
+    log.ok = ok;
+    log.status_code = status;
+    log.duration_ms = finished - started;
+    log.error = err;
+    log.response_preview = preview;
+
+    {
+        std::lock_guard<std::mutex> lock(mu());
+        append_log_locked(log);
+        Job cur;
+        std::string load_err;
+        if (load_job_locked(job.id, cur, load_err)) {
+            cur.last_run_ms = started;
+            cur.last_ok = ok;
+            cur.last_status = status;
+            cur.last_error = err;
+            std::string save_err;
+            save_job_locked(cur, save_err);
+        }
+        auto it = inflight_map().find(job.id);
+        if (it != inflight_map().end() && it->second > 0) {
+            it->second -= 1;
+        }
+    }
+    inflight_total().fetch_sub(1);
+    cv().notify_all();
+
+    log_info("schedule job=" + job.id + " " + job.method + " " + job.url +
+             " ok=" + std::string(ok ? "1" : "0") +
+             " status=" + std::to_string(status) +
+             " ms=" + std::to_string(log.duration_ms));
+}
+
+void spawn_request(Job job) {
+    {
+        std::lock_guard<std::mutex> lock(mu());
+        inflight_map()[job.id] += 1;
+    }
+    inflight_total().fetch_add(1);
+    std::thread([job]() { execute_http(job); }).detach();
+}
+
+void scheduler_loop() {
+    while (running_flag().load()) {
+        const int64_t now = now_ms();
+        std::vector<Job> due;
+        {
+            std::lock_guard<std::mutex> lock(mu());
+            SQLITE_RESULT rows;
+            if (db().is_open() &&
+                db().query(
+                    "SELECT * FROM jobs WHERE enabled=1 ORDER BY created_ms ASC",
+                    rows)) {
+                for (const auto& row : rows) {
+                    Job j = row_to_job(row);
+                    if (j.next_run_ms <= 0) {
+                        j.next_run_ms = now;
+                    }
+                    if (j.next_run_ms > now) {
+                        continue;
+                    }
+                    const int busy = inflight_map()[j.id];
+                    if (busy > 0 && !j.continue_when_busy) {
+                        // Skip this tick; schedule next interval from now.
+                        j.next_run_ms = now + interval_to_ms(j.interval, j.unit);
+                        std::string err;
+                        save_job_locked(j, err);
+                        continue;
+                    }
+                    j.next_run_ms = now + interval_to_ms(j.interval, j.unit);
+                    std::string err;
+                    save_job_locked(j, err);
+                    due.push_back(j);
+                }
+            }
+        }
+        for (const auto& j : due) {
+            spawn_request(j);
+        }
+
+        std::unique_lock<std::mutex> lock(mu());
+        cv().wait_for(lock, kTickInterval,
+                      [] { return !running_flag().load(); });
+    }
+}
+
+}  // namespace
+
+std::string db_path() {
+    if (const char* env = std::getenv("NGS_SCHEDULE_DATABASE")) {
+        if (*env) {
+            return expand_path(env);
+        }
+    }
+#ifdef NGS_SOURCE_DIR
+    return join_path(NGS_SOURCE_DIR, "data/schedule.db");
+#else
+    return join_path("data", "schedule.db");
+#endif
+}
+
+int64_t interval_to_ms(int interval, const std::string& unit) {
+    if (interval < 1) {
+        interval = 1;
+    }
+    int64_t mult = 1000;
+    if (unit == "min") {
+        mult = 60LL * 1000;
+    } else if (unit == "hour") {
+        mult = 3600LL * 1000;
+    } else if (unit == "day") {
+        mult = 86400LL * 1000;
+    }
+    return static_cast<int64_t>(interval) * mult;
+}
+
+void start() {
+    bool expected = false;
+    if (!running_flag().compare_exchange_strong(expected, true)) {
+        return;
+    }
+    std::string err;
+    {
+        std::lock_guard<std::mutex> lock(mu());
+        if (!open_db(err)) {
+            running_flag().store(false);
+            log_error("schedule start failed: " + err);
+            std::cerr << "schedule start failed: " << err << "\n";
+            return;
+        }
+    }
+    worker() = new std::thread(scheduler_loop);
+    log_info("schedule started db=" + db_path());
+}
+
+void stop() {
+    if (!running_flag().exchange(false)) {
+        return;
+    }
+    cv().notify_all();
+    if (worker()) {
+        if (worker()->joinable()) {
+            worker()->join();
+        }
+        delete worker();
+        worker() = nullptr;
+    }
+    // Wait briefly for in-flight HTTP requests.
+    for (int i = 0; i < 100 && inflight_total().load() > 0; ++i) {
+        std::this_thread::sleep_for(std::chrono::milliseconds(50));
+    }
+    std::lock_guard<std::mutex> lock(mu());
+    if (db().is_open()) {
+        db().close();
+    }
+    log_info("schedule stopped");
+}
+
+std::vector<Job> list_jobs() {
+    std::vector<Job> out;
+    std::lock_guard<std::mutex> lock(mu());
+    std::string err;
+    if (!open_db(err)) {
+        return out;
+    }
+    SQLITE_RESULT rows;
+    if (!db().query("SELECT * FROM jobs ORDER BY created_ms DESC", rows)) {
+        return out;
+    }
+    out.reserve(rows.size());
+    for (const auto& row : rows) {
+        Job j = row_to_job(row);
+        j.inflight = inflight_map()[j.id];
+        out.push_back(j);
+    }
+    return out;
+}
+
+bool get_job(const std::string& id, Job& out, std::string& err) {
+    std::lock_guard<std::mutex> lock(mu());
+    if (!open_db(err)) {
+        return false;
+    }
+    if (!load_job_locked(id, out, err)) {
+        return false;
+    }
+    out.inflight = inflight_map()[id];
+    return true;
+}
+
+bool upsert_job(Job& job, std::string& err) {
+    if (!normalize_job(job, err)) {
+        return false;
+    }
+    std::lock_guard<std::mutex> lock(mu());
+    if (!open_db(err)) {
+        return false;
+    }
+    const int64_t now = now_ms();
+    if (job.id.empty()) {
+        job.id = make_id();
+        job.created_ms = now;
+        job.next_run_ms = now;  // run soon after create if enabled
+        job.last_run_ms = 0;
+        job.last_ok = false;
+        job.last_status = 0;
+        job.last_error.clear();
+    } else {
+        Job existing;
+        if (!load_job_locked(job.id, existing, err)) {
+            return false;
+        }
+        job.created_ms = existing.created_ms;
+        job.last_run_ms = existing.last_run_ms;
+        job.last_ok = existing.last_ok;
+        job.last_status = existing.last_status;
+        job.last_error = existing.last_error;
+        // Recalculate next run from now when interval/url changes.
+        job.next_run_ms = now + interval_to_ms(job.interval, job.unit);
+    }
+    return save_job_locked(job, err);
+}
+
+bool delete_job(const std::string& id, std::string& err) {
+    if (id.empty()) {
+        err = "缺少任务 id";
+        return false;
+    }
+    std::lock_guard<std::mutex> lock(mu());
+    if (!open_db(err)) {
+        return false;
+    }
+    if (!exec_db("DELETE FROM jobs WHERE id='" + sql_escape(id) + "'", &err)) {
+        return false;
+    }
+    exec_db("DELETE FROM logs WHERE job_id='" + sql_escape(id) + "'");
+    inflight_map().erase(id);
+    return true;
+}
+
+bool set_enabled(const std::string& id, bool enabled, std::string& err) {
+    std::lock_guard<std::mutex> lock(mu());
+    if (!open_db(err)) {
+        return false;
+    }
+    Job job;
+    if (!load_job_locked(id, job, err)) {
+        return false;
+    }
+    job.enabled = enabled;
+    if (enabled) {
+        job.next_run_ms = now_ms();
+    }
+    return save_job_locked(job, err);
+}
+
+bool run_now(const std::string& id, std::string& err) {
+    Job job;
+    {
+        std::lock_guard<std::mutex> lock(mu());
+        if (!open_db(err)) {
+            return false;
+        }
+        if (!load_job_locked(id, job, err)) {
+            return false;
+        }
+        const int busy = inflight_map()[id];
+        if (busy > 0 && !job.continue_when_busy) {
+            err = "任务正在执行中(未开启「未完成时继续」)";
+            return false;
+        }
+    }
+    spawn_request(job);
+    return true;
+}
+
+bool list_logs(const std::string& job_id, int limit, std::vector<LogEntry>& out,
+               std::string& err) {
+    out.clear();
+    if (limit < 1) {
+        limit = 50;
+    }
+    if (limit > 500) {
+        limit = 500;
+    }
+    std::lock_guard<std::mutex> lock(mu());
+    if (!open_db(err)) {
+        return false;
+    }
+    std::string sql =
+        "SELECT id,job_id,time_ms,ok,status_code,duration_ms,error,"
+        "response_preview FROM logs";
+    if (!job_id.empty()) {
+        sql += " WHERE job_id='" + sql_escape(job_id) + "'";
+    }
+    sql += " ORDER BY id DESC LIMIT " + std::to_string(limit);
+    SQLITE_RESULT rows;
+    if (!db().query(sql, rows)) {
+        err = std::string("查询日志失败: ") + db().last_error();
+        return false;
+    }
+    out.reserve(rows.size());
+    for (const auto& row : rows) {
+        LogEntry e;
+        e.id = to_i64(map_get(row, "id"));
+        e.job_id = map_get(row, "job_id");
+        e.time_ms = to_i64(map_get(row, "time_ms"));
+        e.ok = to_i(map_get(row, "ok")) != 0;
+        e.status_code = to_i(map_get(row, "status_code"));
+        e.duration_ms = to_i64(map_get(row, "duration_ms"));
+        e.error = map_get(row, "error");
+        e.response_preview = map_get(row, "response_preview");
+        out.push_back(e);
+    }
+    return true;
+}
+
+bool clear_logs(const std::string& job_id, std::string& err) {
+    std::lock_guard<std::mutex> lock(mu());
+    if (!open_db(err)) {
+        return false;
+    }
+    if (job_id.empty()) {
+        return exec_db("DELETE FROM logs", &err);
+    }
+    return exec_db("DELETE FROM logs WHERE job_id='" + sql_escape(job_id) + "'",
+                   &err);
+}
+
+}  // namespace schedule
+}  // namespace ngs

+ 63 - 0
src/schedule/schedule.h

@@ -0,0 +1,63 @@
+#ifndef NGS_SCHEDULE_H
+#define NGS_SCHEDULE_H
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace ngs {
+namespace schedule {
+
+struct Job {
+    std::string id;
+    std::string name;
+    bool enabled = true;
+    std::string url;
+    std::string method = "GET";  // GET|POST|HEAD
+    int interval = 60;           // value in unit
+    std::string unit = "sec";    // sec|min|hour|day
+    int timeout_sec = 30;
+    // 上次请求未完成时,是否仍按计划发起下一次
+    bool continue_when_busy = false;
+    int64_t created_ms = 0;
+    int64_t next_run_ms = 0;
+    int64_t last_run_ms = 0;
+    bool last_ok = false;
+    int last_status = 0;
+    std::string last_error;
+    int inflight = 0;  // runtime only
+};
+
+struct LogEntry {
+    int64_t id = 0;
+    std::string job_id;
+    int64_t time_ms = 0;
+    bool ok = false;
+    int status_code = 0;
+    int64_t duration_ms = 0;
+    std::string error;
+    std::string response_preview;
+};
+
+std::string db_path();
+
+void start();
+void stop();
+
+std::vector<Job> list_jobs();
+bool get_job(const std::string& id, Job& out, std::string& err);
+bool upsert_job(Job& job, std::string& err);  // create if id empty
+bool delete_job(const std::string& id, std::string& err);
+bool set_enabled(const std::string& id, bool enabled, std::string& err);
+bool run_now(const std::string& id, std::string& err);
+
+bool list_logs(const std::string& job_id, int limit, std::vector<LogEntry>& out,
+               std::string& err);
+bool clear_logs(const std::string& job_id, std::string& err);
+
+int64_t interval_to_ms(int interval, const std::string& unit);
+
+}  // namespace schedule
+}  // namespace ngs
+
+#endif

+ 15 - 1
src/software/nginx/nginx.cpp

@@ -145,10 +145,24 @@ bool wait_until_stopped(int timeout_sec = 10) {
 }
 
 bool test_config() {
+    std::string detail;
+    return test_config(detail);
+}
+
+bool test_config(std::string& detail) {
+    detail.clear();
     if (!is_installed()) {
+        detail = "Nginx 未安装";
         return false;
     }
-    return run_cmd(nginx_cli("-t"), true) == 0;
+    std::string out;
+    const int rc = run_cmd_capture(nginx_cli("-t") + " 2>&1", out);
+    detail = out;
+    while (!detail.empty() &&
+           (detail.back() == '\n' || detail.back() == '\r')) {
+        detail.pop_back();
+    }
+    return rc == 0;
 }
 
 bool start() {

+ 1 - 0
src/software/nginx/nginx.h

@@ -21,6 +21,7 @@ bool start();
 bool stop();
 bool reload();
 bool test_config();
+bool test_config(std::string& detail);
 bool ensure_vhost_include();
 bool ensure_access_log_format();
 void show_status();

+ 293 - 2
src/software/redis/redis.cpp

@@ -5,6 +5,7 @@
 #include <fstream>
 #include <iostream>
 #include <sstream>
+#include <vector>
 
 namespace ngs {
 namespace redis {
@@ -75,6 +76,198 @@ bool port_busy(int port) {
                false) == 0;
 }
 
+std::string trim_copy(std::string s) {
+    while (!s.empty() &&
+           (s.front() == ' ' || s.front() == '\t' || s.front() == '\r')) {
+        s.erase(s.begin());
+    }
+    while (!s.empty() &&
+           (s.back() == ' ' || s.back() == '\t' || s.back() == '\r' ||
+            s.back() == '\n')) {
+        s.pop_back();
+    }
+    return s;
+}
+
+std::string unquote(std::string s) {
+    s = trim_copy(std::move(s));
+    if (s.size() >= 2) {
+        const char a = s.front();
+        const char b = s.back();
+        if ((a == '"' && b == '"') || (a == '\'' && b == '\'')) {
+            return s.substr(1, s.size() - 2);
+        }
+    }
+    return s;
+}
+
+std::string shell_single_quote(const std::string& s) {
+    std::string out = "'";
+    for (char c : s) {
+        if (c == '\'') {
+            out += "'\\''";
+        } else {
+            out.push_back(c);
+        }
+    }
+    out.push_back('\'');
+    return out;
+}
+
+bool read_conf_lines(std::vector<std::string>& lines, std::string& err) {
+    lines.clear();
+    if (!path_exists(conf_file())) {
+        err = "配置文件不存在";
+        return false;
+    }
+    std::ifstream in(conf_file());
+    if (!in) {
+        err = "无法读取配置文件";
+        return false;
+    }
+    std::string line;
+    while (std::getline(in, line)) {
+        if (!line.empty() && line.back() == '\r') {
+            line.pop_back();
+        }
+        lines.push_back(line);
+    }
+    return true;
+}
+
+bool write_conf_lines(const std::vector<std::string>& lines, std::string& err) {
+    std::ofstream out(conf_file(), std::ios::trunc);
+    if (!out) {
+        err = "无法写入配置文件";
+        return false;
+    }
+    for (const auto& line : lines) {
+        out << line << '\n';
+    }
+    return true;
+}
+
+bool line_directive(const std::string& line, std::string& key,
+                    std::string& value) {
+    const std::string t = trim_copy(line);
+    if (t.empty() || t[0] == '#') {
+        return false;
+    }
+    size_t i = 0;
+    while (i < t.size() && t[i] != ' ' && t[i] != '\t') {
+        ++i;
+    }
+    if (i == 0) {
+        return false;
+    }
+    key = t.substr(0, i);
+    value = trim_copy(t.substr(i));
+    return true;
+}
+
+void upsert_directive(std::vector<std::string>& lines, const std::string& key,
+                      const std::string& value, bool remove_if_empty) {
+    bool replaced = false;
+    for (auto& line : lines) {
+        std::string k;
+        std::string v;
+        if (!line_directive(line, k, v)) {
+            continue;
+        }
+        if (k != key) {
+            continue;
+        }
+        if (remove_if_empty && value.empty()) {
+            line.clear();
+        } else {
+            line = key + " " + value;
+        }
+        replaced = true;
+        // Keep only the first active directive; comment out duplicates.
+    }
+    // Second pass: comment duplicate active keys.
+    bool seen = false;
+    for (auto& line : lines) {
+        std::string k;
+        std::string v;
+        if (!line_directive(line, k, v) || k != key) {
+            continue;
+        }
+        if (!seen) {
+            seen = true;
+            continue;
+        }
+        line = "# " + line;
+    }
+    if (!replaced && !(remove_if_empty && value.empty())) {
+        lines.push_back(key + " " + value);
+    }
+    // Drop emptied lines.
+    std::vector<std::string> cleaned;
+    cleaned.reserve(lines.size());
+    for (const auto& line : lines) {
+        if (!line.empty()) {
+            cleaned.push_back(line);
+        }
+    }
+    lines.swap(cleaned);
+}
+
+void parse_quick_from_lines(const std::vector<std::string>& lines,
+                            QuickConfig& out) {
+    out.bind = "127.0.0.1";
+    out.port = read_port_file();
+    out.requirepass.clear();
+    bool have_bind = false;
+    for (const auto& line : lines) {
+        std::string k;
+        std::string v;
+        if (!line_directive(line, k, v)) {
+            continue;
+        }
+        if (k == "bind") {
+            // Prefer first non-empty bind token list as a single field.
+            out.bind = trim_copy(v);
+            have_bind = true;
+        } else if (k == "port") {
+            try {
+                const int p = std::stoi(unquote(v));
+                if (p > 0 && p <= 65535) {
+                    out.port = p;
+                }
+            } catch (...) {
+            }
+        } else if (k == "requirepass") {
+            out.requirepass = unquote(v);
+        }
+    }
+    if (!have_bind) {
+        out.bind = "127.0.0.1";
+    }
+}
+
+std::string conf_requirepass_from_file() {
+    if (!path_exists(conf_file())) {
+        return "";
+    }
+    std::ifstream in(conf_file());
+    if (!in) {
+        return "";
+    }
+    std::string line;
+    while (std::getline(in, line)) {
+        std::string k;
+        std::string v;
+        if (!line_directive(line, k, v)) {
+            continue;
+        }
+        if (k == "requirepass") {
+            return unquote(v);
+        }
+    }
+    return "";
+}
+
 bool write_redis_conf(int port) {
     ensure_dir(data_dir());
     std::ofstream out(conf_file(), std::ios::trunc);
@@ -144,9 +337,102 @@ std::string installed_version() {
 }
 
 int listen_port() {
+    QuickConfig cfg;
+    std::string err;
+    if (path_exists(conf_file()) && read_quick_config(cfg, err) && cfg.port > 0) {
+        return cfg.port;
+    }
     return read_port_file();
 }
 
+bool read_quick_config(QuickConfig& out, std::string& err) {
+    err.clear();
+    if (!is_installed()) {
+        err = "Redis 未安装";
+        return false;
+    }
+    if (!path_exists(conf_file())) {
+        out = QuickConfig{};
+        out.port = read_port_file();
+        return true;
+    }
+    std::vector<std::string> lines;
+    if (!read_conf_lines(lines, err)) {
+        return false;
+    }
+    parse_quick_from_lines(lines, out);
+    return true;
+}
+
+bool apply_quick_config(const QuickConfig& in, std::string& err) {
+    err.clear();
+    if (!is_installed()) {
+        err = "Redis 未安装";
+        return false;
+    }
+    QuickConfig cfg = in;
+    cfg.bind = trim_copy(cfg.bind);
+    if (cfg.bind.empty()) {
+        cfg.bind = "127.0.0.1";
+    }
+    if (cfg.port < 1 || cfg.port > 65535) {
+        err = "端口无效";
+        return false;
+    }
+    if (!path_exists(conf_file())) {
+        if (!write_redis_conf(cfg.port)) {
+            err = "无法创建配置文件";
+            return false;
+        }
+    }
+    std::vector<std::string> lines;
+    if (!read_conf_lines(lines, err)) {
+        return false;
+    }
+    upsert_directive(lines, "bind", cfg.bind, false);
+    upsert_directive(lines, "port", std::to_string(cfg.port), false);
+    if (cfg.requirepass.empty()) {
+        upsert_directive(lines, "requirepass", "", true);
+    } else {
+        // Quote password so spaces/special chars survive.
+        std::string quoted = "\"";
+        for (char c : cfg.requirepass) {
+            if (c == '\\' || c == '"') {
+                quoted.push_back('\\');
+            }
+            quoted.push_back(c);
+        }
+        quoted.push_back('"');
+        upsert_directive(lines, "requirepass", quoted, false);
+    }
+    if (!write_conf_lines(lines, err)) {
+        return false;
+    }
+    write_port_file(cfg.port);
+
+    const bool was_running = is_running();
+    if (was_running) {
+        if (!stop()) {
+            err = "配置已写入,但停止 Redis 失败,请手动重启";
+            return false;
+        }
+    }
+    if (was_running) {
+        if (port_busy(cfg.port)) {
+            err = "配置已写入,但端口已被占用: " + std::to_string(cfg.port);
+            return false;
+        }
+        if (!start()) {
+            err = "配置已写入,但启动 Redis 失败";
+            return false;
+        }
+    }
+    log_info("redis quick config applied bind=" + cfg.bind +
+             " port=" + std::to_string(cfg.port) +
+             " requirepass=" + (cfg.requirepass.empty() ? "off" : "on"));
+    return true;
+}
+
 void show_status() {
     std::cout << "安装目录: " << install_dir() << "\n";
     if (is_installed()) {
@@ -281,9 +567,14 @@ bool stop() {
     }
     const std::string cli = join_path(install_dir(), "bin/redis-cli");
     const int port = listen_port();
+    const std::string pass = conf_requirepass_from_file();
     if (path_exists(cli)) {
-        run_cmd_noinherit("\"" + cli + "\" -p " + std::to_string(port) +
-                          " shutdown nosave");
+        std::string cmd = "\"" + cli + "\" -p " + std::to_string(port);
+        if (!pass.empty()) {
+            cmd += " -a " + shell_single_quote(pass);
+        }
+        cmd += " --no-auth-warning shutdown nosave";
+        run_cmd_noinherit(cmd);
     }
     for (int i = 0; i < 30; ++i) {
         if (!is_running()) {

+ 9 - 0
src/software/redis/redis.h

@@ -10,6 +10,12 @@ constexpr const char* kDefaultVersion = "7.2.5";
 constexpr int kDefaultPort = 6379;
 constexpr int kFallbackPort = 6380;
 
+struct QuickConfig {
+    std::string bind = "127.0.0.1";
+    int port = kDefaultPort;
+    std::string requirepass;  // empty = no password
+};
+
 std::string install_dir();
 std::string bin_path();
 std::string conf_file();
@@ -18,6 +24,9 @@ bool is_running();
 std::string installed_version();
 int listen_port();
 
+bool read_quick_config(QuickConfig& out, std::string& err);
+bool apply_quick_config(const QuickConfig& in, std::string& err);
+
 bool install(const std::string& version = kDefaultVersion);
 bool uninstall();
 bool start();

+ 20 - 7
src/store/store.cpp

@@ -104,6 +104,8 @@ CREATE TABLE IF NOT EXISTS meta (
     add_column_if_missing("websites", "ssl_key", "TEXT NOT NULL DEFAULT ''");
     add_column_if_missing("websites", "ssl_port", "INTEGER NOT NULL DEFAULT 443");
     add_column_if_missing("websites", "root", "TEXT NOT NULL DEFAULT ''");
+    add_column_if_missing("websites", "group_name", "TEXT NOT NULL DEFAULT ''");
+    add_column_if_missing("websites", "sort_order", "INTEGER NOT NULL DEFAULT 0");
     return true;
 }
 
@@ -345,8 +347,11 @@ std::vector<SiteRecord> list_sites() {
             "COALESCE(ssl_enable,0) AS ssl_enable,"
             "COALESCE(ssl_cert,'') AS ssl_cert,"
             "COALESCE(ssl_key,'') AS ssl_key,"
-            "COALESCE(ssl_port,443) AS ssl_port "
-            "FROM websites ORDER BY name",
+            "COALESCE(ssl_port,443) AS ssl_port,"
+            "COALESCE(group_name,'') AS group_name,"
+            "COALESCE(sort_order,0) AS sort_order "
+            "FROM websites "
+            "ORDER BY (group_name = '') ASC, group_name ASC, sort_order ASC, name ASC",
             rows)) {
         return out;
     }
@@ -367,12 +372,16 @@ std::vector<SiteRecord> list_sites() {
             s.ssl_enable =
                 row.count("ssl_enable") ? (std::stoi(row.at("ssl_enable")) != 0) : false;
             s.ssl_port = row.count("ssl_port") ? std::stoi(row.at("ssl_port")) : 443;
+            s.sort_order =
+                row.count("sort_order") ? std::stoi(row.at("sort_order")) : 0;
         } catch (...) {
             s.ssl_enable = false;
             s.ssl_port = 443;
+            s.sort_order = 0;
         }
         s.ssl_cert = row.count("ssl_cert") ? row.at("ssl_cert") : "";
         s.ssl_key = row.count("ssl_key") ? row.at("ssl_key") : "";
+        s.group_name = row.count("group_name") ? row.at("group_name") : "";
         if (!s.name.empty()) {
             out.push_back(s);
         }
@@ -387,19 +396,21 @@ bool upsert_site(const SiteRecord& site) {
     }
     return exec_sql(
         "INSERT INTO websites(name,type,domain,listen_port,upstream,root,"
-        "ssl_enable,ssl_cert,ssl_key,ssl_port) VALUES(" +
+        "ssl_enable,ssl_cert,ssl_key,ssl_port,group_name,sort_order) VALUES(" +
         sql_quote(site.name) + "," + sql_quote(site.type) + "," +
         sql_quote(site.domain) + "," + std::to_string(site.listen_port) + "," +
         sql_quote(site.upstream) + "," + sql_quote(site.root) + "," +
         std::to_string(site.ssl_enable ? 1 : 0) + "," +
         sql_quote(site.ssl_cert) + "," + sql_quote(site.ssl_key) + "," +
-        std::to_string(site.ssl_port) +
+        std::to_string(site.ssl_port) + "," + sql_quote(site.group_name) + "," +
+        std::to_string(site.sort_order) +
         ") ON CONFLICT(name) DO UPDATE SET type=excluded.type,"
         "domain=excluded.domain,listen_port=excluded.listen_port,"
         "upstream=excluded.upstream,root=excluded.root,"
         "ssl_enable=excluded.ssl_enable,"
         "ssl_cert=excluded.ssl_cert,ssl_key=excluded.ssl_key,"
-        "ssl_port=excluded.ssl_port");
+        "ssl_port=excluded.ssl_port,"
+        "group_name=excluded.group_name,sort_order=excluded.sort_order");
 }
 
 bool delete_site(const std::string& name) {
@@ -425,13 +436,15 @@ bool replace_sites(const std::vector<SiteRecord>& sites) {
     for (const auto& site : sites) {
         if (!exec_sql(
                 "INSERT INTO websites(name,type,domain,listen_port,upstream,root,"
-                "ssl_enable,ssl_cert,ssl_key,ssl_port) VALUES(" +
+                "ssl_enable,ssl_cert,ssl_key,ssl_port,group_name,sort_order) VALUES(" +
                 sql_quote(site.name) + "," + sql_quote(site.type) + "," +
                 sql_quote(site.domain) + "," + std::to_string(site.listen_port) +
                 "," + sql_quote(site.upstream) + "," + sql_quote(site.root) + "," +
                 std::to_string(site.ssl_enable ? 1 : 0) + "," +
                 sql_quote(site.ssl_cert) + "," + sql_quote(site.ssl_key) + "," +
-                std::to_string(site.ssl_port) + ")")) {
+                std::to_string(site.ssl_port) + "," +
+                sql_quote(site.group_name) + "," +
+                std::to_string(site.sort_order) + ")")) {
             exec_sql("ROLLBACK");
             return false;
         }

+ 2 - 0
src/store/store.h

@@ -18,6 +18,8 @@ struct SiteRecord {
     std::string ssl_cert;
     std::string ssl_key;
     int ssl_port = 443;
+    std::string group_name;  // empty = 未分组
+    int sort_order = 0;
 };
 
 struct MysqlDbRecord {

+ 14 - 1
src/system/process.cpp

@@ -6,6 +6,7 @@
 #include <cstring>
 #include <dirent.h>
 #include <fstream>
+#include <limits.h>
 #include <pwd.h>
 #include <signal.h>
 #include <sstream>
@@ -100,6 +101,16 @@ std::string read_cmdline(int pid) {
     return raw;
 }
 
+std::string read_cwd(int pid) {
+    char buf[PATH_MAX];
+    const std::string path = "/proc/" + std::to_string(pid) + "/cwd";
+    const ssize_t n = ::readlink(path.c_str(), buf, sizeof(buf) - 1);
+    if (n <= 0) {
+        return "";
+    }
+    return std::string(buf, static_cast<size_t>(n));
+}
+
 bool parse_stat_full(const std::string& content, ProcessInfo& out) {
     auto lpar = content.find('(');
     auto rpar = content.rfind(')');
@@ -253,6 +264,7 @@ std::vector<ProcessInfo> list_processes(const std::string& q) {
         if (info.cmdline.empty()) {
             info.cmdline = "[" + info.name + "]";
         }
+        info.cwd = read_cwd(pid);
         if (mem_total > 0) {
             info.mem_percent =
                 100.0 * static_cast<double>(info.rss_bytes) /
@@ -261,7 +273,8 @@ std::vector<ProcessInfo> list_processes(const std::string& q) {
         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)) {
+                !contains_ci(info.cmdline, q) && !contains_ci(info.user, q) &&
+                !contains_ci(info.cwd, q)) {
                 continue;
             }
         }

+ 1 - 0
src/system/process.h

@@ -15,6 +15,7 @@ struct ProcessInfo {
     std::string state;   // R/S/D/Z/...
     std::string name;    // comm
     std::string cmdline;
+    std::string cwd;     // /proc/<pid>/cwd
     int threads = 0;
     uint64_t rss_bytes = 0;
     uint64_t vms_bytes = 0;

+ 111 - 44
src/weblog/weblog.cpp

@@ -16,6 +16,7 @@
 #include <condition_variable>
 #include <cstdio>
 #include <ctime>
+#include <dirent.h>
 #include <fstream>
 #include <iostream>
 #include <map>
@@ -294,8 +295,9 @@ bool open_site_db(const std::string& name, ylib::sqlite3& db, std::string& err)
         return false;
     }
     const std::string path = db_path(name);
-    if (!ensure_dir(db_dir())) {
-        err = "无法创建日志目录: " + db_dir();
+    const std::string dir = join_path(sites_log_dir(), name);
+    if (!ensure_dir(dir)) {
+        err = "无法创建日志目录: " + dir;
         return false;
     }
     if (!db.open(path)) {
@@ -654,7 +656,7 @@ double to_d(const std::string& s) {
 
 }  // namespace
 
-std::string db_dir() {
+std::string legacy_db_dir() {
 #ifdef NGS_SOURCE_DIR
     return join_path(NGS_SOURCE_DIR, "data/log");
 #else
@@ -662,8 +664,54 @@ std::string db_dir() {
 #endif
 }
 
+std::string db_dir() {
+    // Site SQLite DBs live beside access.log / error.log under /ngs/log/.
+    return sites_log_dir();
+}
+
 std::string db_path(const std::string& name) {
-    return join_path(db_dir(), name + ".db");
+    const std::string dir = join_path(sites_log_dir(), name);
+    return join_path(dir, "weblog.db");
+}
+
+void migrate_legacy_dbs() {
+    const std::string legacy = legacy_db_dir();
+    if (!is_dir(legacy)) {
+        return;
+    }
+    DIR* d = opendir(legacy.c_str());
+    if (!d) {
+        return;
+    }
+    while (dirent* ent = readdir(d)) {
+        if (!ent || !ent->d_name) {
+            continue;
+        }
+        const std::string file = ent->d_name;
+        if (file.size() < 4 || file.substr(file.size() - 3) != ".db") {
+            continue;
+        }
+        const std::string key = file.substr(0, file.size() - 3);
+        if (!is_safe_site_name(key)) {
+            continue;
+        }
+        const std::string src = join_path(legacy, file);
+        const std::string dst_dir = join_path(sites_log_dir(), key);
+        const std::string dst = db_path(key);
+        if (path_exists(dst)) {
+            continue;
+        }
+        if (!ensure_dir(dst_dir)) {
+            log_warn("weblog migrate: cannot create " + dst_dir);
+            continue;
+        }
+        if (run_cmd("mv \"" + src + "\" \"" + dst + "\"", false) == 0) {
+            log_info("weblog migrated " + src + " -> " + dst);
+        } else {
+            log_warn("weblog migrate failed " + src);
+        }
+    }
+    closedir(d);
 }
 
 bool ensure_db(const std::string& name, std::string& err) {
@@ -711,15 +759,16 @@ void start() {
     if (!running_flag().compare_exchange_strong(expected, true)) {
         return;
     }
-    if (!ensure_dir(db_dir())) {
-        log_warn("weblog: cannot create " + db_dir());
+    if (!ensure_dir(sites_log_dir())) {
+        log_warn("weblog: cannot create " + sites_log_dir());
     }
+    migrate_legacy_dbs();
     for (const auto& s : website::list_sites()) {
         std::string err;
         ensure_db(website::site_key(s), err);
     }
     worker() = new std::thread(worker_loop);
-    log_info("weblog ingest started dir=" + db_dir());
+    log_info("weblog ingest started dir=" + sites_log_dir() + "/<domain>/weblog.db");
 }
 
 void stop() {
@@ -1135,26 +1184,35 @@ bool analyze(const std::string& name, const std::string& range,
         out["methods"] = rows_to_kv_array(rows, "method", "c", "method", "count");
     }
 
-    // ---- top URIs ----
+    // ---- top URIs (path-only + with query args) ----
     {
-        SQLITE_RESULT rows;
-        db.query(
-            "SELECT uri, COUNT(*) AS c, COALESCE(SUM(bytes_sent),0) AS bytes_sent,"
-            " COALESCE(AVG(request_time),0) AS avg_rt FROM access_log WHERE " +
-                tf +
-                " GROUP BY uri ORDER BY c DESC LIMIT 15",
-            rows);
-        ylib::json arr;
-        for (const auto& row : rows) {
-            ylib::json item;
-            item["uri"] = map_get(row, "uri");
-            item["count"] = static_cast<int64>(to_i64(map_get(row, "c")));
-            item["bytes_sent"] =
-                static_cast<int64>(to_i64(map_get(row, "bytes_sent")));
-            item["avg_request_time"] = to_d(map_get(row, "avg_rt"));
-            arr.push_back(item);
-        }
-        out["top_uris"] = arr;
+        auto query_top = [&](bool with_args) {
+            const std::string uri_expr =
+                with_args ? "(CASE WHEN IFNULL(args,'')='' THEN uri "
+                            "ELSE uri || '?' || args END)"
+                          : "uri";
+            SQLITE_RESULT rows;
+            db.query(
+                "SELECT " + uri_expr +
+                    " AS uri, COUNT(*) AS c, COALESCE(SUM(bytes_sent),0) AS bytes_sent,"
+                    " COALESCE(AVG(request_time),0) AS avg_rt FROM access_log WHERE " +
+                    tf + " GROUP BY " + uri_expr +
+                    " ORDER BY c DESC LIMIT 15",
+                rows);
+            ylib::json arr;
+            for (const auto& row : rows) {
+                ylib::json item;
+                item["uri"] = map_get(row, "uri");
+                item["count"] = static_cast<int64>(to_i64(map_get(row, "c")));
+                item["bytes_sent"] =
+                    static_cast<int64>(to_i64(map_get(row, "bytes_sent")));
+                item["avg_request_time"] = to_d(map_get(row, "avg_rt"));
+                arr.push_back(item);
+            }
+            return arr;
+        };
+        out["top_uris"] = query_top(false);
+        out["top_uris_with_args"] = query_top(true);
     }
 
     // ---- top IPs / CC ----
@@ -1223,25 +1281,34 @@ bool analyze(const std::string& name, const std::string& range,
         out["cc"] = cc;
     }
 
-    // ---- slowest URIs ----
+    // ---- slowest URIs (path-only + with query args) ----
     {
-        SQLITE_RESULT rows;
-        db.query(
-            "SELECT uri, COUNT(*) AS c, COALESCE(AVG(request_time),0) AS avg_rt,"
-            " COALESCE(MAX(request_time),0) AS max_rt FROM access_log WHERE " +
-                tf +
-                " GROUP BY uri HAVING c >= 1 ORDER BY avg_rt DESC LIMIT 10",
-            rows);
-        ylib::json arr;
-        for (const auto& row : rows) {
-            ylib::json item;
-            item["uri"] = map_get(row, "uri");
-            item["count"] = static_cast<int64>(to_i64(map_get(row, "c")));
-            item["avg_request_time"] = to_d(map_get(row, "avg_rt"));
-            item["max_request_time"] = to_d(map_get(row, "max_rt"));
-            arr.push_back(item);
-        }
-        out["slow_uris"] = arr;
+        auto query_slow = [&](bool with_args) {
+            const std::string uri_expr =
+                with_args ? "(CASE WHEN IFNULL(args,'')='' THEN uri "
+                            "ELSE uri || '?' || args END)"
+                          : "uri";
+            SQLITE_RESULT rows;
+            db.query(
+                "SELECT " + uri_expr +
+                    " AS uri, COUNT(*) AS c, COALESCE(AVG(request_time),0) AS avg_rt,"
+                    " COALESCE(MAX(request_time),0) AS max_rt FROM access_log WHERE " +
+                    tf + " GROUP BY " + uri_expr +
+                    " HAVING c >= 1 ORDER BY avg_rt DESC LIMIT 10",
+                rows);
+            ylib::json arr;
+            for (const auto& row : rows) {
+                ylib::json item;
+                item["uri"] = map_get(row, "uri");
+                item["count"] = static_cast<int64>(to_i64(map_get(row, "c")));
+                item["avg_request_time"] = to_d(map_get(row, "avg_rt"));
+                item["max_request_time"] = to_d(map_get(row, "max_rt"));
+                arr.push_back(item);
+            }
+            return arr;
+        };
+        out["slow_uris"] = query_slow(false);
+        out["slow_uris_with_args"] = query_slow(true);
     }
 
     // ---- hosts ----

+ 317 - 5
src/website/website.cpp

@@ -48,6 +48,11 @@ bool save_url_proxy_rules(const SiteInfo& s,
 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);
+std::string url_proxy_file(const SiteInfo& s);
+std::string custom_nginx_file(const SiteInfo& s);
+std::string read_custom_nginx_raw(const SiteInfo& s);
+bool custom_defines_location_root(const std::string& raw);
+std::string nginx_custom_block(const SiteInfo& s);
 bool save_sites(const std::vector<SiteInfo>& list);
 std::string make_proxy_id();
 std::string nginx_escape_comment(std::string s);
@@ -587,6 +592,8 @@ 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;
+        s.group_name = r.group_name;
+        s.sort_order = r.sort_order;
 
         if (r.type == "proxy" && !s.upstream.empty()) {
             std::vector<UrlProxyRule> rules;
@@ -617,6 +624,29 @@ std::vector<SiteInfo> load_sites() {
             seed_url_proxy_root(s, target, "Fastweb 后端");
         }
 
+        // Static sites must not keep Fastweb-seeded "/" proxies left behind after
+        // delete+recreate (same domain). Manual proxies use an empty remark.
+        if (s.type == SiteType::Static) {
+            std::vector<UrlProxyRule> rules;
+            std::string perr;
+            if (load_url_proxy_rules(s, rules, perr) && !rules.empty()) {
+                const auto before = rules.size();
+                rules.erase(std::remove_if(rules.begin(), rules.end(),
+                                          [](const UrlProxyRule& r) {
+                                              return r.remark == "Fastweb 后端";
+                                          }),
+                            rules.end());
+                if (rules.size() != before) {
+                    if (rules.empty()) {
+                        remove_path(url_proxy_file(s));
+                    } else {
+                        save_url_proxy_rules(s, rules, perr);
+                    }
+                    migrated = true;
+                }
+            }
+        }
+
         list.push_back(s);
     }
     if (migrated) {
@@ -643,6 +673,8 @@ bool save_sites(const std::vector<SiteInfo>& list) {
         r.ssl_cert = s.ssl_cert;
         r.ssl_key = s.ssl_key;
         r.ssl_port = s.ssl_port > 0 ? s.ssl_port : 443;
+        r.group_name = s.group_name;
+        r.sort_order = s.sort_order;
         rows.push_back(r);
     }
     return store::replace_sites(rows);
@@ -870,12 +902,15 @@ std::string nginx_static_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 {
+    } else if (!custom_defines_location_root(read_custom_nginx_raw(s))) {
+        // Default static root; omitted when custom nginx provides location /
+        // (SPA / rewrite) so users can override without duplicate location.
         out << "    location / {\n"
             << "        try_files $uri $uri/ =404;\n"
             << "    }\n";
     }
 
+    out << nginx_custom_block(s);
     out << nginx_log_block(s) << "}\n";
     return out.str();
 }
@@ -925,12 +960,13 @@ std::string nginx_proxy_conf(const SiteInfo& s) {
     } else if (!s.upstream.empty()) {
         out << nginx_proxy_location_block(
             "/", nginx_proxy_pass_from_upstream(s.upstream), "$host", "");
-    } else {
+    } else if (!custom_defines_location_root(read_custom_nginx_raw(s))) {
         out << "    location / {\n"
             << "        return 502;\n"
             << "    }\n";
     }
 
+    out << nginx_custom_block(s);
     out << nginx_log_block(s) << "}\n";
     return out.str();
 }
@@ -952,6 +988,116 @@ std::string url_proxy_file(const SiteInfo& s) {
     return join_path(url_proxy_dir(), site_id(s) + ".json");
 }
 
+std::string custom_nginx_dir() {
+    return join_path(conf_root(), "custom");
+}
+
+std::string custom_nginx_file(const SiteInfo& s) {
+    return join_path(custom_nginx_dir(), site_id(s) + ".conf");
+}
+
+std::string read_custom_nginx_raw(const SiteInfo& s) {
+    const std::string path = custom_nginx_file(s);
+    if (!path_exists(path)) {
+        return "";
+    }
+    std::ifstream in(path);
+    if (!in) {
+        return "";
+    }
+    std::ostringstream buf;
+    buf << in.rdbuf();
+    return buf.str();
+}
+
+// True when custom nginx defines prefix `location /` (or `location ^~ /`),
+// which would conflict with NGS's built-in root location block.
+bool custom_defines_location_root(const std::string& raw) {
+    std::istringstream in(raw);
+    std::string line;
+    while (std::getline(in, line)) {
+        if (!line.empty() && line.back() == '\r') {
+            line.pop_back();
+        }
+        size_t i = 0;
+        while (i < line.size() &&
+               std::isspace(static_cast<unsigned char>(line[i]))) {
+            ++i;
+        }
+        if (i < line.size() && line[i] == '#') {
+            continue;
+        }
+        if (line.compare(i, 8, "location") != 0) {
+            continue;
+        }
+        i += 8;
+        if (i >= line.size() ||
+            !std::isspace(static_cast<unsigned char>(line[i]))) {
+            continue;
+        }
+        while (i < line.size() &&
+               std::isspace(static_cast<unsigned char>(line[i]))) {
+            ++i;
+        }
+        // location = /  (exact) can coexist with location /
+        if (i < line.size() && line[i] == '=') {
+            continue;
+        }
+        // location ~ / or location ~* / (regex) — not the same key
+        if (i < line.size() && line[i] == '~') {
+            continue;
+        }
+        // location ^~ /
+        if (i + 1 < line.size() && line[i] == '^' && line[i + 1] == '~') {
+            i += 2;
+            while (i < line.size() &&
+                   std::isspace(static_cast<unsigned char>(line[i]))) {
+                ++i;
+            }
+        }
+        // Path must be exactly "/" (not /api, /foo, …)
+        if (i >= line.size() || line[i] != '/') {
+            continue;
+        }
+        ++i;
+        if (i >= line.size() ||
+            std::isspace(static_cast<unsigned char>(line[i])) ||
+            line[i] == '{') {
+            return true;
+        }
+    }
+    return false;
+}
+
+// Indent each line with 4 spaces for insertion inside server { }.
+std::string nginx_custom_block(const SiteInfo& s) {
+    const std::string raw = read_custom_nginx_raw(s);
+    if (raw.empty()) {
+        return "";
+    }
+    std::ostringstream out;
+    out << "    #BEGIN_CUSTOM\n";
+    std::istringstream in(raw);
+    std::string line;
+    while (std::getline(in, line)) {
+        if (!line.empty() && line.back() == '\r') {
+            line.pop_back();
+        }
+        if (line.empty()) {
+            out << "\n";
+            continue;
+        }
+        // Keep user indentation relative, but ensure at least 4 spaces.
+        if (line[0] == ' ' || line[0] == '\t') {
+            out << line << "\n";
+        } else {
+            out << "    " << line << "\n";
+        }
+    }
+    out << "    #END_CUSTOM\n";
+    return out.str();
+}
+
 std::string make_proxy_id() {
     static std::atomic<uint64_t> seq{0};
     using clock = std::chrono::system_clock;
@@ -1446,6 +1592,31 @@ bool create_site_impl(const CreateSiteRequest& req, SiteInfo& created,
     s.listen_port = listen;
     s.upstream.clear();
     s.root = root;
+    s.group_name = req.group_name;
+    while (!s.group_name.empty() &&
+           std::isspace(static_cast<unsigned char>(s.group_name.front()))) {
+        s.group_name.erase(s.group_name.begin());
+    }
+    while (!s.group_name.empty() &&
+           std::isspace(static_cast<unsigned char>(s.group_name.back()))) {
+        s.group_name.pop_back();
+    }
+    {
+        int max_order = 0;
+        for (const auto& old : load_sites()) {
+            if (old.group_name != s.group_name) {
+                continue;
+            }
+            if (old.sort_order > max_order) {
+                max_order = old.sort_order;
+            }
+        }
+        s.sort_order = max_order + 10;
+    }
+
+    // Same domain may leave behind /ngs/conf/proxy/<id>.json from a deleted
+    // Fastweb (or manual) site; never inherit those rules on create.
+    remove_path(url_proxy_file(s));
 
     if (req.type == SiteType::Fastweb) {
         if (!create_fastweb_site(s)) {
@@ -1547,7 +1718,9 @@ bool delete_site_impl(const std::string& name, bool delete_files,
     stop_site_by_name(s.name);
     remove_path(vhost_file(s));
     remove_path(vhost_disabled_file(s));
-    remove_path(url_proxy_file(s));
+    // Do not remove url_proxy_file yet: load_sites() still includes this site and
+    // Fastweb entries re-seed a "/" proxy ("Fastweb 后端"), which would recreate
+    // the file we just deleted and leave it behind after the site is gone.
 
     auto remain = load_sites();
     remain.erase(std::remove_if(remain.begin(), remain.end(),
@@ -1557,6 +1730,10 @@ bool delete_site_impl(const std::string& name, bool delete_files,
                  remain.end());
     save_sites(remain);
 
+    // Remove after DB erase so a subsequent load_sites() cannot reseed it.
+    remove_path(url_proxy_file(s));
+    remove_path(custom_nginx_file(s));
+
     if (nginx::is_installed()) {
         apply_nginx();
     }
@@ -1776,14 +1953,16 @@ bool update_site(const UpdateSiteRequest& req, std::string& err) {
         err = "未找到网站: " + req.name;
         return false;
     }
+    const std::string domain_raw =
+        req.domain.empty() ? s.domain : req.domain;
     std::vector<DomainBind> binds;
-    if (!parse_domain_binds(req.domain, binds, err)) {
+    if (!parse_domain_binds(domain_raw, binds, err)) {
         return false;
     }
     // Legacy: if request still sends listen_port and no :port in domain text,
     // apply that port to all hosts.
     bool any_explicit = false;
-    for (const auto& tok : split_tokens_raw(req.domain)) {
+    for (const auto& tok : split_tokens_raw(domain_raw)) {
         auto pos = tok.rfind(':');
         if (pos == std::string::npos || pos + 1 >= tok.size()) {
             continue;
@@ -1855,6 +2034,17 @@ bool update_site(const UpdateSiteRequest& req, std::string& err) {
         s.ssl_key = req.ssl_key;
         s.ssl_port = req.ssl_port > 0 ? req.ssl_port : 443;
     }
+    if (req.update_group) {
+        s.group_name = req.group_name;
+        while (!s.group_name.empty() &&
+               std::isspace(static_cast<unsigned char>(s.group_name.front()))) {
+            s.group_name.erase(s.group_name.begin());
+        }
+        while (!s.group_name.empty() &&
+               std::isspace(static_cast<unsigned char>(s.group_name.back()))) {
+            s.group_name.pop_back();
+        }
+    }
 
     auto list = load_sites();
     bool found = false;
@@ -1886,6 +2076,44 @@ bool update_site(const UpdateSiteRequest& req, std::string& err) {
     return true;
 }
 
+bool reorder_sites(const std::vector<SiteOrderItem>& items, std::string& err) {
+    err.clear();
+    if (items.empty()) {
+        err = "排序列表为空";
+        return false;
+    }
+    auto list = load_sites();
+    for (const auto& it : items) {
+        bool found = false;
+        for (auto& s : list) {
+            if (s.name != it.name) {
+                continue;
+            }
+            s.group_name = it.group_name;
+            while (!s.group_name.empty() &&
+                   std::isspace(static_cast<unsigned char>(s.group_name.front()))) {
+                s.group_name.erase(s.group_name.begin());
+            }
+            while (!s.group_name.empty() &&
+                   std::isspace(static_cast<unsigned char>(s.group_name.back()))) {
+                s.group_name.pop_back();
+            }
+            s.sort_order = it.sort_order;
+            found = true;
+            break;
+        }
+        if (!found) {
+            err = "未找到网站: " + it.name;
+            return false;
+        }
+    }
+    if (!save_sites(list)) {
+        err = "保存排序失败";
+        return false;
+    }
+    return true;
+}
+
 std::vector<UrlProxyRule> list_url_proxies(const std::string& site_name,
                                            std::string& err) {
     err.clear();
@@ -2006,6 +2234,90 @@ bool delete_url_proxy(const std::string& site_name, const std::string& id,
     return apply_url_proxy_and_reload(s, err);
 }
 
+bool get_custom_nginx(const std::string& site_name, std::string& content,
+                      std::string& err) {
+    err.clear();
+    content.clear();
+    SiteInfo s;
+    if (!find_site_info(site_name, s)) {
+        err = "未找到网站: " + site_name;
+        return false;
+    }
+    content = read_custom_nginx_raw(s);
+    return true;
+}
+
+bool set_custom_nginx(const std::string& site_name, const std::string& content,
+                      std::string& err) {
+    err.clear();
+    SiteInfo s;
+    if (!find_site_info(site_name, s)) {
+        err = "未找到网站: " + site_name;
+        return false;
+    }
+    if (!ensure_dir(custom_nginx_dir())) {
+        err = "无法创建自定义配置目录";
+        return false;
+    }
+    const std::string path = custom_nginx_file(s);
+    const std::string previous = read_custom_nginx_raw(s);
+
+    auto restore_previous = [&]() {
+        if (previous.empty()) {
+            remove_path(path);
+        } else {
+            write_text_file(path, previous);
+        }
+        write_vhost(s);
+    };
+
+    // Normalize to end with a single trailing newline when non-empty.
+    std::string body = content;
+    while (!body.empty() && (body.back() == '\n' || body.back() == '\r')) {
+        body.pop_back();
+    }
+    if (!body.empty()) {
+        body.push_back('\n');
+        if (!write_text_file(path, body)) {
+            err = "写入自定义配置失败";
+            return false;
+        }
+    } else {
+        remove_path(path);
+    }
+    if (!write_vhost(s)) {
+        restore_previous();
+        err = "写入 Nginx 配置失败";
+        return false;
+    }
+    std::string detail;
+    if (!nginx::test_config(detail)) {
+        restore_previous();
+        std::string msg = detail;
+        // Prefer the emerg line for a shorter UI message.
+        const auto pos = detail.find("[emerg]");
+        if (pos != std::string::npos) {
+            msg = detail.substr(pos);
+            const auto nl = msg.find('\n');
+            if (nl != std::string::npos) {
+                msg = msg.substr(0, nl);
+            }
+        }
+        if (msg.empty()) {
+            msg = "Nginx 配置检测失败";
+        }
+        err = msg;
+        return false;
+    }
+    if (vhost_enabled(s.name)) {
+        if (!apply_nginx()) {
+            err = "Nginx 重载失败";
+            return false;
+        }
+    }
+    return true;
+}
+
 bool apply_letsencrypt_impl(const std::string& name, const std::string& email,
                             const std::vector<std::string>& selected_hosts,
                             std::string& err) {

+ 19 - 0
src/website/website.h

@@ -24,6 +24,8 @@ struct SiteInfo {
     std::string ssl_cert;
     std::string ssl_key;
     int ssl_port = 443;
+    std::string group_name;  // empty = 未分组
+    int sort_order = 0;
 };
 
 struct CreateSiteRequest {
@@ -33,6 +35,7 @@ struct CreateSiteRequest {
     int listen_port = 0;     // 0 -> derive from domain token (default 80)
     std::string upstream;    // unused on create; Fastweb allocates backend port
     std::string root;        // optional; default /ngs/wwwroot/<domain-host>
+    std::string group_name;
 };
 
 struct UpdateSiteRequest {
@@ -44,6 +47,14 @@ struct UpdateSiteRequest {
     std::string ssl_key;
     int ssl_port = 443;
     bool update_ssl = false;  // if false, keep existing ssl fields
+    std::string group_name;
+    bool update_group = false;
+};
+
+struct SiteOrderItem {
+    std::string name;
+    std::string group_name;
+    int sort_order = 0;
 };
 
 // Baota-style URL reverse-proxy rules (nginx locations), not Fastweb config.ini.
@@ -80,6 +91,8 @@ std::string site_key_by_name(const std::string& name);
 
 bool create_site(const CreateSiteRequest& req, std::string& err);
 bool update_site(const UpdateSiteRequest& req, std::string& err);
+// Reorder / regroup sites. Each item updates group_name + sort_order for name.
+bool reorder_sites(const std::vector<SiteOrderItem>& items, std::string& err);
 // Apply Let's Encrypt cert (HTTP-01) for selected hosts on one cert (SAN).
 // Certs are stored under /ngs/conf/ssl/<primary-domain>/.
 // hosts empty -> use all site domains.
@@ -101,6 +114,12 @@ bool update_url_proxy(const std::string& site_name, const UrlProxyRule& rule,
 bool delete_url_proxy(const std::string& site_name, const std::string& id,
                       std::string& err);
 
+// Per-site custom nginx directives (persisted across vhost regeneration).
+bool get_custom_nginx(const std::string& site_name, std::string& content,
+                      std::string& err);
+bool set_custom_nginx(const std::string& site_name, const std::string& content,
+                      std::string& err);
+
 void menu();
 
 }  // namespace website

Some files were not shown because too many files changed in this diff