mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 04:35:33 +08:00
feat(nginx-log): NginxLogStat dashboard 视图与 JS(echarts 趋势图)
This commit is contained in:
3
extend/base/admin/view/system/nginx_log_stat/_common.js
Normal file
3
extend/base/admin/view/system/nginx_log_stat/_common.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// NginxLogStat dashboard _common.js
|
||||
// 仪表盘页面不使用 ua.table.render(数据由 AJAX 预取后客户端渲染),故 init 对象为空。
|
||||
// 本文件仅用于满足 STRICT_VIEW_JS=true(AdminControllerBase::fetchJS 要求 _common 模板存在)。
|
||||
121
extend/base/admin/view/system/nginx_log_stat/index.html
Normal file
121
extend/base/admin/view/system/nginx_log_stat/index.html
Normal 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>
|
||||
398
extend/base/admin/view/system/nginx_log_stat/index.js
Normal file
398
extend/base/admin/view/system/nginx_log_stat/index.js
Normal file
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* NginxLogStat dashboard
|
||||
*
|
||||
* 依赖:echarts(_require.html 全局加载)、layui(table / laydate / util)、ua(admin-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) {
|
||||
// ok:code=0
|
||||
var data = (res && res.data) ? res.data : null;
|
||||
if (!data) {
|
||||
renderAll(buildEmptyData());
|
||||
return;
|
||||
}
|
||||
renderAll(data);
|
||||
}, function (res) {
|
||||
// no:code!=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, '"') + '">' + 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 });
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user