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" }); return Unauthorized(new { error = "Invalid API key" });
} }
await _logService.AddLogsAsync(entries); await _logService.AddLogsAsync(entries);
return Ok(new { success = true, count = entries.Count }); return Ok(new { success = true, count = entries.Count });
} }
@@ -48,9 +49,9 @@ public class LogsController : ControllerBase
} }
[HttpGet("stats")] [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); 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> <ItemGroup>
<PackageReference Include="EonaCat.Json" Version="1.2.0" /> <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" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets> <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 --> <!-- Logs Tab -->
<div id="logs-tab" class="tab-content hidden"> <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 --> <!-- Advanced Filters -->
<div class="bg-white rounded-xl shadow-lg p-6 mb-6"> <div class="bg-white rounded-xl shadow-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -556,6 +563,8 @@
</div> </div>
<script> <script>
let liveEnabled = false;
let signalRConnection = null;
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
let levelChart, trendChart; let levelChart, trendChart;
@@ -566,9 +575,35 @@
const levelClasses = ['trace', 'debug', 'info', 'warning', 'error', 'critical', 'security', 'analytics']; const levelClasses = ['trace', 'debug', 'info', 'warning', 'error', 'critical', 'security', 'analytics'];
const levelColors = ['#9ca3af', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#a855f7', '#f97316', '#14b8a6']; const levelColors = ['#9ca3af', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#a855f7', '#f97316', '#14b8a6'];
async function loadStats() { async function initSignalR() {
try { signalRConnection = new signalR.HubConnectionBuilder()
const response = await fetch('/api/logs/stats'); .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(); const stats = await response.json();
document.getElementById('stat-total').textContent = stats.totalLogs.toLocaleString(); document.getElementById('stat-total').textContent = stats.totalLogs.toLocaleString();
@@ -651,28 +686,42 @@
function displayTopApplications(apps) { function displayTopApplications(apps) {
const container = document.getElementById('top-apps'); const container = document.getElementById('top-apps');
if (!apps || apps.length === 0) { if (!apps || apps.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center py-8">No data available</p>'; container.innerHTML = '<p class="text-gray-500 text-center py-8">No data available</p>';
return; return;
} }
const maxCount = Math.max(...apps.map(a => a.count)); const maxCount = Math.max(...apps.map(a => a.count), 1); // prevent division by zero
container.innerHTML = apps.map(app => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition"> container.innerHTML = apps.map(app => {
<div class="flex items-center gap-3 flex-1"> const widthPercent = Math.round((app.count / maxCount) * 100);
<div class="bg-blue-100 rounded-lg p-2"> return `
<i class="fas fa-cube text-blue-600"></i> <div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition">
</div> <div class="flex items-center gap-3 flex-1">
<div class="flex-1"> <div class="bg-blue-100 rounded-lg p-2">
<p class="font-semibold text-gray-800">${app.application}</p> <i class="fas fa-cube text-blue-600"></i>
<div class="w-full bg-gray-200 rounded-full h-2 mt-1"> </div>
<div class="bg-blue-600 h-2 rounded-full transition-all" style="width: ${(app.count / maxCount) * 100}%"></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>
</div> </div>
<span class="badge bg-blue-100 text-blue-800">${app.count.toLocaleString()}</span>
</div> </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) { 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(); loadTrend();
setInterval(() => { setInterval(() => {
if (!document.getElementById('dashboard-tab').classList.contains('hidden')) { if (!document.getElementById('dashboard-tab').classList.contains('hidden')) {
loadStats(); loadStats();

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"]</title> <title>@ViewData["Title"]</title>
<script type="importmap"></script> <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="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head> </head>

View File

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

View File

@@ -7,7 +7,7 @@ public interface ILogService
Task AddLogsAsync(List<LogEntry> entries); Task AddLogsAsync(List<LogEntry> entries);
Task<LogEntry?> GetLogByIdAsync(string id); Task<LogEntry?> GetLogByIdAsync(string id);
Task<PagedResult<LogEntry>> GetLogsAsync(LogQueryParams queryParams); 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<bool> ValidateApiKeyAsync(string apiKey);
Task<List<string>> GetApplicationsAsync(); Task<List<string>> GetApplicationsAsync();
Task<List<string>> GetEnvironmentsAsync(); 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 EonaCat.Logger.Server.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using System;
using System.Text.Json; using System.Text.Json;
namespace EonaCat.Logger.Server.Services; namespace EonaCat.Logger.Server.Services;
@@ -10,18 +13,55 @@ public class LogService : ILogService
{ {
private readonly LoggerDbContext _context; private readonly LoggerDbContext _context;
private readonly IMemoryCache _cache; 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; _context = context;
_hub = hub;
_cache = cache; _cache = cache;
} }
public async Task AddLogsAsync(List<LogEntry> entries) 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.LogEntries.AddRangeAsync(entries);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Invalidate stats cache
_cache.Remove("stats"); _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) 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}"; 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!; return cachedStats!;
var query = _context.LogEntries.AsQueryable(); var query = _context.LogEntries.AsQueryable();
@@ -101,43 +142,71 @@ public class LogService : ILogService
if (!string.IsNullOrEmpty(env)) if (!string.IsNullOrEmpty(env))
query = query.Where(l => l.Environment == env); query = query.Where(l => l.Environment == env);
var last24Hours = DateTime.UtcNow.AddHours(-24); var now = DateTime.UtcNow;
var lastHour = DateTime.UtcNow.AddHours(-1); 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> var stats = new Dictionary<string, object>
{ {
["totalLogs"] = await query.CountAsync(), ["totalLogs"] = counts.Total,
["last24Hours"] = await query.Where(l => l.Timestamp >= last24Hours).CountAsync(), ["last24Hours"] = counts.Last24Hours,
["lastHour"] = await query.Where(l => l.Timestamp >= lastHour).CountAsync(), ["lastHour"] = counts.LastHour,
["errorCount"] = await query.Where(l => l.Level >= 4).CountAsync(), ["errorCount"] = counts.ErrorCount,
["warningCount"] = await query.Where(l => l.Level == 3).CountAsync(), ["warningCount"] = counts.WarningCount,
["criticalCount"] = await query.Where(l => l.Level == 5).CountAsync(), ["criticalCount"] = counts.CriticalCount,
["securityCount"] = await query.Where(l => l.Level == 6).CountAsync(), ["securityCount"] = counts.SecurityCount,
["applications"] = await _context.LogEntries.Select(l => l.ApplicationName).Distinct().CountAsync(), ["applications"] = applicationsCount,
["byLevel"] = await query ["byLevel"] = byLevel,
.GroupBy(l => l.Level) ["topErrors"] = topErrors,
.Select(g => new { Level = g.Key, Count = g.Count() }) ["topApplications"] = topApplications
.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()
}; };
_cache.Set(cacheKey, stats, TimeSpan.FromSeconds(30)); _cache.Set(cacheKey, stats, TimeSpan.FromSeconds(30));
return stats; return stats;
} }
public async Task<bool> ValidateApiKeyAsync(string apiKey) public async Task<bool> ValidateApiKeyAsync(string apiKey)
{ {
return await _context.Applications.AnyAsync(a => a.ApiKey == apiKey && a.IsActive); return await _context.Applications.AnyAsync(a => a.ApiKey == apiKey && a.IsActive);