Added LogHub
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
31
EonaCat.Logger.LogServer/Data/LogDispatcher.cs
Normal file
31
EonaCat.Logger.LogServer/Data/LogDispatcher.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
13
EonaCat.Logger.LogServer/Hubs/LogHub.cs
Normal file
13
EonaCat.Logger.LogServer/Hubs/LogHub.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user