Added LogHub

This commit is contained in:
2026-01-08 20:24:51 +01:00
parent 97995ec5f6
commit f72a78f16c
9 changed files with 300 additions and 53 deletions

View File

@@ -28,6 +28,7 @@ public class LogsController : ControllerBase
return Unauthorized(new { error = "Invalid API key" });
}
await _logService.AddLogsAsync(entries);
return Ok(new { success = true, count = entries.Count });
}
@@ -48,9 +49,9 @@ public class LogsController : ControllerBase
}
[HttpGet("stats")]
public async Task<IActionResult> GetStats([FromQuery] string? app, [FromQuery] string? env)
public async Task<IActionResult> GetStats([FromQuery] string? app, [FromQuery] string? env, [FromQuery] bool skipCache = false)
{
var stats = await _logService.GetStatsAsync(app, env);
var stats = await _logService.GetStatsAsync(app, env, skipCache);
return Ok(stats);
}

View File

@@ -0,0 +1,31 @@
using EonaCat.Logger.LogServer.Hubs;
using EonaCat.Logger.Server.Models;
using Microsoft.AspNetCore.SignalR;
namespace EonaCat.Logger.LogServer.Data
{
public class LogDispatcher
{
private readonly IHubContext<LogHub> _hub;
public LogDispatcher(IHubContext<LogHub> hub)
{
_hub = hub;
}
public async Task PublishAsync(LogEntry log)
{
await _hub.Clients.All.SendAsync("NewLog", new
{
id = log.Id,
level = log.Level,
message = log.Message,
applicationName = log.ApplicationName,
environment = log.Environment,
timestamp = log.Timestamp,
machineName = log.MachineName,
correlationId = log.CorrelationId
});
}
}
}

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="EonaCat.Json" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.SignalR;
namespace EonaCat.Logger.LogServer.Hubs
{
public class LogHub : Hub
{
public override async Task OnConnectedAsync()
{
await Clients.Caller.SendAsync("Connected", Context.ConnectionId);
await base.OnConnectedAsync();
}
}
}

View File

@@ -356,6 +356,13 @@
<!-- Logs Tab -->
<div id="logs-tab" class="tab-content hidden">
<button id="live-toggle"
onclick="toggleLive()"
class="px-4 py-2 rounded-lg bg-green-600 text-white font-semibold flex items-center gap-2">
<span id="live-dot" class="pulse-dot">●</span>
Live
</button>
<!-- Advanced Filters -->
<div class="bg-white rounded-xl shadow-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4">
@@ -556,6 +563,8 @@
</div>
<script>
let liveEnabled = false;
let signalRConnection = null;
let currentPage = 1;
let totalPages = 1;
let levelChart, trendChart;
@@ -566,9 +575,35 @@
const levelClasses = ['trace', 'debug', 'info', 'warning', 'error', 'critical', 'security', 'analytics'];
const levelColors = ['#9ca3af', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#a855f7', '#f97316', '#14b8a6'];
async function loadStats() {
try {
const response = await fetch('/api/logs/stats');
async function initSignalR() {
signalRConnection = new signalR.HubConnectionBuilder()
.withUrl("/logHub")
.withAutomaticReconnect()
.build();
signalRConnection.on("Connected", id => {
console.log("Connected with ID:", id);
});
signalRConnection.on("NewLogBatch", logs =>
{
if (!liveEnabled) return;
logs.forEach(log => prependLiveLog(log));
});
signalRConnection.onreconnected(() => {
console.log("SignalR reconnected");
});
await signalRConnection.start();
}
let statsInterval;
async function loadStats(skipCache = false)
{
try
{
const response = await fetch('/api/logs/stats' + (skipCache ? '?skipCache=true' : ''));
const stats = await response.json();
document.getElementById('stat-total').textContent = stats.totalLogs.toLocaleString();
@@ -651,28 +686,42 @@
function displayTopApplications(apps) {
const container = document.getElementById('top-apps');
if (!apps || apps.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center py-8">No data available</p>';
return;
}
const maxCount = Math.max(...apps.map(a => a.count));
container.innerHTML = apps.map(app => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition">
<div class="flex items-center gap-3 flex-1">
<div class="bg-blue-100 rounded-lg p-2">
<i class="fas fa-cube text-blue-600"></i>
</div>
<div class="flex-1">
<p class="font-semibold text-gray-800">${app.application}</p>
<div class="w-full bg-gray-200 rounded-full h-2 mt-1">
<div class="bg-blue-600 h-2 rounded-full transition-all" style="width: ${(app.count / maxCount) * 100}%"></div>
const maxCount = Math.max(...apps.map(a => a.count), 1); // prevent division by zero
container.innerHTML = apps.map(app => {
const widthPercent = Math.round((app.count / maxCount) * 100);
return `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition">
<div class="flex items-center gap-3 flex-1">
<div class="bg-blue-100 rounded-lg p-2">
<i class="fas fa-cube text-blue-600"></i>
</div>
<div class="flex-1">
<p class="font-semibold text-gray-800">${escapeHtml(app.application)}</p>
<div class="w-full bg-gray-200 rounded-full h-2 mt-1">
<div class="bg-blue-600 h-2 rounded-full transition-all" style="width: ${widthPercent}%;"></div>
</div>
</div>
</div>
<span class="badge bg-blue-100 text-blue-800">${app.count.toLocaleString()}</span>
</div>
<span class="badge bg-blue-100 text-blue-800">${app.count.toLocaleString()}</span>
</div>
`).join('');
`;
}).join('');
}
function escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function displayRecentErrors(errors) {
@@ -1099,8 +1148,86 @@
}
}
loadStats();
let totalLogs = 0;
function prependLiveLog(log) {
const container = document.getElementById('logs-container');
const totalCounter = document.getElementById('log-count');
// Check if any filters are active
const filtersActive = document.getElementById('active-filters')?.children.length > 0;
// Increment total logs counter always
totalLogs++;
if (totalCounter) {
const currentCount = parseInt(totalCounter.textContent.replace(/,/g, '')) || 0;
totalCounter.textContent = (currentCount + 1).toLocaleString();
}
// If filters are active, skip prepending to DOM
if (filtersActive) return;
// Create log element
const level = log.level;
const el = document.createElement('div');
el.className = `log-entry log-${levelClasses[level]} border-l-4 animate-pulse`;
el.onclick = () => showLogDetail(log.id);
el.innerHTML = `
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
<span class="badge bg-white shadow-sm">
${levelIcons[level]} ${levelNames[level]}
</span>
<span class="font-semibold">${escapeHtml(log.applicationName)}</span>
<span class="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
LIVE
</span>
</div>
<span class="text-xs text-gray-500">
${new Date(log.timestamp).toLocaleTimeString()}
</span>
</div>
<p class="font-medium">${escapeHtml(log.message)}</p>
`;
// Prepend to container
container.prepend(el);
// Remove the pulse animation after 2 seconds
setTimeout(() => {
el.classList.remove('animate-pulse');
}, 2000);
// Limit DOM size
if (container.children.length > 200) {
container.removeChild(container.lastChild);
}
}
function toggleLive() {
liveEnabled = !liveEnabled;
const btn = document.getElementById('live-toggle');
const dot = document.getElementById('live-dot');
if (liveEnabled) {
btn.classList.remove('bg-green-600');
btn.classList.add('bg-red-600');
btn.innerHTML = `<span id="live-dot" class="pulse-dot">●</span> Live`;
} else {
btn.classList.remove('bg-red-600');
btn.classList.add('bg-green-600');
btn.innerHTML = `<span>●</span> Live`;
}
}
initSignalR();
toggleLive();
loadStats(true);
loadTrend();
setInterval(() => {
if (!document.getElementById('dashboard-tab').classList.contains('hidden')) {
loadStats();

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"]</title>
<script type="importmap"></script>
<script src="https://cdn.jsdelivr.net/npm/@@microsoft/signalr@8.0.0/dist/browser/signalr.min.js"></script>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>

View File

@@ -1,3 +1,4 @@
using EonaCat.Logger.LogServer.Hubs;
using EonaCat.Logger.Server.Data;
using EonaCat.Logger.Server.Services;
using Microsoft.EntityFrameworkCore;
@@ -22,6 +23,8 @@ builder.Services.AddCors(options =>
});
});
builder.Services.AddSignalR();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
@@ -43,5 +46,6 @@ app.UseCors();
app.UseAuthorization();
app.MapRazorPages();
app.MapControllers();
app.MapHub<LogHub>("/logHub");
app.Run();

View File

@@ -7,7 +7,7 @@ public interface ILogService
Task AddLogsAsync(List<LogEntry> entries);
Task<LogEntry?> GetLogByIdAsync(string id);
Task<PagedResult<LogEntry>> GetLogsAsync(LogQueryParams queryParams);
Task<Dictionary<string, object>> GetStatsAsync(string? app, string? env);
Task<Dictionary<string, object>> GetStatsAsync(string? app, string? env, bool skipCache);
Task<bool> ValidateApiKeyAsync(string apiKey);
Task<List<string>> GetApplicationsAsync();
Task<List<string>> GetEnvironmentsAsync();

View File

@@ -1,7 +1,10 @@
using EonaCat.Logger.Server.Data;
using EonaCat.Logger.LogServer.Hubs;
using EonaCat.Logger.Server.Data;
using EonaCat.Logger.Server.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Text.Json;
namespace EonaCat.Logger.Server.Services;
@@ -10,18 +13,55 @@ public class LogService : ILogService
{
private readonly LoggerDbContext _context;
private readonly IMemoryCache _cache;
private readonly IHubContext<LogHub> _hub;
public LogService(LoggerDbContext context, IMemoryCache cache)
public LogService(LoggerDbContext context, IHubContext<LogHub> hub, IMemoryCache cache)
{
_context = context;
_hub = hub;
_cache = cache;
}
public async Task AddLogsAsync(List<LogEntry> entries)
{
if (entries == null || entries.Count == 0)
{
return;
}
// Generate Unique ids
foreach (var log in entries)
{
if (string.IsNullOrWhiteSpace(log.Id) || _context.LogEntries.Any(e => e.Id == log.Id))
{
log.Id = Guid.NewGuid().ToString();
}
}
// Save logs
await _context.LogEntries.AddRangeAsync(entries);
await _context.SaveChangesAsync();
// Invalidate stats cache
_cache.Remove("stats");
// Generate payload for SignalR
var payload = entries.Select(log => new
{
id = log.Id,
level = log.Level,
message = log.Message,
applicationName = log.ApplicationName,
environment = log.Environment,
timestamp = log.Timestamp,
machineName = log.MachineName,
correlationId = log.CorrelationId
});
// // Send via SignalR
_ = Task.Run(() =>
_hub.Clients.All.SendAsync("NewLogBatch", payload)
);
}
public async Task<LogEntry?> GetLogByIdAsync(string id)
@@ -87,10 +127,11 @@ public class LogService : ILogService
};
}
public async Task<Dictionary<string, object>> GetStatsAsync(string? app, string? env)
public async Task<Dictionary<string, object>> GetStatsAsync(string? app, string? env, bool skipCache = false)
{
var cacheKey = $"stats_{app}_{env}";
if (_cache.TryGetValue(cacheKey, out Dictionary<string, object>? cachedStats))
if (!skipCache && _cache.TryGetValue(cacheKey, out Dictionary<string, object>? cachedStats))
return cachedStats!;
var query = _context.LogEntries.AsQueryable();
@@ -101,43 +142,71 @@ public class LogService : ILogService
if (!string.IsNullOrEmpty(env))
query = query.Where(l => l.Environment == env);
var last24Hours = DateTime.UtcNow.AddHours(-24);
var lastHour = DateTime.UtcNow.AddHours(-1);
var now = DateTime.UtcNow;
var last24Hours = now.AddHours(-24);
var lastHour = now.AddHours(-1);
var counts = await query
.GroupBy(l => 1)
.Select(g => new
{
Total = g.Count(),
Last24Hours = g.Count(l => l.Timestamp >= last24Hours),
LastHour = g.Count(l => l.Timestamp >= lastHour),
ErrorCount = g.Count(l => l.Level >= 4),
WarningCount = g.Count(l => l.Level == 3),
CriticalCount = g.Count(l => l.Level == 5),
SecurityCount = g.Count(l => l.Level == 6)
})
.FirstOrDefaultAsync() ?? new { Total = 0, Last24Hours = 0, LastHour = 0, ErrorCount = 0, WarningCount = 0, CriticalCount = 0, SecurityCount = 0 };
var applicationsCount = await _context.LogEntries
.Select(l => l.ApplicationName)
.Distinct()
.CountAsync();
var byLevel = await query
.GroupBy(l => l.Level)
.Select(g => new { Level = g.Key, Count = g.Count() })
.ToListAsync();
var topErrors = await query
.Where(l => l.Level >= 4 && l.Timestamp >= last24Hours)
.GroupBy(l => l.Message)
.Select(g => new { Message = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count)
.Take(5)
.ToListAsync();
var topApplications = await query
.Where(l => l.Timestamp >= last24Hours)
.GroupBy(l => l.ApplicationName)
.Select(g => new { Application = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count)
.Take(5)
.ToListAsync();
var stats = new Dictionary<string, object>
{
["totalLogs"] = await query.CountAsync(),
["last24Hours"] = await query.Where(l => l.Timestamp >= last24Hours).CountAsync(),
["lastHour"] = await query.Where(l => l.Timestamp >= lastHour).CountAsync(),
["errorCount"] = await query.Where(l => l.Level >= 4).CountAsync(),
["warningCount"] = await query.Where(l => l.Level == 3).CountAsync(),
["criticalCount"] = await query.Where(l => l.Level == 5).CountAsync(),
["securityCount"] = await query.Where(l => l.Level == 6).CountAsync(),
["applications"] = await _context.LogEntries.Select(l => l.ApplicationName).Distinct().CountAsync(),
["byLevel"] = await query
.GroupBy(l => l.Level)
.Select(g => new { Level = g.Key, Count = g.Count() })
.ToListAsync(),
["topErrors"] = await query
.Where(l => l.Level >= 4 && l.Timestamp >= last24Hours)
.GroupBy(l => l.Message)
.Select(g => new { Message = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count)
.Take(5)
.ToListAsync(),
["topApplications"] = await query
.Where(l => l.Timestamp >= last24Hours)
.GroupBy(l => l.ApplicationName)
.Select(g => new { Application = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count)
.Take(5)
.ToListAsync()
["totalLogs"] = counts.Total,
["last24Hours"] = counts.Last24Hours,
["lastHour"] = counts.LastHour,
["errorCount"] = counts.ErrorCount,
["warningCount"] = counts.WarningCount,
["criticalCount"] = counts.CriticalCount,
["securityCount"] = counts.SecurityCount,
["applications"] = applicationsCount,
["byLevel"] = byLevel,
["topErrors"] = topErrors,
["topApplications"] = topApplications
};
_cache.Set(cacheKey, stats, TimeSpan.FromSeconds(30));
return stats;
}
public async Task<bool> ValidateApiKeyAsync(string apiKey)
{
return await _context.Applications.AnyAsync(a => a.ApiKey == apiKey && a.IsActive);