|
@@ -38,6 +38,9 @@
|
|
|
siteSettingsModal: null,
|
|
siteSettingsModal: null,
|
|
|
siteSettings: null, // current site object
|
|
siteSettings: null, // current site object
|
|
|
siteLogKind: "access",
|
|
siteLogKind: "access",
|
|
|
|
|
+ siteLogIp: "",
|
|
|
|
|
+ siteLogQuery: "",
|
|
|
|
|
+ siteLogSearchTimer: null,
|
|
|
siteSslUploadTarget: "", // cert | key
|
|
siteSslUploadTarget: "", // cert | key
|
|
|
analyticsModal: null,
|
|
analyticsModal: null,
|
|
|
analyticsSite: "",
|
|
analyticsSite: "",
|
|
@@ -2621,6 +2624,15 @@
|
|
|
}
|
|
}
|
|
|
state.siteProxies = [];
|
|
state.siteProxies = [];
|
|
|
state.siteLogKind = "access";
|
|
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-access").classList.add("active");
|
|
|
$("#site-log-tab-error").classList.remove("active");
|
|
$("#site-log-tab-error").classList.remove("active");
|
|
|
$("#site-log-view").textContent = "加载中…";
|
|
$("#site-log-view").textContent = "加载中…";
|
|
@@ -2708,27 +2720,75 @@
|
|
|
toast("Nginx 配置已保存并重载");
|
|
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() {
|
|
async function loadSiteLog() {
|
|
|
const site = state.siteSettings;
|
|
const site = state.siteSettings;
|
|
|
if (!site) return;
|
|
if (!site) return;
|
|
|
const view = $("#site-log-view");
|
|
const view = $("#site-log-view");
|
|
|
- const esc = (s) =>
|
|
|
|
|
- String(s ?? "")
|
|
|
|
|
- .replace(/&/g, "&")
|
|
|
|
|
- .replace(/</g, "<")
|
|
|
|
|
- .replace(/>/g, ">")
|
|
|
|
|
- .replace(/"/g, """);
|
|
|
|
|
|
|
+ if (!view) return;
|
|
|
|
|
+ syncSiteLogIpFilter();
|
|
|
|
|
+ const q = String(state.siteLogQuery || "").trim();
|
|
|
|
|
|
|
|
if (state.siteLogKind === "error") {
|
|
if (state.siteLogKind === "error") {
|
|
|
view.classList.add("table-mode");
|
|
view.classList.add("table-mode");
|
|
|
try {
|
|
try {
|
|
|
const qs = new URLSearchParams({ name: site.name, kind: "error", limit: "200" });
|
|
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 data = await api(`/api/websites/logs?${qs.toString()}`);
|
|
|
const items = (data && data.items) || [];
|
|
const items = (data && data.items) || [];
|
|
|
if (!items.length) {
|
|
if (!items.length) {
|
|
|
- view.innerHTML = `<div class="site-log-empty">暂无错误日志</div>`;
|
|
|
|
|
|
|
+ view.innerHTML = `<div class="site-log-empty">${
|
|
|
|
|
+ q ? "没有匹配的错误日志" : "暂无错误日志"
|
|
|
|
|
+ }</div>`;
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
+ const esc = escapeHtml;
|
|
|
const rows = items
|
|
const rows = items
|
|
|
.map((r) => `<tr>
|
|
.map((r) => `<tr>
|
|
|
<td>${esc(r.time)}</td>
|
|
<td>${esc(r.time)}</td>
|
|
@@ -2749,44 +2809,18 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
view.classList.add("table-mode");
|
|
view.classList.add("table-mode");
|
|
|
|
|
+ const ip = String(state.siteLogIp || "").trim();
|
|
|
const qs = new URLSearchParams({ name: site.name, kind: "access", limit: "200" });
|
|
const qs = new URLSearchParams({ name: site.name, kind: "access", limit: "200" });
|
|
|
|
|
+ if (ip) qs.set("ip", ip);
|
|
|
|
|
+ if (q) qs.set("q", q);
|
|
|
try {
|
|
try {
|
|
|
const data = await api(`/api/websites/logs?${qs.toString()}`);
|
|
const data = await api(`/api/websites/logs?${qs.toString()}`);
|
|
|
const items = (data && data.items) || [];
|
|
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;
|
|
view.scrollTop = 0;
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
view.classList.remove("table-mode");
|
|
view.classList.remove("table-mode");
|
|
@@ -2904,13 +2938,60 @@
|
|
|
return `${mo}-${day} ${hh}:${mm}`;
|
|
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) {
|
|
function renderAnalytics(data) {
|
|
|
const body = $("#analytics-body");
|
|
const body = $("#analytics-body");
|
|
|
if (!body) return;
|
|
if (!body) return;
|
|
|
destroyAnalyticsCharts();
|
|
destroyAnalyticsCharts();
|
|
|
const s = data.summary || {};
|
|
const s = data.summary || {};
|
|
|
const cc = data.cc || {};
|
|
const cc = data.cc || {};
|
|
|
- const errors = data.errors || {};
|
|
|
|
|
const esc = escapeHtml;
|
|
const esc = escapeHtml;
|
|
|
|
|
|
|
|
const cards = [
|
|
const cards = [
|
|
@@ -2934,13 +3015,18 @@
|
|
|
.join("");
|
|
.join("");
|
|
|
|
|
|
|
|
const suspects = Array.isArray(cc.suspects) ? cc.suspects : [];
|
|
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
|
|
const suspectHtml = suspects.length
|
|
|
? `<div class="table-responsive"><table class="table table-sm align-middle mb-0">
|
|
? `<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>
|
|
<thead><tr><th>IP</th><th>请求</th><th>错误</th><th>占比</th><th>原因</th></tr></thead>
|
|
|
<tbody>${suspects
|
|
<tbody>${suspects
|
|
|
.map(
|
|
.map(
|
|
|
(r) => `<tr>
|
|
(r) => `<tr>
|
|
|
- <td><code>${esc(r.ip)}</code></td>
|
|
|
|
|
|
|
+ <td><code>${ipLink(r.ip)}</code></td>
|
|
|
<td>${esc(r.count)}</td>
|
|
<td>${esc(r.count)}</td>
|
|
|
<td>${esc(r.errors)}</td>
|
|
<td>${esc(r.errors)}</td>
|
|
|
<td>${Number(r.share || 0).toFixed(1)}%</td>
|
|
<td>${Number(r.share || 0).toFixed(1)}%</td>
|
|
@@ -2953,7 +3039,7 @@
|
|
|
const topIpRows = (data.top_ips || [])
|
|
const topIpRows = (data.top_ips || [])
|
|
|
.map(
|
|
.map(
|
|
|
(r) => `<tr>
|
|
(r) => `<tr>
|
|
|
- <td><code>${esc(r.ip)}</code></td>
|
|
|
|
|
|
|
+ <td><code>${ipLink(r.ip)}</code></td>
|
|
|
<td>${esc(r.count)}</td>
|
|
<td>${esc(r.count)}</td>
|
|
|
<td>${esc(r.errors)}</td>
|
|
<td>${esc(r.errors)}</td>
|
|
|
<td>${esc(formatBytes(r.bytes_recv))}</td>
|
|
<td>${esc(formatBytes(r.bytes_recv))}</td>
|
|
@@ -2985,22 +3071,14 @@
|
|
|
)
|
|
)
|
|
|
.join("");
|
|
.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 = `
|
|
body.innerHTML = `
|
|
|
<div class="row g-2 mb-3">${cards}</div>
|
|
<div class="row g-2 mb-3">${cards}</div>
|
|
|
<div class="row g-3 mb-3">
|
|
<div class="row g-3 mb-3">
|
|
|
<div class="col-lg-8">
|
|
<div class="col-lg-8">
|
|
|
<div class="analytics-panel">
|
|
<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 class="analytics-chart"><canvas id="an-chart-traffic"></canvas></div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -3011,19 +3089,11 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</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>
|
|
|
|
|
+ <div class="analytics-chart"><canvas id="an-chart-bytes"></canvas></div>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="analytics-panel mb-3">
|
|
<div class="analytics-panel mb-3">
|
|
|
<div class="analytics-panel-title">CC / 异常 IP 分析
|
|
<div class="analytics-panel-title">CC / 异常 IP 分析
|
|
@@ -3051,35 +3121,19 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</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>
|
|
|
<div class="small text-secondary">数据源:${esc(data.log_db || "")}</div>
|
|
<div class="small text-secondary">数据源:${esc(data.log_db || "")}</div>
|
|
|
`;
|
|
`;
|
|
|
|
|
|
|
|
if (typeof Chart === "undefined") return;
|
|
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) => {
|
|
const mkLine = (el, datasets, yFmt) => {
|
|
|
if (!el) return null;
|
|
if (!el) return null;
|
|
@@ -3090,10 +3144,11 @@
|
|
|
responsive: true,
|
|
responsive: true,
|
|
|
maintainAspectRatio: false,
|
|
maintainAspectRatio: false,
|
|
|
animation: false,
|
|
animation: false,
|
|
|
|
|
+ spanGaps: false,
|
|
|
interaction: { mode: "index", intersect: false },
|
|
interaction: { mode: "index", intersect: false },
|
|
|
plugins: { legend: { display: datasets.length > 1, position: "top", align: "end", labels: { boxWidth: 10, font: { size: 11 } } } },
|
|
plugins: { legend: { display: datasets.length > 1, position: "top", align: "end", labels: { boxWidth: 10, font: { size: 11 } } } },
|
|
|
scales: {
|
|
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)" } },
|
|
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 } },
|
|
elements: { line: { tension: 0.3, borderWidth: 2 }, point: { radius: 0 } },
|
|
@@ -3105,14 +3160,58 @@
|
|
|
|
|
|
|
|
mkLine(
|
|
mkLine(
|
|
|
$("#an-chart-traffic"),
|
|
$("#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
|
|
(v) => v
|
|
|
);
|
|
);
|
|
|
mkLine(
|
|
mkLine(
|
|
|
$("#an-chart-bytes"),
|
|
$("#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)
|
|
(v) => formatBytes(v)
|
|
|
);
|
|
);
|
|
@@ -3137,32 +3236,6 @@
|
|
|
});
|
|
});
|
|
|
state.analyticsCharts.push(chart);
|
|
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() {
|
|
async function loadSiteAnalytics() {
|
|
@@ -3186,6 +3259,25 @@
|
|
|
await loadSiteAnalytics();
|
|
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() {
|
|
function bindSiteSettingsModal() {
|
|
|
state.siteSettingsModal = new bootstrap.Modal($("#site-settings-modal"));
|
|
state.siteSettingsModal = new bootstrap.Modal($("#site-settings-modal"));
|
|
|
$("#site-settings-close").addEventListener("click", () => state.siteSettingsModal.hide());
|
|
$("#site-settings-close").addEventListener("click", () => state.siteSettingsModal.hide());
|
|
@@ -3265,12 +3357,30 @@
|
|
|
loadSiteLog();
|
|
loadSiteLog();
|
|
|
});
|
|
});
|
|
|
$("#site-log-refresh").addEventListener("click", () => 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", () => {
|
|
$("#site-modules-refresh")?.addEventListener("click", () => {
|
|
|
withBusy(async () => loadSiteModules());
|
|
withBusy(async () => loadSiteModules());
|
|
@@ -3301,6 +3411,13 @@
|
|
|
withBusy(async () => loadSiteAnalytics());
|
|
withBusy(async () => loadSiteAnalytics());
|
|
|
});
|
|
});
|
|
|
$("#site-analytics-modal")?.addEventListener("hidden.bs.modal", () => destroyAnalyticsCharts());
|
|
$("#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 () => {
|
|
$("#btn-logout")?.addEventListener("click", async () => {
|
|
|
const ok = await confirmDialog({
|
|
const ok = await confirmDialog({
|