你的用户名 1 lună în urmă
părinte
comite
ae24b188fc
8 a modificat fișierele cu 469 adăugiri și 134 ștergeri
  1. 29 0
      data/www/assets/app.css
  2. 244 127
      data/www/assets/app.js
  3. 12 7
      data/www/index.html
  4. 20 0
      src/api/api_server.cpp
  5. 48 0
      src/auth/auth.cpp
  6. 6 0
      src/auth/auth.h
  7. 108 0
      src/weblog/weblog.cpp
  8. 2 0
      src/weblog/weblog.h

+ 29 - 0
data/www/assets/app.css

@@ -885,6 +885,35 @@ html, body {
   padding: 1rem;
   color: #94a3b8;
 }
+.analytics-ip-link {
+  color: #0d9488;
+  cursor: pointer;
+  text-decoration: underline;
+  text-underline-offset: 2px;
+}
+.analytics-ip-link:hover {
+  color: #0f766e;
+}
+.site-log-ip-filter {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.15rem;
+  font-weight: 500;
+  color: #334155;
+}
+.site-log-search {
+  width: min(260px, 100%);
+  min-width: 160px;
+}
+.site-log-ip-filter .btn-close {
+  width: 0.55em;
+  height: 0.55em;
+  padding: 0.15em;
+  opacity: 0.55;
+}
+.site-log-ip-filter .btn-close:hover {
+  opacity: 0.9;
+}
 .sites-link {
   color: var(--accent);
   text-decoration: none;

+ 244 - 127
data/www/assets/app.js

@@ -38,6 +38,9 @@
     siteSettingsModal: null,
     siteSettings: null, // current site object
     siteLogKind: "access",
+    siteLogIp: "",
+    siteLogQuery: "",
+    siteLogSearchTimer: null,
     siteSslUploadTarget: "", // cert | key
     analyticsModal: null,
     analyticsSite: "",
@@ -2621,6 +2624,15 @@
     }
     state.siteProxies = [];
     state.siteLogKind = "access";
+    state.siteLogIp = "";
+    state.siteLogQuery = "";
+    if (state.siteLogSearchTimer) {
+      clearTimeout(state.siteLogSearchTimer);
+      state.siteLogSearchTimer = null;
+    }
+    const searchEl = $("#site-log-search");
+    if (searchEl) searchEl.value = "";
+    syncSiteLogIpFilter();
     $("#site-log-tab-access").classList.add("active");
     $("#site-log-tab-error").classList.remove("active");
     $("#site-log-view").textContent = "加载中…";
@@ -2708,27 +2720,75 @@
     toast("Nginx 配置已保存并重载");
   }
 
+  function syncSiteLogIpFilter() {
+    const wrap = $("#site-log-ip-filter");
+    const val = $("#site-log-ip-value");
+    if (!wrap) return;
+    const ip = String(state.siteLogIp || "").trim();
+    wrap.hidden = !ip;
+    if (val) val.textContent = ip;
+  }
+
+  function accessStatusClass(code) {
+    const n = Number(code);
+    if (n >= 500) return "status-server";
+    if (n >= 400) return "status-client";
+    if (n >= 300) return "status-redir";
+    return "status-ok";
+  }
+
+  function renderAccessLogTable(items, emptyText) {
+    const esc = escapeHtml;
+    if (!items.length) {
+      return `<div class="site-log-empty">${esc(emptyText || "暂无访问日志")}</div>`;
+    }
+    const rows = items
+      .map((r) => {
+        const uri = esc(r.uri || "") + (r.args ? "?" + esc(r.args) : "");
+        const rt = r.request_time != null ? Number(r.request_time).toFixed(3) : "";
+        return `<tr>
+          <td title="time_ms=${esc(r.time_ms)}">${esc(r.time)}</td>
+          <td>${esc(r.ip)}</td>
+          <td>${esc(r.method)}</td>
+          <td class="${accessStatusClass(r.status)}">${esc(r.status)}</td>
+          <td class="uri">${uri}</td>
+          <td>${esc(rt)}</td>
+          <td>${esc(formatBytes(Number(r.bytes_recv) || 0))}</td>
+          <td>${esc(formatBytes(Number(r.bytes_sent) || 0))}</td>
+        </tr>`;
+      })
+      .join("");
+    return `<table>
+      <thead><tr>
+        <th>时间</th><th>IP</th><th>方法</th><th>状态</th><th>URI</th>
+        <th>耗时(s)</th><th>接收</th><th>发送</th>
+      </tr></thead>
+      <tbody>${rows}</tbody>
+    </table>`;
+  }
+
   async function loadSiteLog() {
     const site = state.siteSettings;
     if (!site) return;
     const view = $("#site-log-view");
-    const esc = (s) =>
-      String(s ?? "")
-        .replace(/&/g, "&amp;")
-        .replace(/</g, "&lt;")
-        .replace(/>/g, "&gt;")
-        .replace(/"/g, "&quot;");
+    if (!view) return;
+    syncSiteLogIpFilter();
+    const q = String(state.siteLogQuery || "").trim();
 
     if (state.siteLogKind === "error") {
       view.classList.add("table-mode");
       try {
         const qs = new URLSearchParams({ name: site.name, kind: "error", limit: "200" });
+        if (q) qs.set("q", q);
         const data = await api(`/api/websites/logs?${qs.toString()}`);
         const items = (data && data.items) || [];
         if (!items.length) {
-          view.innerHTML = `<div class="site-log-empty">暂无错误日志</div>`;
+          view.innerHTML = `<div class="site-log-empty">${
+            q ? "没有匹配的错误日志" : "暂无错误日志"
+          }</div>`;
           return;
         }
+        const esc = escapeHtml;
         const rows = items
           .map((r) => `<tr>
             <td>${esc(r.time)}</td>
@@ -2749,44 +2809,18 @@
     }
 
     view.classList.add("table-mode");
+    const ip = String(state.siteLogIp || "").trim();
     const qs = new URLSearchParams({ name: site.name, kind: "access", limit: "200" });
+    if (ip) qs.set("ip", ip);
+    if (q) qs.set("q", q);
     try {
       const data = await api(`/api/websites/logs?${qs.toString()}`);
       const items = (data && data.items) || [];
-      if (!items.length) {
-        view.innerHTML = `<div class="site-log-empty">暂无访问日志(写入 access.log 后会自动入库)</div>`;
-        return;
-      }
-      const statusClass = (code) => {
-        const n = Number(code);
-        if (n >= 500) return "status-server";
-        if (n >= 400) return "status-client";
-        if (n >= 300) return "status-redir";
-        return "status-ok";
-      };
-      const rows = items
-        .map((r) => {
-          const uri = esc(r.uri || "") + (r.args ? "?" + esc(r.args) : "");
-          const rt = r.request_time != null ? Number(r.request_time).toFixed(3) : "";
-          return `<tr>
-            <td title="time_ms=${esc(r.time_ms)}">${esc(r.time)}</td>
-            <td>${esc(r.ip)}</td>
-            <td>${esc(r.method)}</td>
-            <td class="${statusClass(r.status)}">${esc(r.status)}</td>
-            <td class="uri">${uri}</td>
-            <td>${esc(rt)}</td>
-            <td>${esc(formatBytes(Number(r.bytes_recv) || 0))}</td>
-            <td>${esc(formatBytes(Number(r.bytes_sent) || 0))}</td>
-          </tr>`;
-        })
-        .join("");
-      view.innerHTML = `<table>
-        <thead><tr>
-          <th>时间</th><th>IP</th><th>方法</th><th>状态</th><th>URI</th>
-          <th>耗时(s)</th><th>接收</th><th>发送</th>
-        </tr></thead>
-        <tbody>${rows}</tbody>
-      </table>`;
+      let empty = "暂无访问日志(写入 access.log 后会自动入库)";
+      if (ip && q) empty = `暂无匹配记录(IP ${ip} · 关键词 ${q})`;
+      else if (ip) empty = `暂无该 IP 的访问记录:${ip}`;
+      else if (q) empty = `没有匹配的访问日志:${q}`;
+      view.innerHTML = renderAccessLogTable(items, empty);
       view.scrollTop = 0;
     } catch (err) {
       view.classList.remove("table-mode");
@@ -2904,13 +2938,60 @@
     return `${mo}-${day} ${hh}:${mm}`;
   }
 
+  function analyticsHourLabel(offsetMs) {
+    const totalMin = Math.floor(Math.max(0, Number(offsetMs) || 0) / 60000);
+    const hh = String(Math.floor(totalMin / 60) % 24).padStart(2, "0");
+    const mm = String(totalMin % 60).padStart(2, "0");
+    return `${hh}:${mm}`;
+  }
+
+  /** Align today/yesterday bucket rows onto a shared clock axis. */
+  function alignTrafficCompare(compare) {
+    const bucketMs = Number(compare?.bucket_ms) || 3600000;
+    const slots = Math.max(1, Math.round(86400000 / bucketMs));
+    const nowOff = Number(compare?.now_offset_ms);
+    const truncateAt = Number.isFinite(nowOff) ? nowOff : null;
+    const toMap = (rows) => {
+      const m = new Map();
+      (rows || []).forEach((r) => {
+        const off = Number(r.offset_ms);
+        if (!Number.isFinite(off) || off < 0) return;
+        m.set(Math.round(off / bucketMs) * bucketMs, r);
+      });
+      return m;
+    };
+    const todayMap = toMap(compare?.today);
+    const yestMap = toMap(compare?.yesterday);
+    const labels = [];
+    const today = { requests: [], bytes_recv: [], bytes_sent: [] };
+    const yesterday = { requests: [], bytes_recv: [], bytes_sent: [] };
+    for (let i = 0; i < slots; i++) {
+      const off = i * bucketMs;
+      labels.push(analyticsHourLabel(off));
+      const y = yestMap.get(off);
+      yesterday.requests.push(y ? Number(y.requests) || 0 : 0);
+      yesterday.bytes_recv.push(y ? Number(y.bytes_recv) || 0 : 0);
+      yesterday.bytes_sent.push(y ? Number(y.bytes_sent) || 0 : 0);
+      if (truncateAt != null && off > truncateAt) {
+        today.requests.push(null);
+        today.bytes_recv.push(null);
+        today.bytes_sent.push(null);
+      } else {
+        const t = todayMap.get(off);
+        today.requests.push(t ? Number(t.requests) || 0 : 0);
+        today.bytes_recv.push(t ? Number(t.bytes_recv) || 0 : 0);
+        today.bytes_sent.push(t ? Number(t.bytes_sent) || 0 : 0);
+      }
+    }
+    return { labels, today, yesterday, bucketMs };
+  }
+
   function renderAnalytics(data) {
     const body = $("#analytics-body");
     if (!body) return;
     destroyAnalyticsCharts();
     const s = data.summary || {};
     const cc = data.cc || {};
-    const errors = data.errors || {};
     const esc = escapeHtml;
 
     const cards = [
@@ -2934,13 +3015,18 @@
       .join("");
 
     const suspects = Array.isArray(cc.suspects) ? cc.suspects : [];
+    const ipLink = (ip) => {
+      const v = String(ip || "").trim();
+      if (!v) return "—";
+      return `<a href="#" class="analytics-ip-link" data-analytics-ip="${esc(v)}" title="查看该 IP 访问明细">${esc(v)}</a>`;
+    };
     const suspectHtml = suspects.length
       ? `<div class="table-responsive"><table class="table table-sm align-middle mb-0">
           <thead><tr><th>IP</th><th>请求</th><th>错误</th><th>占比</th><th>原因</th></tr></thead>
           <tbody>${suspects
             .map(
               (r) => `<tr>
-            <td><code>${esc(r.ip)}</code></td>
+            <td><code>${ipLink(r.ip)}</code></td>
             <td>${esc(r.count)}</td>
             <td>${esc(r.errors)}</td>
             <td>${Number(r.share || 0).toFixed(1)}%</td>
@@ -2953,7 +3039,7 @@
     const topIpRows = (data.top_ips || [])
       .map(
         (r) => `<tr>
-        <td><code>${esc(r.ip)}</code></td>
+        <td><code>${ipLink(r.ip)}</code></td>
         <td>${esc(r.count)}</td>
         <td>${esc(r.errors)}</td>
         <td>${esc(formatBytes(r.bytes_recv))}</td>
@@ -2985,22 +3071,14 @@
       )
       .join("");
 
-    const errRecent = (errors.recent || [])
-      .map(
-        (r) => `<tr>
-        <td>${esc(r.time)}</td>
-        <td>${esc(r.level)}</td>
-        <td class="text-break">${esc(r.message)}</td>
-      </tr>`
-      )
-      .join("");
-
     body.innerHTML = `
       <div class="row g-2 mb-3">${cards}</div>
       <div class="row g-3 mb-3">
         <div class="col-lg-8">
           <div class="analytics-panel">
-            <div class="analytics-panel-title">流量趋势(请求 / 带宽)</div>
+            <div class="analytics-panel-title">请求趋势
+              <span class="small text-secondary ms-2">今日 vs 昨日</span>
+            </div>
             <div class="analytics-chart"><canvas id="an-chart-traffic"></canvas></div>
           </div>
         </div>
@@ -3011,19 +3089,11 @@
           </div>
         </div>
       </div>
-      <div class="row g-3 mb-3">
-        <div class="col-md-6">
-          <div class="analytics-panel">
-            <div class="analytics-panel-title">收发流量趋势</div>
-            <div class="analytics-chart"><canvas id="an-chart-bytes"></canvas></div>
-          </div>
-        </div>
-        <div class="col-md-6">
-          <div class="analytics-panel">
-            <div class="analytics-panel-title">请求方法</div>
-            <div class="analytics-chart analytics-chart-sm"><canvas id="an-chart-method"></canvas></div>
-          </div>
+      <div class="analytics-panel mb-3">
+        <div class="analytics-panel-title">流量趋势
+          <span class="small text-secondary ms-2">今日 vs 昨日</span>
         </div>
+        <div class="analytics-chart"><canvas id="an-chart-bytes"></canvas></div>
       </div>
       <div class="analytics-panel mb-3">
         <div class="analytics-panel-title">CC / 异常 IP 分析
@@ -3051,35 +3121,19 @@
           </div>
         </div>
       </div>
-      <div class="row g-3 mb-3">
-        <div class="col-lg-6">
-          <div class="analytics-panel">
-            <div class="analytics-panel-title">最慢 URI</div>
-            <div class="table-responsive"><table class="table table-sm align-middle mb-0">
-              <thead><tr><th>URI</th><th>次数</th><th>平均</th><th>最大</th></tr></thead>
-              <tbody>${slowRows || `<tr><td colspan="4" class="text-secondary">暂无数据</td></tr>`}</tbody>
-            </table></div>
-          </div>
-        </div>
-        <div class="col-lg-6">
-          <div class="analytics-panel">
-            <div class="analytics-panel-title">错误日志 · ${esc(errors.total ?? 0)}</div>
-            <div class="table-responsive"><table class="table table-sm align-middle mb-0">
-              <thead><tr><th>时间</th><th>级别</th><th>内容</th></tr></thead>
-              <tbody>${errRecent || `<tr><td colspan="3" class="text-secondary">暂无错误</td></tr>`}</tbody>
-            </table></div>
-          </div>
-        </div>
+      <div class="analytics-panel mb-3">
+        <div class="analytics-panel-title">最慢 URI</div>
+        <div class="table-responsive"><table class="table table-sm align-middle mb-0">
+          <thead><tr><th>URI</th><th>次数</th><th>平均</th><th>最大</th></tr></thead>
+          <tbody>${slowRows || `<tr><td colspan="4" class="text-secondary">暂无数据</td></tr>`}</tbody>
+        </table></div>
       </div>
       <div class="small text-secondary">数据源:${esc(data.log_db || "")}</div>
     `;
 
     if (typeof Chart === "undefined") return;
-    const traffic = data.traffic || [];
-    const labels = traffic.map((t) => analyticsLabel(t.time_ms));
-    const reqs = traffic.map((t) => Number(t.requests) || 0);
-    const recv = traffic.map((t) => Number(t.bytes_recv) || 0);
-    const sent = traffic.map((t) => Number(t.bytes_sent) || 0);
+    const cmp = alignTrafficCompare(data.traffic_compare || {});
+    const labels = cmp.labels;
 
     const mkLine = (el, datasets, yFmt) => {
       if (!el) return null;
@@ -3090,10 +3144,11 @@
           responsive: true,
           maintainAspectRatio: false,
           animation: false,
+          spanGaps: false,
           interaction: { mode: "index", intersect: false },
           plugins: { legend: { display: datasets.length > 1, position: "top", align: "end", labels: { boxWidth: 10, font: { size: 11 } } } },
           scales: {
-            x: { ticks: { maxTicksLimit: 8, color: "#94a3b8", font: { size: 10 } }, grid: { display: false } },
+            x: { ticks: { maxTicksLimit: 12, color: "#94a3b8", font: { size: 10 } }, grid: { display: false } },
             y: { beginAtZero: true, ticks: { color: "#94a3b8", font: { size: 10 }, callback: yFmt }, grid: { color: "rgba(148,163,184,.15)" } },
           },
           elements: { line: { tension: 0.3, borderWidth: 2 }, point: { radius: 0 } },
@@ -3105,14 +3160,58 @@
 
     mkLine(
       $("#an-chart-traffic"),
-      [{ label: "请求数", data: reqs, borderColor: "#0d9488", backgroundColor: "rgba(13,148,136,.12)", fill: true }],
+      [
+        {
+          label: "今日",
+          data: cmp.today.requests,
+          borderColor: "#0d9488",
+          backgroundColor: "rgba(13,148,136,.12)",
+          fill: true,
+        },
+        {
+          label: "昨日",
+          data: cmp.yesterday.requests,
+          borderColor: "#94a3b8",
+          backgroundColor: "transparent",
+          borderDash: [6, 4],
+          fill: false,
+        },
+      ],
       (v) => v
     );
     mkLine(
       $("#an-chart-bytes"),
       [
-        { label: "接收", data: recv, borderColor: "#0d9488", backgroundColor: "rgba(13,148,136,.10)", fill: true },
-        { label: "发送", data: sent, borderColor: "#ea580c", backgroundColor: "rgba(234,88,12,.10)", fill: true },
+        {
+          label: "今日接收",
+          data: cmp.today.bytes_recv,
+          borderColor: "#0d9488",
+          backgroundColor: "rgba(13,148,136,.08)",
+          fill: false,
+        },
+        {
+          label: "今日发送",
+          data: cmp.today.bytes_sent,
+          borderColor: "#ea580c",
+          backgroundColor: "rgba(234,88,12,.08)",
+          fill: false,
+        },
+        {
+          label: "昨日接收",
+          data: cmp.yesterday.bytes_recv,
+          borderColor: "#5eead4",
+          backgroundColor: "transparent",
+          borderDash: [6, 4],
+          fill: false,
+        },
+        {
+          label: "昨日发送",
+          data: cmp.yesterday.bytes_sent,
+          borderColor: "#fdba74",
+          backgroundColor: "transparent",
+          borderDash: [6, 4],
+          fill: false,
+        },
       ],
       (v) => formatBytes(v)
     );
@@ -3137,32 +3236,6 @@
       });
       state.analyticsCharts.push(chart);
     }
-
-    const methods = data.methods || [];
-    const mEl = $("#an-chart-method");
-    if (mEl && methods.length) {
-      const chart = new Chart(mEl, {
-        type: "bar",
-        data: {
-          labels: methods.map((x) => x.method || ""),
-          datasets: [{
-            data: methods.map((x) => Number(x.count) || 0),
-            backgroundColor: "#0369a1",
-            borderRadius: 6,
-          }],
-        },
-        options: {
-          responsive: true,
-          maintainAspectRatio: false,
-          plugins: { legend: { display: false } },
-          scales: {
-            x: { ticks: { color: "#94a3b8", font: { size: 10 } }, grid: { display: false } },
-            y: { beginAtZero: true, ticks: { color: "#94a3b8", font: { size: 10 } }, grid: { color: "rgba(148,163,184,.15)" } },
-          },
-        },
-      });
-      state.analyticsCharts.push(chart);
-    }
   }
 
   async function loadSiteAnalytics() {
@@ -3186,6 +3259,25 @@
     await loadSiteAnalytics();
   }
 
+  async function openIpAccessDetail(siteName, ip) {
+    const name = String(siteName || "").trim();
+    const addr = String(ip || "").trim();
+    if (!name || !addr) return;
+    const site = state.sites.find((s) => s.name === name);
+    if (!site) throw new Error("未找到网站");
+    fillSiteSettingsForm(site);
+    state.siteLogKind = "access";
+    state.siteLogIp = addr;
+    $("#site-log-tab-access")?.classList.add("active");
+    $("#site-log-tab-error")?.classList.remove("active");
+    syncSiteLogIpFilter();
+    if (!state.siteSettingsModal) {
+      state.siteSettingsModal = new bootstrap.Modal($("#site-settings-modal"));
+    }
+    showStackedModal(state.siteSettingsModal, $("#site-settings-modal"));
+    setSiteSettingsTab("logs");
+  }
+
   function bindSiteSettingsModal() {
     state.siteSettingsModal = new bootstrap.Modal($("#site-settings-modal"));
     $("#site-settings-close").addEventListener("click", () => state.siteSettingsModal.hide());
@@ -3265,12 +3357,30 @@
       loadSiteLog();
     });
     $("#site-log-refresh").addEventListener("click", () => loadSiteLog());
-    $("#site-log-open-dir").addEventListener("click", () => {
-      const site = state.siteSettings;
-      if (!site || !site.log_dir) return;
-      state.siteSettingsModal.hide();
-      state.filesPath = site.log_dir;
-      setView("files");
+    $("#site-log-ip-clear")?.addEventListener("click", () => {
+      state.siteLogIp = "";
+      syncSiteLogIpFilter();
+      loadSiteLog();
+    });
+    const scheduleSiteLogSearch = () => {
+      if (state.siteLogSearchTimer) clearTimeout(state.siteLogSearchTimer);
+      state.siteLogSearchTimer = setTimeout(() => {
+        state.siteLogSearchTimer = null;
+        const input = $("#site-log-search");
+        state.siteLogQuery = (input?.value || "").trim();
+        loadSiteLog().catch((err) => toast(err.message || String(err), "err"));
+      }, 280);
+    };
+    $("#site-log-search")?.addEventListener("input", scheduleSiteLogSearch);
+    $("#site-log-search")?.addEventListener("keydown", (e) => {
+      if (e.key !== "Enter") return;
+      e.preventDefault();
+      if (state.siteLogSearchTimer) {
+        clearTimeout(state.siteLogSearchTimer);
+        state.siteLogSearchTimer = null;
+      }
+      state.siteLogQuery = (e.currentTarget.value || "").trim();
+      loadSiteLog().catch((err) => toast(err.message || String(err), "err"));
     });
     $("#site-modules-refresh")?.addEventListener("click", () => {
       withBusy(async () => loadSiteModules());
@@ -3301,6 +3411,13 @@
       withBusy(async () => loadSiteAnalytics());
     });
     $("#site-analytics-modal")?.addEventListener("hidden.bs.modal", () => destroyAnalyticsCharts());
+    $("#analytics-body")?.addEventListener("click", (e) => {
+      const link = e.target.closest("[data-analytics-ip]");
+      if (!link) return;
+      e.preventDefault();
+      const ip = link.getAttribute("data-analytics-ip") || "";
+      withBusy(async () => openIpAccessDetail(state.analyticsSite, ip));
+    });
 
     $("#btn-logout")?.addEventListener("click", async () => {
       const ok = await confirmDialog({

+ 12 - 7
data/www/index.html

@@ -419,14 +419,19 @@
               </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="btn-group btn-group-sm" role="group">
-                    <button type="button" class="btn btn-outline-primary active" id="site-log-tab-access" data-log-kind="access">访问日志</button>
-                    <button type="button" class="btn btn-outline-primary" id="site-log-tab-error" data-log-kind="error">error.log</button>
-                  </div>
-                  <div class="btn-group btn-group-sm">
-                    <button type="button" class="btn btn-outline-secondary" id="site-log-refresh">刷新</button>
-                    <button type="button" class="btn btn-outline-secondary" id="site-log-open-dir">打开日志目录</button>
+                  <div class="d-flex flex-wrap align-items-center gap-2 flex-grow-1">
+                    <div class="btn-group btn-group-sm" role="group">
+                      <button type="button" class="btn btn-outline-primary active" id="site-log-tab-access" data-log-kind="access">访问日志</button>
+                      <button type="button" class="btn btn-outline-primary" id="site-log-tab-error" data-log-kind="error">error.log</button>
+                    </div>
+                    <input type="search" class="form-control form-control-sm site-log-search" id="site-log-search"
+                           placeholder="搜索 IP / URI / 状态 / UA…" autocomplete="off" />
+                    <span class="badge text-bg-light border site-log-ip-filter" id="site-log-ip-filter" hidden>
+                      IP:<code id="site-log-ip-value"></code>
+                      <button type="button" class="btn-close btn-close-sm ms-1" id="site-log-ip-clear" aria-label="清除 IP 筛选"></button>
+                    </span>
                   </div>
+                  <button type="button" class="btn btn-sm btn-outline-secondary" id="site-log-refresh">刷新</button>
                 </div>
                 <div class="site-log-view" id="site-log-view">加载中…</div>
               </div>

+ 20 - 0
src/api/api_server.cpp

@@ -297,6 +297,15 @@ void h_auth_login(request* req, response* resp) {
     if (!require_method(req, resp, "POST")) {
         return;
     }
+    int64_t retry_after = 0;
+    if (auth::login_locked(&retry_after)) {
+        const int64_t mins = (retry_after + 59) / 60;
+        reply_err(resp,
+                  "登录失败次数过多,已锁定约 " + std::to_string(mins) +
+                      " 分钟(重启 NGS 可立即解除)",
+                  429);
+        return;
+    }
     auto body = parse_body(req);
     const std::string username = json_str(body, "username");
     const std::string password = json_str(body, "password");
@@ -316,9 +325,18 @@ void h_auth_login(request* req, response* resp) {
         return;
     }
     if (!auth::verify(username, password)) {
+        auth::record_login_failure();
+        if (auth::login_locked(&retry_after)) {
+            reply_err(resp,
+                      "账号或密码错误,已连续失败 3 次,登录锁定 10 分钟"
+                      "(重启 NGS 可立即解除)",
+                      429);
+            return;
+        }
         reply_err(resp, "账号或密码错误", 401);
         return;
     }
+    auth::clear_login_failures();
     const std::string token = auth::create_session(username);
     set_session_cookie(resp, token, false);
     ylib::json data;
@@ -1206,6 +1224,7 @@ void h_websites_logs(request* req, response* resp) {
         q.limit = limit;
         q.before_id = before_id;
         q.level = url_param(req, "level");
+        q.q = url_param(req, "q");
         std::vector<weblog::ErrorRow> rows;
         if (!weblog::query_error(q, rows, err)) {
             reply_err(resp, err.empty() ? "query logs failed" : err);
@@ -1232,6 +1251,7 @@ void h_websites_logs(request* req, response* resp) {
             q.status = 0;
         }
         q.ip = url_param(req, "ip");
+        q.q = url_param(req, "q");
         std::vector<weblog::AccessRow> rows;
         if (!weblog::query_access(q, rows, err)) {
             reply_err(resp, err.empty() ? "query logs failed" : err);

+ 48 - 0
src/auth/auth.cpp

@@ -33,8 +33,13 @@ bool g_ready = false;
 std::map<std::string, Session> g_sessions;
 std::map<std::string, CaptchaEntry> g_captchas;
 
+int g_login_failures = 0;
+int64_t g_login_locked_until = 0;
+
 constexpr int64_t kSessionTtlSec = 7 * 24 * 3600;  // 7 days
 constexpr int64_t kCaptchaTtlSec = 5 * 60;
+constexpr int kLoginMaxFailures = 3;
+constexpr int64_t kLoginLockSec = 10 * 60;
 
 int64_t now_sec() {
     return std::chrono::duration_cast<std::chrono::seconds>(
@@ -210,6 +215,49 @@ bool verify(const std::string& username, const std::string& password) {
     return username == g_cred.username && password == g_cred.password;
 }
 
+bool login_locked(int64_t* retry_after_sec) {
+    std::lock_guard<std::mutex> lock(g_mu);
+    const int64_t now = now_sec();
+    if (g_login_locked_until <= now) {
+        if (g_login_locked_until != 0) {
+            g_login_locked_until = 0;
+            g_login_failures = 0;
+        }
+        if (retry_after_sec) {
+            *retry_after_sec = 0;
+        }
+        return false;
+    }
+    if (retry_after_sec) {
+        *retry_after_sec = g_login_locked_until - now;
+    }
+    return true;
+}
+
+void record_login_failure() {
+    std::lock_guard<std::mutex> lock(g_mu);
+    const int64_t now = now_sec();
+    if (g_login_locked_until > now) {
+        return;
+    }
+    if (g_login_locked_until != 0 && g_login_locked_until <= now) {
+        g_login_locked_until = 0;
+        g_login_failures = 0;
+    }
+    ++g_login_failures;
+    if (g_login_failures >= kLoginMaxFailures) {
+        g_login_locked_until = now + kLoginLockSec;
+        log_info("login locked after " + std::to_string(g_login_failures) +
+                 " failures for " + std::to_string(kLoginLockSec) + "s");
+    }
+}
+
+void clear_login_failures() {
+    std::lock_guard<std::mutex> lock(g_mu);
+    g_login_failures = 0;
+    g_login_locked_until = 0;
+}
+
 CaptchaImage create_captcha() {
     CaptchaImage out;
     std::string code;

+ 6 - 0
src/auth/auth.h

@@ -27,6 +27,12 @@ Credentials credentials();
 
 bool verify(const std::string& username, const std::string& password);
 
+// Login rate limit (in-memory; cleared on NGS restart).
+// After 3 failed password attempts, lock for 10 minutes.
+bool login_locked(int64_t* retry_after_sec = nullptr);
+void record_login_failure();
+void clear_login_failures();
+
 // Image captcha (ylib::img::make_code). Cookie: ngs_captcha=<id>
 CaptchaImage create_captcha();
 bool consume_captcha(const std::string& id, const std::string& code);

+ 108 - 0
src/weblog/weblog.cpp

@@ -82,6 +82,19 @@ std::string sql_quote(const std::string& s) {
     return out;
 }
 
+// Escape LIKE wildcards, then wrap with %...% for substring match.
+std::string sql_like_contains(const std::string& raw) {
+    std::string esc;
+    esc.reserve(raw.size() + 8);
+    for (unsigned char c : raw) {
+        if (c == '\\' || c == '%' || c == '_') {
+            esc.push_back('\\');
+        }
+        esc.push_back(static_cast<char>(c));
+    }
+    return sql_quote("%" + esc + "%") + " ESCAPE '\\'";
+}
+
 std::string jstr(const ylib::json& j, const std::string& key) {
     if (j.is_empty() || !j.exist(key)) {
         return "";
@@ -759,6 +772,21 @@ bool query_access(const AccessQuery& q, std::vector<AccessRow>& rows,
     if (!q.ip.empty()) {
         sql << " AND ip = " << sql_quote(q.ip);
     }
+    if (!q.q.empty()) {
+        const std::string like = sql_like_contains(q.q);
+        sql << " AND ("
+               "ip LIKE " << like << " OR "
+               "method LIKE " << like << " OR "
+               "host LIKE " << like << " OR "
+               "uri LIKE " << like << " OR "
+               "args LIKE " << like << " OR "
+               "CAST(status AS TEXT) LIKE " << like << " OR "
+               "request LIKE " << like << " OR "
+               "ua LIKE " << like << " OR "
+               "referer LIKE " << like << " OR "
+               "upstream_addr LIKE " << like <<
+               ")";
+    }
     sql << " ORDER BY time_ms DESC, id DESC LIMIT " << limit;
 
     SQLITE_RESULT data;
@@ -823,6 +851,14 @@ bool query_error(const ErrorQuery& q, std::vector<ErrorRow>& rows,
     if (!q.level.empty()) {
         sql << " AND level = " << sql_quote(q.level);
     }
+    if (!q.q.empty()) {
+        const std::string like = sql_like_contains(q.q);
+        sql << " AND ("
+               "level LIKE " << like << " OR "
+               "message LIKE " << like << " OR "
+               "raw LIKE " << like <<
+               ")";
+    }
     sql << " ORDER BY time_ms DESC, id DESC LIMIT " << limit;
 
     SQLITE_RESULT data;
@@ -872,6 +908,59 @@ std::string time_filter_sql(int64_t since_ms) {
     return "time_ms >= " + std::to_string(since_ms);
 }
 
+// Local calendar midnight (ms) for the day containing now_ms.
+int64_t local_day_start_ms(int64_t now_ms) {
+    const time_t sec = static_cast<time_t>(now_ms / 1000);
+    std::tm tm {};
+#if defined(_WIN32)
+    localtime_s(&tm, &sec);
+#else
+    localtime_r(&sec, &tm);
+#endif
+    tm.tm_hour = 0;
+    tm.tm_min = 0;
+    tm.tm_sec = 0;
+    return static_cast<int64_t>(mktime(&tm)) * 1000;
+}
+
+ylib::json query_day_buckets(ylib::sqlite3& db, int64_t day_start_ms,
+                             int64_t day_end_ms, int64_t bucket_ms) {
+    ylib::json arr;
+    if (bucket_ms <= 0 || day_end_ms <= day_start_ms) {
+        return arr;
+    }
+    const std::string bucket_expr =
+        "CAST((time_ms - " + std::to_string(day_start_ms) + ") / " +
+        std::to_string(bucket_ms) + " AS INTEGER) * " +
+        std::to_string(bucket_ms);
+    SQLITE_RESULT rows;
+    const std::string sql =
+        "SELECT " + bucket_expr +
+        " AS offset_ms,"
+        " COUNT(*) AS reqs,"
+        " COALESCE(SUM(bytes_recv),0) AS bytes_recv,"
+        " COALESCE(SUM(bytes_sent),0) AS bytes_sent "
+        "FROM access_log WHERE time_ms >= " +
+        std::to_string(day_start_ms) + " AND time_ms < " +
+        std::to_string(day_end_ms) +
+        " GROUP BY offset_ms ORDER BY offset_ms ASC";
+    if (!db.query(sql, rows)) {
+        return arr;
+    }
+    for (const auto& row : rows) {
+        ylib::json item;
+        item["offset_ms"] =
+            static_cast<int64>(to_i64(map_get(row, "offset_ms")));
+        item["requests"] = static_cast<int64>(to_i64(map_get(row, "reqs")));
+        item["bytes_recv"] =
+            static_cast<int64>(to_i64(map_get(row, "bytes_recv")));
+        item["bytes_sent"] =
+            static_cast<int64>(to_i64(map_get(row, "bytes_sent")));
+        arr.push_back(item);
+    }
+    return arr;
+}
+
 ylib::json rows_to_kv_array(const SQLITE_RESULT& rows, const std::string& key_col,
                             const std::string& val_col,
                             const std::string& key_name = "key",
@@ -1009,6 +1098,25 @@ bool analyze(const std::string& name, const std::string& range,
         out["traffic"] = traffic;
     }
 
+    // ---- today vs yesterday (clock-aligned buckets) ----
+    {
+        constexpr int64_t kBucketMs = 3600LL * 1000;  // 1 hour
+        constexpr int64_t kDayMs = 24LL * 3600 * 1000;
+        const int64_t now = now_ms_wall();
+        const int64_t today_start = local_day_start_ms(now);
+        const int64_t yesterday_start = today_start - kDayMs;
+        ylib::json compare;
+        compare["bucket_ms"] = static_cast<int64>(kBucketMs);
+        compare["today_start_ms"] = static_cast<int64>(today_start);
+        compare["yesterday_start_ms"] = static_cast<int64>(yesterday_start);
+        compare["now_offset_ms"] = static_cast<int64>(now - today_start);
+        compare["today"] =
+            query_day_buckets(db, today_start, today_start + kDayMs, kBucketMs);
+        compare["yesterday"] = query_day_buckets(
+            db, yesterday_start, today_start, kBucketMs);
+        out["traffic_compare"] = compare;
+    }
+
     // ---- status distribution ----
     {
         SQLITE_RESULT rows;

+ 2 - 0
src/weblog/weblog.h

@@ -43,6 +43,7 @@ struct AccessQuery {
     int64_t before_id = 0;
     int status = 0;
     std::string ip;
+    std::string q;  // fuzzy search across common fields
 };
 
 struct ErrorQuery {
@@ -50,6 +51,7 @@ struct ErrorQuery {
     size_t limit = 100;
     int64_t before_id = 0;
     std::string level;
+    std::string q;  // fuzzy search across level/message/raw
 };
 
 std::string db_dir();