Add project files.
8748608fdb9f8cb620d77b3f5edbf4158f16d3c5
16 files changed
SeriLoog.slnxSeriLoog/Controllers/DemoController.csSeriLoog/Controllers/LogsController.csSeriLoog/Extensions/HttpContextEnricher.csSeriLoog/Extensions/SerilogBuilderExtensions.csSeriLoog/LogStoreOptions.csSeriLoog/Models/LogEntryDto.csSeriLoog/Program.csSeriLoog/Properties/launchSettings.jsonSeriLoog/SeriLoog.csprojSeriLoog/Services/LogQueryService.csSeriLoog/appsettings.Development.jsonSeriLoog/appsettings.jsonSeriLoog/wwwroot/app.cssSeriLoog/wwwroot/app.jsSeriLoog/wwwroot/index.html
diff --git a/SeriLoog.slnx b/SeriLoog.slnx
new file mode 100644
index 0000000..6a04e97
--- /dev/null
+++ b/SeriLoog.slnx
@@ -0,0 +1,3 @@
+<Solution>
+ <Project Path="SeriLoog/SeriLoog.csproj" />
+</Solution>
diff --git a/SeriLoog/Controllers/DemoController.cs b/SeriLoog/Controllers/DemoController.cs
new file mode 100644
index 0000000..e70d46b
--- /dev/null
+++ b/SeriLoog/Controllers/DemoController.cs
@@ -0,0 +1,73 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace SeriLoog.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public sealed class DemoController : ControllerBase
+{
+ private readonly ILogger<DemoController> _logger;
+ private static readonly string[] Users = { "alphons", "marieke", "jeroen", "sanne", "kees" };
+ private static readonly string[] Orders = { "ORD-1001", "ORD-1002", "ORD-1003", "ORD-1004" };
+ private static readonly Random Rng = new();
+
+ public DemoController(ILogger<DemoController> logger)
+ {
+ _logger = logger;
+ }
+
+ [HttpPost("generate")]
+ public IActionResult Generate([FromQuery] int count = 25)
+ {
+ count = Math.Clamp(count, 1, 500);
+
+ for (var i = 0; i < count; i++)
+ {
+ var user = Users[Rng.Next(Users.Length)];
+ var order = Orders[Rng.Next(Orders.Length)];
+ var durationMs = Rng.Next(5, 2000);
+
+ switch (Rng.Next(0, 8))
+ {
+ case 0:
+ _logger.LogTrace("Cache lookup for key {CacheKey} took {DurationMs}ms", $"user:{user}", durationMs);
+ break;
+ case 1:
+ _logger.LogDebug("Processing order {OrderId} for user {User}", order, user);
+ break;
+ case 2:
+ case 3:
+ _logger.LogInformation("User {User} placed order {OrderId} worth {Amount:C}", user, order, Rng.Next(10, 500));
+ break;
+ case 4:
+ _logger.LogWarning("Order {OrderId} for {User} is taking longer than expected ({DurationMs}ms)", order, user, durationMs);
+ break;
+ case 5:
+ try
+ {
+ throw new InvalidOperationException($"Payment gateway timeout for order {order}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Payment failed for order {OrderId} and user {User}", order, user);
+ }
+ break;
+ case 6:
+ try
+ {
+ throw new ApplicationException($"Unhandled exception while shipping {order}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogCritical(ex, "Fatal error shipping order {OrderId}", order);
+ }
+ break;
+ default:
+ _logger.LogInformation("Health check ok at {CheckedAt}", DateTimeOffset.UtcNow);
+ break;
+ }
+ }
+
+ return Ok(new { generated = count });
+ }
+}
diff --git a/SeriLoog/Controllers/LogsController.cs b/SeriLoog/Controllers/LogsController.cs
new file mode 100644
index 0000000..81aa0ee
--- /dev/null
+++ b/SeriLoog/Controllers/LogsController.cs
@@ -0,0 +1,42 @@
+using Microsoft.AspNetCore.Mvc;
+using SeriLoog.Services;
+
+namespace SeriLoog.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public sealed class LogsController(LogQueryService queryService) : ControllerBase
+{
+ [HttpGet]
+ public async Task<IActionResult> Get(
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 25,
+ [FromQuery] string? level = null,
+ [FromQuery] string? search = null,
+ [FromQuery] DateTimeOffset? from = null,
+ [FromQuery] DateTimeOffset? to = null,
+ [FromQuery] string source = "mongo",
+ CancellationToken ct = default)
+ {
+ var levels = string.IsNullOrWhiteSpace(level)
+ ? null
+ : level.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ var query = new LogQuery
+ {
+ Page = page,
+ PageSize = pageSize,
+ Levels = levels,
+ Search = search,
+ From = from,
+ To = to,
+ Source = source
+ };
+
+ var result = await queryService.QueryAsync(query, ct);
+ return Ok(result);
+ }
+
+ [HttpGet("levels")]
+ public IActionResult Levels() => Ok(new[] { "Verbose", "Debug", "Information", "Warning", "Error", "Fatal" });
+}
diff --git a/SeriLoog/Extensions/HttpContextEnricher.cs b/SeriLoog/Extensions/HttpContextEnricher.cs
new file mode 100644
index 0000000..0ee244c
--- /dev/null
+++ b/SeriLoog/Extensions/HttpContextEnricher.cs
@@ -0,0 +1,54 @@
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Net.Http.Headers;
+using Serilog.Core;
+using Serilog.Events;
+
+namespace SeriLoog.Extensions;
+
+/// <summary>
+/// Enriches every log event with request context, but only when one actually
+/// exists (i.e. we're inside an HTTP request). Background work, startup logs
+/// and anything logged outside a request simply won't get these properties.
+/// </summary>
+public sealed class HttpContextEnricher : ILogEventEnricher
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public HttpContextEnricher(IHttpContextAccessor httpContextAccessor)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
+ {
+ var context = _httpContextAccessor.HttpContext;
+ if (context is null)
+ return;
+
+ var request = context.Request;
+
+ var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ var forwarded = request.Headers["X-Forwarded-For"].FirstOrDefault();
+ if (!string.IsNullOrEmpty(forwarded))
+ {
+ ip = forwarded.Split(',').First().Trim();
+ }
+
+ var sessionId = context.Features.Get<ISessionFeature>() is not null
+ ? context.Session.Id
+ : string.Empty;
+
+ Set(logEvent, propertyFactory, "ClientIp", ip);
+ Set(logEvent, propertyFactory, "RequestUrl", $"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}");
+ Set(logEvent, propertyFactory, "TraceId", context.TraceIdentifier);
+ Set(logEvent, propertyFactory, "Referer", request.Headers[HeaderNames.Referer].FirstOrDefault() ?? string.Empty);
+ Set(logEvent, propertyFactory, "UserAgent", request.Headers[HeaderNames.UserAgent].FirstOrDefault() ?? string.Empty);
+ Set(logEvent, propertyFactory, "SessionId", sessionId);
+ Set(logEvent, propertyFactory, "User", context.User?.Identity?.Name ?? "anonymous");
+ }
+
+ private static void Set(LogEvent logEvent, ILogEventPropertyFactory propertyFactory, string name, string value)
+ {
+ logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(name, value));
+ }
+}
diff --git a/SeriLoog/Extensions/SerilogBuilderExtensions.cs b/SeriLoog/Extensions/SerilogBuilderExtensions.cs
new file mode 100644
index 0000000..928a871
--- /dev/null
+++ b/SeriLoog/Extensions/SerilogBuilderExtensions.cs
@@ -0,0 +1,55 @@
+using Serilog;
+using Serilog.Formatting.Compact;
+
+namespace SeriLoog.Extensions;
+
+public static class SerilogBuilderExtensions
+{
+ /// <summary>
+ /// Configures Serilog (console, file and MongoDB sinks) plus request logging,
+ /// and registers <see cref="LogStoreOptions"/> and <see cref="Services.LogQueryService"/>.
+ /// </summary>
+ public static WebApplicationBuilder AddSerilogRequestLogging(this WebApplicationBuilder builder)
+ {
+ var configuration = builder.Configuration;
+
+ var mongoConnectionString = configuration.GetConnectionString("Mongo") ?? "mongodb://localhost:27017";
+ var mongoDatabase = configuration["SeriLoog:MongoDatabase"] ?? "SeriLoog";
+ var mongoCollection = configuration["SeriLoog:MongoCollection"] ?? "logs";
+ var logFilePath = configuration["SeriLoog:LogFilePath"] ?? "Logs/log-.txt";
+
+ builder.Services.AddHttpContextAccessor();
+
+ builder.Host.UseSerilog((context, services, loggerConfiguration) =>
+ {
+ loggerConfiguration
+ .MinimumLevel.Verbose()
+ .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
+ .Enrich.FromLogContext()
+ .Enrich.WithProperty("Application", "SeriLoog")
+ .Enrich.With(new HttpContextEnricher(services.GetRequiredService<IHttpContextAccessor>()))
+ .WriteTo.Console()
+ .WriteTo.File(
+ formatter: new CompactJsonFormatter(),
+ path: logFilePath,
+ rollingInterval: Serilog.RollingInterval.Day,
+ retainedFileCountLimit: 14,
+ shared: true)
+ .WriteTo.MongoDB(
+ $"{mongoConnectionString}/{mongoDatabase}",
+ collectionName: mongoCollection);
+ });
+
+ builder.Services.AddSingleton(new LogStoreOptions
+ {
+ MongoConnectionString = mongoConnectionString,
+ MongoDatabase = mongoDatabase,
+ MongoCollection = mongoCollection,
+ LogFilePath = logFilePath
+ });
+
+ builder.Services.AddSingleton<Services.LogQueryService>();
+
+ return builder;
+ }
+}
diff --git a/SeriLoog/LogStoreOptions.cs b/SeriLoog/LogStoreOptions.cs
new file mode 100644
index 0000000..68fee6f
--- /dev/null
+++ b/SeriLoog/LogStoreOptions.cs
@@ -0,0 +1,9 @@
+namespace SeriLoog;
+
+public sealed class LogStoreOptions
+{
+ public required string MongoConnectionString { get; init; }
+ public required string MongoDatabase { get; init; }
+ public required string MongoCollection { get; init; }
+ public required string LogFilePath { get; init; }
+}
diff --git a/SeriLoog/Models/LogEntryDto.cs b/SeriLoog/Models/LogEntryDto.cs
new file mode 100644
index 0000000..90ff665
--- /dev/null
+++ b/SeriLoog/Models/LogEntryDto.cs
@@ -0,0 +1,24 @@
+namespace SeriLoog.Models;
+
+public sealed class LogEntryDto
+{
+ public string Id { get; set; } = "";
+ public DateTimeOffset Timestamp { get; set; }
+ public string Level { get; set; } = "Information";
+ public string Message { get; set; } = "";
+ public string? MessageTemplate { get; set; }
+ public string? Exception { get; set; }
+ public Dictionary<string, object?> Properties { get; set; } = new();
+ public string Source { get; set; } = "mongo";
+ public string RawJson { get; set; } = "{}";
+}
+
+public sealed class LogQueryResult
+{
+ public List<LogEntryDto> Items { get; set; } = new();
+ public long Total { get; set; }
+ public int Page { get; set; }
+ public int PageSize { get; set; }
+ public string Source { get; set; } = "mongo";
+ public string? Warning { get; set; }
+}
diff --git a/SeriLoog/Program.cs b/SeriLoog/Program.cs
new file mode 100644
index 0000000..a9f2458
--- /dev/null
+++ b/SeriLoog/Program.cs
@@ -0,0 +1,19 @@
+using Serilog;
+using SeriLoog.Extensions;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddSerilogRequestLogging();
+
+builder.Services.AddControllers();
+
+var app = builder.Build();
+
+app.UseSerilogRequestLogging();
+
+app.UseDefaultFiles();
+app.UseStaticFiles();
+
+app.MapControllers();
+
+app.Run();
diff --git a/SeriLoog/Properties/launchSettings.json b/SeriLoog/Properties/launchSettings.json
new file mode 100644
index 0000000..c6fc859
--- /dev/null
+++ b/SeriLoog/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5063",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7099;http://localhost:5063",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/SeriLoog/SeriLoog.csproj b/SeriLoog/SeriLoog.csproj
new file mode 100644
index 0000000..6fbdf75
--- /dev/null
+++ b/SeriLoog/SeriLoog.csproj
@@ -0,0 +1,22 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Folder Include="wwwroot\" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="MongoDB.Driver" Version="3.10.0" />
+ <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
+ <PackageReference Include="Serilog.Expressions" Version="5.0.0" />
+ <PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
+ <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
+ <PackageReference Include="Serilog.Sinks.MongoDB" Version="7.2.0" />
+ </ItemGroup>
+
+</Project>
diff --git a/SeriLoog/Services/LogQueryService.cs b/SeriLoog/Services/LogQueryService.cs
new file mode 100644
index 0000000..fc4ab95
--- /dev/null
+++ b/SeriLoog/Services/LogQueryService.cs
@@ -0,0 +1,342 @@
+using System.Text.Json;
+using MongoDB.Bson;
+using MongoDB.Driver;
+using SeriLoog.Models;
+
+namespace SeriLoog.Services;
+
+public sealed class LogQuery
+{
+ public int Page { get; set; } = 1;
+ public int PageSize { get; set; } = 25;
+ public string[]? Levels { get; set; }
+ public string? Search { get; set; }
+ public DateTimeOffset? From { get; set; }
+ public DateTimeOffset? To { get; set; }
+ public string Source { get; set; } = "mongo";
+}
+
+public sealed class LogQueryService
+{
+ private readonly LogStoreOptions _options;
+ private readonly ILogger<LogQueryService> _logger;
+
+ public LogQueryService(LogStoreOptions options, ILogger<LogQueryService> logger)
+ {
+ _options = options;
+ _logger = logger;
+ }
+
+ public async Task<LogQueryResult> QueryAsync(LogQuery query, CancellationToken ct)
+ {
+ if (string.Equals(query.Source, "file", StringComparison.OrdinalIgnoreCase))
+ {
+ return QueryFile(query);
+ }
+
+ try
+ {
+ return await QueryMongoAsync(query, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "MongoDB unavailable, falling back to file log source");
+ var result = QueryFile(query);
+ result.Warning = $"MongoDB unavailable ({ex.Message}). Showing file log instead.";
+ return result;
+ }
+ }
+
+ private async Task<LogQueryResult> QueryMongoAsync(LogQuery query, CancellationToken ct)
+ {
+ var client = new MongoClient(_options.MongoConnectionString);
+ var db = client.GetDatabase(_options.MongoDatabase);
+ var collection = db.GetCollection<BsonDocument>(_options.MongoCollection);
+
+ var filters = new List<FilterDefinition<BsonDocument>>();
+ var builder = Builders<BsonDocument>.Filter;
+
+ if (query.Levels is { Length: > 0 })
+ {
+ var levelFilters = query.Levels.Select(l =>
+ builder.Regex("Level", new BsonRegularExpression($"^{System.Text.RegularExpressions.Regex.Escape(l)}", "i")));
+ filters.Add(builder.Or(levelFilters));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Search))
+ {
+ var pattern = new BsonRegularExpression(System.Text.RegularExpressions.Regex.Escape(query.Search), "i");
+ filters.Add(builder.Or(
+ builder.Regex("RenderedMessage", pattern),
+ builder.Regex("MessageTemplate", pattern),
+ builder.Regex("Exception", pattern)));
+ }
+
+ if (query.From is not null)
+ {
+ filters.Add(builder.Gte("Timestamp", query.From.Value.UtcDateTime));
+ }
+
+ if (query.To is not null)
+ {
+ filters.Add(builder.Lte("Timestamp", query.To.Value.UtcDateTime));
+ }
+
+ var filter = filters.Count > 0 ? builder.And(filters) : builder.Empty;
+
+ var total = await collection.CountDocumentsAsync(filter, cancellationToken: ct);
+
+ var page = Math.Max(1, query.Page);
+ var pageSize = Math.Clamp(query.PageSize, 1, 500);
+
+ var docs = await collection.Find(filter)
+ .SortByDescending(d => d["Timestamp"])
+ .Skip((page - 1) * pageSize)
+ .Limit(pageSize)
+ .ToListAsync(ct);
+
+ var items = docs.Select(MapMongoDocument).ToList();
+
+ return new LogQueryResult
+ {
+ Items = items,
+ Total = total,
+ Page = page,
+ PageSize = pageSize,
+ Source = "mongo"
+ };
+ }
+
+ private static LogEntryDto MapMongoDocument(BsonDocument doc)
+ {
+ string GetString(params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (doc.TryGetValue(name, out var v) && !v.IsBsonNull)
+ return v.IsString ? v.AsString : v.ToString() ?? "";
+ }
+ return "";
+ }
+
+ DateTimeOffset GetTimestamp()
+ {
+ foreach (var name in new[] { "Timestamp", "UtcTimeStamp", "@t" })
+ {
+ if (doc.TryGetValue(name, out var v) && !v.IsBsonNull)
+ {
+ return v.BsonType == BsonType.DateTime
+ ? new DateTimeOffset(v.ToUniversalTime(), TimeSpan.Zero)
+ : DateTimeOffset.Parse(v.ToString()!);
+ }
+ }
+ return DateTimeOffset.UtcNow;
+ }
+
+ var properties = new Dictionary<string, object?>();
+ if (doc.TryGetValue("Properties", out var propsVal) && propsVal.IsBsonDocument)
+ {
+ foreach (var el in propsVal.AsBsonDocument)
+ {
+ properties[el.Name] = BsonTypeMapper.MapToDotNetValue(el.Value);
+ }
+ }
+
+ var id = doc.TryGetValue("_id", out var idVal) ? idVal.ToString() ?? "" : "";
+ var json = doc.ToJson(new MongoDB.Bson.IO.JsonWriterSettings { Indent = true });
+
+ return new LogEntryDto
+ {
+ Id = id ?? "",
+ Timestamp = GetTimestamp(),
+ Level = NormalizeLevel(GetString("Level")),
+ Message = GetString("RenderedMessage", "MessageTemplate"),
+ MessageTemplate = GetString("MessageTemplate"),
+ Exception = doc.TryGetValue("Exception", out var exVal) && !exVal.IsBsonNull ? exVal.ToString() : null,
+ Properties = properties,
+ Source = "mongo",
+ RawJson = json
+ };
+ }
+
+ private LogQueryResult QueryFile(LogQuery query)
+ {
+ var dir = Path.GetDirectoryName(Path.GetFullPath(_options.LogFilePath)) ?? ".";
+ var baseName = Path.GetFileNameWithoutExtension(_options.LogFilePath).TrimEnd('-');
+ var ext = Path.GetExtension(_options.LogFilePath);
+
+ var allLines = new List<string>();
+ if (Directory.Exists(dir))
+ {
+ var files = Directory.GetFiles(dir, $"{baseName}*{ext}").OrderByDescending(f => f);
+ foreach (var f in files)
+ {
+ try
+ {
+ using var stream = new FileStream(f, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
+ using var reader = new StreamReader(stream);
+ while (reader.ReadLine() is { } line)
+ {
+ if (!string.IsNullOrWhiteSpace(line)) allLines.Add(line);
+ }
+ }
+ catch (IOException)
+ {
+ // file locked/rolling, skip
+ }
+ }
+ }
+
+ var entries = allLines.Select(ParseCompactJsonLine).Where(e => e is not null).Select(e => e!).ToList();
+ entries.Reverse(); // newest first (file lines are chronological)
+
+ IEnumerable<LogEntryDto> filtered = entries;
+
+ if (query.Levels is { Length: > 0 })
+ {
+ var set = new HashSet<string>(query.Levels, StringComparer.OrdinalIgnoreCase);
+ filtered = filtered.Where(e => set.Contains(e.Level));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.Search))
+ {
+ var s = query.Search;
+ filtered = filtered.Where(e =>
+ e.Message.Contains(s, StringComparison.OrdinalIgnoreCase) ||
+ (e.Exception?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false));
+ }
+
+ if (query.From is not null)
+ {
+ var from = query.From.Value;
+ filtered = filtered.Where(e => e.Timestamp >= from);
+ }
+
+ if (query.To is not null)
+ {
+ var to = query.To.Value;
+ filtered = filtered.Where(e => e.Timestamp <= to);
+ }
+
+ var list = filtered.ToList();
+ var total = list.Count;
+ var page = Math.Max(1, query.Page);
+ var pageSize = Math.Clamp(query.PageSize, 1, 500);
+
+ var pageItems = list.Skip((page - 1) * pageSize).Take(pageSize).ToList();
+
+ return new LogQueryResult
+ {
+ Items = pageItems,
+ Total = total,
+ Page = page,
+ PageSize = pageSize,
+ Source = "file"
+ };
+ }
+
+ private static LogEntryDto? ParseCompactJsonLine(string line)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(line);
+ var root = doc.RootElement;
+
+ var properties = new Dictionary<string, object?>();
+ string? messageTemplate = null;
+ string? exception = null;
+ var timestamp = DateTimeOffset.UtcNow;
+ var level = "Information";
+
+ foreach (var prop in root.EnumerateObject())
+ {
+ switch (prop.Name)
+ {
+ case "@t":
+ timestamp = DateTimeOffset.Parse(prop.Value.GetString()!, null, System.Globalization.DateTimeStyles.AssumeUniversal);
+ break;
+ case "@mt":
+ messageTemplate = prop.Value.GetString();
+ break;
+ case "@l":
+ level = prop.Value.GetString() ?? "Information";
+ break;
+ case "@x":
+ exception = prop.Value.GetString();
+ break;
+ case "@m":
+ case "@i":
+ case "@r":
+ break;
+ default:
+ properties[prop.Name] = JsonElementToObject(prop.Value);
+ break;
+ }
+ }
+
+ var message = RenderMessageTemplate(messageTemplate ?? "", properties);
+
+ return new LogEntryDto
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Timestamp = timestamp,
+ Level = NormalizeLevel(level),
+ Message = message,
+ MessageTemplate = messageTemplate,
+ Exception = exception,
+ Properties = properties,
+ Source = "file",
+ RawJson = System.Text.Json.JsonSerializer.Serialize(JsonDocument.Parse(line).RootElement, new JsonSerializerOptions { WriteIndented = true })
+ };
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static readonly System.Text.RegularExpressions.Regex TokenRegex =
+ new(@"\{(@|\$)?(?<name>[a-zA-Z_][a-zA-Z0-9_]*)(?<format>:[^}]+)?\}", System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ private static string RenderMessageTemplate(string template, Dictionary<string, object?> properties)
+ {
+ return TokenRegex.Replace(template, match =>
+ {
+ var name = match.Groups["name"].Value;
+ if (!properties.TryGetValue(name, out var value))
+ return match.Value;
+
+ var format = match.Groups["format"].Success ? match.Groups["format"].Value[1..] : null;
+
+ if (format is not null && value is IFormattable formattable)
+ {
+ try { return formattable.ToString(format, System.Globalization.CultureInfo.InvariantCulture); }
+ catch { /* fall through to plain rendering */ }
+ }
+
+ return value?.ToString() ?? "";
+ });
+ }
+
+ private static object? JsonElementToObject(JsonElement el) => el.ValueKind switch
+ {
+ JsonValueKind.String => el.GetString(),
+ JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(),
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.Null => null,
+ _ => el.GetRawText()
+ };
+
+ private static string NormalizeLevel(string level) => level switch
+ {
+ "Verbose" or "VRB" or "Trace" => "Verbose",
+ "Debug" or "DBG" => "Debug",
+ "Information" or "INF" or "Info" => "Information",
+ "Warning" or "WRN" or "Warn" => "Warning",
+ "Error" or "ERR" => "Error",
+ "Fatal" or "FTL" or "Critical" => "Fatal",
+ "" => "Information",
+ _ => level
+ };
+}
diff --git a/SeriLoog/appsettings.Development.json b/SeriLoog/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/SeriLoog/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/SeriLoog/appsettings.json b/SeriLoog/appsettings.json
new file mode 100644
index 0000000..6ad229b
--- /dev/null
+++ b/SeriLoog/appsettings.json
@@ -0,0 +1,17 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "Mongo": "mongodb://localhost:27017"
+ },
+ "SeriLoog": {
+ "MongoDatabase": "SeriLoog",
+ "MongoCollection": "logs",
+ "LogFilePath": "Logs/log-.txt"
+ }
+}
diff --git a/SeriLoog/wwwroot/app.css b/SeriLoog/wwwroot/app.css
new file mode 100644
index 0000000..2be84f6
--- /dev/null
+++ b/SeriLoog/wwwroot/app.css
@@ -0,0 +1,321 @@
+:root {
+ color-scheme: light dark;
+ --bg: #0f1115;
+ --bg-elevated: #171a21;
+ --border: #262b36;
+ --text: #e6e8ec;
+ --text-dim: #8b93a3;
+ --accent: #5b9dff;
+ --verbose: #6b7280;
+ --debug: #8b93a3;
+ --information: #4ea1ff;
+ --warning: #e0a63c;
+ --error: #ef5a5a;
+ --fatal: #ff2d55;
+ --radius: 10px;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #f4f5f7;
+ --bg-elevated: #ffffff;
+ --border: #dde1e7;
+ --text: #1b1f27;
+ --text-dim: #5b6472;
+ }
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ font-size: 15px;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ padding-top: max(12px, env(safe-area-inset-top));
+ background: var(--bg-elevated);
+ border-bottom: 1px solid var(--border);
+}
+
+.brand { display: flex; align-items: center; gap: 10px; }
+.brand h1 { font-size: 18px; margin: 0; font-weight: 600; letter-spacing: 0.3px; }
+.dot {
+ width: 10px; height: 10px; border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 10px var(--accent);
+}
+
+.icon-btn {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text);
+ border-radius: 8px;
+ width: 38px; height: 38px;
+ font-size: 16px;
+ cursor: pointer;
+}
+
+.layout {
+ display: flex;
+ max-width: 1400px;
+ margin: 0 auto;
+ align-items: flex-start;
+}
+
+.filters {
+ width: 280px;
+ flex: none;
+ position: sticky;
+ top: 65px;
+ height: calc(100vh - 65px);
+ overflow-y: auto;
+ border-right: 1px solid var(--border);
+ background: var(--bg-elevated);
+}
+
+.filters-inner {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-dim); }
+.field-row { display: flex; gap: 8px; }
+.field.checkbox { flex-direction: row; align-items: center; gap: 8px; }
+.field.checkbox input { width: 16px; height: 16px; }
+
+input, select {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 9px 10px;
+ border-radius: 8px;
+ font-size: 14px;
+ width: 100%;
+}
+input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
+
+.chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.chip {
+ border: 1px solid var(--border);
+ background: var(--bg);
+ color: var(--text-dim);
+ padding: 5px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ cursor: pointer;
+ user-select: none;
+}
+.chip.active {
+ color: #fff;
+ border-color: transparent;
+}
+.chip[data-level="Verbose"].active { background: var(--verbose); }
+.chip[data-level="Debug"].active { background: var(--debug); }
+.chip[data-level="Information"].active { background: var(--information); }
+.chip[data-level="Warning"].active { background: var(--warning); color: #241b00; }
+.chip[data-level="Error"].active { background: var(--error); }
+.chip[data-level="Fatal"].active { background: var(--fatal); }
+
+.btn {
+ flex: 1;
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--bg);
+ color: var(--text);
+ font-size: 14px;
+ cursor: pointer;
+ font-weight: 500;
+}
+.btn.primary { background: var(--accent); border-color: var(--accent); color: #05131f; }
+.btn.accent { background: transparent; border-color: var(--accent); color: var(--accent); }
+.btn:active { transform: scale(0.98); }
+
+.content {
+ flex: 1;
+ min-width: 0;
+ padding: 16px;
+ padding-bottom: 40px;
+}
+
+.warning {
+ background: rgba(224, 166, 60, 0.12);
+ border: 1px solid var(--warning);
+ color: var(--warning);
+ padding: 10px 14px;
+ border-radius: 8px;
+ font-size: 13px;
+ margin-bottom: 12px;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+.hidden { display: none !important; }
+
+.status-bar { color: var(--text-dim); font-size: 13px; margin-bottom: 10px; }
+
+.log-list { display: flex; flex-direction: column; gap: 8px; }
+
+.log-row {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-left: 4px solid var(--information);
+ border-radius: var(--radius);
+ padding: 10px 12px;
+ cursor: pointer;
+ display: grid;
+ grid-template-columns: 90px 100px minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+ overflow-wrap: anywhere;
+}
+.log-row[data-level="Verbose"] { border-left-color: var(--verbose); }
+.log-row[data-level="Debug"] { border-left-color: var(--debug); }
+.log-row[data-level="Information"] { border-left-color: var(--information); }
+.log-row[data-level="Warning"] { border-left-color: var(--warning); }
+.log-row[data-level="Error"] { border-left-color: var(--error); }
+.log-row[data-level="Fatal"] { border-left-color: var(--fatal); }
+
+.log-row:hover { border-color: var(--accent); }
+
+.log-time { font-size: 11px; color: var(--text-dim); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.log-level { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
+.log-level[data-level="Verbose"] { color: var(--verbose); }
+.log-level[data-level="Debug"] { color: var(--debug); }
+.log-level[data-level="Information"] { color: var(--information); }
+.log-level[data-level="Warning"] { color: var(--warning); }
+.log-level[data-level="Error"] { color: var(--error); }
+.log-level[data-level="Fatal"] { color: var(--fatal); }
+
+.log-message { font-size: 13.5px; word-break: break-word; overflow-wrap: anywhere; line-height: 1.4; min-width: 0; }
+.log-exception {
+ color: var(--error);
+ font-size: 12px;
+ margin-top: 4px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+ min-width: 0;
+}
+.log-props { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; }
+.log-props.hidden-props { margin-top: 4px; }
+.log-props.hidden-props.hidden { display: none; }
+
+.prop-toggle {
+ font-size: 11px;
+ background: transparent;
+ border: 1px dashed var(--border);
+ border-radius: 6px;
+ padding: 2px 8px;
+ color: var(--accent);
+ cursor: pointer;
+}
+.prop-toggle:active { transform: scale(0.97); }
+.prop-tag {
+ font-size: 11px;
+ background: rgba(255,255,255,0.05);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 2px 6px;
+ color: var(--text-dim);
+ max-width: 100%;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+.prop-tag b { color: var(--text); font-weight: 600; }
+
+.pager {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ margin-top: 20px;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+.pager .btn { flex: none; padding: 8px 16px; }
+
+.empty {
+ text-align: center;
+ color: var(--text-dim);
+ padding: 60px 20px;
+ font-size: 14px;
+}
+
+.modal-overlay {
+ position: fixed; inset: 0;
+ background: rgba(0,0,0,0.55);
+ display: flex; align-items: flex-end;
+ z-index: 100;
+}
+@media (min-width: 720px) {
+ .modal-overlay { align-items: center; justify-content: center; }
+}
+.modal {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ width: 100%;
+ max-height: 85vh;
+ border-radius: 16px 16px 0 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+@media (min-width: 720px) {
+ .modal { width: 640px; max-height: 80vh; border-radius: 16px; }
+}
+.modal-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 14px 16px; border-bottom: 1px solid var(--border);
+}
+.modal-header h2 { font-size: 15px; margin: 0; }
+.modal-body { padding: 14px 16px; overflow-y: auto; }
+
+.json-viewer {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12.5px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0;
+ line-height: 1.5;
+}
+.jv-key { color: var(--accent); }
+.jv-string { color: #8fd67a; }
+.jv-number { color: #e0a63c; }
+.jv-bool { color: #ef5a5a; }
+.jv-null { color: var(--text-dim); }
+
+@media (max-width: 860px) {
+ .filters {
+ position: fixed;
+ left: 0; top: 61px; bottom: 0;
+ width: 82vw;
+ max-width: 320px;
+ z-index: 30;
+ transform: translateX(-100%);
+ transition: transform 0.2s ease;
+ box-shadow: 2px 0 20px rgba(0,0,0,0.3);
+ }
+ .filters.open { transform: translateX(0); }
+ .log-row { grid-template-columns: 1fr; gap: 4px; }
+ .log-time, .log-level { display: inline-block; margin-right: 8px; }
+}
+
+#menuToggle { display: none; }
+@media (max-width: 860px) {
+ #menuToggle { display: inline-flex; align-items: center; justify-content: center; }
+}
diff --git a/SeriLoog/wwwroot/app.js b/SeriLoog/wwwroot/app.js
new file mode 100644
index 0000000..1fbc139
--- /dev/null
+++ b/SeriLoog/wwwroot/app.js
@@ -0,0 +1,294 @@
+(() => {
+ const LEVELS = ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"];
+ const HIDDEN_PROP_KEYS = new Set(["ActionId", "RequestId", "ConnectionId"]);
+
+ const state = {
+ page: 1,
+ pageSize: 25,
+ total: 0,
+ levels: new Set(),
+ search: "",
+ from: "",
+ to: "",
+ source: "mongo",
+ autoRefresh: false,
+ };
+
+ const $ = (id) => document.getElementById(id);
+ const levelChips = $("levelChips");
+ const list = $("list");
+ const statusText = $("statusText");
+ const pageInfo = $("pageInfo");
+ const warningBox = $("warning");
+ const modalOverlay = $("modalOverlay");
+ const modalJson = $("modalJson");
+
+ let refreshTimer = null;
+
+ function buildChips() {
+ levelChips.innerHTML = "";
+ for (const lvl of LEVELS) {
+ const chip = document.createElement("button");
+ chip.className = "chip";
+ chip.type = "button";
+ chip.textContent = lvl;
+ chip.dataset.level = lvl;
+ chip.addEventListener("click", () => {
+ if (state.levels.has(lvl)) state.levels.delete(lvl);
+ else state.levels.add(lvl);
+ chip.classList.toggle("active");
+ applyFilters();
+ });
+ levelChips.appendChild(chip);
+ }
+ }
+
+ function escapeHtml(str) {
+ return String(str)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+ }
+
+ function formatTime(iso) {
+ const d = new Date(iso);
+ return d.toLocaleString(undefined, {
+ month: "2-digit", day: "2-digit",
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
+ });
+ }
+
+ function syntaxHighlightJson(json) {
+ const escaped = escapeHtml(json);
+ return escaped.replace(
+ /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(\.\d+)?([eE][+-]?\d+)?)/g,
+ (match) => {
+ let cls = "jv-number";
+ if (/^"/.test(match)) {
+ cls = /:$/.test(match) ? "jv-key" : "jv-string";
+ } else if (/true|false/.test(match)) {
+ cls = "jv-bool";
+ } else if (/null/.test(match)) {
+ cls = "jv-null";
+ }
+ return `<span class="${cls}">${match}</span>`;
+ }
+ );
+ }
+
+ function openModal(entry) {
+ $("modalTitle").textContent = `${entry.level} · ${formatTime(entry.timestamp)}`;
+ modalJson.innerHTML = syntaxHighlightJson(entry.rawJson || "{}");
+ modalOverlay.classList.remove("hidden");
+ }
+
+ function closeModal() {
+ modalOverlay.classList.add("hidden");
+ }
+
+ function renderRow(entry) {
+ const row = document.createElement("div");
+ row.className = "log-row";
+ row.setAttribute("role", "listitem");
+ row.dataset.level = entry.level;
+
+ const time = document.createElement("div");
+ time.className = "log-time";
+ time.textContent = formatTime(entry.timestamp);
+
+ const level = document.createElement("div");
+ level.className = "log-level";
+ level.dataset.level = entry.level;
+ level.textContent = entry.level;
+
+ const body = document.createElement("div");
+ const msg = document.createElement("div");
+ msg.className = "log-message";
+ msg.textContent = entry.message || entry.messageTemplate || "(geen bericht)";
+ body.appendChild(msg);
+
+ if (entry.exception) {
+ const ex = document.createElement("div");
+ ex.className = "log-exception";
+ ex.textContent = entry.exception.split("\n").slice(0, 2).join("\n");
+ body.appendChild(ex);
+ }
+
+ const propKeys = Object.keys(entry.properties || {});
+ if (propKeys.length) {
+ const visibleKeys = propKeys.filter((k) => !HIDDEN_PROP_KEYS.has(k)).slice(0, 6);
+ const hiddenKeys = propKeys.filter((k) => HIDDEN_PROP_KEYS.has(k));
+
+ const propsWrap = document.createElement("div");
+ propsWrap.className = "log-props";
+
+ const makeTag = (key) => {
+ const tag = document.createElement("span");
+ tag.className = "prop-tag";
+ tag.innerHTML = `<b>${escapeHtml(key)}</b>=${escapeHtml(entry.properties[key])}`;
+ return tag;
+ };
+
+ for (const key of visibleKeys) propsWrap.appendChild(makeTag(key));
+
+ if (hiddenKeys.length) {
+ const hiddenWrap = document.createElement("div");
+ hiddenWrap.className = "log-props hidden-props hidden";
+ for (const key of hiddenKeys) hiddenWrap.appendChild(makeTag(key));
+
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "prop-toggle";
+ toggle.textContent = `+${hiddenKeys.length} technisch`;
+ toggle.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const isHidden = hiddenWrap.classList.toggle("hidden");
+ toggle.textContent = isHidden ? `+${hiddenKeys.length} technisch` : "− verbergen";
+ });
+
+ propsWrap.appendChild(toggle);
+ body.appendChild(propsWrap);
+ body.appendChild(hiddenWrap);
+ } else {
+ body.appendChild(propsWrap);
+ }
+ }
+
+ row.appendChild(time);
+ row.appendChild(level);
+ row.appendChild(body);
+ row.addEventListener("click", () => openModal(entry));
+ return row;
+ }
+
+ function buildQuery() {
+ const params = new URLSearchParams();
+ params.set("page", state.page);
+ params.set("pageSize", state.pageSize);
+ params.set("source", state.source);
+ if (state.levels.size) params.set("level", [...state.levels].join(","));
+ if (state.search) params.set("search", state.search);
+ if (state.from) params.set("from", new Date(state.from).toISOString());
+ if (state.to) params.set("to", new Date(state.to).toISOString());
+ return params.toString();
+ }
+
+ async function load() {
+ statusText.textContent = "Laden...";
+ try {
+ const res = await fetch(`/api/logs?${buildQuery()}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+
+ state.total = data.total;
+
+ if (data.warning) {
+ warningBox.textContent = data.warning;
+ warningBox.classList.remove("hidden");
+ } else {
+ warningBox.classList.add("hidden");
+ }
+
+ list.innerHTML = "";
+ if (!data.items.length) {
+ const empty = document.createElement("div");
+ empty.className = "empty";
+ empty.textContent = "Geen logregels gevonden voor deze filters.";
+ list.appendChild(empty);
+ } else {
+ for (const entry of data.items) list.appendChild(renderRow(entry));
+ }
+
+ const totalPages = Math.max(1, Math.ceil(data.total / state.pageSize));
+ pageInfo.textContent = `Pagina ${data.page} / ${totalPages} · ${data.total} regels (${data.source})`;
+ statusText.textContent = `${data.total} logregels · bron: ${data.source}`;
+
+ $("prevBtn").disabled = data.page <= 1;
+ $("nextBtn").disabled = data.page >= totalPages;
+ } catch (err) {
+ statusText.textContent = `Fout bij laden: ${err.message}`;
+ list.innerHTML = `<div class="empty">Kon logs niet laden. Controleer of de API bereikbaar is.</div>`;
+ }
+ }
+
+ function applyFilters() {
+ state.page = 1;
+ state.search = $("search").value.trim();
+ state.from = $("fromDate").value;
+ state.to = $("toDate").value;
+ state.source = $("source").value;
+ state.pageSize = parseInt($("pageSize").value, 10);
+ load();
+ document.getElementById("filterPanel").classList.remove("open");
+ }
+
+ function resetFilters() {
+ state.levels.clear();
+ document.querySelectorAll(".chip").forEach((c) => c.classList.remove("active"));
+ $("search").value = "";
+ $("fromDate").value = "";
+ $("toDate").value = "";
+ $("source").value = "mongo";
+ $("pageSize").value = "25";
+ applyFilters();
+ }
+
+ function setupAutoRefresh() {
+ $("autoRefresh").addEventListener("change", (e) => {
+ state.autoRefresh = e.target.checked;
+ if (refreshTimer) clearInterval(refreshTimer);
+ if (state.autoRefresh) {
+ refreshTimer = setInterval(load, 5000);
+ }
+ });
+ }
+
+ function init() {
+ buildChips();
+ setupAutoRefresh();
+
+ $("applyBtn").addEventListener("click", applyFilters);
+ $("resetBtn").addEventListener("click", resetFilters);
+ $("search").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") applyFilters();
+ });
+
+ $("fromDate").addEventListener("change", applyFilters);
+ $("toDate").addEventListener("change", applyFilters);
+ $("source").addEventListener("change", applyFilters);
+ $("pageSize").addEventListener("change", applyFilters);
+
+ $("prevBtn").addEventListener("click", () => {
+ if (state.page > 1) { state.page--; load(); }
+ });
+ $("nextBtn").addEventListener("click", () => {
+ state.page++;
+ load();
+ });
+
+ $("demoBtn").addEventListener("click", async () => {
+ $("demoBtn").disabled = true;
+ $("demoBtn").textContent = "Bezig...";
+ try {
+ await fetch("/api/demo/generate?count=30", { method: "POST" });
+ setTimeout(load, 400);
+ } finally {
+ $("demoBtn").disabled = false;
+ $("demoBtn").textContent = "+ Demo logs";
+ }
+ });
+
+ $("menuToggle").addEventListener("click", () => {
+ $("filterPanel").classList.toggle("open");
+ });
+
+ modalOverlay.addEventListener("click", (e) => {
+ if (e.target === modalOverlay) closeModal();
+ });
+ $("modalClose").addEventListener("click", closeModal);
+
+ load();
+ }
+
+ document.addEventListener("DOMContentLoaded", init);
+})();
diff --git a/SeriLoog/wwwroot/index.html b/SeriLoog/wwwroot/index.html
new file mode 100644
index 0000000..d55b8fd
--- /dev/null
+++ b/SeriLoog/wwwroot/index.html
@@ -0,0 +1,105 @@
+<!doctype html>
+<html lang="nl">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+<title>SeriLoog Viewer</title>
+<link rel="stylesheet" href="app.css" />
+</head>
+<body>
+<header class="topbar">
+ <div class="brand">
+ <span class="dot"></span>
+ <h1>SeriLoog</h1>
+ </div>
+ <button id="menuToggle" class="icon-btn" aria-label="Filters">☰</button>
+</header>
+
+<div class="layout">
+ <aside id="filterPanel" class="filters">
+ <div class="filters-inner">
+ <label class="field">
+ <span>Zoeken</span>
+ <input id="search" type="search" placeholder="bericht, exception..." autocomplete="off" />
+ </label>
+
+ <div class="field">
+ <span>Levels</span>
+ <div id="levelChips" class="chips"></div>
+ </div>
+
+ <label class="field">
+ <span>Van</span>
+ <input id="fromDate" type="datetime-local" />
+ </label>
+
+ <label class="field">
+ <span>Tot</span>
+ <input id="toDate" type="datetime-local" />
+ </label>
+
+ <label class="field">
+ <span>Bron</span>
+ <select id="source">
+ <option value="mongo">MongoDB</option>
+ <option value="file">Bestand (File sink)</option>
+ </select>
+ </label>
+
+ <label class="field">
+ <span>Per pagina</span>
+ <select id="pageSize">
+ <option>10</option>
+ <option selected>25</option>
+ <option>50</option>
+ <option>100</option>
+ </select>
+ </label>
+
+ <div class="field-row">
+ <button id="applyBtn" class="btn primary">Filteren</button>
+ <button id="resetBtn" class="btn">Reset</button>
+ </div>
+
+ <div class="field-row">
+ <button id="demoBtn" class="btn accent">+ Demo logs</button>
+ </div>
+
+ <label class="field checkbox">
+ <input id="autoRefresh" type="checkbox" />
+ <span>Auto-refresh (5s)</span>
+ </label>
+ </div>
+ </aside>
+
+ <main class="content">
+ <div id="warning" class="warning hidden"></div>
+ <div id="statusBar" class="status-bar">
+ <span id="statusText">Laden...</span>
+ </div>
+
+ <div id="list" class="log-list" role="list"></div>
+
+ <div class="pager">
+ <button id="prevBtn" class="btn">‹ Vorige</button>
+ <span id="pageInfo">Pagina 1</span>
+ <button id="nextBtn" class="btn">Volgende ›</button>
+ </div>
+ </main>
+</div>
+
+<div id="modalOverlay" class="modal-overlay hidden">
+ <div class="modal">
+ <div class="modal-header">
+ <h2 id="modalTitle">Details</h2>
+ <button id="modalClose" class="icon-btn" aria-label="Sluiten">✕</button>
+ </div>
+ <div class="modal-body">
+ <pre id="modalJson" class="json-viewer"></pre>
+ </div>
+ </div>
+</div>
+
+<script src="app.js"></script>
+</body>
+</html>