Browse Source

添加实时日志查看功能:1. 在WebSocketManagerService中添加中央日志缓存队列 2. 创建SSE端点用于实时推送日志 3. 新增Logs.cshtml页面,支持虚拟滚动和双栏布局 4. 在导航栏添加实时日志入口

master
root 1 month ago
parent
commit
eda1b7da7b
  1. 8
      LTEMvcApp/Controllers/HomeController.cs
  2. 42
      LTEMvcApp/Controllers/WebSocketController.cs
  3. 25
      LTEMvcApp/Services/WebSocketManagerService.cs
  4. 162
      LTEMvcApp/Views/Home/ClientMessages.cshtml
  5. 268
      LTEMvcApp/Views/Home/Logs.cshtml
  6. 8
      LTEMvcApp/Views/Shared/_Layout.cshtml

8
LTEMvcApp/Controllers/HomeController.cs

@ -172,4 +172,12 @@ public class HomeController : Controller
ViewBag.ClientName = clientName;
return View();
}
/// <summary>
/// 返回 Logs.cshtml 视图
/// </summary>
public IActionResult Logs()
{
return View();
}
}

42
LTEMvcApp/Controllers/WebSocketController.cs

@ -5,6 +5,7 @@ using Newtonsoft.Json.Linq;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
using System.Linq;
using System.Threading;
namespace LTEMvcApp.Controllers
{
@ -365,12 +366,51 @@ namespace LTEMvcApp.Controllers
[HttpPost("test-client/stop")]
public ActionResult StopTestClient()
{
_logger.LogInformation("API 请求: 停止测试客户端");
var success = _webSocketManager.StopTestClient();
if (success)
return Ok(new { message = "测试客户端停止" });
return Ok(new { message = "测试客户端停止成功" });
else
return BadRequest("停止测试客户端失败");
}
/// <summary>
/// 使用 Server-Sent Events (SSE) 实时推送全局日志
/// </summary>
[HttpGet("logs/stream")]
public async Task StreamLogs(CancellationToken cancellationToken)
{
Response.ContentType = "text/event-stream";
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
int logIndex = 0;
// 首先,一次性推送所有已缓存的日志
var initialLogs = _webSocketManager.GetLogCache().ToList();
if (initialLogs.Any())
{
await SendSseEvent("history", new { logs = initialLogs });
await Response.Body.FlushAsync(cancellationToken);
logIndex = initialLogs.Count;
}
while (!cancellationToken.IsCancellationRequested)
{
var logCache = _webSocketManager.GetLogCache();
if (logCache.Count() > logIndex)
{
var newLogs = logCache.Skip(logIndex).ToList();
if (newLogs.Any())
{
await SendSseEvent("new_logs", new { logs = newLogs });
await Response.Body.FlushAsync(cancellationToken);
logIndex = logCache.Count();
}
}
await Task.Delay(250, cancellationToken);
}
}
}
/// <summary>

25
LTEMvcApp/Services/WebSocketManagerService.cs

@ -23,6 +23,8 @@ namespace LTEMvcApp.Services
private readonly ILogger<WebSocketManagerService> _logger;
private readonly IServiceProvider _serviceProvider;
private ClientConfig _testClientConfig;
private const int LogCacheSize = 10000; // 服务器最多缓存10000条最新日志
private readonly ConcurrentQueue<LTELog> _logCache = new ConcurrentQueue<LTELog>();
#endregion
@ -438,6 +440,16 @@ namespace LTEMvcApp.Services
return GetClientInstance(_testClientConfig.Name);
}
/// <summary>
/// 获取当前缓存的日志
/// </summary>
public IEnumerable<LTELog> GetLogCache() => _logCache;
/// <summary>
/// 获取当前缓存的日志总数
/// </summary>
public int GetLogCacheCount() => _logCache.Count;
#endregion
#region 私有方法
@ -466,6 +478,19 @@ namespace LTEMvcApp.Services
private void OnLogsReceived(string clientName, List<LTELog> logs)
{
_logger.LogInformation($"客户端 {clientName} 收到日志: {logs.Count} 条");
// 将新日志存入中央缓存
foreach (var log in logs)
{
_logCache.Enqueue(log);
}
// 维持缓存大小
while (_logCache.Count > LogCacheSize)
{
_logCache.TryDequeue(out _);
}
LogsReceived?.Invoke(this, (clientName, logs));
}

162
LTEMvcApp/Views/Home/ClientMessages.cshtml

@ -26,11 +26,9 @@
<span class="badge badge-primary" id="sentCount">0</span>
</h5>
</div>
<div class="card-body" style="max-height: 600px; overflow-y: auto;">
<div id="sentMessages">
<div class="text-muted text-center">
<i class="fas fa-spinner fa-spin"></i> 正在建立与服务器的连接...
</div>
<div id="sentScrollArea" class="clusterize-scroll" style="height: 600px; overflow-y: auto;">
<div id="sentContentArea" class="clusterize-content">
<div class="clusterize-no-data">正在连接...</div>
</div>
</div>
</div>
@ -43,11 +41,9 @@
<span class="badge badge-info" id="receivedCount">0</span>
</h5>
</div>
<div class="card-body" style="max-height: 600px; overflow-y: auto;">
<div id="receivedMessages">
<div class="text-muted text-center">
<i class="fas fa-spinner fa-spin"></i> 正在建立与服务器的连接...
</div>
<div id="receivedScrollArea" class="clusterize-scroll" style="height: 600px; overflow-y: auto;">
<div id="receivedContentArea" class="clusterize-content">
<div class="clusterize-no-data">正在连接...</div>
</div>
</div>
</div>
@ -60,92 +56,112 @@
</div>
@section Scripts {
<!-- Clusterize.js for virtual scrolling -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/clusterize.js/0.19.0/clusterize.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/clusterize.js/0.19.0/clusterize.min.js"></script>
<!-- highlight.js for syntax highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script>
const clientName = '@clientName';
const MAX_MESSAGES = 500; // 每个列表最多显示500条消息
const MAX_MESSAGES_IN_MEMORY = 10000; // JS内存中最多保留1万条数据
let sentMessagesData = [];
let receivedMessagesData = [];
let sentClusterize, receivedClusterize;
$(document).ready(function() {
// 初始化虚拟滚动列表
sentClusterize = new Clusterize({
rows: [],
scrollId: 'sentScrollArea',
contentId: 'sentContentArea',
tag: 'div',
rows_in_block: 20,
blocks_in_cluster: 4,
no_data_text: '暂无发送消息'
});
receivedClusterize = new Clusterize({
rows: [],
scrollId: 'receivedScrollArea',
contentId: 'receivedContentArea',
tag: 'div',
rows_in_block: 20,
blocks_in_cluster: 4,
no_data_text: '暂无接收消息'
});
// 启动SSE连接
initializeEventSource();
});
function initializeEventSource() {
$('#sentMessages').html('<div class="text-muted text-center"><i class="fas fa-spinner fa-spin"></i> 正在建立与服务器的连接...</div>');
$('#receivedMessages').html('<div class="text-muted text-center"><i class="fas fa-spinner fa-spin"></i> 正在建立与服务器的连接...</div>');
const source = new EventSource(`/api/websocket/clients/${encodeURIComponent(clientName)}/messages/stream`);
const statusBadge = $('#connection-status');
source.addEventListener('open', function(e) {
console.log("SSE connection opened.");
statusBadge.removeClass('badge-secondary badge-danger').addClass('badge-success').text('已连接');
// 清空等待消息
$('#sentMessages').empty();
$('#receivedMessages').empty();
// 重置数据和视图
sentMessagesData = [];
receivedMessagesData = [];
sentClusterize.update([]);
receivedClusterize.update([]);
});
source.addEventListener('update', function(e) {
const data = JSON.parse(e.data);
updateMessageList(data.type, data.messages, data.totalCount);
});
source.addEventListener('error', function(e) {
statusBadge.removeClass('badge-success').addClass('badge-danger').text('连接断开');
if (e.target.readyState === EventSource.CLOSED) {
console.error("SSE connection closed.");
} else {
console.error("SSE error:", e);
const isSent = data.type === 'sent';
const clusterize = isSent ? sentClusterize : receivedClusterize;
const dataArray = isSent ? sentMessagesData : receivedMessagesData;
const scrollArea = isSent ? $('#sentScrollArea') : $('#receivedScrollArea');
// 检查用户是否已滚动到底部
const isScrolledToBottom = scrollArea[0].scrollHeight - scrollArea.scrollTop() < scrollArea.outerHeight() + 50;
const newRows = data.messages.map(function(msg, i) {
let totalIndex = data.totalCount - data.messages.length + i;
return createMessageCardHtml(msg, totalIndex, data.type);
});
// 使用 append 方法,性能更高
clusterize.append(newRows);
dataArray.push(...newRows); // 同步数据到内存数组
// 限制内存中的数据量
if (dataArray.length > MAX_MESSAGES_IN_MEMORY) {
const toRemoveCount = dataArray.length - MAX_MESSAGES_IN_MEMORY;
dataArray.splice(0, toRemoveCount);
// Clusterize.js 内部会自动处理DOM,我们只需更新数据数组即可
clusterize.update(dataArray);
}
// EventSource 会自动尝试重连
});
}
function updateMessageList(type, newMessages, totalCount) {
if (!newMessages || newMessages.length === 0) {
$(`#${type}Count`).text(totalCount);
return;
}
const container = $(`#${type}Messages`);
const fragment = $(document.createDocumentFragment());
// 移除 "暂无消息" 或 "正在连接" 的提示
if (container.children().length === 1 && !container.children().first().hasClass('card')) {
container.empty();
}
let currentIndex = totalCount - newMessages.length;
// 如果用户之前在底部,则自动滚动到底部
if (isScrolledToBottom) {
scrollArea.scrollTop(scrollArea[0].scrollHeight);
}
newMessages.forEach(function(message) {
const card = createMessageCard(message, currentIndex, type);
fragment.append(card);
currentIndex++;
$(`#${data.type}Count`).text(data.totalCount);
});
container.append(fragment);
// 限制DOM节点数量
const messageCards = container.children('.card');
if (messageCards.length > MAX_MESSAGES) {
messageCards.slice(0, messageCards.length - MAX_MESSAGES).remove();
}
$(`#${type}Count`).text(totalCount);
// 高亮新添加的代码
container.find('pre code').not('.hljs').each(function() {
if (typeof hljs !== 'undefined') {
hljs.highlightElement(this);
}
source.addEventListener('error', function(e) {
statusBadge.removeClass('badge-success').addClass('badge-danger').text('连接断开');
console.error("SSE connection error/closed.", e);
});
}
function createMessageCard(message, index, type) {
function createMessageCardHtml(message, index, type) {
const timestamp = new Date().toLocaleTimeString();
const messageType = type === 'sent' ? '发送' : '接收';
const bgClass = type === 'sent' ? 'border-success' : 'border-info';
const iconClass = type === 'sent' ? 'fas fa-paper-plane text-success' : 'fas fa-download text-info';
const cardHtml = `
return `
<div class="card mb-2 ${bgClass}">
<div class="card-header py-2">
<small class="text-muted">
@ -160,11 +176,19 @@
</div>
</div>
`;
return $(cardHtml);
}
function toggleMessage(button) {
$(button).closest('.card').find('.card-body').slideToggle('fast');
const cardBody = $(button).closest('.card').find('.card-body');
cardBody.slideToggle('fast', function() {
// 当内容可见时,才进行语法高亮
if (cardBody.is(':visible')) {
const codeBlock = cardBody.find('pre code');
if (codeBlock.length && typeof hljs !== 'undefined' && !codeBlock.hasClass('hljs')) {
hljs.highlightElement(codeBlock[0]);
}
}
});
$(button).find('i').toggleClass('fa-chevron-down fa-chevron-up');
}
@ -186,8 +210,4 @@
.replace(/'/g, "&#039;");
}
</script>
<!-- 添加代码高亮支持 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
}

268
LTEMvcApp/Views/Home/Logs.cshtml

@ -0,0 +1,268 @@
@{
ViewData["Title"] = "实时日志";
}
<style>
/* 页面布局 */
.log-container {
display: flex;
height: calc(100vh - 120px);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
/* 左侧日志列表 */
.log-list-panel {
flex: 0 0 65%;
border-right: 2px solid #ddd;
display: flex;
flex-direction: column;
overflow: hidden; /* 防止内部元素溢出 */
}
#log-scroll-area {
flex-grow: 1;
overflow-y: auto;
}
#log-content-area {
width: 100%;
}
.log-item {
padding: 6px 10px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
display: flex;
justify-content: space-between;
white-space: nowrap;
}
.log-item:hover {
background-color: #e8f4ff;
}
.log-item.selected {
background-color: #cce7ff;
font-weight: bold;
}
.log-item > span {
text-overflow: ellipsis;
overflow: hidden;
padding-right: 15px;
}
.log-timestamp { flex-basis: 18%; }
.log-layer { flex-basis: 10%; }
.log-direction { flex-basis: 8%; }
.log-message { flex-basis: 24%; }
.log-info { flex-basis: 40%; }
/* 右侧日志详情 */
.log-detail-panel {
flex-grow: 1;
padding: 0 20px;
overflow-y: auto;
}
.log-detail-panel h4 {
margin-top: 10px;
border-bottom: 2px solid #007bff;
padding-bottom: 5px;
color: #0056b3;
}
.detail-item {
margin-bottom: 12px;
}
.detail-item-label {
font-weight: bold;
color: #333;
}
.detail-item-value {
background-color: #f7f7f7;
padding: 8px;
border-radius: 4px;
word-break: break-all;
white-space: pre-wrap;
margin-top: 4px;
}
#detail-placeholder {
color: #999;
text-align: center;
padding-top: 50px;
font-size: 1.2em;
}
/* 状态栏 */
.status-bar {
padding: 8px 10px;
background-color: #f8f9fa;
border-top: 1px solid #ddd;
font-size: 0.9em;
color: #666;
}
</style>
<div class="log-container">
<div class="log-list-panel">
<div id="log-scroll-area">
<div id="log-content-area"></div>
</div>
<div class="status-bar">
总日志条数: <span id="total-logs">0</span>
</div>
</div>
<div class="log-detail-panel">
<div id="detail-placeholder">
<p>请从左侧选择一条日志以查看详情</p>
</div>
<div id="detail-content" class="d-none">
<h4>日志详情</h4>
<!-- 详情将在这里动态生成 -->
</div>
</div>
</div>
@section Scripts {
<script src="https://cdnjs.cloudflare.com/ajax/libs/clusterize.js/0.19.0/clusterize.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/clusterize.js/0.19.0/clusterize.min.css" />
<script>
document.addEventListener("DOMContentLoaded", function () {
const scrollArea = document.getElementById('log-scroll-area');
const contentArea = document.getElementById('log-content-area');
const totalLogsEl = document.getElementById('total-logs');
const detailPlaceholder = document.getElementById('detail-placeholder');
const detailContent = document.getElementById('detail-content');
let allLogsData = [];
let clusterize = new Clusterize({
rows: [],
scrollId: 'log-scroll-area',
contentId: 'log-content-area',
tag: 'div',
rows_in_block: 50,
blocks_in_cluster: 4
});
// 美化方向
function formatDirection(dir) {
return dir === 0 ? "Uplink" : "Downlink";
}
// 格式化日志条目为 HTML 字符串
function formatLogItem(log, index) {
const timestamp = new Date(log.timestamp).toISOString();
return `<div class="log-item" data-index="${index}">
<span class="log-timestamp" title="${timestamp}">${timestamp}</span>
<span class="log-layer" title="${log.layer}">${log.layer}</span>
<span class="log-direction" title="${formatDirection(log.direction)}">${formatDirection(log.direction)}</span>
<span class="log-message" title="${log.message}">${log.message}</span>
<span class="log-info" title="${log.info}">${log.info}</span>
</div>`;
}
// 更新日志列表
function updateLogList(logs, prepend = false) {
const newRows = logs.map((log, i) => formatLogItem(log, allLogsData.length + i));
if (prepend) {
clusterize.prepend(newRows);
} else {
clusterize.append(newRows);
}
allLogsData.push(...logs);
totalLogsEl.textContent = allLogsData.length;
}
// 显示日志详情
function showLogDetail(index) {
const log = allLogsData[index];
if (!log) return;
detailPlaceholder.classList.add('d-none');
const detailHtml = `
<div class="detail-item">
<div class="detail-item-label">Timestamp</div>
<div class="detail-item-value">${new Date(log.timestamp).toISOString()}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Layer</div>
<div class="detail-item-value">${log.layer}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Direction</div>
<div class="detail-item-value">${formatDirection(log.direction)} (${log.direction})</div>
</div>
<div class="detail-item">
<div class="detail-item-label">UeId</div>
<div class="detail-item-value">${log.ueId || 'N/A'}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Rnti</div>
<div class="detail-item-value">${log.rnti || 'N/A'}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Client</div>
<div class="detail-item-value">${log.client || 'N/A'}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Message</div>
<div class="detail-item-value">${log.message}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Info</div>
<div class="detail-item-value">${log.info}</div>
</div>
<div class="detail-item">
<div class="detail-item-label">Data</div>
<div class="detail-item-value">${log.data || 'N/A'}</div>
</div>
`;
detailContent.innerHTML = '<h4>日志详情</h4>' + detailHtml;
detailContent.classList.remove('d-none');
}
// 事件委托处理点击事件
contentArea.addEventListener('click', function(e) {
const item = e.target.closest('.log-item');
if (item) {
// 移除之前选中的
const selected = contentArea.querySelector('.log-item.selected');
if (selected) {
selected.classList.remove('selected');
}
// 添加选中样式
item.classList.add('selected');
const index = parseInt(item.dataset.index, 10);
showLogDetail(index);
}
});
// SSE 连接
const eventSource = new EventSource('/api/websocket/logs/stream');
eventSource.addEventListener('history', function(event) {
console.log("接收到历史日志...");
const data = JSON.parse(event.data);
updateLogList(data.logs);
});
eventSource.addEventListener('new_logs', function(event) {
console.log("接收到新日志...");
const data = JSON.parse(event.data);
updateLogList(data.logs);
});
eventSource.onerror = function (err) {
console.error("SSE 错误:", err);
totalLogsEl.textContent += " (连接已断开)";
eventSource.close();
};
});
</script>
}

8
LTEMvcApp/Views/Shared/_Layout.cshtml

@ -21,7 +21,13 @@
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">首页</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="ClientMessages">客户端消息</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Logs">实时日志</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="TestClientConfig">测试客户端配置</a>

Loading…
Cancel
Save