你的用户名 před 1 měsícem
rodič
revize
395da8ec34

+ 2 - 1
API.md

@@ -302,6 +302,7 @@ password=ngsadmin
 | POST | `/api/websites/delete` | 删除网站 | `{"name":"demo","delete_files":false}` |
 | GET | `/api/websites/analytics` | 站点日志分析(SQLite 聚合) | `?name=demo&range=24h`(`1h\|6h\|24h\|7d\|30d\|all`) |
 | GET | `/api/websites/logs` | 访问/错误日志(每站点 SQLite) | `?name=demo&kind=access|error&limit=100&before_id=&status=&ip=&level=` |
+| POST | `/api/websites/logs/purge` | 清理站点日志文件和/或日志库 | `{"name":"demo","files":true,"db":true}` |
 
 访问日志库路径:`data/log/<name>.db`。表 `access_log` / `error_log`;后台从 `www/log/<name>/access.log`、`error.log` 增量入库。
 
@@ -327,7 +328,7 @@ password=ngsadmin
 - `type=fastweb` 时会自动探测空闲端口并生成站点配置与 `/` URL 代理规则
 - SSL:`POST /api/websites/ssl/apply` 使用 Let's Encrypt(HTTP-01),域名需解析到本机且 **80 端口可达**
 
-列表示例字段:`name` / `type` / `domain` / `domains` / `listen_port` / `upstream` / `running` / `root` / `log_dir` / `access_log` / `error_log` / `log_db` / `conf` / `ssl_*`
+列表示例字段:`name` / `type` / `domain` / `domains` / `listen_port` / `upstream` / `running` / `root` / `log_dir` / `access_log` / `error_log` / `log_db` / `log_file_bytes` / `log_db_bytes` / `log_bytes` / `conf` / `ssl_*`
 
 ---
 

+ 3 - 1
CMakeLists.txt

@@ -59,6 +59,7 @@ find_library(SSL_LIB NAMES ssl REQUIRED)
 find_library(CRYPTO_LIB NAMES crypto REQUIRED)
 find_library(Z_LIB NAMES z REQUIRED)
 find_library(X11_LIB NAMES X11 REQUIRED)
+find_library(CURL_LIB NAMES curl REQUIRED)
 
 set(NGS_LIBS
     ${YLIB_LIB}
@@ -70,6 +71,7 @@ set(NGS_LIBS
     ${CRYPTO_LIB}
     ${Z_LIB}
     ${X11_LIB}
+    ${CURL_LIB}
     pthread
     dl
     rt
@@ -103,7 +105,7 @@ add_custom_target(install-deps
             build-essential cmake pkg-config
             libpcre3-dev zlib1g-dev libssl-dev libsqlite3-dev
             libleveldb-dev libmysqlcppconn-dev libmysqlclient-dev
-            curl tar
+            libcurl4-openssl-dev curl tar
     COMMENT "Install apt build dependencies (ylib and HPSocket must be preinstalled)"
     VERBATIM
 )

+ 1 - 0
build.sh

@@ -29,6 +29,7 @@ APT_DEPS=(
     unixodbc-dev
     libsoci-dev
     libx11-dev
+    libcurl4-openssl-dev
     curl
     tar
 )

+ 52 - 1
data/www/assets/app.js

@@ -1061,6 +1061,16 @@
     return `<span class="${cls}" title="${escapeHtml(site.ssl_not_after || "")}">${days}天</span>`;
   }
 
+  function siteLogCell(site) {
+    const files = Number(site?.log_file_bytes) || 0;
+    const db = Number(site?.log_db_bytes) || 0;
+    const title = `文件 ${formatBytes(files)}(access/error)\n数据库 ${formatBytes(db)}`;
+    return `<button type="button" class="btn btn-link btn-sm p-0 text-start text-decoration-none lh-sm" data-site="${escapeHtml(site.name)}" data-act="site-logs" title="${escapeHtml(title)}">
+      <div>文件 ${escapeHtml(formatBytes(files))}</div>
+      <div class="text-secondary">库 ${escapeHtml(formatBytes(db))}</div>
+    </button>`;
+  }
+
   function siteGroupLabel(name) {
     return name ? name : "未分组";
   }
@@ -1171,7 +1181,7 @@
     const tbody = $("#sites-table tbody");
     const sites = filteredSites();
     if (!sites.length) {
-      tbody.innerHTML = `<tr><td colspan="9" class="text-secondary text-center py-4">暂无网站</td></tr>`;
+      tbody.innerHTML = `<tr><td colspan="10" class="text-secondary text-center py-4">暂无网站</td></tr>`;
       return;
     }
     tbody.innerHTML = sites
@@ -1213,6 +1223,7 @@
           <td><a class="sites-link" href="${escapeHtml(openUrl)}" target="_blank" rel="noopener" title="${escapeHtml(domains.join(", "))}">${escapeHtml(domainText)}</a></td>
           <td><button type="button" class="btn btn-link btn-sm sites-link sites-path p-0" data-site-path="${escapeHtml(root)}" title="${escapeHtml(root)}">${escapeHtml(root || "—")}</button></td>
           <td>${siteSslExpireCell(s)}</td>
+          <td>${siteLogCell(s)}</td>
           <td>${st}</td>
           <td class="text-end">
             <div class="btn-group btn-group-sm">
@@ -4554,6 +4565,46 @@
         withBusy(async () => openSiteAnalytics(name), btn);
         return;
       }
+      if (act === "site-logs") {
+        const site = (state.sites || []).find((s) => s.name === name) || {};
+        const files = Number(site.log_file_bytes) || 0;
+        const db = Number(site.log_db_bytes) || 0;
+        const access = Number(site.log_access_bytes) || 0;
+        const error = Number(site.log_error_bytes) || 0;
+        openModal({
+          title: `日志占用 · ${name}`,
+          bodyHtml: `
+            <div class="small mb-3">
+              <div class="d-flex justify-content-between"><span>日志文件</span><strong>${escapeHtml(formatBytes(files))}</strong></div>
+              <div class="text-secondary">access.log ${escapeHtml(formatBytes(access))} · error.log ${escapeHtml(formatBytes(error))}</div>
+              <div class="d-flex justify-content-between mt-2"><span>日志数据库</span><strong>${escapeHtml(formatBytes(db))}</strong></div>
+              <div class="text-secondary">用于访问日志查询与统计分析</div>
+              <div class="d-flex justify-content-between mt-2 pt-2 border-top"><span>合计</span><strong>${escapeHtml(formatBytes(files + db))}</strong></div>
+            </div>
+            <div class="form-check">
+              <input class="form-check-input" type="checkbox" name="files" value="1" id="purgeLogFiles" checked>
+              <label class="form-check-label" for="purgeLogFiles">清理日志文件</label>
+            </div>
+            <div class="form-check">
+              <input class="form-check-input" type="checkbox" name="db" value="1" id="purgeLogDb" checked>
+              <label class="form-check-label" for="purgeLogDb">清理日志数据库</label>
+            </div>
+            <p class="small text-secondary mb-0 mt-2">清理后不可恢复。截断文件后会通知 Nginx 重新打开日志。</p>`,
+          onSubmit: async (fd) => {
+            const filesOn = fd.get("files") === "1";
+            const dbOn = fd.get("db") === "1";
+            if (!filesOn && !dbOn) {
+              throw new Error("请至少选择一项");
+            }
+            await api("/api/websites/logs/purge", {
+              method: "POST",
+              body: { name, files: filesOn, db: dbOn },
+            });
+            toast("日志已清理");
+          },
+        });
+        return;
+      }
       if (act === "site-delete") {
         openModal({
           title: `删除网站 · ${name}`,

+ 1 - 0
data/www/index.html

@@ -138,6 +138,7 @@
                   <th>域名</th>
                   <th>目录</th>
                   <th>证书到期</th>
+                  <th>日志</th>
                   <th>状态</th>
                   <th class="text-end">操作</th>
                 </tr>

+ 46 - 0
src/api/api_server.cpp

@@ -1152,6 +1152,14 @@ ylib::json site_to_json(const website::SiteInfo& s) {
     item["access_log"] = join_path(log_dir, "access.log");
     item["error_log"] = join_path(log_dir, "error.log");
     item["log_db"] = weblog::db_path(website::site_key(s));
+    {
+        const weblog::LogUsage u = weblog::usage(website::site_key(s));
+        item["log_file_bytes"] = static_cast<int64>(u.file_bytes);
+        item["log_db_bytes"] = static_cast<int64>(u.db_bytes);
+        item["log_access_bytes"] = static_cast<int64>(u.access_bytes);
+        item["log_error_bytes"] = static_cast<int64>(u.error_bytes);
+        item["log_bytes"] = static_cast<int64>(u.file_bytes + u.db_bytes);
+    }
     item["ssl_enable"] = s.ssl_enable;
     item["ssl_cert"] = s.ssl_cert;
     item["ssl_key"] = s.ssl_key;
@@ -1651,6 +1659,43 @@ void h_websites_logs(request* req, response* resp) {
     reply_ok(resp, data);
 }
 
+void h_websites_logs_purge(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    auto body = parse_body(req);
+    const std::string name = json_str(body, "name");
+    if (name.empty()) {
+        reply_err(resp, "name required");
+        return;
+    }
+    website::SiteInfo site;
+    if (!website::find_site(name, site)) {
+        reply_err(resp, "site not found", 404);
+        return;
+    }
+    weblog::PurgeOptions opt;
+    opt.files = json_bool(body, "files", false);
+    opt.db = json_bool(body, "db", false);
+    std::string err;
+    if (!weblog::purge(website::site_key(site), opt, err)) {
+        reply_err(resp, err.empty() ? "清理失败" : err);
+        return;
+    }
+    if (opt.files && !nginx::reopen_logs()) {
+        log_warn("nginx reopen logs failed after purge site=" + name);
+    }
+    const weblog::LogUsage u = weblog::usage(website::site_key(site));
+    ylib::json data;
+    data["name"] = name;
+    data["log_file_bytes"] = static_cast<int64>(u.file_bytes);
+    data["log_db_bytes"] = static_cast<int64>(u.db_bytes);
+    data["log_access_bytes"] = static_cast<int64>(u.access_bytes);
+    data["log_error_bytes"] = static_cast<int64>(u.error_bytes);
+    data["log_bytes"] = static_cast<int64>(u.file_bytes + u.db_bytes);
+    reply_ok(resp, data, "logs purged");
+}
+
 void h_websites_analytics(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
@@ -2313,6 +2358,7 @@ void register_routes(ylib::network::http::router* router) {
     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/logs/purge", h_websites_logs_purge);
     reg(router, "/api/websites/analytics", h_websites_analytics);
     reg(router, "/api/websites/ssl/apply", h_websites_ssl_apply);
 

+ 68 - 29
src/schedule/schedule.cpp

@@ -2,9 +2,10 @@
 
 #include "../utils.h"
 
-#include "net/http_client_plus.h"
 #include "util/sqlite3.h"
 
+#include <curl/curl.h>
+
 #include <atomic>
 #include <chrono>
 #include <condition_variable>
@@ -318,6 +319,27 @@ std::string truncate_preview(std::string s) {
     return s;
 }
 
+void ensure_curl() {
+    static std::once_flag once;
+    std::call_once(once, [] {
+        // HPSocket also inits OpenSSL; curl_global_init is idempotent on 1.1+.
+        // Do not curl_global_cleanup while the API process is still running.
+        curl_global_init(CURL_GLOBAL_DEFAULT);
+    });
+}
+
+size_t curl_write_cb(char* ptr, size_t size, size_t nmemb, void* userdata) {
+    auto* body = static_cast<std::string*>(userdata);
+    const size_t n = size * nmemb;
+    if (body->size() < kResponsePreviewMax) {
+        const size_t room = kResponsePreviewMax - body->size();
+        body->append(ptr, n < room ? n : room);
+    }
+    return n;
+}
+
+// Per-request libcurl easy handle. Jobs stay concurrent (one thread each);
+// ylib client_plus / HPSocket SSL is not safe for overlapping HTTPS clients.
 void execute_http(Job job) {
     const int64_t started = now_ms();
     LogEntry log;
@@ -330,37 +352,53 @@ void execute_http(Job job) {
     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);
+        ensure_curl();
+        CURL* curl = curl_easy_init();
+        if (!curl) {
+            err = "curl init failed";
         } else {
-            sent = client.get(job.url);
-        }
+            std::string body;
+            curl_easy_setopt(curl, CURLOPT_URL, job.url.c_str());
+            curl_easy_setopt(curl, CURLOPT_TIMEOUT, static_cast<long>(job.timeout_sec));
+            curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT,
+                             static_cast<long>(job.timeout_sec));
+            curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
+            curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
+            curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
+            curl_easy_setopt(curl, CURLOPT_USERAGENT, "ngs-schedule/1.0");
+            curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_cb);
+            curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
+            curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
+
+            if (job.method == "POST") {
+                curl_easy_setopt(curl, CURLOPT_POST, 1L);
+                curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "");
+                curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, 0L);
+            } else if (job.method == "HEAD") {
+                curl_easy_setopt(curl, CURLOPT_NOBODY, 1L);
+            }
 
-        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))
-                                 : "请求失败";
+            const CURLcode rc = curl_easy_perform(curl);
+            long http_code = 0;
+            curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
+            status = static_cast<int>(http_code);
+            preview = truncate_preview(std::move(body));
+            curl_easy_cleanup(curl);
+
+            if (rc != CURLE_OK) {
+                err = curl_easy_strerror(rc);
+                if (status >= 200 && status < 400) {
+                    ok = true;
+                    err.clear();
+                } else {
+                    ok = false;
+                }
+            } else if (status >= 200 && status < 400) {
+                ok = true;
+            } else {
+                ok = false;
+                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();
@@ -502,6 +540,7 @@ void start() {
             return;
         }
     }
+    ensure_curl();
     worker() = new std::thread(scheduler_loop);
     log_info("schedule started db=" + db_path());
 }

+ 7 - 0
src/software/nginx/nginx.cpp

@@ -548,6 +548,13 @@ bool reload() {
     return start();
 }
 
+bool reopen_logs() {
+    if (!is_installed() || !is_running()) {
+        return true;
+    }
+    return run_cmd(nginx_cli("-s reopen"), false) == 0;
+}
+
 bool ensure_nginx_user() {
     const std::string conf = conf_file();
     std::ifstream in(conf);

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

@@ -31,6 +31,8 @@ bool uninstall();
 bool start();
 bool stop();
 bool reload();
+// Reopen access/error logs (USR1). Use after truncating log files.
+bool reopen_logs();
 bool test_config();
 bool test_config(std::string& detail);
 bool ensure_vhost_include();

+ 157 - 0
src/weblog/weblog.cpp

@@ -24,6 +24,8 @@
 #include <sstream>
 #include <sys/stat.h>
 #include <thread>
+#include <unistd.h>
+#include <vector>
 
 namespace ngs {
 namespace weblog {
@@ -736,6 +738,85 @@ double to_d(const std::string& s) {
     }
 }
 
+bool is_db_filename(const std::string& name) {
+    return name == "weblog.db" || name.rfind("weblog.db-", 0) == 0;
+}
+
+int64_t file_bytes(const std::string& path) {
+    struct stat st {};
+    if (stat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) {
+        return 0;
+    }
+    return static_cast<int64_t>(st.st_size);
+}
+
+bool stamp_file_meta(ylib::sqlite3& db, const std::string& path,
+                     const std::string& offset_key, const std::string& inode_key,
+                     const std::string& size_key) {
+    struct stat st {};
+    if (stat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) {
+        return meta_set(db, offset_key, "0") && meta_set(db, inode_key, "") &&
+               meta_set(db, size_key, "0");
+    }
+    const std::string inode_s =
+        std::to_string(static_cast<unsigned long long>(st.st_ino));
+    const std::string size_s =
+        std::to_string(static_cast<unsigned long long>(st.st_size));
+    return meta_set(db, offset_key, size_s) && meta_set(db, inode_key, inode_s) &&
+           meta_set(db, size_key, size_s);
+}
+
+bool recreate_empty_file(const std::string& path) {
+    std::ofstream out(path, std::ios::binary | std::ios::trunc);
+    return static_cast<bool>(out);
+}
+
+void unlink_log_dir_files(const std::string& dir, bool drop_files, bool drop_db) {
+    DIR* d = opendir(dir.c_str());
+    if (!d) {
+        return;
+    }
+    std::vector<std::string> names;
+    while (dirent* ent = readdir(d)) {
+        if (!ent || !ent->d_name) {
+            continue;
+        }
+        const std::string fname = ent->d_name;
+        if (fname == "." || fname == "..") {
+            continue;
+        }
+        const bool db_file = is_db_filename(fname);
+        if ((drop_db && db_file) || (drop_files && !db_file)) {
+            names.push_back(fname);
+        }
+    }
+    closedir(d);
+    for (const auto& fname : names) {
+        const std::string path = join_path(dir, fname);
+        if (!is_dir(path)) {
+            remove_path(path);
+        }
+    }
+}
+
+bool recreate_empty_db(const std::string& name, const std::string& dir,
+                       std::string& err) {
+    ylib::sqlite3 db;
+    if (!open_site_db(name, db, err)) {
+        return false;
+    }
+    if (!ensure_schema(db)) {
+        err = std::string("重建日志库失败: ") + db.last_error();
+        db.close();
+        return false;
+    }
+    stamp_file_meta(db, join_path(dir, "access.log"), "offset", "inode", "size");
+    stamp_file_meta(db, join_path(dir, "error.log"), "error_offset",
+                    "error_inode", "error_size");
+    db.close();
+    return true;
+}
+
 }  // namespace
 
 std::string legacy_db_dir() {
@@ -823,6 +904,82 @@ bool remove_db(const std::string& name) {
     return remove_path(path);
 }
 
+LogUsage usage(const std::string& name) {
+    LogUsage u;
+    if (!is_safe_site_name(name)) {
+        return u;
+    }
+    const std::string dir = join_path(sites_log_dir(), name);
+    DIR* d = opendir(dir.c_str());
+    if (!d) {
+        return u;
+    }
+    while (dirent* ent = readdir(d)) {
+        if (!ent || !ent->d_name) {
+            continue;
+        }
+        const std::string fname = ent->d_name;
+        if (fname == "." || fname == "..") {
+            continue;
+        }
+        const std::string path = join_path(dir, fname);
+        const int64_t sz = file_bytes(path);
+        if (is_db_filename(fname)) {
+            u.db_bytes += sz;
+        } else {
+            u.file_bytes += sz;
+            if (fname == "access.log") {
+                u.access_bytes = sz;
+            } else if (fname == "error.log") {
+                u.error_bytes = sz;
+            }
+        }
+    }
+    closedir(d);
+    return u;
+}
+
+bool purge(const std::string& name, const PurgeOptions& opt, std::string& err) {
+    if (!opt.files && !opt.db) {
+        err = "请选择要清理的内容";
+        return false;
+    }
+    if (!is_safe_site_name(name)) {
+        err = "无效站点名";
+        return false;
+    }
+    std::lock_guard<std::mutex> lock(mu());
+    const std::string dir = join_path(sites_log_dir(), name);
+    if (!ensure_dir(dir)) {
+        err = "无法创建日志目录: " + dir;
+        return false;
+    }
+    unlink_log_dir_files(dir, opt.files, opt.db);
+    if (opt.files) {
+        if (!recreate_empty_file(join_path(dir, "access.log")) ||
+            !recreate_empty_file(join_path(dir, "error.log"))) {
+            err = "重建日志文件失败";
+            return false;
+        }
+    }
+    if (opt.db) {
+        if (!recreate_empty_db(name, dir, err)) {
+            return false;
+        }
+    } else if (opt.files) {
+        ylib::sqlite3 db;
+        std::string open_err;
+        if (open_site_db(name, db, open_err) && ensure_schema(db)) {
+            stamp_file_meta(db, join_path(dir, "access.log"), "offset", "inode",
+                            "size");
+            stamp_file_meta(db, join_path(dir, "error.log"), "error_offset",
+                            "error_inode", "error_size");
+        }
+        db.close();
+    }
+    return true;
+}
+
 void ingest_once() {
     std::vector<website::SiteInfo> sites;
     try {

+ 15 - 0
src/weblog/weblog.h

@@ -58,6 +58,21 @@ struct ErrorQuery {
 std::string db_dir();
 std::string db_path(const std::string& name);
 
+struct LogUsage {
+    int64_t file_bytes = 0;    // access/error and rotated logs
+    int64_t db_bytes = 0;      // weblog.db (+ wal/shm)
+    int64_t access_bytes = 0;
+    int64_t error_bytes = 0;
+};
+
+struct PurgeOptions {
+    bool files = false;
+    bool db = false;
+};
+
+LogUsage usage(const std::string& name);
+bool purge(const std::string& name, const PurgeOptions& opt, std::string& err);
+
 bool ensure_db(const std::string& name, std::string& err);
 bool remove_db(const std::string& name);