feat(nginx-log): NginxLogStat dashboard 视图与 JS(echarts 趋势图)

This commit is contained in:
augushong
2026-07-28 06:34:30 +08:00
parent 3e983d8b8f
commit 8a5a0d9877
3 changed files with 522 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
// NginxLogStat dashboard _common.js
// 仪表盘页面不使用 ua.table.render数据由 AJAX 预取后客户端渲染),故 init 对象为空。
// 本文件仅用于满足 STRICT_VIEW_JS=trueAdminControllerBase::fetchJS 要求 _common 模板存在)。

View File

@@ -0,0 +1,121 @@
<div class="layuimini-container">
<div class="layuimini-main" id="nginxStatDashboard"
data-period="{$period|default='day'}"
data-start="{$start|default=''}"
data-end="{$end|default=''}">
<!-- 时间范围选择器 -->
<div class="layui-card">
<div class="layui-card-body" style="padding: 10px 15px;">
<div class="layui-btn-group" id="periodGroup">
<button type="button" class="layui-btn layui-btn-sm" data-period="day">今天</button>
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary" data-period="week">近7天</button>
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary" data-period="month">近30天</button>
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary" data-period="year">本年</button>
</div>
<span class="nginx-stat-custom-range" style="margin-left: 15px; display: inline-flex; align-items: center; gap: 5px; vertical-align: middle;">
<input type="text" class="layui-input" id="dateStart" placeholder="开始日期" autocomplete="off" style="width:130px;" readonly>
<span style="color:#999;">~</span>
<input type="text" class="layui-input" id="dateEnd" placeholder="结束日期" autocomplete="off" style="width:130px;" readonly>
<button type="button" class="layui-btn layui-btn-sm layui-btn-normal" id="customSearchBtn">查询</button>
</span>
</div>
</div>
<!-- 指标卡片 -->
<div class="layui-row layui-col-space15" style="margin-top: 0;">
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">访问量 PV</div>
<div class="layui-card-body nginx-stat-metric">
<span class="nginx-stat-metric-value" id="metricPv">-</span>
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">
独立访客 UV
<span class="layui-badge layui-bg-orange" id="uvEstimateBadge" style="display:none;">近似</span>
</div>
<div class="layui-card-body nginx-stat-metric">
<span class="nginx-stat-metric-value" id="metricUv">-</span>
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">流量</div>
<div class="layui-card-body nginx-stat-metric">
<span class="nginx-stat-metric-value" id="metricBytes">-</span>
</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">平均响应时间</div>
<div class="layui-card-body nginx-stat-metric">
<span class="nginx-stat-metric-value" id="metricAvgTime">-</span>
</div>
</div>
</div>
</div>
<!-- 图表区:状态码饼图 + 趋势折线图 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">状态码分布</div>
<div class="layui-card-body">
<div id="statusPieChart" style="width:100%;height:320px;"></div>
<div class="nginx-stat-empty-tip" id="statusPieEmpty" style="display:none;">暂无数据</div>
</div>
</div>
</div>
<div class="layui-col-md8">
<div class="layui-card">
<div class="layui-card-header">PV / UV 趋势</div>
<div class="layui-card-body">
<div id="trendChart" style="width:100%;height:320px;"></div>
<div class="nginx-stat-empty-tip" id="trendEmpty" style="display:none;">暂无数据</div>
</div>
</div>
</div>
</div>
<!-- 表格区Top URL / Top Referer / UA 分布 -->
<div class="layui-row layui-col-space15">
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">Top URL</div>
<div class="layui-card-body" style="padding:5px 10px;">
<table id="topUrlTable" lay-filter="topUrlTable"></table>
</div>
</div>
</div>
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">Top Referer</div>
<div class="layui-card-body" style="padding:5px 10px;">
<table id="topRefererTable" lay-filter="topRefererTable"></table>
</div>
</div>
</div>
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">UA 分布</div>
<div class="layui-card-body" style="padding:5px 10px;">
<table id="topUaTable" lay-filter="topUaTable"></table>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.nginx-stat-metric { padding: 15px; }
.nginx-stat-metric-value { font-size: 28px; font-weight: 600; color: #333; }
.nginx-stat-empty-tip { text-align: center; color: #999; padding: 40px 0; font-size: 14px; }
</style>

View File

@@ -0,0 +1,398 @@
/**
* NginxLogStat dashboard
*
* 依赖echarts_require.html 全局加载、layuitable / laydate / util、uaadmin-core.js
*
* 后端 URL 不硬编码:用 ua.url('system.nginx_log_stat/index') 由 CONFIG.ADMIN 拼接。
* AJAX 触发方式URL 带 _ajax=1不依赖 Accept header命令行测试友好
*/
layui.use(['table', 'laydate', 'util'], function () {
var table = layui.table;
var laydate = layui.laydate;
var util = layui.util;
// ── 配置URL 不硬编码:由 ua.url 拼接 /{ADMIN}/system.nginx_log_stat/index──
var STAT_INDEX_URL = ua.url('system.nginx_log_stat/index');
// ── DOM 引用 ──
var $dashboard = $('#nginxStatDashboard');
var $periodGroup = $('#periodGroup');
var $metricPv = $('#metricPv');
var $metricUv = $('#metricUv');
var $metricBytes = $('#metricBytes');
var $metricAvgTime = $('#metricAvgTime');
var $uvBadge = $('#uvEstimateBadge');
var $statusPie = $('#statusPieChart');
var $statusPieEmpty = $('#statusPieEmpty');
var $trend = $('#trendChart');
var $trendEmpty = $('#trendEmpty');
// ── echarts 实例(惰性初始化,容器存在时才创建)──
var statusPieChart = null;
var trendChart = null;
// ── 状态:当前选中的 period ──
var currentPeriod = $dashboard.data('period') || 'day';
// ================================================================
// 工具函数
// ================================================================
/**
* 格式化字节数为人类可读B / KB / MB / GB
*/
function formatBytes(bytes) {
bytes = Number(bytes) || 0;
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
/**
* 格式化响应时间(秒 → ms / s
*/
function formatTime(seconds) {
seconds = Number(seconds) || 0;
if (seconds < 1) return Math.round(seconds * 1000) + ' ms';
return seconds.toFixed(2) + ' s';
}
/**
* 数字千分位
*/
function formatNumber(num) {
num = Number(num) || 0;
return util.number ? util.number(num) : num.toLocaleString();
}
/**
* 截断长文本(表格 label 列)
*/
function truncate(text, maxLen) {
text = String(text || '');
maxLen = maxLen || 50;
if (text.length <= maxLen) return text;
return text.substring(0, maxLen) + '...';
}
// ================================================================
// period 按钮切换
// ================================================================
/**
* 设置 period 按钮高亮active 用 layui-btn 实色,其余 layui-btn-primary
*/
function setActivePeriod(period) {
currentPeriod = period;
$periodGroup.find('button[data-period]').each(function () {
var $btn = $(this);
if ($btn.data('period') === period) {
$btn.removeClass('layui-btn-primary');
} else {
$btn.addClass('layui-btn-primary');
}
});
}
$periodGroup.on('click', 'button[data-period]', function () {
var period = $(this).data('period');
setActivePeriod(period);
loadData({ period: period });
});
// ================================================================
// 自定义日期区间
// ================================================================
laydate.render({
elem: '#dateStart',
type: 'date',
format: 'yyyy-MM-dd',
});
laydate.render({
elem: '#dateEnd',
type: 'date',
format: 'yyyy-MM-dd',
});
$('#customSearchBtn').on('click', function () {
var startVal = $('#dateStart').val();
var endVal = $('#dateEnd').val();
if (!startVal || !endVal) {
ua.msg.error('请选择开始和结束日期');
return;
}
// 选了自定义区间后取消 period 按钮高亮
setActivePeriod('');
loadData({ period: 'custom', start: startVal, end: endVal });
});
// ================================================================
// AJAX 加载数据
// ================================================================
/**
* 请求后端统计数据并重绘全部区块
*
* @param {object} extra 额外参数period / start / end
*/
function loadData(extra) {
extra = extra || {};
var params = $.extend({ _ajax: 1, period: currentPeriod }, extra);
ua.request.get({
url: STAT_INDEX_URL + '?_ajax=1',
data: params,
statusName: 'code',
statusCode: 0,
}, function (res) {
// okcode=0
var data = (res && res.data) ? res.data : null;
if (!data) {
renderAll(buildEmptyData());
return;
}
renderAll(data);
}, function (res) {
// nocode!=0 或业务错误
var msg = (res && res.msg) ? res.msg : '加载统计数据失败';
ua.msg.error(msg);
renderAll(buildEmptyData());
}, function () {
// ex网络异常等
renderAll(buildEmptyData());
});
}
function buildEmptyData() {
return {
summary: {
pv: 0, uv: 0, total_bytes: 0,
status_2xx: 0, status_3xx: 0, status_4xx: 0, status_5xx: 0, status_other: 0,
avg_request_time: 0,
},
trend: [],
top_urls: [],
top_referers: [],
top_uas: [],
uv_is_estimate: false,
};
}
// ================================================================
// 渲染:汇总入口
// ================================================================
function renderAll(data) {
renderSummary(data.summary || {}, data.uv_is_estimate);
renderStatusPie(data.summary || {});
renderTrend(data.trend || []);
renderTopTables({
top_urls: data.top_urls || [],
top_referers: data.top_referers || [],
top_uas: data.top_uas || [],
});
}
// ================================================================
// 渲染:指标卡片
// ================================================================
function renderSummary(summary, uvIsEstimate) {
$metricPv.text(formatNumber(summary.pv));
$metricUv.text(formatNumber(summary.uv));
$metricBytes.text(formatBytes(summary.total_bytes));
$metricAvgTime.text(formatTime(summary.avg_request_time));
// UV 近似角标
if (uvIsEstimate) {
$uvBadge.show();
} else {
$uvBadge.hide();
}
}
// ================================================================
// 渲染:状态码饼图
// ================================================================
function renderStatusPie(summary) {
var segments = [
{ name: '2xx', value: Number(summary.status_2xx) || 0, color: '#5FB878' },
{ name: '3xx', value: Number(summary.status_3xx) || 0, color: '#1E9FFF' },
{ name: '4xx', value: Number(summary.status_4xx) || 0, color: '#FFB800' },
{ name: '5xx', value: Number(summary.status_5xx) || 0, color: '#FF5722' },
{ name: '其他', value: Number(summary.status_other) || 0, color: '#999' },
];
var total = 0;
for (var i = 0; i < segments.length; i++) {
total += segments[i].value;
}
// 空数据 → 隐藏 chart、显示占位
if (total === 0) {
$statusPie.hide();
$statusPieEmpty.show();
return;
}
$statusPie.show();
$statusPieEmpty.hide();
if (!statusPieChart) {
statusPieChart = echarts.init($statusPie[0]);
}
statusPieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, left: 'center', data: segments.map(function (s) { return s.name; }) },
series: [{
name: '状态码',
type: 'pie',
radius: ['40%', '65%'],
center: ['50%', '45%'],
avoidLabelOverlap: true,
label: { show: true, formatter: '{b}\n{d}%' },
labelLine: { show: true },
data: segments.map(function (s) {
return { name: s.name, value: s.value, itemStyle: { color: s.color } };
}),
}],
});
}
// ================================================================
// 渲染趋势折线图PV/UV 双 Y 轴)
// ================================================================
function renderTrend(trend) {
var hasData = trend && trend.length > 0;
var pvSum = 0;
if (hasData) {
for (var i = 0; i < trend.length; i++) {
pvSum += Number(trend[i].pv) || 0;
}
}
// 全部点 PV 都为 0 → 视为空数据
if (!hasData || pvSum === 0) {
$trend.hide();
$trendEmpty.show();
return;
}
$trend.show();
$trendEmpty.hide();
if (!trendChart) {
trendChart = echarts.init($trend[0]);
}
var labels = trend.map(function (d) { return d.label; });
var pvData = trend.map(function (d) { return Number(d.pv) || 0; });
var uvData = trend.map(function (d) { return Number(d.uv) || 0; });
trendChart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['PV', 'UV'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: labels },
yAxis: [
{ type: 'value', name: 'PV', position: 'left' },
{ type: 'value', name: 'UV', position: 'right' },
],
series: [
{
name: 'PV',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: '#1E9FFF' },
areaStyle: { opacity: 0.1 },
data: pvData,
},
{
name: 'UV',
type: 'line',
smooth: true,
yAxisIndex: 1,
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: '#5FB878' },
areaStyle: { opacity: 0.1 },
data: uvData,
},
],
}, true);
}
// ================================================================
// 渲染Top 表格(客户端数据,无分页)
// ================================================================
/**
* 通用 Top 表格渲染(客户端 data 模式)
*
* @param {string} elem 表格选择器
* @param {string} filter lay-filter
* @param {array} rows 数据行 [{label,pv,uv}]
* @param {number} maxLabelLen label 截断长度
*/
function renderTopTable(elem, filter, rows, maxLabelLen) {
// layui table 客户端渲染:传 data 数组(非 url
table.render({
elem: elem,
filter: filter,
data: rows || [],
page: false,
limit: 1000,
even: true,
text: { none: '暂无数据' },
cols: [[
{ type: 'numbers', title: '#', width: 45 },
{
field: 'label', title: '名称', minWidth: 120,
templet: function (d) {
var text = truncate(d.label, maxLabelLen || 50);
// 鼠标悬停显示完整内容
if (d.label && String(d.label).length > (maxLabelLen || 50)) {
return '<span title="' + String(d.label).replace(/"/g, '&quot;') + '">' + text + '</span>';
}
return text;
},
},
{ field: 'pv', title: 'PV', width: 80, sort: true },
{ field: 'uv', title: 'UV', width: 80, sort: true },
]],
});
}
function renderTopTables(data) {
renderTopTable('#topUrlTable', 'topUrlTable', data.top_urls, 50);
renderTopTable('#topRefererTable', 'topRefererTable', data.top_referers, 40);
renderTopTable('#topUaTable', 'topUaTable', data.top_uas, 30);
}
// ================================================================
// 窗口 resize → echarts 自适应
// ================================================================
$(window).on('resize', function () {
if (statusPieChart) statusPieChart.resize();
if (trendChart) trendChart.resize();
});
// ================================================================
// 初始化
// ================================================================
// 初始高亮当前 period 按钮(由控制器 assign 的 period 决定)
setActivePeriod(currentPeriod);
// 首次加载数据
loadData({ period: currentPeriod });
});