Initial version
This commit is contained in:
27
.gitignore
vendored
27
.gitignore
vendored
@@ -2,7 +2,7 @@
|
|||||||
## Ignore Visual Studio temporary files, build results, and
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
## files generated by popular Visual Studio add-ons.
|
## files generated by popular Visual Studio add-ons.
|
||||||
##
|
##
|
||||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
@@ -83,8 +83,6 @@ StyleCopReport.xml
|
|||||||
*.pgc
|
*.pgc
|
||||||
*.pgd
|
*.pgd
|
||||||
*.rsp
|
*.rsp
|
||||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
|
||||||
!Directory.Build.rsp
|
|
||||||
*.sbr
|
*.sbr
|
||||||
*.tlb
|
*.tlb
|
||||||
*.tli
|
*.tli
|
||||||
@@ -209,6 +207,9 @@ PublishScripts/
|
|||||||
*.nuget.props
|
*.nuget.props
|
||||||
*.nuget.targets
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Nuget personal access tokens and Credentials
|
||||||
|
nuget.config
|
||||||
|
|
||||||
# Microsoft Azure Build Output
|
# Microsoft Azure Build Output
|
||||||
csx/
|
csx/
|
||||||
*.build.csdef
|
*.build.csdef
|
||||||
@@ -297,17 +298,6 @@ node_modules/
|
|||||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
*.vbw
|
*.vbw
|
||||||
|
|
||||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
|
||||||
*.vbp
|
|
||||||
|
|
||||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
|
||||||
*.dsw
|
|
||||||
*.dsp
|
|
||||||
|
|
||||||
# Visual Studio 6 technical files
|
|
||||||
*.ncb
|
|
||||||
*.aps
|
|
||||||
|
|
||||||
# Visual Studio LightSwitch build output
|
# Visual Studio LightSwitch build output
|
||||||
**/*.HTMLClient/GeneratedArtifacts
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
**/*.DesktopClient/GeneratedArtifacts
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
@@ -364,9 +354,6 @@ ASALocalRun/
|
|||||||
# Local History for Visual Studio
|
# Local History for Visual Studio
|
||||||
.localhistory/
|
.localhistory/
|
||||||
|
|
||||||
# Visual Studio History (VSHistory) files
|
|
||||||
.vshistory/
|
|
||||||
|
|
||||||
# BeatPulse healthcheck temp database
|
# BeatPulse healthcheck temp database
|
||||||
healthchecksdb
|
healthchecksdb
|
||||||
|
|
||||||
@@ -398,6 +385,7 @@ FodyWeavers.xsd
|
|||||||
*.msp
|
*.msp
|
||||||
|
|
||||||
# JetBrains Rider
|
# JetBrains Rider
|
||||||
|
.idea/
|
||||||
*.sln.iml
|
*.sln.iml
|
||||||
|
|
||||||
# ---> VisualStudioCode
|
# ---> VisualStudioCode
|
||||||
@@ -406,11 +394,8 @@ FodyWeavers.xsd
|
|||||||
!.vscode/tasks.json
|
!.vscode/tasks.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
!.vscode/*.code-snippets
|
*.code-workspace
|
||||||
|
|
||||||
# Local History for Visual Studio Code
|
# Local History for Visual Studio Code
|
||||||
.history/
|
.history/
|
||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
|
||||||
*.vsix
|
|
||||||
|
|
||||||
|
|||||||
14
ConsoleApp1/ConsoleApp1.csproj
Normal file
14
ConsoleApp1/ConsoleApp1.csproj
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EonaCat.LogStack\EonaCat.LogStack.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
34
ConsoleApp1/Program.cs
Normal file
34
ConsoleApp1/Program.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using EonaCat.LogStack.Configuration;
|
||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
var logger = new LogBuilder("MyApp")
|
||||||
|
.WithMinimumLevel(LogLevel.Information)
|
||||||
|
.WriteToConsole()
|
||||||
|
.WriteToFile("C:\\tesss", maxFileSize: 50 * 1024 * 1024)
|
||||||
|
//.WriteToJsonFile("./logs", maxFileSize: 50 * 1024 * 1024)
|
||||||
|
//.WriteToHttp("https://127.0.0.1")
|
||||||
|
//.WriteToUdp("127.0.0.1", 514)
|
||||||
|
//.WriteToTcp("127.0.0.1", 514)
|
||||||
|
//.WriteToDatabase(null)
|
||||||
|
//.WriteToDiscord("https://discord.com/api/webhooks/...")
|
||||||
|
//.WriteToMicrosoftTeams("https://outlook.office.com/webhook/...")
|
||||||
|
//.WriteToElasticSearch("http://localhost:9200/logs")
|
||||||
|
//.WriteToGraylogFlow(null)
|
||||||
|
//.WriteToZabbixFlow(null)
|
||||||
|
.BoostWithCorrelationId()
|
||||||
|
.BoostWithProcessId()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
logger.Information("Application started");
|
||||||
|
logger.Error(new Exception("Nerd!"), "Something went wrong");
|
||||||
|
await Task.Delay(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logger.DisposeAsync(); // Flushes all logs
|
||||||
39
EonaCat.LogStack.LogClient/EonaCat.LogStack.LogClient.csproj
Normal file
39
EonaCat.LogStack.LogClient/EonaCat.LogStack.LogClient.csproj
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
|
||||||
|
<PackageId>EonaCat.LogStack.LogClient</PackageId>
|
||||||
|
<Version>0.0.1</Version>
|
||||||
|
<Authors>EonaCat (Jeroen Saey)</Authors>
|
||||||
|
<Description>Logging client for the EonaCat Logger LogServer LogStack</Description>
|
||||||
|
<PackageTags>logging;monitoring;analytics;diagnostics</PackageTags>
|
||||||
|
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||||
|
<PackageIcon>icon.png</PackageIcon>
|
||||||
|
<PackageReadmeFile>readme.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.LogStack.LogClient</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\EonaCat.LogStack\icon.png">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
<None Include="..\LICENSE">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Net.Http.Json" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EonaCat.LogStack\EonaCat.LogStack.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="readme.md">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
186
EonaCat.LogStack.LogClient/LogCentralClient.cs
Normal file
186
EonaCat.LogStack.LogClient/LogCentralClient.cs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
using EonaCat.Json;
|
||||||
|
using EonaCat.LogStack.Extensions;
|
||||||
|
using EonaCat.LogStack.LogClient.Models;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.LogClient
|
||||||
|
{
|
||||||
|
public class EonaCatPayLoad
|
||||||
|
{
|
||||||
|
public List<EonaCatLogEvent>? Events { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EonaCatLogEvent
|
||||||
|
{
|
||||||
|
public string Timestamp { get; set; } = default!;
|
||||||
|
public string Level { get; set; } = default!;
|
||||||
|
public string Message { get; set; } = default!;
|
||||||
|
public string Category { get; set; } = default!;
|
||||||
|
public ExceptionDto? Exception { get; set; }
|
||||||
|
public Dictionary<string, object?>? Properties { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExceptionDto
|
||||||
|
{
|
||||||
|
public string Type { get; set; } = default!;
|
||||||
|
public string Message { get; set; } = default!;
|
||||||
|
public string? StackTrace { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LogCentralClient : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly LogCentralOptions _options;
|
||||||
|
private readonly ConcurrentQueue<LogEntry> _logQueue;
|
||||||
|
private readonly Timer _flushTimer;
|
||||||
|
private readonly SemaphoreSlim _flushSemaphore;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public LogCentralClient(LogCentralOptions options)
|
||||||
|
{
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_httpClient = new HttpClient { BaseAddress = new Uri(_options.ServerUrl) };
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("X-API-Key", _options.ApiKey);
|
||||||
|
_logQueue = new ConcurrentQueue<LogEntry>();
|
||||||
|
_flushSemaphore = new SemaphoreSlim(1, 1);
|
||||||
|
_flushTimer = new Timer(async _ => await FlushAsync(), null,
|
||||||
|
TimeSpan.FromSeconds(_options.FlushIntervalSeconds),
|
||||||
|
TimeSpan.FromSeconds(_options.FlushIntervalSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LogAsync(LogEntry entry)
|
||||||
|
{
|
||||||
|
entry.Source = _options.ApplicationName;
|
||||||
|
entry.Timestamp = DateTime.UtcNow;
|
||||||
|
entry.Message ??= "";
|
||||||
|
|
||||||
|
var properties = new Dictionary<string, object?>();
|
||||||
|
if (_options.ApplicationName != null)
|
||||||
|
{
|
||||||
|
properties.Add("ApplicationName", _options.ApplicationName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_options.ApplicationVersion != null)
|
||||||
|
{
|
||||||
|
properties.Add("ApplicationVersion", _options.ApplicationVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_options.Environment != null)
|
||||||
|
{
|
||||||
|
properties.Add("Environment", _options.Environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(entry.TraceId))
|
||||||
|
{
|
||||||
|
properties.Add("TraceId", entry.TraceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Properties = JsonHelper.ToJson(properties);
|
||||||
|
|
||||||
|
_logQueue.Enqueue(entry);
|
||||||
|
|
||||||
|
if (_logQueue.Count >= _options.BatchSize)
|
||||||
|
{
|
||||||
|
await FlushAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FlushAsync()
|
||||||
|
{
|
||||||
|
if (_logQueue.IsEmpty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _flushSemaphore.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var batch = new List<LogEntry>();
|
||||||
|
while (batch.Count < _options.BatchSize && _logQueue.TryDequeue(out var entry))
|
||||||
|
{
|
||||||
|
batch.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.Count > 0)
|
||||||
|
{
|
||||||
|
await SendBatchToEonaCatAsync(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_flushSemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendBatchToEonaCatAsync(List<LogEntry> batch)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var eventsArray = batch.Select(e => new
|
||||||
|
{
|
||||||
|
timestamp = e.Timestamp.ToString("O"),
|
||||||
|
level = e.Level,
|
||||||
|
message = e.Message ?? "", // empty message is fine
|
||||||
|
exception = string.IsNullOrEmpty(e.Exception) ? null : new
|
||||||
|
{
|
||||||
|
type = "Exception",
|
||||||
|
message = e.Exception,
|
||||||
|
stackTrace = e.StackTrace
|
||||||
|
},
|
||||||
|
properties = string.IsNullOrEmpty(e.Properties)
|
||||||
|
? new Dictionary<string, object?>() // <-- same type now
|
||||||
|
: JsonHelper.ToObject<Dictionary<string, object?>>(e.Properties)
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
var json = JsonHelper.ToJson(eventsArray);
|
||||||
|
|
||||||
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
var response = await _httpClient.PostAsync("api/logs/eonacat", content);
|
||||||
|
|
||||||
|
var responseContent = await response.Content.ReadAsStringAsync();
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (_options.EnableFallbackLogging)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[LogCentral] Failed to send logs to EonaCat: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entry in batch)
|
||||||
|
{
|
||||||
|
_logQueue.Enqueue(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task FlushAndDisposeAsync()
|
||||||
|
{
|
||||||
|
await FlushAsync();
|
||||||
|
Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_flushTimer?.Dispose();
|
||||||
|
FlushAsync().GetAwaiter().GetResult();
|
||||||
|
_httpClient?.Dispose();
|
||||||
|
_flushSemaphore?.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
EonaCat.LogStack.LogClient/LogCentralEonaCatAdapter.cs
Normal file
64
EonaCat.LogStack.LogClient/LogCentralEonaCatAdapter.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using EonaCat.LogStack.Configuration;
|
||||||
|
using EonaCat.LogStack.Extensions;
|
||||||
|
using EonaCat.LogStack.LogClient.Models;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.LogClient
|
||||||
|
{
|
||||||
|
public class LogCentralEonaCatAdapter : IDisposable
|
||||||
|
{
|
||||||
|
private readonly LogCentralClient _client;
|
||||||
|
private LogBuilder _logBuilder;
|
||||||
|
|
||||||
|
public LogCentralEonaCatAdapter(LogBuilder logBuilder, LogCentralClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_logBuilder.OnLog += LogSettings_OnLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogSettings_OnLog(object sender, LogMessage e)
|
||||||
|
{
|
||||||
|
var entry = new LogEntry
|
||||||
|
{
|
||||||
|
Level = e.Level.ToString().ToLower(),
|
||||||
|
Message = e.Message,
|
||||||
|
Properties = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "Source", e.Origin ?? "Unknown" },
|
||||||
|
{ "Category", e.Category ?? "General" }
|
||||||
|
}.ToJson()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (e.Exception != null)
|
||||||
|
{
|
||||||
|
entry.Exception = e.Exception.ToString();
|
||||||
|
entry.StackTrace = e.Exception.StackTrace;
|
||||||
|
}
|
||||||
|
|
||||||
|
_client.LogAsync(entry).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
private static LogLevel MapLogLevel(Core.LogLevel logType)
|
||||||
|
{
|
||||||
|
return logType switch
|
||||||
|
{
|
||||||
|
Core.LogLevel.Trace => LogLevel.Trace,
|
||||||
|
Core.LogLevel.Debug => LogLevel.Debug,
|
||||||
|
Core.LogLevel.Information => LogLevel.Information,
|
||||||
|
Core.LogLevel.Warning => LogLevel.Warning,
|
||||||
|
Core.LogLevel.Error => LogLevel.Error,
|
||||||
|
Core.LogLevel.Critical => LogLevel.Critical,
|
||||||
|
_ => LogLevel.Information
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_logBuilder.OnLog -= LogSettings_OnLog;
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
EonaCat.LogStack.LogClient/LogCentralOptions.cs
Normal file
21
EonaCat.LogStack.LogClient/LogCentralOptions.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.LogClient
|
||||||
|
{
|
||||||
|
public class LogCentralOptions
|
||||||
|
{
|
||||||
|
public string ServerUrl { get; set; } = "https://localhost:62299";
|
||||||
|
public string ApiKey { get; set; } = string.Empty;
|
||||||
|
public string ApplicationName { get; set; } = string.Empty;
|
||||||
|
public string ApplicationVersion { get; set; } = "1.0.0";
|
||||||
|
public string Environment { get; set; } = "Production";
|
||||||
|
public int BatchSize { get; set; } = 1;
|
||||||
|
public int FlushIntervalSeconds { get; set; } = 5;
|
||||||
|
public bool EnableFallbackLogging { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
EonaCat.LogStack.LogClient/LogLevel.cs
Normal file
18
EonaCat.LogStack.LogClient/LogLevel.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace EonaCat.LogStack.LogClient
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public enum LogLevel
|
||||||
|
{
|
||||||
|
Trace = 0,
|
||||||
|
Debug = 1,
|
||||||
|
Information = 2,
|
||||||
|
Warning = 3,
|
||||||
|
Error = 4,
|
||||||
|
Critical = 5,
|
||||||
|
Traffic = 6,
|
||||||
|
Security = 7,
|
||||||
|
Analytics = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
21
EonaCat.LogStack.LogClient/Models/LogEntry.cs
Normal file
21
EonaCat.LogStack.LogClient/Models/LogEntry.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.LogClient.Models
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class LogEntry
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Source { get; set; } = "system";
|
||||||
|
public string Level { get; set; } = "info";
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
public string? Properties { get; set; }
|
||||||
|
public string? Exception { get; set; }
|
||||||
|
public string? StackTrace { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
public string? TraceId { get; set; }
|
||||||
|
public string? Host { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
389
EonaCat.LogStack.LogClient/readme.md
Normal file
389
EonaCat.LogStack.LogClient/readme.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# EonaCat.LogStack.LogClient
|
||||||
|
|
||||||
|
### Client Installation
|
||||||
|
|
||||||
|
#### Via NuGet Package Manager:
|
||||||
|
```bash
|
||||||
|
dotnet add package EonaCat.LogStack.LogClient
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Via Package Manager Console:
|
||||||
|
```powershell
|
||||||
|
Install-Package EonaCat.LogStack.LogClient
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 Usage Examples
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using EonaCat.LogStack.LogClient;
|
||||||
|
using EonaCat.LogStack.LogClient.Models;
|
||||||
|
|
||||||
|
// Configure the client
|
||||||
|
var options = new LogCentralOptions
|
||||||
|
{
|
||||||
|
ServerUrl = "https://your-logcentral-server.com",
|
||||||
|
ApiKey = "your-api-key-here",
|
||||||
|
ApplicationName = "MyAwesomeApp",
|
||||||
|
ApplicationVersion = "1.0.0",
|
||||||
|
Environment = "Production",
|
||||||
|
BatchSize = 50,
|
||||||
|
FlushIntervalSeconds = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
var logClient = new LogCentralClient(options);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with EonaCat.LogStack
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using EonaCat.LogStack;
|
||||||
|
using EonaCat.LogStack.LogClient.Integration;
|
||||||
|
|
||||||
|
var loggerSettings = new LoggerSettings();
|
||||||
|
loggerSettings.UseLocalTime = true;
|
||||||
|
loggerSettings.Id = "TEST";
|
||||||
|
var logger = new LogManager(loggerSettings);
|
||||||
|
|
||||||
|
// Create the adapter
|
||||||
|
var adapter = new LogCentralEonaCatAdapter(loggerSettings, logClient);
|
||||||
|
|
||||||
|
// Now all EonaCat.LogStack logs will be sent to LogCentral automatically
|
||||||
|
logger.Log("Application started", LogLevel.Info);
|
||||||
|
logger.Log("User logged in", LogLevel.Info, "Authentication");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Logging
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Simple log
|
||||||
|
await logClient.LogAsync(new LogEntry
|
||||||
|
{
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Category = "Startup",
|
||||||
|
Message = "Application started successfully"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log with properties
|
||||||
|
await logClient.LogAsync(new LogEntry
|
||||||
|
{
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Category = "UserAction",
|
||||||
|
Message = "User performed action",
|
||||||
|
UserId = "user123",
|
||||||
|
Properties = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Action"] = "Purchase",
|
||||||
|
["Amount"] = 99.99,
|
||||||
|
["ProductId"] = "prod-456"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log exception
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Your code
|
||||||
|
throw new Exception("Something went wrong");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await logClient.LogExceptionAsync(ex, "Error processing order",
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["OrderId"] = "12345",
|
||||||
|
["CustomerId"] = "cust-789"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Event Logging
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await logClient.LogSecurityEventAsync(
|
||||||
|
"LoginAttempt",
|
||||||
|
"Failed login attempt detected",
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Username"] = "admin",
|
||||||
|
["IPAddress"] = "192.168.1.100",
|
||||||
|
["Attempts"] = 5
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await logClient.LogSecurityEventAsync(
|
||||||
|
"UnauthorizedAccess",
|
||||||
|
"Unauthorized API access attempt",
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Endpoint"] = "/api/admin/users",
|
||||||
|
["Method"] = "DELETE",
|
||||||
|
["UserId"] = "user456"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analytics Logging
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Track user events
|
||||||
|
await logClient.LogAnalyticsAsync("PageView",
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Page"] = "/products/electronics",
|
||||||
|
["Duration"] = 45.2,
|
||||||
|
["Source"] = "Google"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await logClient.LogAnalyticsAsync("Purchase",
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["ProductId"] = "prod-123",
|
||||||
|
["Price"] = 299.99,
|
||||||
|
["Category"] = "Electronics",
|
||||||
|
["PaymentMethod"] = "CreditCard"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await logClient.LogAnalyticsAsync("FeatureUsage",
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Feature"] = "DarkMode",
|
||||||
|
["Enabled"] = true,
|
||||||
|
["Platform"] = "iOS"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ASP.NET Core Integration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Program.cs or Startup.cs
|
||||||
|
public class Program
|
||||||
|
{
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Register LogCentral
|
||||||
|
var logCentralOptions = new LogCentralOptions
|
||||||
|
{
|
||||||
|
ServerUrl = builder.Configuration["LogCentral:ServerUrl"],
|
||||||
|
ApiKey = builder.Configuration["LogCentral:ApiKey"],
|
||||||
|
ApplicationName = "MyWebApp",
|
||||||
|
ApplicationVersion = "1.0.0",
|
||||||
|
Environment = builder.Environment.EnvironmentName
|
||||||
|
};
|
||||||
|
|
||||||
|
var logClient = new LogCentralClient(logCentralOptions);
|
||||||
|
builder.Services.AddSingleton(logClient);
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Use middleware to log requests
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
var requestId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
await logClient.LogAsync(new LogEntry
|
||||||
|
{
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Category = "HTTP",
|
||||||
|
Message = $"{context.Request.Method} {context.Request.Path}",
|
||||||
|
RequestId = requestId,
|
||||||
|
Properties = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Method"] = context.Request.Method,
|
||||||
|
["Path"] = context.Request.Path.Value,
|
||||||
|
["QueryString"] = context.Request.QueryString.Value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows Service / Console App
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using EonaCat.LogStack.LogClient;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
public class Worker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly LogCentralClient _logClient;
|
||||||
|
|
||||||
|
public Worker(LogCentralClient logClient)
|
||||||
|
{
|
||||||
|
_logClient = logClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await _logClient.LogAsync(new LogEntry
|
||||||
|
{
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Category = "Service",
|
||||||
|
Message = "Worker service started"
|
||||||
|
});
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Your work here
|
||||||
|
await Task.Delay(1000, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _logClient.LogExceptionAsync(ex, "Error in worker");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _logClient.FlushAndDisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WPF / WinForms Application
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private readonly LogCentralClient _logClient;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_logClient = new LogCentralClient(new LogCentralOptions
|
||||||
|
{
|
||||||
|
ServerUrl = "https://logs.mycompany.com",
|
||||||
|
ApiKey = "your-api-key",
|
||||||
|
ApplicationName = "MyDesktopApp",
|
||||||
|
ApplicationVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(),
|
||||||
|
Environment = "Production"
|
||||||
|
});
|
||||||
|
|
||||||
|
Application.Current.DispatcherUnhandledException += OnUnhandledException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
await _logClient.LogExceptionAsync(e.Exception, "Unhandled exception in UI");
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnClosing(CancelEventArgs e)
|
||||||
|
{
|
||||||
|
await _logClient.FlushAndDisposeAsync();
|
||||||
|
base.OnClosing(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Advanced Features
|
||||||
|
|
||||||
|
### Correlation IDs for Distributed Tracing
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
await logClient.LogAsync(new LogEntry
|
||||||
|
{
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Category = "OrderProcessing",
|
||||||
|
Message = "Order created",
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
Properties = new Dictionary<string, object> { ["OrderId"] = "12345" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// In another service
|
||||||
|
await logClient.LogAsync(new LogEntry
|
||||||
|
{
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Category = "PaymentProcessing",
|
||||||
|
Message = "Payment processed",
|
||||||
|
CorrelationId = correlationId, // Same ID
|
||||||
|
Properties = new Dictionary<string, object> { ["Amount"] = 99.99 }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Monitoring
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Your operation
|
||||||
|
await SomeSlowOperation();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
await logClient.LogAsync(new LogEntry
|
||||||
|
{
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Category = "Performance",
|
||||||
|
Message = "Operation completed",
|
||||||
|
Properties = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Operation"] = "DatabaseQuery",
|
||||||
|
["DurationMs"] = stopwatch.ElapsedMilliseconds,
|
||||||
|
["Status"] = "Success"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Dashboard Features
|
||||||
|
|
||||||
|
- **Real-time monitoring**: Auto-refreshes every 30 seconds
|
||||||
|
- **Advanced search**: Full-text search across all log fields
|
||||||
|
- **Filtering**: By application, environment, level, date range
|
||||||
|
- **Charts**: Visual representation of log levels and trends
|
||||||
|
- **Export**: Download logs as CSV or JSON
|
||||||
|
- **Alerts**: Configure notifications for critical events (planned)
|
||||||
|
|
||||||
|
## 🔒 Security Best Practices
|
||||||
|
|
||||||
|
1. **Use HTTPS** for production deployments
|
||||||
|
2. **Rotate API keys** regularly
|
||||||
|
3. **Limit API key permissions** by application
|
||||||
|
4. **Store API keys** in secure configuration (Azure Key Vault, AWS Secrets Manager)
|
||||||
|
5. **Enable authentication** for dashboard access (add authentication middleware)
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "EonaCat.LogStack.LogServer.dll"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Azure Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
az webapp create --resource-group MyResourceGroup --plan MyPlan --name logcentral --runtime "DOTNETCORE:8.0"
|
||||||
|
az webapp deployment source config-zip --resource-group MyResourceGroup --name logcentral --src logcentral.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Scalability
|
||||||
|
|
||||||
|
For high-volume applications:
|
||||||
|
|
||||||
|
1. Use **Redis** for caching
|
||||||
|
2. Implement **Elasticsearch** for faster searches
|
||||||
|
3. Use **message queues** (RabbitMQ, Azure Service Bus) for async processing
|
||||||
|
4. Partition database by date ranges
|
||||||
|
5. Implement log archival and retention policies
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
|
||||||
|
<PackageId>EonaCat.LogStack.OpenTelemetryFlow</PackageId>
|
||||||
|
<Title>EonaCat.LogStack.OpenTelemetryFlow</Title>
|
||||||
|
<Version>0.0.1</Version>
|
||||||
|
<Authors>EonaCat.LogStack.OpenTelemetryFlow</Authors>
|
||||||
|
<Company>EonaCat (Jeroen Saey)</Company>
|
||||||
|
<Description>EonaCat OpenTelemetry Flow for LogStack</Description>
|
||||||
|
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||||
|
<PackageProjectUrl>https://git.saey.me/EonaCat/EonaCat.LogStack</PackageProjectUrl>
|
||||||
|
<PackageIcon>icon.png</PackageIcon>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.LogStack</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageTags>EonaCat; OpenTelemetry; Flow; LogStack; Logging; Jeroen;Saey</PackageTags>
|
||||||
|
<PackageLicenseFile>README.md</PackageLicenseFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\EonaCat.LogStack\icon.png">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
<None Include="..\README.md">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="OpenTelemetry" Version="1.15.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
|
||||||
|
<PackageReference Include="System.Threading.AccessControl" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EonaCat.LogStack\EonaCat.LogStack.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
33
EonaCat.LogStack.OpenTelemetryFlow/LogBuilder.cs
Normal file
33
EonaCat.LogStack.OpenTelemetryFlow/LogBuilder.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using EonaCat.LogStack.Configuration;
|
||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using EonaCat.LogStack.Flows;
|
||||||
|
using OpenTelemetry.Exporter;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Flows.WindowsEventLog
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public static class EonaCatLogStackExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Write to OpenTelemetry
|
||||||
|
/// </summary>
|
||||||
|
public static LogBuilder WriteToOpenTelemetry(this LogBuilder logBuilder,
|
||||||
|
string serviceName,
|
||||||
|
Uri endpoint,
|
||||||
|
OtlpExportProtocol protocol = OtlpExportProtocol.Grpc,
|
||||||
|
LogLevel minimumLevel = LogLevel.Trace)
|
||||||
|
{
|
||||||
|
logBuilder.AddFlow(new OpenTelemetryFlow(
|
||||||
|
serviceName,
|
||||||
|
endpoint,
|
||||||
|
protocol,
|
||||||
|
minimumLevel));
|
||||||
|
return logBuilder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
EonaCat.LogStack.OpenTelemetryFlow/OpenTelemetryFlow.cs
Normal file
162
EonaCat.LogStack.OpenTelemetryFlow/OpenTelemetryFlow.cs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OpenTelemetry.Exporter;
|
||||||
|
using OpenTelemetry.Logs;
|
||||||
|
using OpenTelemetry.Resources;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LogLevel = EonaCat.LogStack.Core.LogLevel;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Flows
|
||||||
|
{
|
||||||
|
public sealed class OpenTelemetryFlow : FlowBase
|
||||||
|
{
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
public OpenTelemetryFlow(string serviceName, Uri endpoint, OtlpExportProtocol protocol = OtlpExportProtocol.Grpc, LogLevel minimumLevel = LogLevel.Trace) : base("OpenTelemetry:" + serviceName, minimumLevel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(serviceName))
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(serviceName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
_loggerFactory = LoggerFactory.Create(builder =>
|
||||||
|
{
|
||||||
|
builder.ClearProviders();
|
||||||
|
|
||||||
|
builder.AddOpenTelemetry(options =>
|
||||||
|
{
|
||||||
|
options.SetResourceBuilder(
|
||||||
|
ResourceBuilder.CreateDefault()
|
||||||
|
.AddService(serviceName)
|
||||||
|
.AddAttributes(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["host.name"] = Environment.MachineName,
|
||||||
|
["process.id"] = Process.GetCurrentProcess().Id
|
||||||
|
}));
|
||||||
|
|
||||||
|
options.AddOtlpExporter(otlp =>
|
||||||
|
{
|
||||||
|
otlp.Endpoint = endpoint;
|
||||||
|
otlp.Protocol = protocol;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.IncludeScopes = true;
|
||||||
|
options.IncludeFormattedMessage = true;
|
||||||
|
options.ParseStateValues = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger = _loggerFactory.CreateLogger(serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<WriteResult> BlastAsync(
|
||||||
|
LogEvent logEvent,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
|
||||||
|
{
|
||||||
|
return Task.FromResult(WriteResult.LevelFiltered);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog(logEvent);
|
||||||
|
Interlocked.Increment(ref BlastedCount);
|
||||||
|
|
||||||
|
return Task.FromResult(WriteResult.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<WriteResult> BlastBatchAsync(
|
||||||
|
ReadOnlyMemory<LogEvent> logEvents,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!IsEnabled)
|
||||||
|
{
|
||||||
|
return Task.FromResult(WriteResult.FlowDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var e in logEvents.Span)
|
||||||
|
{
|
||||||
|
if (e.Level < MinimumLevel)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog(e);
|
||||||
|
Interlocked.Increment(ref BlastedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(WriteResult.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteLog(LogEvent log)
|
||||||
|
{
|
||||||
|
var state = new List<KeyValuePair<string, object>>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(log.Category))
|
||||||
|
{
|
||||||
|
state.Add(new KeyValuePair<string, object>("category", log.Category));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var prop in log.Properties)
|
||||||
|
{
|
||||||
|
state.Add(new KeyValuePair<string, object>(prop.Key, prop.Value ?? "null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.Exception != null)
|
||||||
|
{
|
||||||
|
state.Add(new KeyValuePair<string, object>("exception.type", log.Exception.GetType().FullName));
|
||||||
|
state.Add(new KeyValuePair<string, object>("exception.message", log.Exception.Message));
|
||||||
|
state.Add(new KeyValuePair<string, object>("exception.stacktrace", log.Exception.StackTrace));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Log(
|
||||||
|
MapLevel(log.Level),
|
||||||
|
new EventId(0, log.Category),
|
||||||
|
state,
|
||||||
|
log.Exception,
|
||||||
|
(s, e) => log.Message.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Microsoft.Extensions.Logging.LogLevel MapLevel(LogLevel level)
|
||||||
|
{
|
||||||
|
return level switch
|
||||||
|
{
|
||||||
|
LogLevel.Trace => Microsoft.Extensions.Logging.LogLevel.Trace,
|
||||||
|
LogLevel.Debug => Microsoft.Extensions.Logging.LogLevel.Debug,
|
||||||
|
LogLevel.Information => Microsoft.Extensions.Logging.LogLevel.Information,
|
||||||
|
LogLevel.Warning => Microsoft.Extensions.Logging.LogLevel.Warning,
|
||||||
|
LogLevel.Error => Microsoft.Extensions.Logging.LogLevel.Error,
|
||||||
|
LogLevel.Critical => Microsoft.Extensions.Logging.LogLevel.Critical,
|
||||||
|
_ => Microsoft.Extensions.Logging.LogLevel.Information
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!IsEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsEnabled = false;
|
||||||
|
|
||||||
|
_loggerFactory?.Dispose();
|
||||||
|
|
||||||
|
await base.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task FlushAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Network" Version="3.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
169
EonaCat.LogStack.SerilogTest/Program.cs
Normal file
169
EonaCat.LogStack.SerilogTest/Program.cs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
|
using Serilog.Formatting.Json;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.Enrich.WithProperty("Id", "TEST")
|
||||||
|
.Enrich.WithProperty("AppName", "[ALL YOUR BASE ARE BELONG TO US!]")
|
||||||
|
.WriteTo.Async(a => a.Console())
|
||||||
|
.WriteTo.Async(a => a.File(
|
||||||
|
path: "logs/web-.log",
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
fileSizeLimitBytes: 1_000_000,
|
||||||
|
rollOnFileSizeLimit: true,
|
||||||
|
retainedFileCountLimit: 5,
|
||||||
|
shared: true))
|
||||||
|
.WriteTo.Async(a => a.File(
|
||||||
|
new JsonFormatter(),
|
||||||
|
path: "logs/test.json",
|
||||||
|
rollingInterval: RollingInterval.Day))
|
||||||
|
//.WriteTo.Seq("http://localhost:5341") // central logging
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "keys")))
|
||||||
|
.SetApplicationName("SerilogStressTest");
|
||||||
|
|
||||||
|
builder.Services.AddRazorPages();
|
||||||
|
|
||||||
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
|
{
|
||||||
|
options.ListenAnyIP(6000);
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapRazorPages();
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
_ = Task.Run(RunLoggingTestsAsync);
|
||||||
|
_ = Task.Run(RunWebLoggingTestsAsync);
|
||||||
|
_ = Task.Run(RunLoggingExceptionTests);
|
||||||
|
_ = Task.Run(RunWebLoggingExceptionTests);
|
||||||
|
//_ = Task.Run(RunMemoryLeakTest);
|
||||||
|
_ = Task.Run(RunTcpLoggerTest);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
|
||||||
|
async Task RunLoggingTestsAsync()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 9_000_000; i++)
|
||||||
|
{
|
||||||
|
Log.Information("test to file {i} INFO", i);
|
||||||
|
Log.Fatal("test to file {i} CRITICAL", i);
|
||||||
|
Log.Debug("test to file {i} DEBUG", i);
|
||||||
|
Log.Error("test to file {i} ERROR", i);
|
||||||
|
Log.Verbose("test to file {i} TRACE", i);
|
||||||
|
Log.Warning("test to file {i} WARNING", i);
|
||||||
|
|
||||||
|
Console.WriteLine($"Logged: {i}");
|
||||||
|
await Task.Delay(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RunWebLoggingTestsAsync()
|
||||||
|
{
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
|
||||||
|
Log.Information("web-test {i}", i);
|
||||||
|
Log.Debug("web-test {i}", i);
|
||||||
|
Log.Warning("web-test {i}", i);
|
||||||
|
Log.Error("web-test {i}", i);
|
||||||
|
Log.Verbose("web-test {i}", i);
|
||||||
|
|
||||||
|
await Task.Delay(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunLoggingExceptionTests()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
throw new Exception($"Normal Exception {i}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Exception {Index}", i);
|
||||||
|
Console.WriteLine($"Normal ExceptionLogged: {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunWebLoggingExceptionTests()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
throw new Exception($"WebException {i}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Fatal(ex, "CRITICAL");
|
||||||
|
Log.Debug(ex, "DEBUG");
|
||||||
|
Log.Error(ex, "ERROR");
|
||||||
|
Log.Verbose(ex, "TRACE");
|
||||||
|
Log.Warning(ex, "WARNING");
|
||||||
|
Log.Information(ex, "INFORMATION");
|
||||||
|
|
||||||
|
Console.WriteLine($"WebExceptionLogged: {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async Task RunMemoryLeakTest()
|
||||||
|
{
|
||||||
|
var managedLeak = new List<byte[]>();
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
managedLeak.Add(new byte[5_000_000]); // 5MB
|
||||||
|
Marshal.AllocHGlobal(10_000_000); // 10MB unmanaged
|
||||||
|
|
||||||
|
await Task.Delay(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RunTcpLoggerTest()
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.ConnectAsync("192.168.1.1", 12345);
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var message = Encoding.UTF8.GetBytes($"TCP log {++i}\n");
|
||||||
|
await client.GetStream().WriteAsync(message);
|
||||||
|
await Task.Delay(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Log.Warning("TCP server not reachable");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
EonaCat.LogStack.SerilogTest/Properties/launchSettings.json
Normal file
12
EonaCat.LogStack.SerilogTest/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"EonaCat.LogStack.SerilogTest": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:56815;http://localhost:56816"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
EonaCat.LogStack.Server/EonaCat.Logger.Server.csproj
Normal file
28
EonaCat.LogStack.Server/EonaCat.Logger.Server.csproj
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
|
||||||
|
<Title>EonaCat.LogStack.Server</Title>
|
||||||
|
<Company>EonaCat (Jeroen Saey)</Company>
|
||||||
|
<Description>EonaCat.LogStack.Server is a server for the logging library</Description>
|
||||||
|
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||||
|
<PackageProjectUrl>https://www.nuget.org/packages/EonaCat.LogStack.Server/</PackageProjectUrl>
|
||||||
|
<PackageIcon>icon.png</PackageIcon>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<PackageTags>EonaCat;Logger;EonaCatLogStack;server;Log;Writer;Jeroen;Saey</PackageTags>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\EonaCat.LogStack\icon.png">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
<None Include="..\README.md">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
287
EonaCat.LogStack.Server/Server.cs
Normal file
287
EonaCat.LogStack.Server/Server.cs
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Threading;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Server
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
public class Server
|
||||||
|
{
|
||||||
|
private TcpListener _tcpListener;
|
||||||
|
private UdpClient _udpListener;
|
||||||
|
private CancellationTokenSource _cts;
|
||||||
|
private bool _isRunning;
|
||||||
|
private readonly bool _useUdp;
|
||||||
|
private const long MaxLogFileSize = 200 * 1024 * 1024; // 200MB log rollover limit
|
||||||
|
private readonly int _logRetentionDays; // Number of days to retain logs
|
||||||
|
private readonly long _maxLogDirectorySize; // Maximum allowed size of the logs directory
|
||||||
|
private const int UdpBufferSize = 65507; // Maximum UDP packet size (65507 bytes for UDP payload)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EonaCat Log Server
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="useUdp">Determine if we need to start a udp server (default: true)</param>
|
||||||
|
/// <param name="logRetentionDays">Max log retention days (default: 30)</param>
|
||||||
|
/// <param name="maxLogDirectorySize">Max log directory size (default: 10GB)</param>
|
||||||
|
public Server(bool useUdp = true, int logRetentionDays = 30, long maxLogDirectorySize = 10L * 1024 * 1024 * 1024) // Default 10GB max directory size
|
||||||
|
{
|
||||||
|
_useUdp = useUdp;
|
||||||
|
_logRetentionDays = logRetentionDays;
|
||||||
|
_maxLogDirectorySize = maxLogDirectorySize;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual Task ProcessLogAsync(string logData)
|
||||||
|
{
|
||||||
|
string logsRootDirectory = "logs";
|
||||||
|
|
||||||
|
// Create root log directory if it doesn't exist
|
||||||
|
if (!Directory.Exists(logsRootDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(logsRootDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a daily directory for logs
|
||||||
|
string dailyLogsDirectory = Path.Combine(logsRootDirectory, DateTime.Now.ToString("yyyyMMdd"));
|
||||||
|
if (!Directory.Exists(dailyLogsDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dailyLogsDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base log file name
|
||||||
|
string baseLogFilePath = Path.Combine(dailyLogsDirectory, "EonaCatLogs");
|
||||||
|
string logFilePath = baseLogFilePath + ".log";
|
||||||
|
|
||||||
|
int fileIndex = 1;
|
||||||
|
while (File.Exists(logFilePath) && new FileInfo(logFilePath).Length > MaxLogFileSize)
|
||||||
|
{
|
||||||
|
logFilePath = baseLogFilePath + $"_{fileIndex}.log";
|
||||||
|
fileIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After processing log, check directory size and clean up if needed
|
||||||
|
CleanUpOldLogs();
|
||||||
|
|
||||||
|
return File.AppendAllTextAsync(logFilePath, logData + Environment.NewLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanUpOldLogs()
|
||||||
|
{
|
||||||
|
string logsRootDirectory = "logs";
|
||||||
|
if (!Directory.Exists(logsRootDirectory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old directories
|
||||||
|
foreach (var directory in Directory.GetDirectories(logsRootDirectory))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DirectoryInfo dirInfo = new DirectoryInfo(directory);
|
||||||
|
if (dirInfo.CreationTime < DateTime.Now.AddDays(-_logRetentionDays))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Deleting old log directory: {directory}");
|
||||||
|
Directory.Delete(directory, true); // Delete directory and its contents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error deleting old directory {directory}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure total size of log directory doesn't exceed max limit
|
||||||
|
long totalDirectorySize = GetDirectorySize(logsRootDirectory);
|
||||||
|
if (totalDirectorySize > _maxLogDirectorySize)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Log directory size exceeded limit, cleaning up...");
|
||||||
|
|
||||||
|
// Delete the oldest directories until the size limit is met
|
||||||
|
foreach (var directory in Directory.GetDirectories(logsRootDirectory).OrderBy(d => new DirectoryInfo(d).CreationTime))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DirectoryInfo dirInfo = new DirectoryInfo(directory);
|
||||||
|
long dirSize = GetDirectorySize(directory);
|
||||||
|
totalDirectorySize -= dirSize;
|
||||||
|
|
||||||
|
// Delete the directory if the total size exceeds the limit
|
||||||
|
Directory.Delete(directory, true);
|
||||||
|
Console.WriteLine($"Deleted directory: {directory}");
|
||||||
|
|
||||||
|
// Stop deleting if we are under the size limit
|
||||||
|
if (totalDirectorySize <= _maxLogDirectorySize)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error deleting directory {directory}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long GetDirectorySize(string directory)
|
||||||
|
{
|
||||||
|
long size = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Add size of files in the directory
|
||||||
|
size += Directory.GetFiles(directory).Sum(file => new FileInfo(file).Length);
|
||||||
|
|
||||||
|
// Add size of files in subdirectories
|
||||||
|
foreach (var subdirectory in Directory.GetDirectories(directory))
|
||||||
|
{
|
||||||
|
size += GetDirectorySize(subdirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error calculating size for directory {directory}: {ex.Message}");
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Start(IPAddress ipAddress = null, int port = 5555)
|
||||||
|
{
|
||||||
|
if (ipAddress == null)
|
||||||
|
{
|
||||||
|
ipAddress = IPAddress.Any;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_isRunning = true;
|
||||||
|
|
||||||
|
if (_useUdp)
|
||||||
|
{
|
||||||
|
_udpListener = new UdpClient(port);
|
||||||
|
Console.WriteLine($"EonaCat UDP Log Server started on port {port}...");
|
||||||
|
await ListenUdpAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_tcpListener = new TcpListener(ipAddress, port);
|
||||||
|
_tcpListener.Start();
|
||||||
|
Console.WriteLine($"EonaCat TCP Log Server started on port {port}...");
|
||||||
|
await ListenTcpAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenTcpAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
TcpClient client = await _tcpListener.AcceptTcpClientAsync();
|
||||||
|
_ = Task.Run(() => HandleTcpClient(client));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Console.WriteLine("TCP Server stopping...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenUdpAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Increased buffer size for UDP
|
||||||
|
UdpReceiveResult result = await _udpListener.ReceiveAsync();
|
||||||
|
string logData = Encoding.UTF8.GetString(result.Buffer);
|
||||||
|
|
||||||
|
// If the received data is too large, process it in chunks
|
||||||
|
if (result.Buffer.Length > UdpBufferSize)
|
||||||
|
{
|
||||||
|
// Handle fragmentation and reassembly (this is a basic placeholder logic)
|
||||||
|
Console.WriteLine("Received large UDP data. Handling fragmentation.");
|
||||||
|
await ProcessLargeDataAsync(result.Buffer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Received UDP Log: {logData}");
|
||||||
|
await ProcessLogAsync(logData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Console.WriteLine("UDP Server stopping...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessLargeDataAsync(byte[] data)
|
||||||
|
{
|
||||||
|
// You can implement your own logic here for processing large UDP data, such as fragmentation handling
|
||||||
|
string largeDataString = Encoding.UTF8.GetString(data);
|
||||||
|
await ProcessLogAsync(largeDataString);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
if (_isRunning)
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
|
||||||
|
// Proper cleanup of resources
|
||||||
|
_cts.Dispose();
|
||||||
|
if (_useUdp)
|
||||||
|
{
|
||||||
|
_udpListener?.Close();
|
||||||
|
_udpListener?.Dispose();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_tcpListener?.Stop();
|
||||||
|
_tcpListener?.Server?.Dispose(); // Dispose of the socket (if any)
|
||||||
|
}
|
||||||
|
|
||||||
|
_isRunning = false;
|
||||||
|
Console.WriteLine("EonaCat Log Server stopped.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleTcpClient(TcpClient client)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (NetworkStream stream = client.GetStream())
|
||||||
|
using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))
|
||||||
|
{
|
||||||
|
char[] buffer = new char[8192]; // 8KB buffer size for large data
|
||||||
|
int bytesRead;
|
||||||
|
StringBuilder logData = new StringBuilder();
|
||||||
|
|
||||||
|
while ((bytesRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||||
|
{
|
||||||
|
logData.Append(new string(buffer, 0, bytesRead));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Received TCP Log: {logData.ToString()}");
|
||||||
|
await ProcessLogAsync(logData.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Ensure client is properly disposed
|
||||||
|
client.Close();
|
||||||
|
client.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
554
EonaCat.LogStack.Status/Controllers/ApiController.cs
Normal file
554
EonaCat.LogStack.Status/Controllers/ApiController.cs
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Controllers;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api")]
|
||||||
|
public class ApiController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _database;
|
||||||
|
private readonly MonitoringService _monitorService;
|
||||||
|
private readonly IngestionService _ingestionService;
|
||||||
|
|
||||||
|
public ApiController(DatabaseContext database, MonitoringService monitorService, IngestionService ingestionService)
|
||||||
|
{
|
||||||
|
_database = database;
|
||||||
|
_monitorService = monitorService;
|
||||||
|
_ingestionService = ingestionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("status/summary")]
|
||||||
|
public async Task<IActionResult> GetSummary()
|
||||||
|
{
|
||||||
|
var isAdmin = IsAdmin();
|
||||||
|
var stats = await _monitorService.GetStatsAsync(isAdmin);
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("monitors")]
|
||||||
|
public async Task<IActionResult> GetMonitors()
|
||||||
|
{
|
||||||
|
var isAdmin = IsAdmin();
|
||||||
|
var query = _database.Monitors.Where(m => m.IsActive);
|
||||||
|
if (!isAdmin)
|
||||||
|
{
|
||||||
|
query = query.Where(m => m.IsPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(await query.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("monitors/{id}")]
|
||||||
|
public async Task<IActionResult> GetMonitor(int id)
|
||||||
|
{
|
||||||
|
var monitor = await _database.Monitors.FindAsync(id);
|
||||||
|
if (monitor == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAdmin() && !monitor.IsPublic)
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(monitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("monitors/{id}/check")]
|
||||||
|
public async Task<IActionResult> CheckMonitor(int id)
|
||||||
|
{
|
||||||
|
var monitor = await _database.Monitors.FindAsync(id);
|
||||||
|
if (monitor == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var check = await _monitorService.CheckMonitorAsync(monitor);
|
||||||
|
return Ok(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the last N checks for a monitor (default 100).</summary>
|
||||||
|
[HttpGet("monitors/{id}/history")]
|
||||||
|
public async Task<IActionResult> GetMonitorHistory(int id, [FromQuery] int limit = 100)
|
||||||
|
{
|
||||||
|
var monitor = await _database.Monitors.FindAsync(id);
|
||||||
|
if (monitor == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAdmin() && !monitor.IsPublic)
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var checks = await _database.MonitorChecks
|
||||||
|
.Where(c => c.MonitorId == id)
|
||||||
|
.OrderByDescending(c => c.CheckedAt)
|
||||||
|
.Take(Math.Clamp(limit, 1, 1000))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(checks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns uptime percentages over 24h / 7d / 30d windows.</summary>
|
||||||
|
[HttpGet("monitors/{id}/uptime")]
|
||||||
|
public async Task<IActionResult> GetMonitorUptime(int id)
|
||||||
|
{
|
||||||
|
var monitor = await _database.Monitors.FindAsync(id);
|
||||||
|
if (monitor == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAdmin() && !monitor.IsPublic)
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = await _monitorService.GetUptimeReportAsync(id);
|
||||||
|
return Ok(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pause or resume a monitor (admin only).</summary>
|
||||||
|
[HttpPost("monitors/{id}/pause")]
|
||||||
|
public async Task<IActionResult> PauseMonitor(int id)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var monitor = await _database.Monitors.FindAsync(id);
|
||||||
|
if (monitor == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.IsActive = false;
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
return Ok(new { id, active = false });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("monitors/{id}/resume")]
|
||||||
|
public async Task<IActionResult> ResumeMonitor(int id)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var monitor = await _database.Monitors.FindAsync(id);
|
||||||
|
if (monitor == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.IsActive = true;
|
||||||
|
monitor.LastChecked = null; // force immediate re-check
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
return Ok(new { id, active = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("monitors/{id}/alerts")]
|
||||||
|
public async Task<IActionResult> GetAlertRules(int id)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules = await _database.AlertRules.Where(r => r.MonitorId == id).ToListAsync();
|
||||||
|
return Ok(rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("monitors/{id}/alerts")]
|
||||||
|
public async Task<IActionResult> CreateAlertRule(int id, [FromBody] AlertRule rule)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var monitor = await _database.Monitors.FindAsync(id);
|
||||||
|
if (monitor == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.MonitorId = id;
|
||||||
|
_database.AlertRules.Add(rule);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
return Ok(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("alerts/{ruleId}")]
|
||||||
|
public async Task<IActionResult> DeleteAlertRule(int ruleId)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var rule = await _database.AlertRules.FindAsync(ruleId);
|
||||||
|
if (rule == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_database.AlertRules.Remove(rule);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
return Ok(new { deleted = ruleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>List incidents (admin sees all; public sees only IsPublic incidents).</summary>
|
||||||
|
[HttpGet("incidents")]
|
||||||
|
public async Task<IActionResult> GetIncidents([FromQuery] bool activeOnly = false)
|
||||||
|
{
|
||||||
|
var isAdmin = IsAdmin();
|
||||||
|
var query = _database.Incidents.Include(i => i.Updates).AsQueryable();
|
||||||
|
if (!isAdmin)
|
||||||
|
{
|
||||||
|
query = query.Where(i => i.IsPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeOnly)
|
||||||
|
{
|
||||||
|
query = query.Where(i => i.Status != IncidentStatus.Resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = await query.OrderByDescending(i => i.CreatedAt).ToListAsync();
|
||||||
|
return Ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("incidents/{id}")]
|
||||||
|
public async Task<IActionResult> GetIncident(int id)
|
||||||
|
{
|
||||||
|
var incident = await _database.Incidents.Include(i => i.Updates).FirstOrDefaultAsync(i => i.Id == id);
|
||||||
|
if (incident == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAdmin() && !incident.IsPublic)
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("incidents")]
|
||||||
|
public async Task<IActionResult> CreateIncident([FromBody] Incident incident)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
incident.CreatedAt = DateTime.UtcNow;
|
||||||
|
incident.UpdatedAt = DateTime.UtcNow;
|
||||||
|
_database.Incidents.Add(incident);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
return Ok(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("incidents/{id}")]
|
||||||
|
public async Task<IActionResult> UpdateIncident(int id, [FromBody] IncidentPatchDto patch)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var incident = await _database.Incidents.FindAsync(id);
|
||||||
|
if (incident == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.Status.HasValue)
|
||||||
|
{
|
||||||
|
incident.Status = patch.Status.Value;
|
||||||
|
if (patch.Status == IncidentStatus.Resolved)
|
||||||
|
{
|
||||||
|
incident.ResolvedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patch.Severity.HasValue)
|
||||||
|
{
|
||||||
|
incident.Severity = patch.Severity.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.Body != null)
|
||||||
|
{
|
||||||
|
incident.Body = patch.Body;
|
||||||
|
}
|
||||||
|
|
||||||
|
incident.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(patch.UpdateMessage))
|
||||||
|
{
|
||||||
|
_database.IncidentUpdates.Add(new IncidentUpdate
|
||||||
|
{
|
||||||
|
IncidentId = id,
|
||||||
|
Message = patch.UpdateMessage,
|
||||||
|
Status = patch.Status ?? incident.Status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
return Ok(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("incidents/{id}")]
|
||||||
|
public async Task<IActionResult> DeleteIncident(int id)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var incident = await _database.Incidents.FindAsync(id);
|
||||||
|
if (incident == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_database.Incidents.Remove(incident);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
return Ok(new { deleted = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("logs")]
|
||||||
|
public async Task<IActionResult> QueryLogs(
|
||||||
|
[FromQuery] string? level,
|
||||||
|
[FromQuery] string? source,
|
||||||
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 100)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = _database.Logs.AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(level))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Level == level.ToLower());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(source))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Source == source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Message.Contains(search));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Timestamp >= from.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Timestamp <= to.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var entries = await query
|
||||||
|
.OrderByDescending(x => x.Timestamp)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(new { total, page, pageSize, entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns hourly log volume buckets for charting.</summary>
|
||||||
|
[HttpGet("logs/stats")]
|
||||||
|
public async Task<IActionResult> GetLogStats([FromQuery] int hours = 24)
|
||||||
|
{
|
||||||
|
if (!IsAdmin())
|
||||||
|
{
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var buckets = await _monitorService.GetLogStatsAsync(Math.Clamp(hours, 1, 168));
|
||||||
|
return Ok(buckets);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logs/ingest")]
|
||||||
|
public async Task<IActionResult> IngestLog([FromBody] LogEntry entry)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(entry.Message))
|
||||||
|
{
|
||||||
|
return BadRequest("message required");
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Level = (entry.Level ?? "info").ToLower();
|
||||||
|
entry.Timestamp = DateTime.UtcNow;
|
||||||
|
await _ingestionService.IngestAsync(entry);
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logs/batch")]
|
||||||
|
public async Task<IActionResult> IngestBatch([FromBody] List<LogEntry> entries)
|
||||||
|
{
|
||||||
|
if (entries == null || !entries.Any())
|
||||||
|
{
|
||||||
|
return BadRequest("entries required");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
entry.Level = (entry.Level ?? "info").ToLower();
|
||||||
|
if (entry.Timestamp == default)
|
||||||
|
{
|
||||||
|
entry.Timestamp = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _ingestionService.IngestBatchAsync(entries);
|
||||||
|
return Ok(new { success = true, count = entries.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logs/eonacat")]
|
||||||
|
public async Task<IActionResult> IngestEonaCat([FromBody] object[] events)
|
||||||
|
{
|
||||||
|
var entries = new List<LogEntry>();
|
||||||
|
foreach (var evtObj in events)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(evtObj);
|
||||||
|
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!;
|
||||||
|
var logEntry = new LogEntry
|
||||||
|
{
|
||||||
|
Source = dict.TryGetValue("properties", out var props) &&
|
||||||
|
props.ValueKind == JsonValueKind.Object &&
|
||||||
|
props.Deserialize<Dictionary<string, object?>>()!.TryGetValue("Application", out var appObj)
|
||||||
|
? appObj?.ToString() ?? "EonaCat.LogStack" : "EonaCat.LogStack",
|
||||||
|
Level = dict.TryGetValue("level", out var level) ? level.GetString() ?? "Info" : "Info",
|
||||||
|
Message = dict.TryGetValue("message", out var msg) ? msg.GetString() ?? "" : "",
|
||||||
|
Exception = dict.TryGetValue("exception", out var ex) ? ex.ToString() : null,
|
||||||
|
Host = dict.TryGetValue("host", out var host) ? host.GetString() : null,
|
||||||
|
TraceId = dict.TryGetValue("traceId", out var traceId) ? traceId.GetString() : null,
|
||||||
|
Properties = dict.TryGetValue("properties", out var properties) ? properties.GetRawText() : null,
|
||||||
|
Timestamp = dict.TryGetValue("timestamp", out var ts) && DateTime.TryParse(ts.GetString(), out var dt) ? dt : DateTime.UtcNow
|
||||||
|
};
|
||||||
|
logEntry.Level = MapEonaCatLevel(logEntry.Level);
|
||||||
|
entries.Add(logEntry);
|
||||||
|
}
|
||||||
|
if (entries.Any())
|
||||||
|
{
|
||||||
|
await _ingestionService.IngestBatchAsync(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { success = true, count = entries.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("logs/serilog")]
|
||||||
|
public async Task<IActionResult> IngestSerilog([FromBody] SerilogPayload payload)
|
||||||
|
{
|
||||||
|
var entries = payload.Events?.Select(e => new LogEntry
|
||||||
|
{
|
||||||
|
Source = e.Properties?.TryGetValue("Application", out var app) == true ? app?.ToString() ?? "serilog" : "serilog",
|
||||||
|
Level = MapSerilogLevel(e.Level),
|
||||||
|
Message = e.RenderedMessage ?? e.MessageTemplate ?? "",
|
||||||
|
Exception = e.Exception,
|
||||||
|
Properties = e.Properties != null ? JsonSerializer.Serialize(e.Properties) : null,
|
||||||
|
Timestamp = e.Timestamp == default ? DateTime.UtcNow : e.Timestamp
|
||||||
|
}).ToList() ?? new List<LogEntry>();
|
||||||
|
|
||||||
|
if (entries.Any())
|
||||||
|
{
|
||||||
|
await _ingestionService.IngestBatchAsync(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { success = true, count = entries.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsAdmin() => HttpContext.Session.GetString("IsAdmin") == "true";
|
||||||
|
|
||||||
|
private static string MapSerilogLevel(string? l) => l?.ToLower() switch
|
||||||
|
{
|
||||||
|
"verbose" or "debug" => "debug",
|
||||||
|
"information" => "info",
|
||||||
|
"warning" => "warn",
|
||||||
|
"error" => "error",
|
||||||
|
"fatal" => "critical",
|
||||||
|
_ => "info"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string MapEonaCatLevel(string? l) => l?.ToLower() switch
|
||||||
|
{
|
||||||
|
"trace" or "debug" => "debug",
|
||||||
|
"information" => "info",
|
||||||
|
"warning" => "warn",
|
||||||
|
"error" => "error",
|
||||||
|
"critical" => "critical",
|
||||||
|
_ => "info"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public class IncidentPatchDto
|
||||||
|
{
|
||||||
|
public IncidentStatus? Status { get; set; }
|
||||||
|
public IncidentSeverity? Severity { get; set; }
|
||||||
|
public string? Body { get; set; }
|
||||||
|
/// <summary>If provided, a new IncidentUpdate is appended with this message.</summary>
|
||||||
|
public string? UpdateMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EonaCatPayLoad
|
||||||
|
{
|
||||||
|
public List<EonaCatLogEvent>? Events { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EonaCatLogEvent
|
||||||
|
{
|
||||||
|
public string Timestamp { get; set; } = default!;
|
||||||
|
public string Level { get; set; } = default!;
|
||||||
|
public string Message { get; set; } = default!;
|
||||||
|
public string Category { get; set; } = default!;
|
||||||
|
public int ThreadId { get; set; }
|
||||||
|
public string? TraceId { get; set; }
|
||||||
|
public string? SpanId { get; set; }
|
||||||
|
public ExceptionDto? Exception { get; set; }
|
||||||
|
public Dictionary<string, object?>? Properties { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExceptionDto
|
||||||
|
{
|
||||||
|
public string Type { get; set; } = default!;
|
||||||
|
public string Message { get; set; } = default!;
|
||||||
|
public string? StackTrace { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SerilogPayload
|
||||||
|
{
|
||||||
|
public List<SerilogEvent>? Events { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SerilogEvent
|
||||||
|
{
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public string? Level { get; set; }
|
||||||
|
public string? MessageTemplate { get; set; }
|
||||||
|
public string? RenderedMessage { get; set; }
|
||||||
|
public string? Exception { get; set; }
|
||||||
|
public Dictionary<string, object?>? Properties { get; set; }
|
||||||
|
}
|
||||||
67
EonaCat.LogStack.Status/Data/DatabaseContext.cs
Normal file
67
EonaCat.LogStack.Status/Data/DatabaseContext.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Data;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class DatabaseContext : DbContext
|
||||||
|
{
|
||||||
|
public DatabaseContext(DbContextOptions<DatabaseContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<Monitor> Monitors => Set<Monitor>();
|
||||||
|
public DbSet<MonitorCheck> MonitorChecks => Set<MonitorCheck>();
|
||||||
|
public DbSet<CertificateEntry> Certificates => Set<CertificateEntry>();
|
||||||
|
public DbSet<LogEntry> Logs => Set<LogEntry>();
|
||||||
|
public DbSet<AppSettings> Settings => Set<AppSettings>();
|
||||||
|
|
||||||
|
public DbSet<Incident> Incidents => Set<Incident>();
|
||||||
|
public DbSet<IncidentUpdate> IncidentUpdates => Set<IncidentUpdate>();
|
||||||
|
public DbSet<AlertRule> AlertRules => Set<AlertRule>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
// existing indexes
|
||||||
|
modelBuilder.Entity<MonitorCheck>().HasIndex(c => new { c.MonitorId, c.CheckedAt });
|
||||||
|
modelBuilder.Entity<LogEntry>().HasIndex(l => l.Timestamp);
|
||||||
|
modelBuilder.Entity<LogEntry>().HasIndex(l => new { l.Level, l.Source });
|
||||||
|
modelBuilder.Entity<AppSettings>().HasIndex(s => s.Key).IsUnique();
|
||||||
|
|
||||||
|
// incident indexes
|
||||||
|
modelBuilder.Entity<Incident>().HasIndex(i => i.Status);
|
||||||
|
modelBuilder.Entity<Incident>().HasIndex(i => i.CreatedAt);
|
||||||
|
modelBuilder.Entity<IncidentUpdate>().HasIndex(u => u.IncidentId);
|
||||||
|
|
||||||
|
// alert rule indexes
|
||||||
|
modelBuilder.Entity<AlertRule>().HasIndex(a => a.MonitorId);
|
||||||
|
|
||||||
|
// relationships
|
||||||
|
modelBuilder.Entity<Incident>()
|
||||||
|
.HasMany(i => i.Updates)
|
||||||
|
.WithOne(u => u.Incident)
|
||||||
|
.HasForeignKey(u => u.IncidentId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Monitor>()
|
||||||
|
.HasMany(m => m.AlertRules)
|
||||||
|
.WithOne(a => a.Monitor)
|
||||||
|
.HasForeignKey(a => a.MonitorId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// Seed default settings
|
||||||
|
modelBuilder.Entity<AppSettings>().HasData(
|
||||||
|
new AppSettings { Id = 1, Key = "AdminPasswordHash", Value = BCrypt.Net.BCrypt.EnhancedHashPassword("adminEonaCat") },
|
||||||
|
new AppSettings { Id = 2, Key = "SiteName", Value = "Status" },
|
||||||
|
new AppSettings { Id = 3, Key = "ShowLogsPublicly", Value = "false" },
|
||||||
|
new AppSettings { Id = 4, Key = "ShowUptimePublicly", Value = "true" },
|
||||||
|
new AppSettings { Id = 5, Key = "MaxLogRetentionDays", Value = "30" },
|
||||||
|
new AppSettings { Id = 6, Key = "AlertEmail", Value = "" },
|
||||||
|
// new settings
|
||||||
|
new AppSettings { Id = 7, Key = "AlertWebhookUrl", Value = "" },
|
||||||
|
new AppSettings { Id = 8, Key = "ShowIncidentsPublicly", Value = "true" },
|
||||||
|
new AppSettings { Id = 9, Key = "AutoCreateIncidents", Value = "false" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
EonaCat.LogStack.Status/EonaCat.LogStack.Status.csproj
Normal file
14
EonaCat.LogStack.Status/EonaCat.LogStack.Status.csproj
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>Status</RootNamespace>
|
||||||
|
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0" />
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
BIN
EonaCat.LogStack.Status/EonaCat.LogStack.Status.db
Normal file
BIN
EonaCat.LogStack.Status/EonaCat.LogStack.Status.db
Normal file
Binary file not shown.
BIN
EonaCat.LogStack.Status/EonaCat.LogStack.Status.db-shm
Normal file
BIN
EonaCat.LogStack.Status/EonaCat.LogStack.Status.db-shm
Normal file
Binary file not shown.
BIN
EonaCat.LogStack.Status/EonaCat.LogStack.Status.db-wal
Normal file
BIN
EonaCat.LogStack.Status/EonaCat.LogStack.Status.db-wal
Normal file
Binary file not shown.
206
EonaCat.LogStack.Status/Models/Models.cs
Normal file
206
EonaCat.LogStack.Status/Models/Models.cs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Models;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public enum MonitorType { TCP, UDP, AppLocal, AppRemote, HTTP, HTTPS, Ping }
|
||||||
|
|
||||||
|
public enum MonitorStatus { Unknown, Up, Down, Warning, Degraded }
|
||||||
|
|
||||||
|
public enum IncidentSeverity { Minor, Major, Critical }
|
||||||
|
|
||||||
|
public enum IncidentStatus { Investigating, Identified, Monitoring, Resolved }
|
||||||
|
|
||||||
|
public enum AlertRuleCondition { IsDown, IsUp, ResponseAboveMs, CertExpiresWithinDays }
|
||||||
|
|
||||||
|
public class Monitor
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
[Required] public string Name { get; set; } = "";
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public MonitorType Type { get; set; }
|
||||||
|
public string Host { get; set; } = "";
|
||||||
|
public int? Port { get; set; }
|
||||||
|
public string? Url { get; set; }
|
||||||
|
public string? ProcessName { get; set; }
|
||||||
|
public int IntervalSeconds { get; set; } = 60;
|
||||||
|
public int TimeoutMs { get; set; } = 5000;
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public bool IsPublic { get; set; } = true;
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
public string? GroupName { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? LastChecked { get; set; }
|
||||||
|
public MonitorStatus LastStatus { get; set; } = MonitorStatus.Unknown;
|
||||||
|
public double? LastResponseMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of consecutive failing checks since the monitor last went down.</summary>
|
||||||
|
public int ConsecutiveFailures { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>How many consecutive failures before the monitor is marked Down (default: 1).</summary>
|
||||||
|
public int FailureThreshold { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>Optional expected HTTP keyword in the response body (HTTP/HTTPS monitors).</summary>
|
||||||
|
public string? ExpectedKeyword { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional expected HTTP status code (HTTP/HTTPS monitors, default: any 2xx/3xx).</summary>
|
||||||
|
public int? ExpectedStatusCode { get; set; }
|
||||||
|
|
||||||
|
public ICollection<MonitorCheck> Checks { get; set; } = new List<MonitorCheck>();
|
||||||
|
public ICollection<AlertRule> AlertRules { get; set; } = new List<AlertRule>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MonitorCheck
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int MonitorId { get; set; }
|
||||||
|
public Monitor? Monitor { get; set; }
|
||||||
|
public DateTime CheckedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public MonitorStatus Status { get; set; }
|
||||||
|
public double ResponseMs { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CertificateEntry
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
[Required] public string Name { get; set; } = "";
|
||||||
|
[Required] public string Domain { get; set; } = "";
|
||||||
|
public int Port { get; set; } = 443;
|
||||||
|
public DateTime? ExpiresAt { get; set; }
|
||||||
|
public DateTime? IssuedAt { get; set; }
|
||||||
|
public string? Issuer { get; set; }
|
||||||
|
public string? Subject { get; set; }
|
||||||
|
public string? Thumbprint { get; set; }
|
||||||
|
public bool IsPublic { get; set; } = true;
|
||||||
|
public bool AlertOnExpiry { get; set; } = true;
|
||||||
|
public int AlertDaysBeforeExpiry { get; set; } = 30;
|
||||||
|
public DateTime? LastChecked { get; set; }
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LogEntry
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Source { get; set; } = "system";
|
||||||
|
public string Level { get; set; } = "info";
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
public string? Properties { get; set; }
|
||||||
|
public string? Exception { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
public string? TraceId { get; set; }
|
||||||
|
public string? Host { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a manually-posted or auto-generated incident that is displayed
|
||||||
|
/// on the public status page to communicate outages and maintenance.
|
||||||
|
/// </summary>
|
||||||
|
public class Incident
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
[Required] public string Title { get; set; } = "";
|
||||||
|
public string? Body { get; set; }
|
||||||
|
public IncidentSeverity Severity { get; set; } = IncidentSeverity.Minor;
|
||||||
|
public IncidentStatus Status { get; set; } = IncidentStatus.Investigating;
|
||||||
|
public int? MonitorId { get; set; }
|
||||||
|
public Monitor? Monitor { get; set; }
|
||||||
|
public bool IsPublic { get; set; } = true;
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? ResolvedAt { get; set; }
|
||||||
|
public ICollection<IncidentUpdate> Updates { get; set; } = new List<IncidentUpdate>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A timestamped update appended to an incident (visible on the public page).
|
||||||
|
/// </summary>
|
||||||
|
public class IncidentUpdate
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int IncidentId { get; set; }
|
||||||
|
public Incident? Incident { get; set; }
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
public IncidentStatus Status { get; set; }
|
||||||
|
public DateTime PostedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A configurable alert rule attached to a monitor or applied globally.
|
||||||
|
/// When the condition is met, a notification is dispatched (e-mail / webhook).
|
||||||
|
/// </summary>
|
||||||
|
public class AlertRule
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int? MonitorId { get; set; }
|
||||||
|
public Monitor? Monitor { get; set; }
|
||||||
|
public AlertRuleCondition Condition { get; set; }
|
||||||
|
/// <summary>Threshold value used by <see cref="AlertRuleCondition.ResponseAboveMs"/> and <see cref="AlertRuleCondition.CertExpiresWithinDays"/>.</summary>
|
||||||
|
public double? ThresholdValue { get; set; }
|
||||||
|
/// <summary>Webhook URL to POST a JSON payload to when the rule fires (optional).</summary>
|
||||||
|
public string? WebhookUrl { get; set; }
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public DateTime? LastFiredAt { get; set; }
|
||||||
|
/// <summary>Minimum minutes between repeated firings (0 = every check).</summary>
|
||||||
|
public int CooldownMinutes { get; set; } = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppSettings
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LogFilter
|
||||||
|
{
|
||||||
|
public string? Level { get; set; }
|
||||||
|
public string? Source { get; set; }
|
||||||
|
public string? Search { get; set; }
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
public DateTime? To { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DashboardStats
|
||||||
|
{
|
||||||
|
public int TotalMonitors { get; set; }
|
||||||
|
public int UpCount { get; set; }
|
||||||
|
public int DownCount { get; set; }
|
||||||
|
public int WarnCount { get; set; }
|
||||||
|
public int UnknownCount { get; set; }
|
||||||
|
public int CertCount { get; set; }
|
||||||
|
public int CertExpiringSoon { get; set; }
|
||||||
|
public int CertExpired { get; set; }
|
||||||
|
public long TotalLogs { get; set; }
|
||||||
|
public long ErrorLogs { get; set; }
|
||||||
|
public double OverallUptime { get; set; }
|
||||||
|
public int ActiveIncidents { get; set; }
|
||||||
|
public int ResolvedIncidents { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Uptime percentage over a configurable window.</summary>
|
||||||
|
public class UptimeReport
|
||||||
|
{
|
||||||
|
public int MonitorId { get; set; }
|
||||||
|
public string MonitorName { get; set; } = "";
|
||||||
|
public double Uptime24h { get; set; }
|
||||||
|
public double Uptime7d { get; set; }
|
||||||
|
public double Uptime30d { get; set; }
|
||||||
|
public int TotalChecks { get; set; }
|
||||||
|
public int UpChecks { get; set; }
|
||||||
|
public int DownChecks { get; set; }
|
||||||
|
public double AvgResponseMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Log volume aggregated per hour / day for charting.</summary>
|
||||||
|
public class LogStatsBucket
|
||||||
|
{
|
||||||
|
public DateTime BucketStart { get; set; }
|
||||||
|
public long Total { get; set; }
|
||||||
|
public long Errors { get; set; }
|
||||||
|
public long Warnings { get; set; }
|
||||||
|
}
|
||||||
118
EonaCat.LogStack.Status/Pages/Admin/AlertRules.cshtml
Normal file
118
EonaCat.LogStack.Status/Pages/Admin/AlertRules.cshtml
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
@page
|
||||||
|
@model Status.Pages.Admin.AlertRulesModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Alert Rules";
|
||||||
|
ViewData["Page"] = "admin-alerts";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Message))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">✓ @Model.Message</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Alert Rules</span>
|
||||||
|
<button class="btn btn-primary" onclick="openModal('add-rule-modal')">+ Add Rule</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Monitor</th>
|
||||||
|
<th>Condition</th>
|
||||||
|
<th>Threshold</th>
|
||||||
|
<th>Webhook</th>
|
||||||
|
<th>Cooldown</th>
|
||||||
|
<th>Last Fired</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in Model.Rules)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td style="color:var(--text-primary)">@(r.Monitor?.Name ?? "All Monitors")</td>
|
||||||
|
<td class="mono" style="font-size:11px">@r.Condition</td>
|
||||||
|
<td class="mono" style="font-size:11px">@(r.ThresholdValue?.ToString() ?? "-")</td>
|
||||||
|
<td style="font-size:11px;color:var(--text-muted)">@(string.IsNullOrEmpty(r.WebhookUrl) ? "-" : "✓ configured")</td>
|
||||||
|
<td class="mono" style="font-size:11px">@r.CooldownMinutes min</td>
|
||||||
|
<td class="mono" style="font-size:11px">@(r.LastFiredAt?.ToString("yyyy-MM-dd HH:mm") ?? "never")</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" asp-page-handler="Toggle" style="display:inline">
|
||||||
|
<input type="hidden" name="id" value="@r.Id" />
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm">
|
||||||
|
@(r.IsEnabled ? "🟢 On" : "⚫ Off")
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" asp-page-handler="Delete" style="display:inline" onsubmit="return confirm('Delete rule?')">
|
||||||
|
<input type="hidden" name="id" value="@r.Id" />
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!Model.Rules.Any())
|
||||||
|
{
|
||||||
|
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-muted)">No alert rules configured.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Rule Modal -->
|
||||||
|
<div class="modal-overlay" id="add-rule-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Add Alert Rule</span>
|
||||||
|
<button class="modal-close" onclick="closeModal('add-rule-modal')">✕</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="Save">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Monitor (leave blank to apply to all)</label>
|
||||||
|
<select name="NewRule.MonitorId" class="form-control">
|
||||||
|
<option value="">All Monitors</option>
|
||||||
|
@foreach (var m in Model.Monitors)
|
||||||
|
{ <option value="@m.Id">@m.Name</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Condition *</label>
|
||||||
|
<select name="NewRule.Condition" class="form-control" onchange="updateThresholdVisibility(this.value)">
|
||||||
|
@foreach (var c in Enum.GetValues<AlertRuleCondition>())
|
||||||
|
{ <option value="@c">@c</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="threshold-group">
|
||||||
|
<label class="form-label">Threshold Value</label>
|
||||||
|
<input type="number" name="NewRule.ThresholdValue" class="form-control" step="any" placeholder="e.g. 500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Webhook URL (optional)</label>
|
||||||
|
<input type="url" name="NewRule.WebhookUrl" class="form-control" placeholder="https://hooks.slack.com/..." />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Cooldown (minutes)</label>
|
||||||
|
<input type="number" name="NewRule.CooldownMinutes" class="form-control" value="10" min="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="closeModal('add-rule-modal')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Rule</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
function updateThresholdVisibility(condition) {
|
||||||
|
const needsThreshold = ['ResponseAboveMs', 'CertExpiresWithinDays'].includes(condition);
|
||||||
|
document.getElementById('threshold-group').style.opacity = needsThreshold ? '1' : '0.4';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
89
EonaCat.LogStack.Status/Pages/Admin/AlertRules.cshtml.cs
Normal file
89
EonaCat.LogStack.Status/Pages/Admin/AlertRules.cshtml.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class AlertRulesModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _database;
|
||||||
|
public AlertRulesModel(DatabaseContext database) => _database = database;
|
||||||
|
|
||||||
|
public List<AlertRule> Rules { get; set; } = new();
|
||||||
|
public List<Monitor> Monitors { get; set; } = new();
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
[BindProperty] public AlertRule NewRule { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules = await _database.AlertRules
|
||||||
|
.Include(r => r.Monitor)
|
||||||
|
.OrderBy(r => r.MonitorId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Monitors = await _database.Monitors.Where(m => m.IsActive).OrderBy(m => m.Name).ToListAsync();
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostSaveAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
NewRule.IsEnabled = true;
|
||||||
|
_database.AlertRules.Add(NewRule);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
Message = "Alert rule saved.";
|
||||||
|
return await OnGetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostToggleAsync(int id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var rule = await _database.AlertRules.FindAsync(id);
|
||||||
|
if (rule != null)
|
||||||
|
{
|
||||||
|
rule.IsEnabled = !rule.IsEnabled;
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Message = "Rule updated.";
|
||||||
|
return await OnGetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var rule = await _database.AlertRules.FindAsync(id);
|
||||||
|
if (rule != null)
|
||||||
|
{
|
||||||
|
_database.AlertRules.Remove(rule);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Message = "Rule deleted.";
|
||||||
|
return await OnGetAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
126
EonaCat.LogStack.Status/Pages/Admin/Certificates.cshtml
Normal file
126
EonaCat.LogStack.Status/Pages/Admin/Certificates.cshtml
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
@page
|
||||||
|
@model Status.Pages.Admin.CertificatesModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Manage Certificates";
|
||||||
|
ViewData["Page"] = "admin-certs";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Message))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">✓ @Model.Message</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">SSL Certificates</span>
|
||||||
|
<button class="btn btn-primary" onclick="openModal('add-cert-modal')">+ Add Certificate</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Issuer</th>
|
||||||
|
<th>Issued</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Days Left</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in Model.Certificates)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var days = c.ExpiresAt.HasValue ? (int)(c.ExpiresAt.Value - now).TotalDays : (int?)null;
|
||||||
|
var cls = days == null ? "" : days <= 0 ? "cert-expiry-expired" : days <= 7 ? "cert-expiry-critical" : days <= 30 ? "cert-expiry-warn" : "cert-expiry-ok";
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;color:var(--text-primary)">@c.Name</td>
|
||||||
|
<td class="mono" style="font-size:11px">@c.Domain:@c.Port</td>
|
||||||
|
<td style="font-size:11px;color:var(--text-muted);max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="@c.Issuer">@(c.Issuer?.Split(',')[0] ?? "-")</td>
|
||||||
|
<td class="mono" style="font-size:11px">@(c.IssuedAt?.ToString("yyyy-MM-dd") ?? "-")</td>
|
||||||
|
<td class="mono @cls" style="font-size:11px">@(c.ExpiresAt?.ToString("yyyy-MM-dd") ?? "-")</td>
|
||||||
|
<td class="mono @cls" style="font-size:11px;font-weight:700">@(days.HasValue? days +"d" : "-")</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(c.LastError)) { <span class="badge badge-down" title="@c.LastError">ERROR</span> }
|
||||||
|
else if (days == null) { <span class="badge badge-unknown">Unchecked</span> }
|
||||||
|
else if (days <= 0) { <span class="badge badge-down">EXPIRED</span> }
|
||||||
|
else if (days <= 7) { <span class="badge badge-down">CRITICAL</span> }
|
||||||
|
else if (days <= 30) { <span class="badge badge-warn">EXPIRING</span> }
|
||||||
|
else { <span class="badge badge-up">VALID</span> }
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<form method="post" asp-page-handler="CheckNow">
|
||||||
|
<input type="hidden" name="id" value="@c.Id" />
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm">▶ Check</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" asp-page-handler="Delete" onsubmit="return confirm('Delete @c.Name?')">
|
||||||
|
<input type="hidden" name="id" value="@c.Id" />
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!Model.Certificates.Any())
|
||||||
|
{
|
||||||
|
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-muted)">No certificates tracked yet.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Cert Modal -->
|
||||||
|
<div class="modal-overlay" id="add-cert-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Add Certificate</span>
|
||||||
|
<button class="modal-close" onclick="closeModal('add-cert-modal')">✕</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="Save">
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="EditCert.Id" value="0" />
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Name *</label>
|
||||||
|
<input type="text" name="EditCert.Name" class="form-control" required placeholder="My Site" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Domain *</label>
|
||||||
|
<input type="text" name="EditCert.Domain" class="form-control" required placeholder="example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<input type="number" name="EditCert.Port" class="form-control" value="443" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Alert Days Before Expiry</label>
|
||||||
|
<input type="number" name="EditCert.AlertDaysBeforeExpiry" class="form-control" value="30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 align-center">
|
||||||
|
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="EditCert.IsPublic" checked />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span style="font-size:13px">Public</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="EditCert.AlertOnExpiry" checked />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span style="font-size:13px">Alert on Expiry</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="closeModal('add-cert-modal')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
90
EonaCat.LogStack.Status/Pages/Admin/Certificates.cshtml.cs
Normal file
90
EonaCat.LogStack.Status/Pages/Admin/Certificates.cshtml.cs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||||
|
|
||||||
|
public class CertificatesModel : PageModel
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
private readonly DatabaseContext _db;
|
||||||
|
private readonly MonitoringService _monSvc;
|
||||||
|
public CertificatesModel(DatabaseContext db, MonitoringService monSvc) { _db = db; _monSvc = monSvc; }
|
||||||
|
|
||||||
|
public List<CertificateEntry> Certificates { get; set; } = new();
|
||||||
|
[BindProperty] public CertificateEntry EditCert { get; set; } = new();
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(string? msg)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
Certificates = await _db.Certificates.OrderBy(c => c.ExpiresAt).ToListAsync();
|
||||||
|
Message = msg;
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostSaveAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditCert.Id == 0)
|
||||||
|
{
|
||||||
|
_db.Certificates.Add(EditCert);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var e = await _db.Certificates.FindAsync(EditCert.Id);
|
||||||
|
if (e != null)
|
||||||
|
{
|
||||||
|
e.Name = EditCert.Name;
|
||||||
|
e.Domain = EditCert.Domain;
|
||||||
|
e.Port = EditCert.Port;
|
||||||
|
e.IsPublic = EditCert.IsPublic;
|
||||||
|
e.AlertOnExpiry = EditCert.AlertOnExpiry;
|
||||||
|
e.AlertDaysBeforeExpiry = EditCert.AlertDaysBeforeExpiry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return RedirectToPage(new { msg = "Certificate saved." });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var c = await _db.Certificates.FindAsync(id);
|
||||||
|
if (c != null) { _db.Certificates.Remove(c); await _db.SaveChangesAsync(); }
|
||||||
|
return RedirectToPage(new { msg = "Certificate deleted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostCheckNowAsync(int id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var c = await _db.Certificates.FindAsync(id);
|
||||||
|
if (c != null)
|
||||||
|
{
|
||||||
|
await _monSvc.CheckCertificateAsync(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage(new { msg = "Certificate checked." });
|
||||||
|
}
|
||||||
|
}
|
||||||
165
EonaCat.LogStack.Status/Pages/Admin/Incidents.cshtml
Normal file
165
EonaCat.LogStack.Status/Pages/Admin/Incidents.cshtml
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
@page
|
||||||
|
@model Status.Pages.Admin.IncidentsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Manage Incidents";
|
||||||
|
ViewData["Page"] = "admin-incidents";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Message))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">✓ @Model.Message</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Incidents</span>
|
||||||
|
<button class="btn btn-primary" onclick="openModal('add-incident-modal')">+ New Incident</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Monitor</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Resolved</th>
|
||||||
|
<th>Visibility</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var i in Model.Incidents)
|
||||||
|
{
|
||||||
|
var severityBadge = i.Severity switch {
|
||||||
|
IncidentSeverity.Critical => "badge-down",
|
||||||
|
IncidentSeverity.Major => "badge-warn",
|
||||||
|
_ => "badge-info"
|
||||||
|
};
|
||||||
|
var statusBadge = i.Status == IncidentStatus.Resolved ? "badge-up" : "badge-warn";
|
||||||
|
<tr>
|
||||||
|
<td style="color:var(--text-primary);font-weight:500">@i.Title</td>
|
||||||
|
<td><span class="badge @severityBadge">@i.Severity</span></td>
|
||||||
|
<td><span class="badge @statusBadge">@i.Status</span></td>
|
||||||
|
<td style="font-size:12px">@(i.Monitor?.Name ?? "-")</td>
|
||||||
|
<td class="mono" style="font-size:11px">@i.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||||
|
<td class="mono" style="font-size:11px">@(i.ResolvedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-")</td>
|
||||||
|
<td>@(i.IsPublic ? "🌐 Public" : "🔒 Private")</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="openUpdate(@i.Id,'@i.Status')">Update</button>
|
||||||
|
@if (i.Status != IncidentStatus.Resolved)
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="Resolve" style="display:inline">
|
||||||
|
<input type="hidden" name="id" value="@i.Id" />
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm">Resolve</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
<form method="post" asp-page-handler="Delete" style="display:inline" onsubmit="return confirm('Delete incident?')">
|
||||||
|
<input type="hidden" name="id" value="@i.Id" />
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!Model.Incidents.Any())
|
||||||
|
{
|
||||||
|
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-muted)">No incidents recorded.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Incident Modal -->
|
||||||
|
<div class="modal-overlay" id="add-incident-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">New Incident</span>
|
||||||
|
<button class="modal-close" onclick="closeModal('add-incident-modal')">✕</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="Create">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Title *</label>
|
||||||
|
<input type="text" name="NewIncident.Title" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Severity</label>
|
||||||
|
<select name="NewIncident.Severity" class="form-control">
|
||||||
|
@foreach (var s in Enum.GetValues<IncidentSeverity>())
|
||||||
|
{ <option value="@s">@s</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select name="NewIncident.Status" class="form-control">
|
||||||
|
@foreach (var s in Enum.GetValues<IncidentStatus>())
|
||||||
|
{ <option value="@s">@s</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Related Monitor</label>
|
||||||
|
<select name="NewIncident.MonitorId" class="form-control">
|
||||||
|
<option value="">None</option>
|
||||||
|
@foreach (var m in Model.Monitors)
|
||||||
|
{ <option value="@m.Id">@m.Name</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea name="NewIncident.Body" class="form-control" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 align-center">
|
||||||
|
<label class="toggle"><input type="checkbox" name="NewIncident.IsPublic" checked /><span class="toggle-slider"></span></label>
|
||||||
|
<span style="font-size:13px">Visible on public status page</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="closeModal('add-incident-modal')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Incident</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Update Modal -->
|
||||||
|
<div class="modal-overlay" id="update-incident-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Post Update</span>
|
||||||
|
<button class="modal-close" onclick="closeModal('update-incident-modal')">✕</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="PostUpdate">
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="UpdateDto.IncidentId" id="update-incident-id" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">New Status</label>
|
||||||
|
<select name="UpdateDto.Status" id="update-status" class="form-control">
|
||||||
|
@foreach (var s in Enum.GetValues<IncidentStatus>())
|
||||||
|
{ <option value="@s">@s</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Update Message *</label>
|
||||||
|
<textarea name="UpdateDto.Message" class="form-control" rows="3" required></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="closeModal('update-incident-modal')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Post Update</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
function openUpdate(id, status) {
|
||||||
|
document.getElementById('update-incident-id').value = id;
|
||||||
|
document.getElementById('update-status').value = status;
|
||||||
|
openModal('update-incident-modal');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
138
EonaCat.LogStack.Status/Pages/Admin/Incidents.cshtml.cs
Normal file
138
EonaCat.LogStack.Status/Pages/Admin/Incidents.cshtml.cs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class IncidentsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _database;
|
||||||
|
public IncidentsModel(DatabaseContext database) => _database = database;
|
||||||
|
|
||||||
|
public List<Incident> Incidents { get; set; } = new();
|
||||||
|
public List<Monitor> Monitors { get; set; } = new();
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
[BindProperty] public Incident NewIncident { get; set; } = new();
|
||||||
|
|
||||||
|
public class UpdateDto
|
||||||
|
{
|
||||||
|
public int IncidentId { get; set; }
|
||||||
|
public IncidentStatus Status { get; set; }
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[BindProperty] public UpdateDto UpdateDtoModel { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
Incidents = await _database.Incidents
|
||||||
|
.Include(i => i.Updates)
|
||||||
|
.Include(i => i.Monitor)
|
||||||
|
.OrderByDescending(i => i.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Monitors = await _database.Monitors.Where(m => m.IsActive).ToListAsync();
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostCreateAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
NewIncident.CreatedAt = DateTime.UtcNow;
|
||||||
|
NewIncident.UpdatedAt = DateTime.UtcNow;
|
||||||
|
_database.Incidents.Add(NewIncident);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
Message = "Incident created.";
|
||||||
|
return await OnGetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostPostUpdateAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var incident = await _database.Incidents.FindAsync(UpdateDtoModel.IncidentId);
|
||||||
|
if (incident != null)
|
||||||
|
{
|
||||||
|
incident.Status = UpdateDtoModel.Status;
|
||||||
|
incident.UpdatedAt = DateTime.UtcNow;
|
||||||
|
if (UpdateDtoModel.Status == IncidentStatus.Resolved)
|
||||||
|
{
|
||||||
|
incident.ResolvedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
_database.IncidentUpdates.Add(new IncidentUpdate
|
||||||
|
{
|
||||||
|
IncidentId = incident.Id,
|
||||||
|
Message = UpdateDtoModel.Message,
|
||||||
|
Status = UpdateDtoModel.Status
|
||||||
|
});
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Message = "Update posted.";
|
||||||
|
return await OnGetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostResolveAsync(int id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var incident = await _database.Incidents.FindAsync(id);
|
||||||
|
if (incident != null)
|
||||||
|
{
|
||||||
|
incident.Status = IncidentStatus.Resolved;
|
||||||
|
incident.ResolvedAt = DateTime.UtcNow;
|
||||||
|
incident.UpdatedAt = DateTime.UtcNow;
|
||||||
|
_database.IncidentUpdates.Add(new IncidentUpdate
|
||||||
|
{
|
||||||
|
IncidentId = id,
|
||||||
|
Message = "Incident resolved.",
|
||||||
|
Status = IncidentStatus.Resolved
|
||||||
|
});
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Message = "Incident resolved.";
|
||||||
|
return await OnGetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var incident = await _database.Incidents.FindAsync(id);
|
||||||
|
if (incident != null)
|
||||||
|
{
|
||||||
|
_database.Incidents.Remove(incident);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Message = "Incident deleted.";
|
||||||
|
return await OnGetAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
133
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml
Normal file
133
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
@page
|
||||||
|
@model Status.Pages.Admin.IngestModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Log Ingestion";
|
||||||
|
ViewData["Page"] = "admin-ingest";
|
||||||
|
var host = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-title mb-2">Log Ingestion API</div>
|
||||||
|
<p style="color:var(--text-muted);margin-bottom:20px;font-size:13px">
|
||||||
|
Status accepts logs from any application via HTTP POST. Compatible with custom HTTP sinks from Serilog, NLog, log4net, and any HTTP client.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="two-col" style="align-items:start">
|
||||||
|
<div>
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><span class="card-title">Single Log Entry</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">POST @host/api/logs/ingest
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"source": "my-app",
|
||||||
|
"level": "error",
|
||||||
|
"message": "Something went wrong",
|
||||||
|
"exception": "System.Exception: ...",
|
||||||
|
"properties": "{\"userId\": 42}",
|
||||||
|
"host": "prod-server-01",
|
||||||
|
"traceId": "abc123"
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><span class="card-title">Batch Ingestion</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">POST @host/api/logs/batch
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
[
|
||||||
|
{"source":"app","level":"info","message":"Started"},
|
||||||
|
{"source":"app","level":"warn","message":"Slow query","properties":"{\"ms\":2400}"}
|
||||||
|
]</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><span class="card-title">EonaCat.LogStack HTTP Flow (.NET)</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">// Install: EonaCat.LogStack
|
||||||
|
var logger = new LogBuilder().WriteToHttp("@host/api/logs/eonacat").Build();
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><span class="card-title">Serilog HTTP Sink (.NET)</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">
|
||||||
|
// Install: Serilog.Sinks.Http
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.WriteTo.Http(
|
||||||
|
requestUri: "@host/api/logs/serilog",
|
||||||
|
queueLimitBytes: null)
|
||||||
|
.CreateLogger();
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Syslog (UDP)</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">
|
||||||
|
# Send a syslog message using netcat (Linux/macOS)
|
||||||
|
|
||||||
|
echo "<14>1 $(date -u +%Y-%m-%dT%H:%M:%SZ) my-host my-app - - - Hello from syslog" \
|
||||||
|
| nc -u -w0 YOUR_HOST 514
|
||||||
|
|
||||||
|
# Example using logger (Linux)
|
||||||
|
|
||||||
|
logger -n YOUR_HOST -P 514 "Hello from syslog"
|
||||||
|
|
||||||
|
|
||||||
|
# Example raw syslog message (RFC5424)
|
||||||
|
|
||||||
|
<14>1 2026-03-28T12:00:00Z my-host my-app - - - Something happened
|
||||||
|
|
||||||
|
|
||||||
|
# Example JSON over syslog (auto-detected)
|
||||||
|
|
||||||
|
{
|
||||||
|
"source": "my-app",
|
||||||
|
"level": "error",
|
||||||
|
"message": "Something failed",
|
||||||
|
"host": "server-01",
|
||||||
|
"traceId": "abc123"
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><span class="card-title">Python (requests)</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">import requests
|
||||||
|
|
||||||
|
requests.post("@host/api/logs/ingest", json={
|
||||||
|
"source": "my-python-app",
|
||||||
|
"level": "info",
|
||||||
|
"message": "App started",
|
||||||
|
"host": socket.gethostname()
|
||||||
|
})</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">Log Levels</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="data-table">
|
||||||
|
<tr><td><span class="badge badge-unknown">DEBUG</span></td><td style="font-size:12px">Verbose diagnostic info</td></tr>
|
||||||
|
<tr><td><span class="badge badge-info">INFO</span></td><td style="font-size:12px">Normal operation events</td></tr>
|
||||||
|
<tr><td><span class="badge badge-warn">WARNING</span></td><td style="font-size:12px">Warning requires attention</td></tr>
|
||||||
|
<tr><td><span class="badge badge-down">ERROR</span></td><td style="font-size:12px">Errors requiring attention</td></tr>
|
||||||
|
<tr><td><span class="badge badge-down">CRITICAL</span></td><td style="font-size:12px">System critical failure / crash</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
20
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml.cs
Normal file
20
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||||
|
|
||||||
|
public class IngestModel : PageModel
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public IActionResult OnGet()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
52
EonaCat.LogStack.Status/Pages/Admin/Login.cshtml
Normal file
52
EonaCat.LogStack.Status/Pages/Admin/Login.cshtml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@page
|
||||||
|
@model Status.Pages.Admin.LoginModel
|
||||||
|
@{
|
||||||
|
Layout = null;
|
||||||
|
}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Admin Login - Status</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="~/css/site.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-wrap">
|
||||||
|
<div class="login-card">
|
||||||
|
<div style="text-align:center;margin-bottom:28px">
|
||||||
|
<div style="font-size:32px;color:var(--accent);margin-bottom:8px">◈</div>
|
||||||
|
<div class="login-title">Status</div>
|
||||||
|
<div class="login-sub">Admin authentication required</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Error))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@Model.Error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="Password" class="form-control" placeholder="Enter admin password" autofocus />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;padding:10px">
|
||||||
|
Authenticate →
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;text-align:center">
|
||||||
|
<a href="/" style="color:var(--text-muted);font-size:12px">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:24px;padding-top:16px;border-top:1px solid var(--border);text-align:center">
|
||||||
|
<span style="font-family:var(--font-mono);font-size:9px;color:var(--text-muted);letter-spacing:1px">
|
||||||
|
DEFAULT PASSWORD: adminEonaCat
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
EonaCat.LogStack.Status/Pages/Admin/Login.cshtml.cs
Normal file
30
EonaCat.LogStack.Status/Pages/Admin/Login.cshtml.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class LoginModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AuthenticationService _auth;
|
||||||
|
public LoginModel(AuthenticationService auth) => _auth = auth;
|
||||||
|
|
||||||
|
[BindProperty] public string Password { get; set; } = "";
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
public void OnGet() { }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(Password) && await _auth.ValidatePasswordAsync(Password))
|
||||||
|
{
|
||||||
|
HttpContext.Session.SetString("IsAdmin", "true");
|
||||||
|
return RedirectToPage("/Index");
|
||||||
|
}
|
||||||
|
Error = "Invalid password.";
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
2
EonaCat.LogStack.Status/Pages/Admin/Logout.cshtml
Normal file
2
EonaCat.LogStack.Status/Pages/Admin/Logout.cshtml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@page
|
||||||
|
@model Status.Pages.Admin.LogoutModel
|
||||||
16
EonaCat.LogStack.Status/Pages/Admin/Logout.cshtml.cs
Normal file
16
EonaCat.LogStack.Status/Pages/Admin/Logout.cshtml.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class LogoutModel : PageModel
|
||||||
|
{
|
||||||
|
public IActionResult OnGet()
|
||||||
|
{
|
||||||
|
HttpContext.Session.Clear();
|
||||||
|
return RedirectToPage("/Index");
|
||||||
|
}
|
||||||
|
}
|
||||||
229
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml
Normal file
229
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
@page
|
||||||
|
@model EonaCat.LogStack.Status.Pages.Admin.MonitorsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Manage Monitors";
|
||||||
|
ViewData["Page"] = "admin-monitors";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Message))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">✓ @Model.Message</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Monitors</span>
|
||||||
|
<button class="btn btn-primary" onclick="openModal('add-monitor-modal')">+ Add Monitor</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Host / URL</th>
|
||||||
|
<th>Group</th>
|
||||||
|
<th>Interval</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Visibility</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var m in Model.Monitors)
|
||||||
|
{
|
||||||
|
var badgeClass = m.LastStatus switch {
|
||||||
|
MonitorStatus.Up => "badge-up",
|
||||||
|
MonitorStatus.Down => "badge-down",
|
||||||
|
MonitorStatus.Warning or MonitorStatus.Degraded => "badge-warn",
|
||||||
|
_ => "badge-unknown"
|
||||||
|
};
|
||||||
|
<tr>
|
||||||
|
<td style="color:var(--text-primary);font-weight:500">@m.Name
|
||||||
|
@if (!m.IsActive) { <span class="badge badge-unknown" style="font-size:8px">PAUSED</span> }
|
||||||
|
</td>
|
||||||
|
<td class="mono" style="font-size:11px">@m.Type</td>
|
||||||
|
<td class="mono" style="font-size:11px;color:var(--text-muted)">@(m.Url ?? (m.Host + (m.Port.HasValue ? ":" + m.Port : "")))</td>
|
||||||
|
<td style="font-size:12px">@(m.GroupName ?? "-")</td>
|
||||||
|
<td class="mono" style="font-size:11px">@m.IntervalSeconds s</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge @badgeClass">@m.LastStatus</span>
|
||||||
|
@if (m.ConsecutiveFailures > 0 && m.LastStatus != MonitorStatus.Down)
|
||||||
|
{
|
||||||
|
<span class="mono" style="font-size:9px;color:var(--warn)" title="Consecutive failures">(@m.ConsecutiveFailures/@m.FailureThreshold)</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>@(m.IsPublic ? "🌐 Public" : "🔒 Private")</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<form method="post" asp-page-handler="CheckNow" style="display:inline">
|
||||||
|
<input type="hidden" name="id" value="@m.Id" />
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm" title="Check Now">▶</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="editMonitor(@m.Id,'@m.Name','@(m.Description ?? "")','@m.Type','@m.Host','@(m.Port?.ToString() ?? "")','@(m.Url ?? "")','@(m.ProcessName ?? "")','@m.IntervalSeconds','@m.TimeoutMs','@m.IsActive'.toLowerCase(),'@m.IsPublic'.toLowerCase(),'@(m.Tags ?? "")','@(m.GroupName ?? "")','@m.FailureThreshold','@(m.ExpectedKeyword ?? "")','@(m.ExpectedStatusCode?.ToString() ?? "")')">Edit</button>
|
||||||
|
<form method="post" asp-page-handler="Delete" style="display:inline" onsubmit="return confirm('Delete @m.Name?')">
|
||||||
|
<input type="hidden" name="id" value="@m.Id" />
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!Model.Monitors.Any())
|
||||||
|
{
|
||||||
|
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-muted)">No monitors yet. Add one above.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Monitor Modal -->
|
||||||
|
<div class="modal-overlay" id="add-monitor-modal">
|
||||||
|
<div class="modal" style="max-width:640px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title" id="modal-title">Add Monitor</span>
|
||||||
|
<button class="modal-close" onclick="closeModal('add-monitor-modal')">✕</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" asp-page-handler="Save">
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" name="EditMonitor.Id" id="edit-id" value="0" />
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Name *</label>
|
||||||
|
<input type="text" name="EditMonitor.Name" id="edit-name" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Type *</label>
|
||||||
|
<select name="EditMonitor.Type" id="edit-type" class="form-control" onchange="updateTypeFields()">
|
||||||
|
<option value="TCP">TCP</option>
|
||||||
|
<option value="UDP">UDP</option>
|
||||||
|
<option value="HTTP">HTTP</option>
|
||||||
|
<option value="HTTPS">HTTPS</option>
|
||||||
|
<option value="Ping">Ping (ICMP)</option>
|
||||||
|
<option value="AppLocal">App (Local)</option>
|
||||||
|
<option value="AppRemote">App (Remote)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" name="EditMonitor.Description" id="edit-desc" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="two-col" id="host-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
<input type="text" name="EditMonitor.Host" id="edit-host" class="form-control" placeholder="hostname or IP" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="port-col">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<input type="number" name="EditMonitor.Port" id="edit-port" class="form-control" placeholder="e.g. 443" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="url-row" style="display:none">
|
||||||
|
<label class="form-label">URL</label>
|
||||||
|
<input type="text" name="EditMonitor.Url" id="edit-url" class="form-control" placeholder="https://example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="process-row" style="display:none">
|
||||||
|
<label class="form-label">Process Name</label>
|
||||||
|
<input type="text" name="EditMonitor.ProcessName" id="edit-process" class="form-control" placeholder="nginx" />
|
||||||
|
</div>
|
||||||
|
<!-- HTTP-specific assertions -->
|
||||||
|
<div id="http-assertions" style="display:none">
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Expected Keyword (optional)</label>
|
||||||
|
<input type="text" name="EditMonitor.ExpectedKeyword" id="edit-keyword" class="form-control" placeholder="OK" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Expected Status Code (optional)</label>
|
||||||
|
<input type="number" name="EditMonitor.ExpectedStatusCode" id="edit-statuscode" class="form-control" placeholder="200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Check Interval (seconds)</label>
|
||||||
|
<input type="number" name="EditMonitor.IntervalSeconds" id="edit-interval" class="form-control" value="60" min="10" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Timeout (ms)</label>
|
||||||
|
<input type="number" name="EditMonitor.TimeoutMs" id="edit-timeout" class="form-control" value="5000" min="500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Failure Threshold</label>
|
||||||
|
<input type="number" name="EditMonitor.FailureThreshold" id="edit-threshold" class="form-control" value="1" min="1" max="10" />
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);margin-top:3px">Consecutive failures before marking as down.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Group Name</label>
|
||||||
|
<input type="text" name="EditMonitor.GroupName" id="edit-group" class="form-control" placeholder="Production" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tags (comma-separated)</label>
|
||||||
|
<input type="text" name="EditMonitor.Tags" id="edit-tags" class="form-control" placeholder="web, api" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 align-center mt-1">
|
||||||
|
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="EditMonitor.IsActive" id="edit-active" checked />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span style="font-size:13px">Active</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="EditMonitor.IsPublic" id="edit-public" checked />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span style="font-size:13px">Public</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="closeModal('add-monitor-modal')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Monitor</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
function updateTypeFields() {
|
||||||
|
const type = document.getElementById('edit-type').value;
|
||||||
|
const isHttp = ['HTTP', 'HTTPS'].includes(type);
|
||||||
|
const isPing = type === 'Ping';
|
||||||
|
const isLocal = type === 'AppLocal';
|
||||||
|
|
||||||
|
document.getElementById('host-row').style.display = isHttp ? 'none' : 'grid';
|
||||||
|
document.getElementById('port-col').style.display = (isPing || isLocal) ? 'none' : 'block';
|
||||||
|
document.getElementById('url-row').style.display = isHttp ? 'block' : 'none';
|
||||||
|
document.getElementById('process-row').style.display = isLocal ? 'block' : 'none';
|
||||||
|
document.getElementById('http-assertions').style.display = isHttp ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function editMonitor(id,name,desc,type,host,port,url,process,interval,timeout,active,pub,tags,group,threshold,keyword,statuscode) {
|
||||||
|
document.getElementById('modal-title').textContent = 'Edit Monitor';
|
||||||
|
document.getElementById('edit-id').value = id;
|
||||||
|
document.getElementById('edit-name').value = name;
|
||||||
|
document.getElementById('edit-desc').value = desc;
|
||||||
|
document.getElementById('edit-type').value = type;
|
||||||
|
document.getElementById('edit-host').value = host;
|
||||||
|
document.getElementById('edit-port').value = port;
|
||||||
|
document.getElementById('edit-url').value = url;
|
||||||
|
document.getElementById('edit-process').value = process;
|
||||||
|
document.getElementById('edit-interval').value = interval;
|
||||||
|
document.getElementById('edit-timeout').value = timeout;
|
||||||
|
document.getElementById('edit-active').checked = active === 'true';
|
||||||
|
document.getElementById('edit-public').checked = pub === 'true';
|
||||||
|
document.getElementById('edit-tags').value = tags;
|
||||||
|
document.getElementById('edit-group').value = group;
|
||||||
|
document.getElementById('edit-threshold').value = threshold;
|
||||||
|
document.getElementById('edit-keyword').value = keyword;
|
||||||
|
document.getElementById('edit-statuscode').value = statuscode;
|
||||||
|
updateTypeFields();
|
||||||
|
openModal('add-monitor-modal');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
121
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml.cs
Normal file
121
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class MonitorsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _db;
|
||||||
|
private readonly MonitoringService _monSvc;
|
||||||
|
public MonitorsModel(DatabaseContext db, MonitoringService monSvc) { _db = db; _monSvc = monSvc; }
|
||||||
|
|
||||||
|
public List<Monitor> Monitors { get; set; } = new();
|
||||||
|
|
||||||
|
[BindProperty] public Monitor EditMonitor { get; set; } = new();
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync(string? msg)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
Monitors = await _db.Monitors.OrderBy(m => m.GroupName).ThenBy(m => m.Name).ToListAsync();
|
||||||
|
Message = msg;
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostSaveAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no host, extract from URL
|
||||||
|
if (string.IsNullOrWhiteSpace(EditMonitor.Host) && !string.IsNullOrEmpty(EditMonitor.Url))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(EditMonitor.Url);
|
||||||
|
EditMonitor.Host = uri.Host;
|
||||||
|
if (EditMonitor.Port == null || EditMonitor.Port == 0)
|
||||||
|
{
|
||||||
|
EditMonitor.Port = uri.Port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditMonitor.Id == 0)
|
||||||
|
{
|
||||||
|
_db.Monitors.Add(EditMonitor);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = await _db.Monitors.FindAsync(EditMonitor.Id);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
return RedirectToPage(new { msg = "Monitor not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Name = EditMonitor.Name;
|
||||||
|
existing.Description = EditMonitor.Description;
|
||||||
|
existing.Type = EditMonitor.Type;
|
||||||
|
existing.Host = EditMonitor.Host;
|
||||||
|
existing.Port = EditMonitor.Port;
|
||||||
|
existing.Url = EditMonitor.Url;
|
||||||
|
existing.ProcessName = EditMonitor.ProcessName;
|
||||||
|
existing.IntervalSeconds = EditMonitor.IntervalSeconds;
|
||||||
|
existing.TimeoutMs = EditMonitor.TimeoutMs;
|
||||||
|
existing.IsActive = EditMonitor.IsActive;
|
||||||
|
existing.IsPublic = EditMonitor.IsPublic;
|
||||||
|
existing.Tags = EditMonitor.Tags;
|
||||||
|
existing.GroupName = EditMonitor.GroupName;
|
||||||
|
// new fields
|
||||||
|
existing.FailureThreshold = Math.Max(1, EditMonitor.FailureThreshold);
|
||||||
|
existing.ExpectedKeyword = EditMonitor.ExpectedKeyword;
|
||||||
|
existing.ExpectedStatusCode = EditMonitor.ExpectedStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return RedirectToPage(new { msg = "Monitor saved." });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var m = await _db.Monitors.FindAsync(id);
|
||||||
|
if (m != null) { _db.Monitors.Remove(m); await _db.SaveChangesAsync(); }
|
||||||
|
return RedirectToPage(new { msg = "Monitor deleted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostCheckNowAsync(int id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var m = await _db.Monitors.FindAsync(id);
|
||||||
|
if (m != null)
|
||||||
|
{
|
||||||
|
await _monSvc.CheckMonitorAsync(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage(new { msg = "Check completed." });
|
||||||
|
}
|
||||||
|
}
|
||||||
129
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml
Normal file
129
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
@page
|
||||||
|
@model EonaCat.LogStack.Status.Pages.Admin.SettingsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Settings";
|
||||||
|
ViewData["Page"] = "admin-settings";
|
||||||
|
var pwParts = (Model.PasswordMessage ?? "").Split(':', 2);
|
||||||
|
var pwType = pwParts.Length > 1 ? pwParts[0] : "";
|
||||||
|
var pwMsg = pwParts.Length > 1 ? pwParts[1] : pwParts.ElementAtOrDefault(0) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Message))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">✓ @Model.Message</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="two-col" style="align-items:start">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="section-title mb-2">General Settings</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" asp-page-handler="SaveSettings">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Site Name</label>
|
||||||
|
<input type="text" name="SiteName" value="@Model.SiteName" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Alert Email</label>
|
||||||
|
<input type="email" name="AlertEmail" value="@Model.AlertEmail" class="form-control" placeholder="alerts@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Alert Webhook URL</label>
|
||||||
|
<input type="url" name="AlertWebhookUrl" value="@Model.AlertWebhookUrl" class="form-control" placeholder="https://hooks.slack.com/..." />
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Global fallback for alert rules without a specific webhook.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Log Retention (days)</label>
|
||||||
|
<input type="number" name="MaxLogRetentionDays" value="@Model.MaxLogRetentionDays" class="form-control" min="1" max="365" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="flex align-center gap-2" style="cursor:pointer;margin-bottom:10px">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="ShowUptimePublicly" @(Model.ShowUptimePublicly ? "checked" : "") />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span>Show uptime % publicly</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex align-center gap-2" style="cursor:pointer;margin-bottom:10px">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="ShowIncidentsPublicly" @(Model.ShowIncidentsPublicly ? "checked" : "") />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span>Show incidents on public page</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex align-center gap-2" style="cursor:pointer;margin-bottom:10px">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="AutoCreateIncidents" @(Model.AutoCreateIncidents ? "checked" : "") />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span>Auto-create incidents when monitors go down</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="ShowLogsPublicly" @(Model.ShowLogsPublicly ? "checked" : "") />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span>Show logs publicly (not recommended)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="section-title mb-2">Change Password</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
@if (!string.IsNullOrEmpty(pwMsg))
|
||||||
|
{
|
||||||
|
<div class="alert @(pwType == "success" ? "alert-success" : "alert-danger")">@pwMsg</div>
|
||||||
|
}
|
||||||
|
<form method="post" asp-page-handler="ChangePassword">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Current Password</label>
|
||||||
|
<input type="password" name="CurrentPassword" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">New Password</label>
|
||||||
|
<input type="password" name="NewPassword" class="form-control" required minlength="6" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Confirm New Password</label>
|
||||||
|
<input type="password" name="ConfirmPassword" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title mb-2 mt-3">API Endpoints</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">Use these endpoints to ingest logs or query status from external applications.</p>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:12px;border-radius:4px;line-height:2">
|
||||||
|
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/logs/ingest</span></div>
|
||||||
|
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/logs/batch</span></div>
|
||||||
|
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/logs/serilog</span></div>
|
||||||
|
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/logs/eonacat</span></div>
|
||||||
|
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/logs</span> <span style="color:var(--text-muted)">?level=&source=&search=&from=&to=&page=&pageSize=</span></div>
|
||||||
|
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/logs/stats</span> <span style="color:var(--text-muted)">?hours=24</span></div>
|
||||||
|
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/status/summary</span></div>
|
||||||
|
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/monitors</span></div>
|
||||||
|
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/monitors/{id}/check</span></div>
|
||||||
|
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/monitors/{id}/history</span> <span style="color:var(--text-muted)">?limit=100</span></div>
|
||||||
|
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/monitors/{id}/uptime</span></div>
|
||||||
|
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/monitors/{id}/pause</span></div>
|
||||||
|
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/monitors/{id}/resume</span></div>
|
||||||
|
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/incidents</span> <span style="color:var(--text-muted)">?activeOnly=true</span></div>
|
||||||
|
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/incidents</span></div>
|
||||||
|
<div><span style="color:var(--warn)">PATCH</span> <span style="color:var(--text-primary)">/api/incidents/{id}</span></div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/ingest" class="btn btn-outline btn-sm mt-2">View Ingest Docs →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
86
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml.cs
Normal file
86
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class SettingsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AuthenticationService _authenticationService;
|
||||||
|
public SettingsModel(AuthenticationService authentication) => _authenticationService = authentication;
|
||||||
|
|
||||||
|
[BindProperty] public string SiteName { get; set; } = "";
|
||||||
|
[BindProperty] public bool ShowLogsPublicly { get; set; }
|
||||||
|
[BindProperty] public bool ShowUptimePublicly { get; set; }
|
||||||
|
[BindProperty] public bool ShowIncidentsPublicly { get; set; }
|
||||||
|
[BindProperty] public bool AutoCreateIncidents { get; set; }
|
||||||
|
[BindProperty] public int MaxLogRetentionDays { get; set; } = 30;
|
||||||
|
[BindProperty] public string AlertEmail { get; set; } = "";
|
||||||
|
[BindProperty] public string AlertWebhookUrl { get; set; } = "";
|
||||||
|
[BindProperty] public string CurrentPassword { get; set; } = "";
|
||||||
|
[BindProperty] public string NewPassword { get; set; } = "";
|
||||||
|
[BindProperty] public string ConfirmPassword { get; set; } = "";
|
||||||
|
public string? Message { get; set; }
|
||||||
|
public string? PasswordMessage { get; set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteName = await _authenticationService.GetSettingAsync("SiteName", "Status");
|
||||||
|
ShowLogsPublicly = (await _authenticationService.GetSettingAsync("ShowLogsPublicly", "false")) == "true";
|
||||||
|
ShowUptimePublicly = (await _authenticationService.GetSettingAsync("ShowUptimePublicly", "true")) == "true";
|
||||||
|
ShowIncidentsPublicly = (await _authenticationService.GetSettingAsync("ShowIncidentsPublicly", "true")) == "true";
|
||||||
|
AutoCreateIncidents = (await _authenticationService.GetSettingAsync("AutoCreateIncidents", "false")) == "true";
|
||||||
|
MaxLogRetentionDays = int.TryParse(await _authenticationService.GetSettingAsync("MaxLogRetentionDays", "30"), out var d) ? d : 30;
|
||||||
|
AlertEmail = await _authenticationService.GetSettingAsync("AlertEmail", "");
|
||||||
|
AlertWebhookUrl = await _authenticationService.GetSettingAsync("AlertWebhookUrl", "");
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostSaveSettingsAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _authenticationService.SetSettingAsync("SiteName", SiteName ?? "Status");
|
||||||
|
await _authenticationService.SetSettingAsync("ShowLogsPublicly", ShowLogsPublicly ? "true" : "false");
|
||||||
|
await _authenticationService.SetSettingAsync("ShowUptimePublicly", ShowUptimePublicly ? "true" : "false");
|
||||||
|
await _authenticationService.SetSettingAsync("ShowIncidentsPublicly", ShowIncidentsPublicly ? "true" : "false");
|
||||||
|
await _authenticationService.SetSettingAsync("AutoCreateIncidents", AutoCreateIncidents ? "true" : "false");
|
||||||
|
await _authenticationService.SetSettingAsync("MaxLogRetentionDays", MaxLogRetentionDays.ToString());
|
||||||
|
await _authenticationService.SetSettingAsync("AlertEmail", AlertEmail ?? "");
|
||||||
|
await _authenticationService.SetSettingAsync("AlertWebhookUrl", AlertWebhookUrl ?? "");
|
||||||
|
Message = "Settings saved.";
|
||||||
|
return await OnGetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostChangePasswordAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NewPassword != ConfirmPassword) { PasswordMessage = "error:Passwords do not match."; return await OnGetAsync(); }
|
||||||
|
if (NewPassword.Length < 6) { PasswordMessage = "error:Password must be at least 6 characters."; return await OnGetAsync(); }
|
||||||
|
if (await _authenticationService.ChangePasswordAsync(CurrentPassword, NewPassword))
|
||||||
|
{
|
||||||
|
PasswordMessage = "success:Password changed successfully.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PasswordMessage = "error:Current password is incorrect.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return await OnGetAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
80
EonaCat.LogStack.Status/Pages/Analytics.cshtml
Normal file
80
EonaCat.LogStack.Status/Pages/Analytics.cshtml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
@page
|
||||||
|
@model EonaCat.LogStack.Status.Pages.AnalyticsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Analytics";
|
||||||
|
ViewData["Page"] = "analytics";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Uptime & Performance</span>
|
||||||
|
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.Reports.Count monitors</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!Model.Reports.Any())
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">◎</div>
|
||||||
|
<div class="empty-state-text">No check history yet</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Monitor</th>
|
||||||
|
<th>24h Uptime</th>
|
||||||
|
<th>7d Uptime</th>
|
||||||
|
<th>30d Uptime</th>
|
||||||
|
<th>Avg Response</th>
|
||||||
|
<th>Checks (30d)</th>
|
||||||
|
<th>Response Trend</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in Model.Reports)
|
||||||
|
{
|
||||||
|
string UptimeCls(double pct) => pct >= 99 ? "rt-good" : pct >= 95 ? "rt-ok" : "rt-slow";
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;color:var(--text-primary)">@r.MonitorName</td>
|
||||||
|
<td class="mono @UptimeCls(r.Uptime24h)" style="font-size:12px">@r.Uptime24h.ToString("F1")%</td>
|
||||||
|
<td class="mono @UptimeCls(r.Uptime7d)" style="font-size:12px">@r.Uptime7d.ToString("F1")%</td>
|
||||||
|
<td class="mono @UptimeCls(r.Uptime30d)" style="font-size:12px">@r.Uptime30d.ToString("F1")%</td>
|
||||||
|
<td class="mono" style="font-size:12px">@((int)r.AvgResponseMs)ms</td>
|
||||||
|
<td class="mono" style="font-size:12px">
|
||||||
|
<span style="color:var(--up)">@r.UpChecks ↑</span>
|
||||||
|
<span style="color:var(--down);margin-left:6px">@r.DownChecks ↓</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<canvas id="spark-@r.MonitorId" width="90" height="28"
|
||||||
|
data-values="@Model.SparklineData[r.MonitorId]"
|
||||||
|
class="sparkline-canvas"></canvas>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log error rate chart -->
|
||||||
|
<div class="chart-wrap mt-2">
|
||||||
|
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);letter-spacing:1px;margin-bottom:10px">LOG ERROR RATE - LAST 24H</div>
|
||||||
|
<canvas id="log-stats-chart" style="height:180px"></canvas>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Render response-time sparklines
|
||||||
|
document.querySelectorAll('.sparkline-canvas').forEach(canvas => {
|
||||||
|
const raw = canvas.dataset.values;
|
||||||
|
if (!raw) return;
|
||||||
|
const values = raw.split(',').map(Number).filter(n => !isNaN(n));
|
||||||
|
drawSparkline(canvas, values, 'rgba(0,212,170,0.7)');
|
||||||
|
});
|
||||||
|
|
||||||
|
loadLogStatsChart('log-stats-chart');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
56
EonaCat.LogStack.Status/Pages/Analytics.cshtml.cs
Normal file
56
EonaCat.LogStack.Status/Pages/Analytics.cshtml.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class AnalyticsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _db;
|
||||||
|
private readonly MonitoringService _monSvc;
|
||||||
|
|
||||||
|
public AnalyticsModel(DatabaseContext db, MonitoringService monSvc)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_monSvc = monSvc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UptimeReport> Reports { get; set; } = new();
|
||||||
|
/// <summary>Comma-separated response times (ms) for the last 30 checks - keyed by MonitorId.</summary>
|
||||||
|
public Dictionary<int, string> SparklineData { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
var monitors = await _db.Monitors.Where(m => m.IsActive).OrderBy(m => m.Name).ToListAsync();
|
||||||
|
|
||||||
|
foreach (var m in monitors)
|
||||||
|
{
|
||||||
|
var report = await _monSvc.GetUptimeReportAsync(m.Id);
|
||||||
|
Reports.Add(report);
|
||||||
|
|
||||||
|
// Last 30 response times for sparkline
|
||||||
|
var recent = await _db.MonitorChecks
|
||||||
|
.Where(c => c.MonitorId == m.Id)
|
||||||
|
.OrderByDescending(c => c.CheckedAt)
|
||||||
|
.Take(30)
|
||||||
|
.Select(c => c.ResponseMs)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
recent.Reverse();
|
||||||
|
SparklineData[m.Id] = string.Join(",", recent.Select(v => ((int)v).ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
72
EonaCat.LogStack.Status/Pages/Certificates.cshtml
Normal file
72
EonaCat.LogStack.Status/Pages/Certificates.cshtml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
@page
|
||||||
|
@model Status.Pages.CertificatesModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Certificates";
|
||||||
|
ViewData["Page"] = "certs";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">SSL/TLS Certificates</span>
|
||||||
|
@if (Model.IsAdmin) { <a href="/admin/certificates" class="btn btn-outline btn-sm">Manage →</a> }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!Model.Certificates.Any())
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">◧</div>
|
||||||
|
<div class="empty-state-text">No certificates tracked</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="stats-grid">
|
||||||
|
@{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var valid = Model.Certificates.Count(c => c.ExpiresAt.HasValue && (c.ExpiresAt.Value - now).TotalDays > 30);
|
||||||
|
var expiringSoon = Model.Certificates.Count(c => c.ExpiresAt.HasValue && (c.ExpiresAt.Value - now).TotalDays is > 0 and <= 30);
|
||||||
|
var expired = Model.Certificates.Count(c => c.ExpiresAt.HasValue && c.ExpiresAt.Value <= now);
|
||||||
|
}
|
||||||
|
<div class="stat-card up"><div class="stat-label">Valid</div><div class="stat-value">@valid</div></div>
|
||||||
|
<div class="stat-card warn"><div class="stat-label">Expiring <30d</div><div class="stat-value">@expiringSoon</div></div>
|
||||||
|
<div class="stat-card down"><div class="stat-label">Expired</div><div class="stat-value">@expired</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Issuer</th>
|
||||||
|
<th>Valid From</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Days Left</th>
|
||||||
|
<th>Fingerprint</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var certificate in Model.Certificates)
|
||||||
|
{
|
||||||
|
var days = certificate.ExpiresAt.HasValue ? (int)(certificate.ExpiresAt.Value - now).TotalDays : (int?)null;
|
||||||
|
var cls = days == null ? "" : days <= 0 ? "cert-expiry-expired" : days <= 7 ? "cert-expiry-critical" : days <= 30 ? "cert-expiry-warn" : "cert-expiry-ok";
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;color:var(--text-primary)">@certificate.Name</td>
|
||||||
|
<td class="mono" style="font-size:11px">@certificate.Domain</td>
|
||||||
|
<td style="font-size:11px;color:var(--text-muted);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">@(certificate.Issuer?.Split(',').FirstOrDefault()?.Replace("CN=", "") ?? "-")</td>
|
||||||
|
<td class="mono" style="font-size:11px">@(certificate.IssuedAt?.ToString("yyyy-MM-dd") ?? "-")</td>
|
||||||
|
<td class="mono @cls" style="font-size:11px">@(certificate.ExpiresAt?.ToString("yyyy-MM-dd") ?? "-")</td>
|
||||||
|
<td class="mono @cls" style="font-weight:700">@(days.HasValue? days +"d" : "-")</td>
|
||||||
|
<td class="mono" style="font-size:10px;color:var(--text-muted)">@(certificate.Thumbprint?[..16] ?? "-")…</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(certificate.LastError)) { <span class="badge badge-down" title="@certificate.LastError">ERROR</span> }
|
||||||
|
else if (days == null) { <span class="badge badge-unknown">Unchecked</span> }
|
||||||
|
else if (days <= 0) { <span class="badge badge-down">EXPIRED</span> }
|
||||||
|
else if (days <= 7) { <span class="badge badge-down">CRITICAL</span> }
|
||||||
|
else if (days <= 30) { <span class="badge badge-warn">EXPIRING</span> }
|
||||||
|
else { <span class="badge badge-up">VALID</span> }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
20
EonaCat.LogStack.Status/Pages/Certificates.cshtml.cs
Normal file
20
EonaCat.LogStack.Status/Pages/Certificates.cshtml.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages;
|
||||||
|
|
||||||
|
public class CertificatesModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _db;
|
||||||
|
public CertificatesModel(DatabaseContext db) => _db = db;
|
||||||
|
public List<CertificateEntry> Certificates { get; set; } = new List<CertificateEntry>();
|
||||||
|
public bool IsAdmin { get; set; }
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
IsAdmin = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||||
|
Certificates = await _db.Certificates.OrderBy(c => c.ExpiresAt).ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
70
EonaCat.LogStack.Status/Pages/Incidents.cshtml
Normal file
70
EonaCat.LogStack.Status/Pages/Incidents.cshtml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
@page
|
||||||
|
@model EonaCat.LogStack.Status.Pages.IncidentsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Incidents";
|
||||||
|
ViewData["Page"] = "incidents";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Incident History</span>
|
||||||
|
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.Incidents.Count total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!Model.Incidents.Any())
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">✓</div>
|
||||||
|
<div class="empty-state-text">No incidents recorded - all systems nominal.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var inc in Model.Incidents)
|
||||||
|
{
|
||||||
|
var headerClass = inc.Severity switch {
|
||||||
|
IncidentSeverity.Critical => "down",
|
||||||
|
IncidentSeverity.Major => "warn",
|
||||||
|
_ => "info"
|
||||||
|
};
|
||||||
|
var statusBadge = inc.Status == IncidentStatus.Resolved ? "badge-up" : "badge-warn";
|
||||||
|
|
||||||
|
<div class="card mt-2">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<span class="card-title">@inc.Title</span>
|
||||||
|
<span class="badge @statusBadge" style="margin-left:8px">@inc.Status</span>
|
||||||
|
<span class="badge badge-@headerClass" style="margin-left:4px">@inc.Severity</span>
|
||||||
|
</div>
|
||||||
|
<span class="mono" style="font-size:11px;color:var(--text-muted)">
|
||||||
|
@inc.CreatedAt.ToString("yyyy-MM-dd HH:mm") UTC
|
||||||
|
@if (inc.ResolvedAt.HasValue) { <span> - resolved @inc.ResolvedAt.Value.ToString("yyyy-MM-dd HH:mm")</span> }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px 16px">
|
||||||
|
@if (!string.IsNullOrEmpty(inc.Body))
|
||||||
|
{
|
||||||
|
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">@inc.Body</p>
|
||||||
|
}
|
||||||
|
@if (inc.Monitor != null)
|
||||||
|
{
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">
|
||||||
|
Affected service: <strong style="color:var(--text-primary)">@inc.Monitor.Name</strong>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (inc.Updates.Any())
|
||||||
|
{
|
||||||
|
<div style="border-left:2px solid var(--border);padding-left:12px;margin-top:8px">
|
||||||
|
@foreach (var u in inc.Updates.OrderByDescending(u => u.PostedAt))
|
||||||
|
{
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px">
|
||||||
|
<span class="mono" style="font-size:10px;color:var(--text-muted)">@u.PostedAt.ToString("yyyy-MM-dd HH:mm")</span>
|
||||||
|
<span class="badge badge-info" style="font-size:9px">@u.Status</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:var(--text-secondary)">@u.Message</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
48
EonaCat.LogStack.Status/Pages/Incidents.cshtml.cs
Normal file
48
EonaCat.LogStack.Status/Pages/Incidents.cshtml.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class IncidentsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _db;
|
||||||
|
private readonly AuthenticationService _authSvc;
|
||||||
|
|
||||||
|
public IncidentsModel(DatabaseContext db, AuthenticationService authSvc)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_authSvc = authSvc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Incident> Incidents { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
var isAdmin = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||||
|
var showIncidents = (await _authSvc.GetSettingAsync("ShowIncidentsPublicly", "true")) == "true";
|
||||||
|
|
||||||
|
if (!isAdmin && !showIncidents)
|
||||||
|
{
|
||||||
|
Incidents = new List<Incident>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = _db.Incidents
|
||||||
|
.Include(i => i.Updates)
|
||||||
|
.Include(i => i.Monitor)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (!isAdmin)
|
||||||
|
{
|
||||||
|
query = query.Where(i => i.IsPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
Incidents = await query.OrderByDescending(i => i.CreatedAt).ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
198
EonaCat.LogStack.Status/Pages/Index.cshtml
Normal file
198
EonaCat.LogStack.Status/Pages/Index.cshtml
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
@page
|
||||||
|
@model IndexModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Dashboard";
|
||||||
|
ViewData["Page"] = "dashboard";
|
||||||
|
}
|
||||||
|
<div data-autorefresh="true">
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card @(Model.Stats.DownCount > 0 ? "down" : Model.Stats.WarnCount > 0 ? "warn" : Model.Stats.UpCount > 0 ? "up" : "neutral")">
|
||||||
|
<div class="stat-label">Overall</div>
|
||||||
|
<div class="stat-value" style="font-size:22px">
|
||||||
|
@if (Model.Stats.DownCount > 0) { <span style="color:var(--down)">DEGRADED</span> }
|
||||||
|
else if (Model.Stats.WarnCount > 0) { <span style="color:var(--warn)">WARNING</span> }
|
||||||
|
else if (Model.Stats.UpCount > 0) { <span style="color:var(--up)">OPERATIONAL</span> }
|
||||||
|
else { <span style="color:var(--unknown)">UNKNOWN</span> }
|
||||||
|
</div>
|
||||||
|
<div class="stat-sub">@Model.Stats.TotalMonitors monitor(s) active</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card up">
|
||||||
|
<div class="stat-label">Online</div>
|
||||||
|
<div class="stat-value">@Model.Stats.UpCount</div>
|
||||||
|
<div class="stat-sub">monitors up</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card down">
|
||||||
|
<div class="stat-label">Offline</div>
|
||||||
|
<div class="stat-value">@Model.Stats.DownCount</div>
|
||||||
|
<div class="stat-sub">monitors down</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card warn">
|
||||||
|
<div class="stat-label">Warnings</div>
|
||||||
|
<div class="stat-value">@Model.Stats.WarnCount</div>
|
||||||
|
<div class="stat-sub">monitors degraded</div>
|
||||||
|
</div>
|
||||||
|
@if (Model.ShowUptime)
|
||||||
|
{
|
||||||
|
<div class="stat-card info">
|
||||||
|
<div class="stat-label">Uptime</div>
|
||||||
|
<div class="stat-value">@Model.Stats.OverallUptime.ToString("F1")%</div>
|
||||||
|
<div class="stat-sub">overall availability</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="stat-card @(Model.Stats.CertExpired > 0 ? "down" : Model.Stats.CertExpiringSoon > 0 ? "warn" : "neutral")">
|
||||||
|
<div class="stat-label">Certificates</div>
|
||||||
|
<div class="stat-value">@Model.Stats.CertCount</div>
|
||||||
|
<div class="stat-sub">
|
||||||
|
@if (Model.Stats.CertExpired > 0) { <span class="text-down">@Model.Stats.CertExpired expired</span> }
|
||||||
|
else if (Model.Stats.CertExpiringSoon > 0) { <span class="text-warn">@Model.Stats.CertExpiringSoon expiring soon</span> }
|
||||||
|
else { <span>all valid</span> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (Model.IsAdmin)
|
||||||
|
{
|
||||||
|
<div class="stat-card @(Model.Stats.ErrorLogs > 0 ? "warn" : "neutral")">
|
||||||
|
<div class="stat-label">Log Errors</div>
|
||||||
|
<div class="stat-value">@Model.Stats.ErrorLogs</div>
|
||||||
|
<div class="stat-sub">@Model.Stats.TotalLogs total entries</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card @(Model.Stats.ActiveIncidents > 0 ? "down" : "neutral")">
|
||||||
|
<div class="stat-label">Incidents</div>
|
||||||
|
<div class="stat-value">@Model.Stats.ActiveIncidents</div>
|
||||||
|
<div class="stat-sub">@Model.Stats.ResolvedIncidents resolved</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Active Incidents Banner *@
|
||||||
|
@if (Model.ShowIncidents && Model.ActiveIncidents.Any())
|
||||||
|
{
|
||||||
|
foreach (var inc in Model.ActiveIncidents)
|
||||||
|
{
|
||||||
|
var incClass = inc.Severity switch {
|
||||||
|
IncidentSeverity.Critical => "alert-danger",
|
||||||
|
IncidentSeverity.Major => "alert-warning",
|
||||||
|
_ => "alert-info"
|
||||||
|
};
|
||||||
|
<div class="alert @incClass mt-2" style="border-left:4px solid currentColor">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<strong>⚠ @inc.Title</strong>
|
||||||
|
<span class="mono" style="font-size:11px">@inc.Status • @inc.Severity</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(inc.Body))
|
||||||
|
{
|
||||||
|
<div style="margin-top:4px;font-size:12px">@inc.Body</div>
|
||||||
|
}
|
||||||
|
@if (inc.Updates.Any())
|
||||||
|
{
|
||||||
|
var latest = inc.Updates.OrderByDescending(u => u.PostedAt).First();
|
||||||
|
<div style="margin-top:4px;font-size:11px;color:var(--text-muted)">
|
||||||
|
Latest update (@latest.PostedAt.ToString("yyyy-MM-dd HH:mm")): @latest.Message
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Monitors.Any())
|
||||||
|
{
|
||||||
|
var groups = Model.Monitors.GroupBy(m => m.GroupName ?? "General");
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
<div class="card mb-2 mt-2">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">@group.Key</span>
|
||||||
|
<span class="mono" style="font-size:11px;color:var(--text-muted)">@group.Count() services</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 8px;">
|
||||||
|
@foreach (var m in group)
|
||||||
|
{
|
||||||
|
var badgeClass = m.LastStatus switch {
|
||||||
|
MonitorStatus.Up => "badge-up",
|
||||||
|
MonitorStatus.Down => "badge-down",
|
||||||
|
MonitorStatus.Warning or MonitorStatus.Degraded => "badge-warn",
|
||||||
|
_ => "badge-unknown"
|
||||||
|
};
|
||||||
|
var checks = Model.RecentChecks.ContainsKey(m.Id) ? Model.RecentChecks[m.Id] : new();
|
||||||
|
<div class="monitor-row">
|
||||||
|
<div>
|
||||||
|
<div class="monitor-name">@m.Name
|
||||||
|
@if (!m.IsPublic && Model.IsAdmin) { <span class="badge badge-info" style="font-size:8px;padding:1px 5px">PRIVATE</span> }
|
||||||
|
@if (!m.IsActive) { <span class="badge badge-unknown" style="font-size:8px;padding:1px 5px">PAUSED</span> }
|
||||||
|
</div>
|
||||||
|
<div class="monitor-host">@(m.Url ?? (m.Host + (m.Port.HasValue ? ":" + m.Port : "")))</div>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-type">@m.Type.ToString().ToUpper()</div>
|
||||||
|
<div>
|
||||||
|
@if (Model.ShowUptime && checks.Any())
|
||||||
|
{
|
||||||
|
<div class="uptime-bar" title="Last 7 days">
|
||||||
|
@foreach (var c in checks)
|
||||||
|
{
|
||||||
|
var cls = c.Status switch { MonitorStatus.Up => "up", MonitorStatus.Down => "down", MonitorStatus.Warning or MonitorStatus.Degraded => "warn", _ => "unknown" };
|
||||||
|
<div class="uptime-block @cls" title="@c.CheckedAt.ToString("g"): @c.Status"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="monitor-latency">
|
||||||
|
@if (m.LastResponseMs.HasValue) { <span>@((int)m.LastResponseMs.Value)ms</span> }
|
||||||
|
</div>
|
||||||
|
<div><span class="badge @badgeClass">@m.LastStatus</span></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">◎</div>
|
||||||
|
<div class="empty-state-text">No monitors have been configured</div>
|
||||||
|
@if (Model.IsAdmin) { <div class="mt-2"><a href="/admin/monitors" class="btn btn-primary">Add Monitor</a></div> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Certificates.Any())
|
||||||
|
{
|
||||||
|
<div class="card mt-2">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">SSL Certificates</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 0 4px;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Days Left</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in Model.Certificates)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var daysLeft = c.ExpiresAt.HasValue ? (int)(c.ExpiresAt.Value - now).TotalDays : (int?)null;
|
||||||
|
var expiryClass = daysLeft == null ? "" : daysLeft <= 0 ? "cert-expiry-expired" : daysLeft <= 7 ? "cert-expiry-critical" : daysLeft <= 30 ? "cert-expiry-warn" : "cert-expiry-ok";
|
||||||
|
<tr>
|
||||||
|
<td class="mono" style="color:var(--text-primary)">@c.Domain:@c.Port</td>
|
||||||
|
<td>@c.Name</td>
|
||||||
|
<td class="mono @expiryClass">@(c.ExpiresAt?.ToString("yyyy-MM-dd") ?? "unknown")</td>
|
||||||
|
<td class="mono @expiryClass">@(daysLeft.HasValue ? daysLeft + "d" : "-")</td>
|
||||||
|
<td>
|
||||||
|
@if (daysLeft == null) { <span class="badge badge-unknown">Unknown</span> }
|
||||||
|
else if (daysLeft <= 0) { <span class="badge badge-down">EXPIRED</span> }
|
||||||
|
else if (daysLeft <= 7) { <span class="badge badge-down">CRITICAL</span> }
|
||||||
|
else if (daysLeft <= 30) { <span class="badge badge-warn">EXPIRING</span> }
|
||||||
|
else { <span class="badge badge-up">VALID</span> }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
78
EonaCat.LogStack.Status/Pages/Index.cshtml.cs
Normal file
78
EonaCat.LogStack.Status/Pages/Index.cshtml.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class IndexModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _db;
|
||||||
|
private readonly MonitoringService _monSvc;
|
||||||
|
private readonly AuthenticationService _authSvc;
|
||||||
|
|
||||||
|
public IndexModel(DatabaseContext db, MonitoringService monSvc, AuthenticationService authSvc)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_monSvc = monSvc;
|
||||||
|
_authSvc = authSvc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DashboardStats Stats { get; set; } = new();
|
||||||
|
public List<Monitor> Monitors { get; set; } = new();
|
||||||
|
public List<CertificateEntry> Certificates { get; set; } = new();
|
||||||
|
public List<Incident> ActiveIncidents { get; set; } = new();
|
||||||
|
public bool IsAdmin { get; set; }
|
||||||
|
public bool ShowUptime { get; set; }
|
||||||
|
public bool ShowIncidents { get; set; }
|
||||||
|
public string SiteName { get; set; } = "Status";
|
||||||
|
public Dictionary<int, List<MonitorCheck>> RecentChecks { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
IsAdmin = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||||
|
ShowUptime = (await _authSvc.GetSettingAsync("ShowUptimePublicly", "true")) == "true";
|
||||||
|
ShowIncidents = (await _authSvc.GetSettingAsync("ShowIncidentsPublicly", "true")) == "true";
|
||||||
|
SiteName = await _authSvc.GetSettingAsync("SiteName", "Status");
|
||||||
|
Stats = await _monSvc.GetStatsAsync(IsAdmin);
|
||||||
|
|
||||||
|
var query = _db.Monitors.Where(m => m.IsActive);
|
||||||
|
if (!IsAdmin)
|
||||||
|
{
|
||||||
|
query = query.Where(m => m.IsPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
Monitors = await query.OrderBy(m => m.GroupName).ThenBy(m => m.Name).ToListAsync();
|
||||||
|
|
||||||
|
Certificates = await _db.Certificates.OrderBy(c => c.ExpiresAt).ToListAsync();
|
||||||
|
|
||||||
|
// Active incidents (public or admin)
|
||||||
|
var incidentQuery = _db.Incidents
|
||||||
|
.Include(i => i.Updates)
|
||||||
|
.Where(i => i.Status != IncidentStatus.Resolved);
|
||||||
|
if (!IsAdmin)
|
||||||
|
{
|
||||||
|
incidentQuery = incidentQuery.Where(i => i.IsPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
ActiveIncidents = await incidentQuery.OrderByDescending(i => i.CreatedAt).ToListAsync();
|
||||||
|
|
||||||
|
// Recent checks for uptime bars (last 7 days, up to 90 per monitor)
|
||||||
|
var monitorIds = Monitors.Select(m => m.Id).ToList();
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-7);
|
||||||
|
var checks = await _db.MonitorChecks
|
||||||
|
.Where(c => monitorIds.Contains(c.MonitorId) && c.CheckedAt >= cutoff)
|
||||||
|
.OrderByDescending(c => c.CheckedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var m in Monitors)
|
||||||
|
{
|
||||||
|
RecentChecks[m.Id] = checks.Where(c => c.MonitorId == m.Id).Take(90).Reverse().ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
EonaCat.LogStack.Status/Pages/Logs.cshtml
Normal file
133
EonaCat.LogStack.Status/Pages/Logs.cshtml
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
@page
|
||||||
|
@model LogsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Log Stream";
|
||||||
|
ViewData["Page"] = "logs";
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
.chart-wrap canvas { height: 180px !important; }
|
||||||
|
</style>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">
|
||||||
|
Log Stream
|
||||||
|
<span class="live-count" id="live-count"></span>
|
||||||
|
</span>
|
||||||
|
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.TotalCount entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log volume chart (last 24 h) -->
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);letter-spacing:1px;margin-bottom:10px">LOG VOLUME - LAST 24H</div>
|
||||||
|
<canvas id="log-stats-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="filter-bar mt-2">
|
||||||
|
<select name="Level" class="form-control" onchange="this.form.submit()">
|
||||||
|
<option value="">All Levels</option>
|
||||||
|
@foreach (var lvl in new[] { "debug", "info", "warn", "error", "critical" })
|
||||||
|
{
|
||||||
|
<option value="@lvl" selected="@(Model.Level?.ToLower() == lvl)">@lvl.ToUpper()</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<select name="Source" class="form-control" onchange="this.form.submit()">
|
||||||
|
<option value="">All Sources</option>
|
||||||
|
@foreach (var s in Model.Sources)
|
||||||
|
{
|
||||||
|
<option value="@s" selected="@(Model.Source == s)">@s</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<input id="log-search" type="text" name="Search" value="@Model.Search"
|
||||||
|
placeholder="Search messages… (/ to focus)" class="form-control" style="max-width:280px" />
|
||||||
|
<input type="date" name="FromDate" value="@Model.FromDate?.ToString("yyyy-MM-dd")" class="form-control" style="max-width:145px" title="From date" />
|
||||||
|
<input type="date" name="ToDate" value="@Model.ToDate?.ToString("yyyy-MM-dd")" class="form-control" style="max-width:145px" title="To date" />
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm">Apply</button>
|
||||||
|
<a href="/logs" class="btn btn-outline btn-sm">Clear</a>
|
||||||
|
<button type="button" class="btn btn-outline btn-sm" onclick="exportLogs()" title="Export visible entries as JSON">⬇ Export</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Log toolbar -->
|
||||||
|
<div class="log-toolbar">
|
||||||
|
<span class="mono" style="font-size:10px;color:var(--text-muted)">ENTRIES</span>
|
||||||
|
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||||
|
<span class="kbd" title="Keyboard shortcut">/</span> <span style="font-size:11px;color:var(--text-muted)">search</span>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="scrollLogsToBottom()" title="Jump to bottom">↓ Bottom</button>
|
||||||
|
<button class="btn btn-outline btn-sm" id="btn-scroll-top" onclick="document.getElementById('log-stream').scrollTop=0" title="Jump to top">↑ Top</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-stream" id="log-stream" data-last-id="@(Model.Entries.FirstOrDefault()?.Id ?? 0)">
|
||||||
|
@if (!Model.Entries.Any())
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">▦</div>
|
||||||
|
<div class="empty-state-text">No log entries match your filters</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@foreach (var e in Model.Entries.AsEnumerable().Reverse())
|
||||||
|
{
|
||||||
|
<div class="log-entry">
|
||||||
|
<span class="log-ts">@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</span>
|
||||||
|
<span class="log-level @e.Level.ToLower()">@e.Level.ToUpper()</span>
|
||||||
|
<span class="log-source" title="@e.Source">@e.Source</span>
|
||||||
|
<span class="log-message">
|
||||||
|
@e.Message
|
||||||
|
@if (!string.IsNullOrEmpty(e.TraceId))
|
||||||
|
{
|
||||||
|
<span class="mono" style="font-size:9px;color:var(--text-muted);margin-left:6px">trace=@e.TraceId</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(e.Exception))
|
||||||
|
{
|
||||||
|
<details style="margin-top:3px">
|
||||||
|
<summary style="color:var(--down);cursor:pointer;font-size:10px">Exception ▾</summary>
|
||||||
|
<pre style="font-size:10px;color:var(--text-muted);margin-top:4px;white-space:pre-wrap">@e.Exception</pre>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.TotalPages > 1)
|
||||||
|
{
|
||||||
|
<div class="flex gap-2 mt-2 align-center">
|
||||||
|
@if (Model.PageIndex > 1)
|
||||||
|
{
|
||||||
|
<a href="@Url.Page("/Logs", null, new { PageIndex = Model.PageIndex - 1, Level = Model.Level, Source = Model.Source, Search = Model.Search }, null)" class="btn btn-outline btn-sm">← Prev</a>
|
||||||
|
}
|
||||||
|
<span class="mono" style="font-size:11px;color:var(--text-muted)">Page @Model.PageIndex of @Model.TotalPages</span>
|
||||||
|
@if (Model.PageIndex < Model.TotalPages)
|
||||||
|
{
|
||||||
|
<a href="@Url.Page("/Logs", null, new { PageIndex = Model.PageIndex + 1, Level = Model.Level, Source = Model.Source, Search = Model.Search }, null)" class="btn btn-outline btn-sm">Next →</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadLogStatsChart('log-stats-chart');
|
||||||
|
});
|
||||||
|
|
||||||
|
function exportLogs() {
|
||||||
|
const entries = [];
|
||||||
|
document.querySelectorAll('.log-entry').forEach(row => {
|
||||||
|
entries.push({
|
||||||
|
timestamp: row.querySelector('.log-ts')?.textContent?.trim(),
|
||||||
|
level: row.querySelector('.log-level')?.textContent?.trim(),
|
||||||
|
source: row.querySelector('.log-source')?.textContent?.trim(),
|
||||||
|
message: row.querySelector('.log-message')?.childNodes[0]?.textContent?.trim()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' });
|
||||||
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `logs_${new Date().toISOString().slice(0,10)}.json`;
|
||||||
|
a.click();
|
||||||
|
showToast('Exported ' + entries.length + ' entries', 'success');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
78
EonaCat.LogStack.Status/Pages/Logs.cshtml.cs
Normal file
78
EonaCat.LogStack.Status/Pages/Logs.cshtml.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class LogsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _db;
|
||||||
|
public LogsModel(DatabaseContext db) => _db = db;
|
||||||
|
|
||||||
|
public List<LogEntry> Entries { get; set; } = new();
|
||||||
|
public List<string> Sources { get; set; } = new();
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
public int TotalPages { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)] public string? Level { get; set; }
|
||||||
|
[BindProperty(SupportsGet = true)] public string? Source { get; set; }
|
||||||
|
[BindProperty(SupportsGet = true)] public string? Search { get; set; }
|
||||||
|
[BindProperty(SupportsGet = true)] public DateTime? FromDate { get; set; }
|
||||||
|
[BindProperty(SupportsGet = true)] public DateTime? ToDate { get; set; }
|
||||||
|
[BindProperty(SupportsGet = true)] public int PageIndex { get; set; } = 1;
|
||||||
|
|
||||||
|
private const int PageSize = 100;
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||||
|
{
|
||||||
|
return RedirectToPage("/Admin/Login");
|
||||||
|
}
|
||||||
|
|
||||||
|
Sources = await _db.Logs.Select(l => l.Source).Distinct().OrderBy(s => s).ToListAsync();
|
||||||
|
|
||||||
|
var q = _db.Logs.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Level))
|
||||||
|
{
|
||||||
|
q = q.Where(l => l.Level == Level.ToLower());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Source))
|
||||||
|
{
|
||||||
|
q = q.Where(l => l.Source == Source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Search))
|
||||||
|
{
|
||||||
|
q = q.Where(l => l.Message.Contains(Search) || (l.Exception != null && l.Exception.Contains(Search)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FromDate.HasValue)
|
||||||
|
{
|
||||||
|
q = q.Where(l => l.Timestamp >= FromDate.Value.ToUniversalTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ToDate.HasValue)
|
||||||
|
{
|
||||||
|
q = q.Where(l => l.Timestamp <= ToDate.Value.AddDays(1).ToUniversalTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
TotalCount = await q.CountAsync();
|
||||||
|
TotalPages = (int)Math.Ceiling((double)TotalCount / PageSize);
|
||||||
|
|
||||||
|
Entries = await q
|
||||||
|
.OrderByDescending(l => l.Timestamp)
|
||||||
|
.Skip((PageIndex - 1) * PageSize)
|
||||||
|
.Take(PageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
}
|
||||||
83
EonaCat.LogStack.Status/Pages/Monitors.cshtml
Normal file
83
EonaCat.LogStack.Status/Pages/Monitors.cshtml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
@page
|
||||||
|
@model EonaCat.LogStack.Status.Pages.MonitorsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Monitors";
|
||||||
|
ViewData["Page"] = "monitors";
|
||||||
|
var groups = Model.Monitors.GroupBy(m => m.GroupName ?? "General");
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">All Monitors</span>
|
||||||
|
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.Monitors.Count services</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@foreach (var group in groups)
|
||||||
|
{
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">@group.Key</span>
|
||||||
|
<span style="font-size:11px;color:var(--text-muted)">
|
||||||
|
@group.Count(m => m.LastStatus == MonitorStatus.Up) / @group.Count() up
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Monitor</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Endpoint</th>
|
||||||
|
<th>Response</th>
|
||||||
|
<th>30d Uptime</th>
|
||||||
|
<th>Last Checked</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var monitor in group)
|
||||||
|
{
|
||||||
|
var badgeClass = monitor.LastStatus switch
|
||||||
|
{
|
||||||
|
MonitorStatus.Up => "badge-up",
|
||||||
|
MonitorStatus.Down => "badge-down",
|
||||||
|
MonitorStatus.Warning or MonitorStatus.Degraded => "badge-warn",
|
||||||
|
_ => "badge-unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
var uptime = Model.UptimePercent.ContainsKey(monitor.Id) ? Model.UptimePercent[monitor.Id] : 0;
|
||||||
|
var uptimeColor = uptime >= 99 ? "var(--up)" : uptime >= 95 ? "var(--warn)" : "var(--down)";
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight:500;color:var(--text-primary)">@monitor.Name</div>
|
||||||
|
@if (!string.IsNullOrEmpty(monitor.Description)) { <div style="font-size:11px;color:var(--text-muted)">@monitor.Description</div> }
|
||||||
|
</td>
|
||||||
|
<td><span class="badge badge-info" style="font-size:9px">@monitor.Type</span></td>
|
||||||
|
<td class="mono" style="font-size:11px;color:var(--text-muted)">
|
||||||
|
@(monitor.Type is MonitorType.HTTP or MonitorType.HTTPS ? monitor.Url : $"{monitor.Host}{(monitor.Port.HasValue ? ":" + monitor.Port : "")}")
|
||||||
|
</td>
|
||||||
|
<td class="mono" style="font-size:11px">
|
||||||
|
@if (monitor.LastResponseMs.HasValue) { <span>@((int)monitor.LastResponseMs.Value)ms</span> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
|
||||||
|
<span style="color:var(--text-muted)">-</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:12px;color:@uptimeColor">@uptime.ToString("F1")%</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:11px;color:var(--text-muted)">
|
||||||
|
@(monitor.LastChecked.HasValue ? monitor.LastChecked.Value.ToString("HH:mm:ss") + " UTC" : "Never")
|
||||||
|
</td>
|
||||||
|
<td><span class="badge @badgeClass">@monitor.LastStatus</span></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!Model.Monitors.Any())
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">◎</div>
|
||||||
|
<div class="empty-state-text">No monitors configured</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
37
EonaCat.LogStack.Status/Pages/Monitors.cshtml.cs
Normal file
37
EonaCat.LogStack.Status/Pages/Monitors.cshtml.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Pages;
|
||||||
|
|
||||||
|
public class MonitorsModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly DatabaseContext _db;
|
||||||
|
public MonitorsModel(DatabaseContext db) => _db = db;
|
||||||
|
public List<Monitor> Monitors { get; set; } = new();
|
||||||
|
public bool IsAdmin { get; set; }
|
||||||
|
public Dictionary<int, double> UptimePercent { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
IsAdmin = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||||
|
var q = _db.Monitors.Where(m => m.IsActive);
|
||||||
|
if (!IsAdmin)
|
||||||
|
{
|
||||||
|
q = q.Where(m => m.IsPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
Monitors = await q.OrderBy(m => m.GroupName).ThenBy(m => m.Name).ToListAsync();
|
||||||
|
|
||||||
|
var ids = Monitors.Select(m => m.Id).ToList();
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-30);
|
||||||
|
var checks = await _db.MonitorChecks.Where(c => ids.Contains(c.MonitorId) && c.CheckedAt >= cutoff).ToListAsync();
|
||||||
|
foreach (var m in Monitors)
|
||||||
|
{
|
||||||
|
var mc = checks.Where(c => c.MonitorId == m.Id).ToList();
|
||||||
|
UptimePercent[m.Id] = mc.Any() ? (double)mc.Count(c => c.Status == MonitorStatus.Up) / mc.Count * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
EonaCat.LogStack.Status/Pages/Shared/_Layout.cshtml
Normal file
162
EonaCat.LogStack.Status/Pages/Shared/_Layout.cshtml
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<!--
|
||||||
|
This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>@ViewData["Title"] - Status</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="~/css/site.css" />
|
||||||
|
@RenderSection("Styles", required: false)
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<nav class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-icon">◈</span>
|
||||||
|
<span class="logo-text">EonaCat LogStack<br /><strong>Status</strong></span>
|
||||||
|
</div>
|
||||||
|
<button class="sidebar-toggle" onclick="toggleSidebar()" title="Toggle sidebar">⟨</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-section">
|
||||||
|
<span class="nav-label">MONITORING</span>
|
||||||
|
<a href="/" data-label="Dashboard" class="nav-item @(ViewData["Page"]?.ToString() == "dashboard" ? "active" : "")">
|
||||||
|
<span class="nav-icon">⬡</span>
|
||||||
|
<span class="nav-text">Dashboard</span>
|
||||||
|
<span class="status-dot" id="overall-dot"></span>
|
||||||
|
</a>
|
||||||
|
<a href="/monitors" data-label="Monitors" class="nav-item @(ViewData["Page"]?.ToString() == "monitors" ? "active" : "")">
|
||||||
|
<span class="nav-icon">◎</span>
|
||||||
|
<span class="nav-text">Monitors</span>
|
||||||
|
</a>
|
||||||
|
<a href="/certificates" data-label="Certificates" class="nav-item @(ViewData["Page"]?.ToString() == "certs" ? "active" : "")">
|
||||||
|
<span class="nav-icon">◧</span>
|
||||||
|
<span class="nav-text">Certificates</span>
|
||||||
|
</a>
|
||||||
|
<a href="/incidents" data-label="Incidents" class="nav-item @(ViewData["Page"]?.ToString() == "incidents" ? "active" : "")">
|
||||||
|
<span class="nav-icon">⚠</span>
|
||||||
|
<span class="nav-text">Incidents</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Context.Session.GetString("IsAdmin") == "true")
|
||||||
|
{
|
||||||
|
<div class="nav-section">
|
||||||
|
<span class="nav-label">ANALYTICS</span>
|
||||||
|
<a href="/logs" data-label="Log Stream" class="nav-item @(ViewData["Page"]?.ToString() == "logs" ? "active" : "")">
|
||||||
|
<span class="nav-icon">▦</span>
|
||||||
|
<span class="nav-text">Log Stream</span>
|
||||||
|
</a>
|
||||||
|
<a href="/analytics" data-label="Analytics" class="nav-item @(ViewData["Page"]?.ToString() == "analytics" ? "active" : "")">
|
||||||
|
<span class="nav-icon">⬡</span>
|
||||||
|
<span class="nav-text">Analytics</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-section">
|
||||||
|
<span class="nav-label">ADMIN</span>
|
||||||
|
<a href="/admin/monitors" data-label="Monitors" class="nav-item @(ViewData["Page"]?.ToString() == "admin-monitors" ? "active" : "")">
|
||||||
|
<span class="nav-icon">⊞</span>
|
||||||
|
<span class="nav-text">Manage Monitors</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/certificates" data-label="Certificates" class="nav-item @(ViewData["Page"]?.ToString() == "admin-certs" ? "active" : "")">
|
||||||
|
<span class="nav-icon">⊟</span>
|
||||||
|
<span class="nav-text">Manage Certificates</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/incidents" data-label="Incidents" class="nav-item @(ViewData["Page"]?.ToString() == "admin-incidents" ? "active" : "")">
|
||||||
|
<span class="nav-icon">⚑</span>
|
||||||
|
<span class="nav-text">Manage Incidents</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/alertrules" data-label="Alert Rules" class="nav-item @(ViewData["Page"]?.ToString() == "admin-alerts" ? "active" : "")">
|
||||||
|
<span class="nav-icon">◉</span>
|
||||||
|
<span class="nav-text">Alert Rules</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/settings" data-label="Settings" class="nav-item @(ViewData["Page"]?.ToString() == "admin-settings" ? "active" : "")">
|
||||||
|
<span class="nav-icon">⚙</span>
|
||||||
|
<span class="nav-text">Settings</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/ingest" data-label="Log Ingest" class="nav-item @(ViewData["Page"]?.ToString() == "admin-ingest" ? "active" : "")">
|
||||||
|
<span class="nav-icon">⊕</span>
|
||||||
|
<span class="nav-text">Log Ingest</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-section">
|
||||||
|
<a href="/admin/logout" data-label="Logout" class="nav-item nav-item--danger">
|
||||||
|
<span class="nav-icon">⊘</span>
|
||||||
|
<span class="nav-text">Logout</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="nav-section" style="margin-top:auto">
|
||||||
|
<a href="/admin/login" data-label="Admin Login" class="nav-item">
|
||||||
|
<span class="nav-icon">⊛</span>
|
||||||
|
<span class="nav-text">Admin Login</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<span class="clock" id="clock">--:--:--</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main-content" id="main-content">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="breadcrumb">@ViewData["Title"]</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<!-- Refresh countdown ring -->
|
||||||
|
<div class="refresh-ring" id="refresh-ring" title="Auto refresh" style="display:none">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18">
|
||||||
|
<circle class="refresh-ring-track" cx="9" cy="9" r="8" />
|
||||||
|
<circle class="refresh-ring-fill" id="refresh-ring-fill" cx="9" cy="9" r="8" />
|
||||||
|
</svg>
|
||||||
|
<span class="refresh-ring-label" id="refresh-ring-label"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-indicator" title="Online">
|
||||||
|
<span class="pulse-dot"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (SyslogUdpService.IsRunning)
|
||||||
|
{
|
||||||
|
<div class="live-indicator" title="Syslog">
|
||||||
|
<span class="pulse-dot"></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Context.Session.GetString("IsAdmin") == "true")
|
||||||
|
{
|
||||||
|
<span class="admin-badge">ADMIN</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
@RenderBody()
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-footer">
|
||||||
|
© @DateTime.Now.Year
|
||||||
|
<strong>
|
||||||
|
<a href="https://EonaCat.com" target="_blank">EonaCat (Jeroen Saey)</a>
|
||||||
|
</strong>
|
||||||
|
All rights reserved.
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global toast container -->
|
||||||
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="~/js/site.js"></script>
|
||||||
|
@RenderSection("Scripts", required: false)
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
EonaCat.LogStack.Status/Pages/_ViewImports.cshtml
Normal file
5
EonaCat.LogStack.Status/Pages/_ViewImports.cshtml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@using EonaCat.LogStack.Status
|
||||||
|
@using EonaCat.LogStack.Status.Models
|
||||||
|
@using EonaCat.LogStack.Status.Services
|
||||||
|
@namespace EonaCat.LogStack.Status.Pages
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
3
EonaCat.LogStack.Status/Pages/_ViewStart.cshtml
Normal file
3
EonaCat.LogStack.Status/Pages/_ViewStart.cshtml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
45
EonaCat.LogStack.Status/Program.cs
Normal file
45
EonaCat.LogStack.Status/Program.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.Services.AddRazorPages();
|
||||||
|
builder.Services.AddSession(options =>
|
||||||
|
{
|
||||||
|
options.IdleTimeout = TimeSpan.FromHours(8);
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Cookie.IsEssential = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var dbPath = Path.Combine(builder.Environment.ContentRootPath, "EonaCat.LogStack.Status.db");
|
||||||
|
builder.Services.AddDbContextFactory<DatabaseContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
||||||
|
|
||||||
|
// Register DatabaseContext directly as well (for controllers that inject it directly)
|
||||||
|
builder.Services.AddDbContext<DatabaseContext>(options => options.UseSqlite($"Data Source={dbPath}"));
|
||||||
|
|
||||||
|
builder.Services.AddScoped<MonitoringService>();
|
||||||
|
builder.Services.AddScoped<AuthenticationService>();
|
||||||
|
builder.Services.AddScoped<IngestionService>();
|
||||||
|
builder.Services.AddHostedService<SyslogUdpService>();
|
||||||
|
builder.Services.AddHostedService<MonitoringBackgroundService>();
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Ensure database is created and apply any pending migrations
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<IDbContextFactory<DatabaseContext>>().CreateDbContext();
|
||||||
|
database.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseSession();
|
||||||
|
app.MapRazorPages();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
12
EonaCat.LogStack.Status/Properties/launchSettings.json
Normal file
12
EonaCat.LogStack.Status/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"EonaCat.LogStack.Status": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:5001;http://localhost:5000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
EonaCat.LogStack.Status/README.md
Normal file
119
EonaCat.LogStack.Status/README.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# EonaCat.LogStack.Status 🟢
|
||||||
|
|
||||||
|
A self-hosted, application monitoring platform.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Category | Details |
|
||||||
|
|----------|---------|
|
||||||
|
| **TCP Monitor** | Port connectivity checks |
|
||||||
|
| **UDP Monitor** | UDP reachability checks |
|
||||||
|
| **HTTP/HTTPS Monitor** | Full HTTP status monitoring |
|
||||||
|
| **App (Local)** | Monitor local processes by name |
|
||||||
|
| **App (Remote)** | Remote TCP-based app health |
|
||||||
|
| **Certificate Manager** | Track SSL certs, expiry, issuer |
|
||||||
|
| **Certificate Checker** | Auto-refresh cert details hourly |
|
||||||
|
| **Log Aggregator** | Ingest logs from any app via HTTP |
|
||||||
|
| **Log Viewer** | Search, filter, paginate log stream |
|
||||||
|
| **Dashboard** | Live status overview with uptime bars |
|
||||||
|
| **Admin Panel** | Full CRUD for all resources |
|
||||||
|
| **Visibility Control** | Per-monitor public/private toggle |
|
||||||
|
| **REST API** | Log ingestion + status endpoints |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8)
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd EonaCat.LogStack.Status
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
Open: http://localhost:8000
|
||||||
|
|
||||||
|
### Default Admin Password
|
||||||
|
`adminEonaCat` - **Change this immediately** in Settings!
|
||||||
|
|
||||||
|
## Log Ingestion
|
||||||
|
|
||||||
|
Send logs from any app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single log
|
||||||
|
curl -X POST http://localhost:8000/api/logs/ingest \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"source":"my-app","level":"error","message":"Something broke"}'
|
||||||
|
|
||||||
|
# Batch
|
||||||
|
curl -X POST http://localhost:8000/api/logs/batch \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '[{"source":"app","level":"info","message":"Started"},{"source":"app","level":"warn","message":"High memory"}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
### EonaCat.LogStack HTTP Flow
|
||||||
|
```csharp
|
||||||
|
var logger = new LogBuilder().WriteToHttp("http://localhost:8000/api/logs/eonacat").Build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serilog (.NET)
|
||||||
|
```csharp
|
||||||
|
// Install: Serilog.Sinks.Http
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.WriteTo.Http("http://localhost:8000/api/logs/serilog")
|
||||||
|
.CreateLogger();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
requests.post("http://localhost:8000/api/logs/ingest", json={
|
||||||
|
"source": "my-python-app", "level": "info", "message": "Hello"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/api/status/summary` | Dashboard stats |
|
||||||
|
| `GET` | `/api/monitors` | All public monitors |
|
||||||
|
| `GET` | `/api/monitors/{id}/check` | Trigger check |
|
||||||
|
| `POST` | `/api/logs/ingest` | Ingest single log |
|
||||||
|
| `POST` | `/api/logs/batch` | Ingest log array |
|
||||||
|
| `POST` | `/api/logs/eonacat` | EonaCat.LogStack HTTP Flow |
|
||||||
|
| `POST` | `/api/logs/serilog` | Serilog HTTP sink |
|
||||||
|
| `GET` | `/api/logs` | Query logs (admin) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visibility Model
|
||||||
|
|
||||||
|
- **Public monitors** are visible to everyone
|
||||||
|
- **Private monitors** are visible to admins only
|
||||||
|
- **Log viewer** is admin-only
|
||||||
|
- **Uptime %** can be toggled public/private in Settings
|
||||||
|
- **Certificates** are always visible (toggle per cert coming soon)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet publish -c Release -o ./publish
|
||||||
|
# Run with:
|
||||||
|
./publish/EonaCat.LogStack.Status
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Docker:
|
||||||
|
```dockerfile
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ./publish .
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["dotnet", "EonaCat.LogStack.Status.dll"]
|
||||||
|
```
|
||||||
77
EonaCat.LogStack.Status/Services/AuthenticationService.cs
Normal file
77
EonaCat.LogStack.Status/Services/AuthenticationService.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Services;
|
||||||
|
|
||||||
|
public class AuthenticationService
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<DatabaseContext> _dbFactory;
|
||||||
|
|
||||||
|
public AuthenticationService(IDbContextFactory<DatabaseContext> dbFactory) => _dbFactory = dbFactory;
|
||||||
|
|
||||||
|
public async Task<bool> ValidatePasswordAsync(string password)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var setting = await db.Settings.FirstOrDefaultAsync(s => s.Key == "AdminPasswordHash");
|
||||||
|
if (setting == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BCrypt.Net.BCrypt.EnhancedVerify(password, setting.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ChangePasswordAsync(string currentPassword, string newPassword)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(currentPassword))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(newPassword))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await ValidatePasswordAsync(currentPassword))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var setting = await db.Settings.FirstAsync(s => s.Key == "AdminPasswordHash");
|
||||||
|
setting.Value = BCrypt.Net.BCrypt.EnhancedHashPassword(newPassword);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetSettingAsync(string key, string defaultValue = "")
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var s = await db.Settings.FirstOrDefaultAsync(x => x.Key == key);
|
||||||
|
return s?.Value ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetSettingAsync(string key, string value)
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var setting = await db.Settings.FirstOrDefaultAsync(x => x.Key == key);
|
||||||
|
if (setting == null)
|
||||||
|
{
|
||||||
|
db.Settings.Add(new Models.AppSettings { Key = key, Value = value });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setting.Value = value;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
537
EonaCat.LogStack.Status/Services/MonitoringService.cs
Normal file
537
EonaCat.LogStack.Status/Services/MonitoringService.cs
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Net.Security;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EonaCat.LogStack.Status.Data;
|
||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Status.Services;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public class MonitoringService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<DatabaseContext> _dbFactory;
|
||||||
|
private readonly ILogger<MonitoringService> _log;
|
||||||
|
|
||||||
|
public MonitoringService(IDbContextFactory<DatabaseContext> dbFactory, ILogger<MonitoringService> log)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check ─
|
||||||
|
|
||||||
|
public async Task<MonitorCheck> CheckMonitorAsync(Monitor monitor)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
MonitorStatus status;
|
||||||
|
string? message = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(status, message) = monitor.Type switch
|
||||||
|
{
|
||||||
|
MonitorType.TCP => await CheckTcpAsync(monitor.Host, monitor.Port ?? 80, monitor.TimeoutMs),
|
||||||
|
MonitorType.UDP => await CheckUdpAsync(monitor.Host, monitor.Port ?? 53, monitor.TimeoutMs),
|
||||||
|
MonitorType.Ping => await CheckPingAsync(monitor.Host, monitor.TimeoutMs),
|
||||||
|
MonitorType.AppLocal => CheckLocalProcess(monitor.ProcessName ?? monitor.Name),
|
||||||
|
MonitorType.AppRemote => await CheckTcpAsync(monitor.Host, monitor.Port ?? 80, monitor.TimeoutMs),
|
||||||
|
MonitorType.HTTP => await CheckHttpAsync(monitor.Url ?? $"http://{monitor.Host}", monitor.TimeoutMs, monitor.ExpectedKeyword, monitor.ExpectedStatusCode),
|
||||||
|
MonitorType.HTTPS => await CheckHttpAsync(monitor.Url ?? $"https://{monitor.Host}", monitor.TimeoutMs, monitor.ExpectedKeyword, monitor.ExpectedStatusCode),
|
||||||
|
_ => (MonitorStatus.Unknown, "Unknown monitor type")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status = MonitorStatus.Down;
|
||||||
|
message = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
// failure threshold ─
|
||||||
|
if (status == MonitorStatus.Down || status == MonitorStatus.Warning)
|
||||||
|
{
|
||||||
|
monitor.ConsecutiveFailures++;
|
||||||
|
if (monitor.ConsecutiveFailures < monitor.FailureThreshold)
|
||||||
|
{
|
||||||
|
// Not enough consecutive failures yet - keep previous status
|
||||||
|
status = monitor.LastStatus == MonitorStatus.Unknown ? MonitorStatus.Unknown : monitor.LastStatus;
|
||||||
|
message = $"[Grace: {monitor.ConsecutiveFailures}/{monitor.FailureThreshold}] {message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
monitor.ConsecutiveFailures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var check = new MonitorCheck
|
||||||
|
{
|
||||||
|
MonitorId = monitor.Id,
|
||||||
|
Status = status,
|
||||||
|
ResponseMs = sw.Elapsed.TotalMilliseconds,
|
||||||
|
Message = message,
|
||||||
|
CheckedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var prevStatus = monitor.LastStatus;
|
||||||
|
|
||||||
|
db.MonitorChecks.Add(check);
|
||||||
|
monitor.LastChecked = DateTime.UtcNow;
|
||||||
|
monitor.LastStatus = status;
|
||||||
|
monitor.LastResponseMs = check.ResponseMs;
|
||||||
|
db.Monitors.Update(monitor);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await EvaluateAlertRulesAsync(monitor, check, prevStatus, db);
|
||||||
|
|
||||||
|
return check;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(MonitorStatus, string?)> CheckTcpAsync(string host, int port, int timeoutMs)
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
var cts = new CancellationTokenSource(timeoutMs);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.ConnectAsync(host, port, cts.Token);
|
||||||
|
return (MonitorStatus.Up, $"Connected to {host}:{port}");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Down, $"Timeout connecting to {host}:{port}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Down, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(MonitorStatus, string?)> CheckUdpAsync(string host, int port, int timeoutMs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var udp = new UdpClient();
|
||||||
|
udp.Connect(host, port);
|
||||||
|
var data = new byte[] { 0x00 };
|
||||||
|
await udp.SendAsync(data, data.Length);
|
||||||
|
return (MonitorStatus.Up, $"UDP {host}:{port} reachable");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Warning, $"UDP check: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ICMP ping check.</summary>
|
||||||
|
private async Task<(MonitorStatus, string?)> CheckPingAsync(string host, int timeoutMs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var ping = new Ping();
|
||||||
|
var reply = await ping.SendPingAsync(host, timeoutMs);
|
||||||
|
if (reply.Status == IPStatus.Success)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Up, $"Ping {host} = {reply.RoundtripTime}ms TTL={reply.Options?.Ttl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (MonitorStatus.Down, $"Ping {host}: {reply.Status}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Down, $"Ping error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private (MonitorStatus, string?) CheckLocalProcess(string processName)
|
||||||
|
{
|
||||||
|
var procs = Process.GetProcessesByName(processName);
|
||||||
|
if (procs.Length > 0)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Up, $"Process '{processName}' running (PID: {procs[0].Id})");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (MonitorStatus.Down, $"Process '{processName}' not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(MonitorStatus, string?)> CheckHttpAsync(string url, int timeoutMs, string? expectedKeyword, int? expectedStatusCode)
|
||||||
|
{
|
||||||
|
using var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||||
|
};
|
||||||
|
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await client.GetAsync(url);
|
||||||
|
var code = (int)resp.StatusCode;
|
||||||
|
string? body = null;
|
||||||
|
|
||||||
|
// Keyword assertion
|
||||||
|
if (!string.IsNullOrEmpty(expectedKeyword))
|
||||||
|
{
|
||||||
|
body = await resp.Content.ReadAsStringAsync();
|
||||||
|
if (!body.Contains(expectedKeyword, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Down, $"HTTP {code} - keyword '{expectedKeyword}' not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status code assertion
|
||||||
|
if (expectedStatusCode.HasValue)
|
||||||
|
{
|
||||||
|
if (code == expectedStatusCode.Value)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Up, $"HTTP {code} (expected)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return code >= 200 && code < 400
|
||||||
|
? (MonitorStatus.Warning, $"HTTP {code} (expected {expectedStatusCode})")
|
||||||
|
: (MonitorStatus.Down, $"HTTP {code} (expected {expectedStatusCode})");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 200 && code < 400)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Up, $"HTTP {code}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 400 && code < 500)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Warning, $"HTTP {code}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (MonitorStatus.Down, $"HTTP {code}");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Down, "Timeout");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (MonitorStatus.Down, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CertificateEntry> CheckCertificateAsync(CertificateEntry cert)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
await client.ConnectAsync(cert.Domain, cert.Port);
|
||||||
|
using var ssl = new SslStream(client.GetStream(), false, (_, c, _, _) => true);
|
||||||
|
await ssl.AuthenticateAsClientAsync(cert.Domain);
|
||||||
|
|
||||||
|
var x509 = ssl.RemoteCertificate as X509Certificate2
|
||||||
|
?? new X509Certificate2(ssl.RemoteCertificate!);
|
||||||
|
|
||||||
|
cert.ExpiresAt = x509.NotAfter.ToUniversalTime();
|
||||||
|
cert.IssuedAt = x509.NotBefore.ToUniversalTime();
|
||||||
|
cert.Issuer = x509.Issuer;
|
||||||
|
cert.Subject = x509.Subject;
|
||||||
|
cert.Thumbprint = x509.Thumbprint;
|
||||||
|
cert.LastError = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
cert.LastError = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
cert.LastChecked = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
db.Certificates.Update(cert);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardStats> GetStatsAsync(bool isAdmin)
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var monitors = await db.Monitors.Where(m => m.IsActive && (isAdmin || m.IsPublic)).ToListAsync();
|
||||||
|
var certs = await db.Certificates.ToListAsync();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
return new DashboardStats
|
||||||
|
{
|
||||||
|
TotalMonitors = monitors.Count,
|
||||||
|
UpCount = monitors.Count(m => m.LastStatus == MonitorStatus.Up),
|
||||||
|
DownCount = monitors.Count(m => m.LastStatus == MonitorStatus.Down),
|
||||||
|
WarnCount = monitors.Count(m => m.LastStatus == MonitorStatus.Warning || m.LastStatus == MonitorStatus.Degraded),
|
||||||
|
UnknownCount = monitors.Count(m => m.LastStatus == MonitorStatus.Unknown),
|
||||||
|
CertCount = certs.Count,
|
||||||
|
CertExpiringSoon = certs.Count(c => c.ExpiresAt.HasValue && c.ExpiresAt.Value > now && (c.ExpiresAt.Value - now).TotalDays <= 30),
|
||||||
|
CertExpired = certs.Count(c => c.ExpiresAt.HasValue && c.ExpiresAt.Value <= now),
|
||||||
|
TotalLogs = await db.Logs.LongCountAsync(),
|
||||||
|
ErrorLogs = await db.Logs.LongCountAsync(l => l.Level == "error" || l.Level == "critical"),
|
||||||
|
OverallUptime = monitors.Count > 0 ? (double)monitors.Count(m => m.LastStatus == MonitorStatus.Up) / monitors.Count * 100 : 0,
|
||||||
|
ActiveIncidents = await db.Incidents.CountAsync(i => i.Status != IncidentStatus.Resolved),
|
||||||
|
ResolvedIncidents = await db.Incidents.CountAsync(i => i.Status == IncidentStatus.Resolved)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns uptime percentages and response time stats for a single monitor.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<UptimeReport> GetUptimeReportAsync(int monitorId)
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var monitor = await db.Monitors.FindAsync(monitorId);
|
||||||
|
if (monitor == null)
|
||||||
|
{
|
||||||
|
throw new KeyNotFoundException($"Monitor {monitorId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var checks24h = await db.MonitorChecks
|
||||||
|
.Where(c => c.MonitorId == monitorId && c.CheckedAt >= now.AddHours(-24))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var checks7d = await db.MonitorChecks
|
||||||
|
.Where(c => c.MonitorId == monitorId && c.CheckedAt >= now.AddDays(-7))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var checks30d = await db.MonitorChecks
|
||||||
|
.Where(c => c.MonitorId == monitorId && c.CheckedAt >= now.AddDays(-30))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
static double CalcUptime(List<MonitorCheck> list) =>
|
||||||
|
list.Count == 0 ? 100.0 : (double)list.Count(c => c.Status == MonitorStatus.Up) / list.Count * 100.0;
|
||||||
|
|
||||||
|
return new UptimeReport
|
||||||
|
{
|
||||||
|
MonitorId = monitorId,
|
||||||
|
MonitorName = monitor.Name,
|
||||||
|
Uptime24h = CalcUptime(checks24h),
|
||||||
|
Uptime7d = CalcUptime(checks7d),
|
||||||
|
Uptime30d = CalcUptime(checks30d),
|
||||||
|
TotalChecks = checks30d.Count,
|
||||||
|
UpChecks = checks30d.Count(c => c.Status == MonitorStatus.Up),
|
||||||
|
DownChecks = checks30d.Count(c => c.Status == MonitorStatus.Down),
|
||||||
|
AvgResponseMs = checks30d.Count > 0 ? checks30d.Average(c => c.ResponseMs) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns log volume bucketed by hour for the last <paramref name="hours"/> hours.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<LogStatsBucket>> GetLogStatsAsync(int hours = 24)
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var from = DateTime.UtcNow.AddHours(-hours);
|
||||||
|
var logs = await db.Logs.Where(l => l.Timestamp >= from).ToListAsync();
|
||||||
|
|
||||||
|
return logs
|
||||||
|
.GroupBy(l => new DateTime(l.Timestamp.Year, l.Timestamp.Month, l.Timestamp.Day, l.Timestamp.Hour, 0, 0, DateTimeKind.Utc))
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => new LogStatsBucket
|
||||||
|
{
|
||||||
|
BucketStart = g.Key,
|
||||||
|
Total = g.LongCount(),
|
||||||
|
Errors = g.LongCount(l => l.Level == "error" || l.Level == "critical"),
|
||||||
|
Warnings = g.LongCount(l => l.Level == "warn" || l.Level == "warning")
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EvaluateAlertRulesAsync(Monitor monitor, MonitorCheck check, MonitorStatus prevStatus, DatabaseContext db)
|
||||||
|
{
|
||||||
|
var rules = await db.AlertRules
|
||||||
|
.Where(r => r.IsEnabled && (r.MonitorId == monitor.Id || r.MonitorId == null))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var globalWebhook = await db.Settings.FirstOrDefaultAsync(s => s.Key == "AlertWebhookUrl");
|
||||||
|
var webhookUrl = globalWebhook?.Value;
|
||||||
|
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
bool fired = rule.Condition switch
|
||||||
|
{
|
||||||
|
AlertRuleCondition.IsDown => check.Status == MonitorStatus.Down && prevStatus != MonitorStatus.Down,
|
||||||
|
AlertRuleCondition.IsUp => check.Status == MonitorStatus.Up && prevStatus == MonitorStatus.Down,
|
||||||
|
AlertRuleCondition.ResponseAboveMs => check.ResponseMs > (rule.ThresholdValue ?? double.MaxValue),
|
||||||
|
AlertRuleCondition.CertExpiresWithinDays => false, // evaluated by cert loop separately
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fired)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown check
|
||||||
|
if (rule.LastFiredAt.HasValue &&
|
||||||
|
(DateTime.UtcNow - rule.LastFiredAt.Value).TotalMinutes < rule.CooldownMinutes)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.LastFiredAt = DateTime.UtcNow;
|
||||||
|
db.AlertRules.Update(rule);
|
||||||
|
|
||||||
|
// Auto-create incident when a monitor goes down
|
||||||
|
var autoIncidents = await db.Settings.FirstOrDefaultAsync(s => s.Key == "AutoCreateIncidents");
|
||||||
|
if (autoIncidents?.Value == "true" && rule.Condition == AlertRuleCondition.IsDown)
|
||||||
|
{
|
||||||
|
var incident = new Incident
|
||||||
|
{
|
||||||
|
Title = $"{monitor.Name} is down",
|
||||||
|
Body = check.Message,
|
||||||
|
Severity = IncidentSeverity.Major,
|
||||||
|
Status = IncidentStatus.Investigating,
|
||||||
|
MonitorId = monitor.Id,
|
||||||
|
IsPublic = monitor.IsPublic
|
||||||
|
};
|
||||||
|
db.Incidents.Add(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire webhook
|
||||||
|
var target = rule.WebhookUrl ?? webhookUrl;
|
||||||
|
if (!string.IsNullOrEmpty(target))
|
||||||
|
{
|
||||||
|
_ = Task.Run(() => FireWebhookAsync(target, monitor, check, rule.Condition));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FireWebhookAsync(string url, Monitor monitor, MonitorCheck check, AlertRuleCondition condition)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
var payload = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
monitorId = monitor.Id,
|
||||||
|
monitorName = monitor.Name,
|
||||||
|
condition = condition.ToString(),
|
||||||
|
status = check.Status.ToString(),
|
||||||
|
responseMs = check.ResponseMs,
|
||||||
|
message = check.Message,
|
||||||
|
checkedAt = check.CheckedAt.ToString("o")
|
||||||
|
});
|
||||||
|
await client.PostAsync(url, new StringContent(payload, Encoding.UTF8, "application/json"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning("Webhook delivery to {Url} failed: {Msg}", url, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MonitoringBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<MonitoringBackgroundService> _log;
|
||||||
|
|
||||||
|
public MonitoringBackgroundService(IServiceScopeFactory scopeFactory, ILogger<MonitoringBackgroundService> log)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<DatabaseContext>>();
|
||||||
|
|
||||||
|
await using var db = await dbFactory.CreateDbContextAsync(stoppingToken);
|
||||||
|
var monitors = await db.Monitors.Where(m => m.IsActive).ToListAsync(stoppingToken);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
foreach (var m in monitors)
|
||||||
|
{
|
||||||
|
if (m.LastChecked == null || (now - m.LastChecked.Value).TotalSeconds >= m.IntervalSeconds)
|
||||||
|
{
|
||||||
|
var captured = m;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var checkScope = _scopeFactory.CreateScope();
|
||||||
|
var svc = checkScope.ServiceProvider.GetRequiredService<MonitoringService>();
|
||||||
|
await svc.CheckMonitorAsync(captured);
|
||||||
|
}, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check certs every hour
|
||||||
|
var certs = await db.Certificates.ToListAsync(stoppingToken);
|
||||||
|
foreach (var c in certs)
|
||||||
|
{
|
||||||
|
if (c.LastChecked == null || (now - c.LastChecked.Value).TotalHours >= 1)
|
||||||
|
{
|
||||||
|
var captured = c;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var certScope = _scopeFactory.CreateScope();
|
||||||
|
var svc = certScope.ServiceProvider.GetRequiredService<MonitoringService>();
|
||||||
|
await svc.CheckCertificateAsync(captured);
|
||||||
|
}, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log retention purge - run once per hour
|
||||||
|
if (now.Minute == 0)
|
||||||
|
{
|
||||||
|
using var purgeScope = _scopeFactory.CreateScope();
|
||||||
|
var ingest = purgeScope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||||
|
var auth = purgeScope.ServiceProvider.GetRequiredService<AuthenticationService>();
|
||||||
|
var days = int.TryParse(await auth.GetSettingAsync("MaxLogRetentionDays", "30"), out var d) ? d : 30;
|
||||||
|
await ingest.PurgeOldLogsAsync(days);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "Error in monitor loop");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(10_000, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class IngestionService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<DatabaseContext> _dbFactory;
|
||||||
|
|
||||||
|
public IngestionService(IDbContextFactory<DatabaseContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IngestAsync(LogEntry entry)
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
db.Logs.Add(entry);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IngestBatchAsync(IEnumerable<LogEntry> entries)
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
db.Logs.AddRange(entries);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PurgeOldLogsAsync(int retentionDays)
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
|
||||||
|
// Use ExecuteDeleteAsync for efficiency with large tables
|
||||||
|
await db.Logs.Where(l => l.Timestamp < cutoff).ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
241
EonaCat.LogStack.Status/Services/SyslogService.cs
Normal file
241
EonaCat.LogStack.Status/Services/SyslogService.cs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
using EonaCat.LogStack.Status.Models;
|
||||||
|
using EonaCat.LogStack.Status.Services;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
|
public class SyslogUdpService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<SyslogUdpService> _logger;
|
||||||
|
private readonly IngestionService _ingestionService;
|
||||||
|
private readonly int _port;
|
||||||
|
|
||||||
|
private readonly Channel<LogEntry> _channel;
|
||||||
|
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
|
||||||
|
public static bool IsRunning { get; private set; }
|
||||||
|
|
||||||
|
public SyslogUdpService(ILogger<SyslogUdpService> logger, IServiceScopeFactory scopeFactory, IConfiguration config)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_port = config.GetValue("Syslog:Port", 514);
|
||||||
|
|
||||||
|
_channel = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(10_000)
|
||||||
|
{
|
||||||
|
FullMode = BoundedChannelFullMode.DropOldest
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var receiverTask = ReceiveLoop(stoppingToken);
|
||||||
|
var processorTask = ProcessLoop(stoppingToken);
|
||||||
|
|
||||||
|
await Task.WhenAll(receiverTask, processorTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReceiveLoop(CancellationToken token)
|
||||||
|
{
|
||||||
|
using var udpClient = new UdpClient(_port);
|
||||||
|
udpClient.Client.ReceiveBufferSize = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
_logger.LogInformation("Syslog UDP server listening on port {Port}", _port);
|
||||||
|
IsRunning = true;
|
||||||
|
|
||||||
|
while (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await udpClient.ReceiveAsync(token);
|
||||||
|
|
||||||
|
var message = Encoding.UTF8.GetString(result.Buffer);
|
||||||
|
var remoteIp = result.RemoteEndPoint.Address.ToString();
|
||||||
|
|
||||||
|
var entry = ParseMessage(message, remoteIp);
|
||||||
|
|
||||||
|
if (entry != null)
|
||||||
|
{
|
||||||
|
await _channel.Writer.WriteAsync(entry, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Receive loop error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessLoop(CancellationToken token)
|
||||||
|
{
|
||||||
|
var batch = new List<LogEntry>(100);
|
||||||
|
|
||||||
|
while (await _channel.Reader.WaitToReadAsync(token))
|
||||||
|
{
|
||||||
|
while (_channel.Reader.TryRead(out var entry))
|
||||||
|
{
|
||||||
|
batch.Add(entry);
|
||||||
|
|
||||||
|
if (batch.Count >= 100)
|
||||||
|
{
|
||||||
|
await Flush(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.Count > 0)
|
||||||
|
{
|
||||||
|
await Flush(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Flush(List<LogEntry> batch)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var ingestionService = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||||
|
|
||||||
|
await ingestionService.IngestBatchAsync(batch.ToArray());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Batch ingestion failed");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
batch.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LogEntry ParseMessage(string rawMessage, string remoteIp)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (IsJson(rawMessage))
|
||||||
|
return ParseJson(rawMessage, remoteIp);
|
||||||
|
|
||||||
|
return ParseSyslogAdvanced(rawMessage, remoteIp);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Parsing failed");
|
||||||
|
|
||||||
|
return new LogEntry
|
||||||
|
{
|
||||||
|
Source = "Syslog.Unknown",
|
||||||
|
Level = "Info",
|
||||||
|
Message = rawMessage,
|
||||||
|
Host = remoteIp,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsJson(string input)
|
||||||
|
{
|
||||||
|
input = input.TrimStart();
|
||||||
|
return input.StartsWith("{") || input.StartsWith("[");
|
||||||
|
}
|
||||||
|
|
||||||
|
private LogEntry ParseJson(string json, string remoteIp)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
string Get(string name) =>
|
||||||
|
root.TryGetProperty(name, out var val) ? val.ToString() : null;
|
||||||
|
|
||||||
|
return new LogEntry
|
||||||
|
{
|
||||||
|
Source = Get("source") ?? "Custom.Json",
|
||||||
|
Level = MapLevel(Get("level")),
|
||||||
|
Message = Get("message"),
|
||||||
|
Exception = Get("exception"),
|
||||||
|
Host = Get("host") ?? remoteIp,
|
||||||
|
TraceId = Get("traceId"),
|
||||||
|
Properties = json,
|
||||||
|
Timestamp = DateTime.TryParse(Get("timestamp"), out var dt)
|
||||||
|
? dt : DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private LogEntry ParseSyslogAdvanced(string message, string remoteIp)
|
||||||
|
{
|
||||||
|
var entry = new LogEntry
|
||||||
|
{
|
||||||
|
Source = "Syslog",
|
||||||
|
Host = remoteIp,
|
||||||
|
Message = message,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
Level = "Info"
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// PRI parsing
|
||||||
|
if (message.StartsWith("<"))
|
||||||
|
{
|
||||||
|
var end = message.IndexOf('>');
|
||||||
|
if (end > 0)
|
||||||
|
{
|
||||||
|
var pri = int.Parse(message[1..end]);
|
||||||
|
var severity = pri % 8;
|
||||||
|
entry.Level = MapSyslogSeverity(severity);
|
||||||
|
|
||||||
|
message = message[(end + 1)..].Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try RFC5424 parsing
|
||||||
|
var parts = message.Split(' ', 7, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
if (parts.Length >= 7 && int.TryParse(parts[0], out _))
|
||||||
|
{
|
||||||
|
entry.Timestamp = DateTime.TryParse(parts[1], out var ts)
|
||||||
|
? ts : entry.Timestamp;
|
||||||
|
|
||||||
|
entry.Host = parts[2];
|
||||||
|
entry.Source = parts[3];
|
||||||
|
entry.Message = parts[6];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entry.Message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string MapSyslogSeverity(int severity) => severity switch
|
||||||
|
{
|
||||||
|
0 or 1 => "Fatal",
|
||||||
|
2 or 3 => "Error",
|
||||||
|
4 => "Warning",
|
||||||
|
5 or 6 => "Info",
|
||||||
|
7 => "Debug",
|
||||||
|
_ => "Info"
|
||||||
|
};
|
||||||
|
|
||||||
|
private string MapLevel(string level) => level?.ToLower() switch
|
||||||
|
{
|
||||||
|
"trace" => "Debug",
|
||||||
|
"debug" => "Debug",
|
||||||
|
"info" => "Info",
|
||||||
|
"warn" or "warning" => "Warning",
|
||||||
|
"error" => "Error",
|
||||||
|
"fatal" => "Fatal",
|
||||||
|
_ => "Info"
|
||||||
|
};
|
||||||
|
}
|
||||||
9
EonaCat.LogStack.Status/appsettings.json
Normal file
9
EonaCat.LogStack.Status/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
803
EonaCat.LogStack.Status/wwwroot/css/site.css
Normal file
803
EonaCat.LogStack.Status/wwwroot/css/site.css
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
/* This file is part of the EonaCat project(s) which is released under the Apache License. */
|
||||||
|
/* See the LICENSE file or go to https://EonaCat.com/License for full license details. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-base: #0a0b0e;
|
||||||
|
--bg-surface: #0f1117;
|
||||||
|
--bg-elevated: #161922;
|
||||||
|
--bg-card: #1a1d28;
|
||||||
|
--bg-hover: #1f2335;
|
||||||
|
--border: #252836;
|
||||||
|
--border-light: #2e3347;
|
||||||
|
--text-primary: #e8eaf0;
|
||||||
|
--text-secondary: #8b8fa8;
|
||||||
|
--text-muted: #4e5268;
|
||||||
|
--accent: #00d4aa;
|
||||||
|
--accent-dim: rgba(0,212,170,0.12);
|
||||||
|
--accent-glow: rgba(0,212,170,0.3);
|
||||||
|
--up: #00d4aa;
|
||||||
|
--down: #ff4b6e;
|
||||||
|
--warn: #ffb547;
|
||||||
|
--unknown: #5c6080;
|
||||||
|
--info: #5b9cf6;
|
||||||
|
--font-mono: 'Space Mono', monospace;
|
||||||
|
--font-body: 'DM Sans', sans-serif;
|
||||||
|
--radius: 6px;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer a:hover,
|
||||||
|
.page-footer a:active,
|
||||||
|
.page-footer a:visited {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrapper */
|
||||||
|
.app-shell { display: flex; min-height: 100vh; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed { width: 56px; }
|
||||||
|
|
||||||
|
/* hide text/labels when collapsed */
|
||||||
|
.sidebar.collapsed .logo-text,
|
||||||
|
.sidebar.collapsed .nav-label,
|
||||||
|
.sidebar.collapsed .nav-text,
|
||||||
|
.sidebar.collapsed .sidebar-footer,
|
||||||
|
.sidebar.collapsed .status-dot { display: none; }
|
||||||
|
|
||||||
|
/* centre icons when collapsed */
|
||||||
|
.sidebar.collapsed .nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .sidebar-header {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 18px 0 14px;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .sidebar-toggle { margin: 0; }
|
||||||
|
|
||||||
|
/* tooltip on collapsed nav items */
|
||||||
|
.sidebar.collapsed .nav-item { position: relative; }
|
||||||
|
.sidebar.collapsed .nav-item::after {
|
||||||
|
content: attr(data-label);
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 10px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .nav-item:hover::after { opacity: 1; }
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 18px 16px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.logo-icon { color: var(--accent); font-size: 18px; }
|
||||||
|
.logo-text { font-family: var(--font-mono); font-size: 13px; letter-spacing: -0.5px; color: var(--text-primary); }
|
||||||
|
.logo-text strong { color: var(--accent); }
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
background: none; border: none; color: var(--text-muted);
|
||||||
|
cursor: pointer; font-size: 14px; padding: 2px 6px;
|
||||||
|
border-radius: 3px; transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.sidebar-toggle:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.nav-section {
|
||||||
|
padding: 12px 0 4px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.nav-section:last-of-type { border-bottom: none; margin-top: auto; }
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
display: block;
|
||||||
|
padding: 4px 16px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s;
|
||||||
|
position: relative;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: var(--bg-elevated); color: var(--text-primary); border-left-color: var(--border-light); }
|
||||||
|
.nav-item.active { background: var(--accent-dim); color: var(--accent); border-left-color: var(--accent); }
|
||||||
|
.nav-item--danger:hover { color: var(--down); border-left-color: var(--down); }
|
||||||
|
.nav-icon { font-size: 14px; width: 16px; text-align: center; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--unknown);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.clock { font-family: var(--font-mono); font-size: 11px; color: var(--accent); }
|
||||||
|
|
||||||
|
/* Main */
|
||||||
|
.main-content {
|
||||||
|
margin-left: 220px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: margin-left 0.25s ease;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed ~ .main-content,
|
||||||
|
body.sidebar-collapsed .main-content { margin-left: 56px; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right { display: flex; align-items: center; gap: 16px; }
|
||||||
|
|
||||||
|
.live-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
background: rgba(91,156,246,0.15);
|
||||||
|
color: var(--info);
|
||||||
|
border: 1px solid rgba(91,156,246,0.3);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content { padding: 24px; flex: 1; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.card-body { padding: 18px; }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 18px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.stat-card:hover { border-color: var(--border-light); }
|
||||||
|
.stat-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
.stat-card.up::before { background: var(--up); }
|
||||||
|
.stat-card.down::before { background: var(--down); }
|
||||||
|
.stat-card.warn::before { background: var(--warn); }
|
||||||
|
.stat-card.info::before { background: var(--info); }
|
||||||
|
.stat-card.neutral::before { background: var(--border-light); }
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.stat-card.up .stat-value { color: var(--up); }
|
||||||
|
.stat-card.down .stat-value { color: var(--down); }
|
||||||
|
.stat-card.warn .stat-value { color: var(--warn); }
|
||||||
|
.stat-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge-up { background: rgba(0,212,170,0.12); color: var(--up); border: 1px solid rgba(0,212,170,0.25); }
|
||||||
|
.badge-down { background: rgba(255,75,110,0.12); color: var(--down); border: 1px solid rgba(255,75,110,0.25); }
|
||||||
|
.badge-warn { background: rgba(255,181,71,0.12); color: var(--warn); border: 1px solid rgba(255,181,71,0.25); }
|
||||||
|
.badge-unknown { background: rgba(92,96,128,0.12); color: var(--unknown); border: 1px solid rgba(92,96,128,0.25); }
|
||||||
|
.badge-info { background: rgba(91,156,246,0.12); color: var(--info); border: 1px solid rgba(91,156,246,0.25); }
|
||||||
|
|
||||||
|
.badge::before { content: '●'; font-size: 6px; }
|
||||||
|
|
||||||
|
/* Monitoring table */
|
||||||
|
.monitor-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.monitor-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 16px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.monitor-row:hover { border-color: var(--border-light); }
|
||||||
|
.monitor-name { font-weight: 500; color: var(--text-primary); font-size: 13px; }
|
||||||
|
.monitor-host { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
|
||||||
|
.monitor-type { font-family: var(--font-mono); font-size: 10px; color: var(--text-secondary); }
|
||||||
|
.monitor-latency { font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary); width: 70px; text-align: right; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.data-table tr:last-child td { border-bottom: none; }
|
||||||
|
.data-table tr:hover td { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
/* Form crap */
|
||||||
|
.form-group { margin-bottom: 16px; }
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.form-control:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||||
|
.form-control::placeholder { color: var(--text-muted); }
|
||||||
|
select.form-control { cursor: pointer; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 700; }
|
||||||
|
.btn-primary:hover { background: #00f0c0; }
|
||||||
|
.btn-outline { background: transparent; color: var(--text-secondary); border-color: var(--border); }
|
||||||
|
.btn-outline:hover { border-color: var(--border-light); color: var(--text-primary); background: var(--bg-elevated); }
|
||||||
|
.btn-danger { background: rgba(255,75,110,0.1); color: var(--down); border-color: rgba(255,75,110,0.3); }
|
||||||
|
.btn-danger:hover { background: rgba(255,75,110,0.2); }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 10px; }
|
||||||
|
|
||||||
|
/* Log viewer */
|
||||||
|
.log-stream {
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 600px;
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 60px 130px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||||
|
align-items: start;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.log-entry:hover { background: var(--bg-elevated); }
|
||||||
|
.log-ts { color: var(--text-muted); font-size: 11px; }
|
||||||
|
.log-level { font-weight: 700; font-size: 10px; letter-spacing: 0.5px; }
|
||||||
|
.log-level.error, .log-level.critical { color: var(--down); }
|
||||||
|
.log-level.warn { color: var(--warn); }
|
||||||
|
.log-level.info { color: var(--info); }
|
||||||
|
.log-level.debug { color: var(--text-muted); }
|
||||||
|
.log-source { color: var(--text-secondary); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.log-message { color: var(--text-primary); word-break: break-word; }
|
||||||
|
|
||||||
|
/* certificate table */
|
||||||
|
.cert-expiry-ok { color: var(--up); }
|
||||||
|
.cert-expiry-warn { color: var(--warn); }
|
||||||
|
.cert-expiry-critical { color: var(--down); }
|
||||||
|
.cert-expiry-expired { color: var(--down); font-weight: 700; }
|
||||||
|
|
||||||
|
/* section header */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uptime */
|
||||||
|
.uptime-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.uptime-block {
|
||||||
|
flex: 1;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 4px;
|
||||||
|
}
|
||||||
|
.uptime-block.up { background: var(--up); opacity: 0.7; }
|
||||||
|
.uptime-block.down { background: var(--down); }
|
||||||
|
.uptime-block.warn { background: var(--warn); opacity: 0.7; }
|
||||||
|
.uptime-block.unknown { background: var(--bg-elevated); }
|
||||||
|
|
||||||
|
/* Layouts */
|
||||||
|
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
||||||
|
|
||||||
|
/* alerts */
|
||||||
|
.alert {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.alert-success { background: rgba(0,212,170,0.1); border: 1px solid rgba(0,212,170,0.25); color: var(--up); }
|
||||||
|
.alert-danger { background: rgba(255,75,110,0.1); border: 1px solid rgba(255,75,110,0.25); color: var(--down); }
|
||||||
|
.alert-warn, .alert-warning { background: rgba(255,181,71,0.1); border: 1px solid rgba(255,181,71,0.25); color: var(--warn); }
|
||||||
|
.alert-info { background: rgba(91,156,246,0.1); border: 1px solid rgba(91,156,246,0.25); color: var(--info); }
|
||||||
|
|
||||||
|
/* Filtering */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.filter-bar .form-control { max-width: 200px; }
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 40px;
|
||||||
|
width: 380px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.login-sub { color: var(--text-muted); font-size: 13px; margin-bottom: 28px; }
|
||||||
|
|
||||||
|
/* Toggle crap */
|
||||||
|
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; }
|
||||||
|
.toggle input { display: none; }
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
left: 2px; top: 2px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.toggle input:checked + .toggle-slider { background: var(--accent-dim); border-color: var(--accent); }
|
||||||
|
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: var(--accent); }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.empty-state-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.3; }
|
||||||
|
.empty-state-text { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.5px; }
|
||||||
|
|
||||||
|
/* Mobile crap */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar { width: 56px; }
|
||||||
|
.sidebar .logo-text, .sidebar .nav-label,
|
||||||
|
.sidebar .nav-text,
|
||||||
|
.sidebar .sidebar-footer { display: none; }
|
||||||
|
.sidebar .nav-item { justify-content: center; padding: 10px 0; gap: 0; }
|
||||||
|
.sidebar .sidebar-header { justify-content: center; padding: 18px 0 14px; }
|
||||||
|
.main-content { margin-left: 56px; }
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.two-col, .three-col { grid-template-columns: 1fr; }
|
||||||
|
.monitor-row { grid-template-columns: 1fr auto; }
|
||||||
|
.monitor-latency, .monitor-type { display: none; }
|
||||||
|
.log-entry { grid-template-columns: 100px 50px 1fr; }
|
||||||
|
.log-source { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg-base); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
||||||
|
|
||||||
|
/* Other crap */
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-up { color: var(--up); }
|
||||||
|
.text-down { color: var(--down); }
|
||||||
|
.text-warn { color: var(--warn); }
|
||||||
|
.mt-1 { margin-top: 8px; } .mt-2 { margin-top: 16px; } .mt-3 { margin-top: 24px; }
|
||||||
|
.mb-1 { margin-bottom: 8px; } .mb-2 { margin-bottom: 16px; } .mb-3 { margin-bottom: 24px; }
|
||||||
|
.flex { display: flex; } .gap-2 { gap: 8px; } .gap-3 { gap: 12px; }
|
||||||
|
.align-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
|
||||||
|
/* MODAL */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
z-index: 200;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 500px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.modal-title { font-family: var(--font-mono); font-size: 12px; letter-spacing: 1px; color: var(--text-primary); text-transform: uppercase; }
|
||||||
|
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 18px; }
|
||||||
|
.modal-close:hover { color: var(--text-primary); }
|
||||||
|
.modal-body { padding: 20px; }
|
||||||
|
.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* Latency sparkline canvas inside monitor rows */
|
||||||
|
.sparkline-wrap { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.sparkline-canvas { display: block; }
|
||||||
|
|
||||||
|
/* Response-time colour helpers */
|
||||||
|
.rt-good { color: var(--up); }
|
||||||
|
.rt-ok { color: var(--warn); }
|
||||||
|
.rt-slow { color: var(--down); }
|
||||||
|
|
||||||
|
/* Log stream toolbar */
|
||||||
|
.log-toolbar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
}
|
||||||
|
.log-stream { border-radius: 0 0 var(--radius) var(--radius); }
|
||||||
|
|
||||||
|
/* Highlight search hits */
|
||||||
|
mark {
|
||||||
|
background: rgba(255,181,71,0.25);
|
||||||
|
color: var(--warn);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast improvements */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed; bottom: 20px; right: 20px;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 240px;
|
||||||
|
animation: slideIn 0.25s ease;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.toast.toast-success { border-color: rgba(0,212,170,0.4); color: var(--up); }
|
||||||
|
.toast.toast-error { border-color: rgba(255,75,110,0.4); color: var(--down); }
|
||||||
|
.toast.toast-warn { border-color: rgba(255,181,71,0.4); color: var(--warn); }
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart wrapper */
|
||||||
|
.chart-wrap {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.chart-wrap canvas { max-height: 220px; }
|
||||||
|
|
||||||
|
/* Keyboard shortcut hints */
|
||||||
|
.kbd {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refresh countdown ring */
|
||||||
|
.refresh-ring {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
position: relative; display: inline-flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.refresh-ring svg { position: absolute; top: 0; left: 0; transform: rotate(-90deg); }
|
||||||
|
.refresh-ring-track { fill: none; stroke: var(--border); stroke-width: 2; }
|
||||||
|
.refresh-ring-fill { fill: none; stroke: var(--accent); stroke-width: 2;
|
||||||
|
stroke-dasharray: 50.27; stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 1s linear; }
|
||||||
|
.refresh-ring-label { font-family: var(--font-mono); font-size: 9px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Incident timeline */
|
||||||
|
.incident-timeline { border-left: 2px solid var(--border); padding-left: 16px; }
|
||||||
|
.incident-timeline-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
margin-left: -20px;
|
||||||
|
margin-right: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini tag chips */
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live log badge */
|
||||||
|
.live-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(0,212,170,0.1);
|
||||||
|
border: 1px solid rgba(0,212,170,0.2);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
317
EonaCat.LogStack.Status/wwwroot/js/site.js
Normal file
317
EonaCat.LogStack.Status/wwwroot/js/site.js
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const el = document.getElementById('clock');
|
||||||
|
if (el) el.textContent = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||||||
|
}
|
||||||
|
updateClock();
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const main = document.getElementById('main-content');
|
||||||
|
const btn = sidebar && sidebar.querySelector('.sidebar-toggle');
|
||||||
|
const collapsed = sidebar.classList.toggle('collapsed');
|
||||||
|
|
||||||
|
// Sync main content margin
|
||||||
|
if (main) main.style.marginLeft = collapsed ? '56px' : '220px';
|
||||||
|
|
||||||
|
// Flip the arrow glyph
|
||||||
|
if (btn) btn.textContent = collapsed ? '⟩' : '⟨';
|
||||||
|
|
||||||
|
// Persist preference
|
||||||
|
try { localStorage.setItem('sidebarCollapsed', collapsed ? '1' : '0'); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore sidebar state on load
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
const pref = localStorage.getItem('sidebarCollapsed');
|
||||||
|
if (pref === '1') {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const main = document.getElementById('main-content');
|
||||||
|
const btn = sidebar && sidebar.querySelector('.sidebar-toggle');
|
||||||
|
if (sidebar) { sidebar.classList.add('collapsed'); }
|
||||||
|
if (main) main.style.marginLeft = '56px';
|
||||||
|
if (btn) btn.textContent = '⟩';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function openModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.classList.add('open');
|
||||||
|
}
|
||||||
|
function closeModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.classList.remove('open');
|
||||||
|
}
|
||||||
|
// Close modal on overlay click
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target.classList.contains('modal-overlay')) {
|
||||||
|
e.target.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Close modal on Escape
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showToast(msg, type = 'info', durationMs = 4000) {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
if (!container) return;
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
const icons = { success: '✓', error: '✕', warn: '⚠', info: 'ℹ' };
|
||||||
|
toast.innerHTML = `<span>${icons[type] ?? 'ℹ'}</span><span>${msg}</span>`;
|
||||||
|
container.appendChild(toast);
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transition = 'opacity 0.3s';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_REFRESH_SECS = 30;
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const pageEl = document.querySelector('[data-autorefresh]');
|
||||||
|
if (!pageEl) return;
|
||||||
|
|
||||||
|
const ringEl = document.getElementById('refresh-ring');
|
||||||
|
const fillEl = document.getElementById('refresh-ring-fill');
|
||||||
|
const labelEl = document.getElementById('refresh-ring-label');
|
||||||
|
const circumference = 50.27; // 2π × 8
|
||||||
|
|
||||||
|
if (ringEl) ringEl.style.display = 'inline-flex';
|
||||||
|
|
||||||
|
let remaining = AUTO_REFRESH_SECS;
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
remaining--;
|
||||||
|
if (labelEl) labelEl.textContent = remaining;
|
||||||
|
if (fillEl) {
|
||||||
|
const offset = circumference * (1 - remaining / AUTO_REFRESH_SECS);
|
||||||
|
fillEl.style.strokeDashoffset = offset;
|
||||||
|
}
|
||||||
|
if (remaining <= 0) location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelEl) labelEl.textContent = remaining;
|
||||||
|
if (fillEl) fillEl.style.strokeDashoffset = 0;
|
||||||
|
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
fetch('/api/status/summary')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const dot = document.getElementById('overall-dot');
|
||||||
|
if (!dot) return;
|
||||||
|
if (d.downCount > 0) dot.style.background = 'var(--down)';
|
||||||
|
else if (d.warnCount > 0) dot.style.background = 'var(--warn)';
|
||||||
|
else if (d.upCount > 0) dot.style.background = 'var(--up)';
|
||||||
|
else dot.style.background = 'var(--unknown)';
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const input = document.getElementById('log-search');
|
||||||
|
if (!input) return;
|
||||||
|
let timer;
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const form = input.closest('form');
|
||||||
|
if (form) form.submit();
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function scrollLogsToBottom() {
|
||||||
|
const el = document.getElementById('log-stream');
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', scrollLogsToBottom);
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const term = params.get('Search') || params.get('search');
|
||||||
|
if (!term || term.length < 2) return;
|
||||||
|
|
||||||
|
const stream = document.getElementById('log-stream');
|
||||||
|
if (!stream) return;
|
||||||
|
|
||||||
|
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(`(${escaped})`, 'gi');
|
||||||
|
|
||||||
|
stream.querySelectorAll('.log-message').forEach(el => {
|
||||||
|
el.innerHTML = el.textContent.replace(regex, '<mark>$1</mark>');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const stream = document.getElementById('log-stream');
|
||||||
|
const liveCountEl = document.getElementById('live-count');
|
||||||
|
if (!stream || !liveCountEl) return;
|
||||||
|
|
||||||
|
let lastId = parseInt(stream.dataset.lastId || '0', 10);
|
||||||
|
|
||||||
|
async function pollLogs() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/logs?page=1&pageSize=20`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const newEntries = (data.entries || []).filter(e => e.id > lastId);
|
||||||
|
if (newEntries.length === 0) return;
|
||||||
|
|
||||||
|
newEntries.reverse().forEach(e => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'log-entry';
|
||||||
|
div.innerHTML = `
|
||||||
|
<span class="log-ts">${e.timestamp.replace('T', ' ').substring(0, 19)}</span>
|
||||||
|
<span class="log-level ${e.level}">${e.level.toUpperCase()}</span>
|
||||||
|
<span class="log-source">${e.source || ''}</span>
|
||||||
|
<span class="log-message">${escHtml(e.message)}</span>`;
|
||||||
|
stream.prepend(div);
|
||||||
|
lastId = Math.max(lastId, e.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (liveCountEl) liveCountEl.textContent = `+${newEntries.length} new`;
|
||||||
|
setTimeout(() => { if (liveCountEl) liveCountEl.textContent = ''; }, 2500);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(pollLogs, 5000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
// Don't fire inside inputs / textareas
|
||||||
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'b': toggleSidebar(); break; // B = toggle sidebar
|
||||||
|
case 'g':
|
||||||
|
if (e.shiftKey) { window.location.href = '/'; } // G = dashboard
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
if (e.shiftKey) { window.location.href = '/logs'; } // L = logs
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
if (e.shiftKey) { window.location.href = '/monitors';} // M = monitors
|
||||||
|
break;
|
||||||
|
case 'r': location.reload(); break; // R = refresh
|
||||||
|
case '/': { // / = focus search
|
||||||
|
const s = document.getElementById('log-search');
|
||||||
|
if (s) { e.preventDefault(); s.focus(); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyText(text, label) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showToast(`${label || 'Copied'} to clipboard`, 'success', 2000);
|
||||||
|
}).catch(() => showToast('Copy failed', 'error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(name) {
|
||||||
|
return confirm(`Delete "${name}"? This cannot be undone.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSparkline(canvas, values, color) {
|
||||||
|
if (!canvas || !values || values.length === 0) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const w = canvas.width, h = canvas.height;
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const min = 0;
|
||||||
|
const step = w / (values.length - 1 || 1);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.strokeStyle = color || 'var(--accent)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
values.forEach((v, i) => {
|
||||||
|
const x = i * step;
|
||||||
|
const y = h - ((v - min) / (max - min)) * h * 0.9 - h * 0.05;
|
||||||
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMs(ms) {
|
||||||
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUptimeRing(pct, size = 36) {
|
||||||
|
const r = (size / 2) - 3;
|
||||||
|
const circ = 2 * Math.PI * r;
|
||||||
|
const fill = circ * (pct / 100);
|
||||||
|
const color = pct >= 99 ? 'var(--up)' : pct >= 95 ? 'var(--warn)' : 'var(--down)';
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="transform:rotate(-90deg)">
|
||||||
|
<circle cx="${size/2}" cy="${size/2}" r="${r}" fill="none" stroke="var(--border)" stroke-width="3"/>
|
||||||
|
<circle cx="${size/2}" cy="${size/2}" r="${r}" fill="none" stroke="${color}" stroke-width="3"
|
||||||
|
stroke-dasharray="${fill} ${circ}" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span style="position:absolute;font-size:9px;font-family:var(--font-mono);color:${color}">${Math.round(pct)}%</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogStatsChart(canvasId) {
|
||||||
|
const canvas = document.getElementById(canvasId);
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/logs/stats?hours=24');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const buckets = await res.json();
|
||||||
|
if (!buckets.length) return;
|
||||||
|
|
||||||
|
const labels = buckets.map(b => {
|
||||||
|
const d = new Date(b.bucketStart);
|
||||||
|
return `${String(d.getHours()).padStart(2,'0')}:00`;
|
||||||
|
});
|
||||||
|
const totals = buckets.map(b => b.total);
|
||||||
|
const errors = buckets.map(b => b.errors);
|
||||||
|
const warnings = buckets.map(b => b.warnings);
|
||||||
|
|
||||||
|
// Use Chart.js if available, otherwise draw with canvas API
|
||||||
|
if (window.Chart) {
|
||||||
|
new window.Chart(canvas, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Total', data: totals, backgroundColor: 'rgba(91,156,246,0.25)', borderColor: 'rgba(91,156,246,0.7)', borderWidth: 1 },
|
||||||
|
{ label: 'Errors', data: errors, backgroundColor: 'rgba(255,75,110,0.25)', borderColor: 'rgba(255,75,110,0.7)', borderWidth: 1 },
|
||||||
|
{ label: 'Warnings', data: warnings, backgroundColor: 'rgba(255,181,71,0.2)', borderColor: 'rgba(255,181,71,0.6)', borderWidth: 1 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { labels: { color: '#8b8fa8', font: { family: 'Space Mono', size: 10 } } } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#4e5268', font: { family: 'Space Mono', size: 9 } }, grid: { color: 'rgba(255,255,255,0.04)' } },
|
||||||
|
y: { ticks: { color: '#4e5268', font: { family: 'Space Mono', size: 9 } }, grid: { color: 'rgba(255,255,255,0.04)' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: simple bar sparkline
|
||||||
|
drawSparkline(canvas, totals, 'rgba(91,156,246,0.7)');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
|
||||||
|
<Title>EonaCat.LogStack.Flows.WindowsEventLog</Title>
|
||||||
|
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||||
|
<PackageProjectUrl>https://git.saey.me/EonaCat/EonaCat.LogStack</PackageProjectUrl>
|
||||||
|
<PackageIcon>icon.png</PackageIcon>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.LogStack</RepositoryUrl>
|
||||||
|
<PackageTags>EonaCat;Windows;EventLog;Flow</PackageTags>
|
||||||
|
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||||
|
<Version>0.0.1</Version>
|
||||||
|
<Company>EonaCat (Jeroen Saey)</Company>
|
||||||
|
<PackageId>EonaCat.LogStack.Flows.WindowsEventLog</PackageId>
|
||||||
|
<Authors>EonaCat (Jeroen Saey)</Authors>
|
||||||
|
<Description>EonaCat Windows EventLog Flow for LogStack</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\EonaCat.LogStack\icon.png">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
<None Include="..\LICENSE">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
<None Include="..\README.md">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Diagnostics.EventLog" Version="10.0.3" />
|
||||||
|
<PackageReference Include="System.Threading.AccessControl" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EonaCat.LogStack\EonaCat.LogStack.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
32
EonaCat.LogStack.WindowsEventLogFlow/LogBuilder.cs
Normal file
32
EonaCat.LogStack.WindowsEventLogFlow/LogBuilder.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using EonaCat.LogStack.Configuration;
|
||||||
|
using EonaCat.LogStack.Flows;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Flows.WindowsEventLog
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public static class EonaCatLogStackExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Write to Windows Event log
|
||||||
|
/// </summary>
|
||||||
|
public static LogBuilder WriteToWindowsEventLog(this LogBuilder logBuilder,
|
||||||
|
string sourceName = "EonaCatLogStack",
|
||||||
|
string logName = "Application",
|
||||||
|
int maxMessageLength = 30000,
|
||||||
|
Core.LogLevel minimumLevel = Core.LogLevel.Warning)
|
||||||
|
{
|
||||||
|
logBuilder.AddFlow(new WindowsEventLogFlow(
|
||||||
|
sourceName,
|
||||||
|
logName,
|
||||||
|
maxMessageLength,
|
||||||
|
minimumLevel));
|
||||||
|
WindowsEventLogFlow.EnsureSourceExists();
|
||||||
|
return logBuilder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
256
EonaCat.LogStack.WindowsEventLogFlow/WindowsEventLogFlow.cs
Normal file
256
EonaCat.LogStack.WindowsEventLogFlow/WindowsEventLogFlow.cs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Flows
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes log events to the Windows Event Log.
|
||||||
|
///
|
||||||
|
/// Requires the source to be registered before first use.
|
||||||
|
/// Call <see cref="EnsureSourceExists"/> once during application setup
|
||||||
|
/// (requires elevated privileges the first time).
|
||||||
|
///
|
||||||
|
/// .NET 4.8.1 compatible. Silently no-ops on non-Windows platforms.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WindowsEventLogFlow : FlowBase
|
||||||
|
{
|
||||||
|
private readonly string _sourceName;
|
||||||
|
private readonly string _logName;
|
||||||
|
private readonly int _maxMessageLength;
|
||||||
|
private System.Diagnostics.EventLog _eventLog;
|
||||||
|
private readonly object _initLock = new object();
|
||||||
|
private volatile bool _initialized;
|
||||||
|
|
||||||
|
public WindowsEventLogFlow(
|
||||||
|
string sourceName = "EonaCatLogStack",
|
||||||
|
string logName = "Application",
|
||||||
|
int maxMessageLength = 30000,
|
||||||
|
LogLevel minimumLevel = LogLevel.Warning)
|
||||||
|
: base("WindowsEventLog:" + sourceName, minimumLevel)
|
||||||
|
{
|
||||||
|
if (sourceName == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException("sourceName");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logName == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException("logName");
|
||||||
|
}
|
||||||
|
|
||||||
|
_sourceName = sourceName;
|
||||||
|
_logName = logName;
|
||||||
|
_maxMessageLength = maxMessageLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the event source with the OS. Must be called with admin rights
|
||||||
|
/// the first time on each machine. Safe to call repeatedly.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsureSourceExists(string sourceName = "EonaCatLogStack",
|
||||||
|
string logName = "Application")
|
||||||
|
{
|
||||||
|
if (!IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!System.Diagnostics.EventLog.SourceExists(sourceName))
|
||||||
|
{
|
||||||
|
System.Diagnostics.EventLog.CreateEventSource(sourceName, logName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[WindowsEventLogFlow] Cannot create source: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<WriteResult> BlastAsync(
|
||||||
|
LogEvent logEvent,
|
||||||
|
CancellationToken cancellationToken = default(CancellationToken))
|
||||||
|
{
|
||||||
|
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
|
||||||
|
{
|
||||||
|
return Task.FromResult(WriteResult.LevelFiltered);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsWindows())
|
||||||
|
{
|
||||||
|
return Task.FromResult(WriteResult.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureInitialized();
|
||||||
|
if (_eventLog == null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(WriteResult.Dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string msg = BuildMessage(logEvent);
|
||||||
|
if (msg.Length > _maxMessageLength)
|
||||||
|
{
|
||||||
|
msg = msg.Substring(0, _maxMessageLength) + "... [truncated]";
|
||||||
|
}
|
||||||
|
|
||||||
|
_eventLog.WriteEntry(msg, ToEventType(logEvent.Level), ToEventId(logEvent.Level));
|
||||||
|
Interlocked.Increment(ref BlastedCount);
|
||||||
|
return Task.FromResult(WriteResult.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[WindowsEventLogFlow] Write error: " + ex.Message);
|
||||||
|
Interlocked.Increment(ref DroppedCount);
|
||||||
|
return Task.FromResult(WriteResult.Dropped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<WriteResult> BlastBatchAsync(
|
||||||
|
ReadOnlyMemory<LogEvent> logEvents,
|
||||||
|
CancellationToken cancellationToken = default(CancellationToken))
|
||||||
|
{
|
||||||
|
if (!IsEnabled)
|
||||||
|
{
|
||||||
|
return Task.FromResult(WriteResult.FlowDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (LogEvent e in logEvents.ToArray())
|
||||||
|
{
|
||||||
|
if (IsLogLevelEnabled(e))
|
||||||
|
{
|
||||||
|
BlastAsync(e, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(WriteResult.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken))
|
||||||
|
=> Task.FromResult(0);
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
IsEnabled = false;
|
||||||
|
if (_eventLog != null) { try { _eventLog.Dispose(); } catch { } }
|
||||||
|
await base.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureInitialized()
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_initLock)
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (System.Diagnostics.EventLog.SourceExists(_sourceName))
|
||||||
|
{
|
||||||
|
_eventLog = new System.Diagnostics.EventLog(_logName) { Source = _sourceName };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[WindowsEventLogFlow] Source '" + _sourceName +
|
||||||
|
"' not registered. Call EnsureSourceExists() with admin rights.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[WindowsEventLogFlow] Init error: " + ex.Message);
|
||||||
|
}
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildMessage(LogEvent log)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder(512);
|
||||||
|
sb.Append("Level: ").AppendLine(LevelString(log.Level));
|
||||||
|
sb.Append("Category: ").AppendLine(log.Category ?? string.Empty);
|
||||||
|
sb.Append("Time: ").AppendLine(LogEvent.GetDateTime(log.Timestamp).ToString("O"));
|
||||||
|
sb.Append("Message: ").AppendLine(log.Message.Length > 0 ? log.Message.ToString() : string.Empty);
|
||||||
|
|
||||||
|
if (log.Exception != null)
|
||||||
|
{
|
||||||
|
sb.Append("Exception: ").AppendLine(log.Exception.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.Properties.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("Properties:");
|
||||||
|
foreach (var kv in log.Properties.ToArray())
|
||||||
|
{
|
||||||
|
sb.Append(" ").Append(kv.Key).Append(" = ")
|
||||||
|
.AppendLine(kv.Value != null ? kv.Value.ToString() : "null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static System.Diagnostics.EventLogEntryType ToEventType(LogLevel level)
|
||||||
|
{
|
||||||
|
switch (level)
|
||||||
|
{
|
||||||
|
case LogLevel.Warning: return System.Diagnostics.EventLogEntryType.Warning;
|
||||||
|
case LogLevel.Error:
|
||||||
|
case LogLevel.Critical: return System.Diagnostics.EventLogEntryType.Error;
|
||||||
|
default: return System.Diagnostics.EventLogEntryType.Information;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ToEventId(LogLevel level)
|
||||||
|
{
|
||||||
|
// Stable event IDs per level for easy filtering in Event Viewer
|
||||||
|
switch (level)
|
||||||
|
{
|
||||||
|
case LogLevel.Trace: return 1000;
|
||||||
|
case LogLevel.Debug: return 1001;
|
||||||
|
case LogLevel.Information: return 1002;
|
||||||
|
case LogLevel.Warning: return 1003;
|
||||||
|
case LogLevel.Error: return 1004;
|
||||||
|
case LogLevel.Critical: return 1005;
|
||||||
|
default: return 1999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LevelString(LogLevel level)
|
||||||
|
{
|
||||||
|
switch (level)
|
||||||
|
{
|
||||||
|
case LogLevel.Trace: return "TRACE";
|
||||||
|
case LogLevel.Debug: return "DEBUG";
|
||||||
|
case LogLevel.Information: return "INFO";
|
||||||
|
case LogLevel.Warning: return "WARN";
|
||||||
|
case LogLevel.Error: return "ERROR";
|
||||||
|
case LogLevel.Critical: return "CRITICAL";
|
||||||
|
default: return level.ToString().ToUpperInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWindows()
|
||||||
|
{
|
||||||
|
#if NET48 || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET481
|
||||||
|
return true;
|
||||||
|
#else
|
||||||
|
return System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
EonaCat.LogStack.sln
Normal file
140
EonaCat.LogStack.sln
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 18
|
||||||
|
VisualStudioVersion = 18.1.11312.151
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EonaCat.LogStack", "EonaCat.LogStack\EonaCat.LogStack.csproj", "{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.LogClient", "EonaCat.LogStack.LogClient\EonaCat.LogStack.LogClient.csproj", "{D1025803-9588-46EB-8771-88E25209B780}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleApp1", "ConsoleApp1\ConsoleApp1.csproj", "{C9F66B51-6661-467A-9E22-E0E578EB76A1}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.Flows.WindowsEventLog", "EonaCat.LogStack.WindowsEventLogFlow\EonaCat.LogStack.Flows.WindowsEventLog.csproj", "{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.OpenTelemetryFlow", "EonaCat.LogStack.OpenTelemetryFlow\EonaCat.LogStack.OpenTelemetryFlow.csproj", "{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.SerilogTest", "EonaCat.LogStack.SerilogTest\EonaCat.LogStack.SerilogTest.csproj", "{F360998D-46E0-5A88-BA3E-47A4162C8EB4}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.Test.Web", "Testers\EonaCat.LogStack.Test.Web\EonaCat.LogStack.Test.Web.csproj", "{9240A706-1852-C232-FB58-E54A5A528135}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{85A2505C-8976-4046-963B-D7B63EF81E47}"
|
||||||
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
README.md = README.md
|
||||||
|
EndProjectSection
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EonaCat.LogStack.Status", "EonaCat.LogStack.Status\EonaCat.LogStack.Status.csproj", "{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{DCD1D32E-0F24-4D0F-A6B6-59941C0F9BB7}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{D1025803-9588-46EB-8771-88E25209B780}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{C9F66B51-6661-467A-9E22-E0E578EB76A1}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{F5EFDDEA-C4A4-4AE7-B853-DF91062D4558}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{CBF0AF0C-CF27-7D45-BCC2-DA7B7A40539C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{F360998D-46E0-5A88-BA3E-47A4162C8EB4}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{9240A706-1852-C232-FB58-E54A5A528135}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{34C47EBC-BB59-0A5C-9D93-416E4F3D7816}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {B01183F3-D85E-45FB-9749-DA281F465A0F}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
11
EonaCat.LogStack/Constants.cs
Normal file
11
EonaCat.LogStack/Constants.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace EonaCat.LogStack;
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public static class Constants
|
||||||
|
{
|
||||||
|
public static class DateTimeFormats
|
||||||
|
{
|
||||||
|
public static string LOGGING { get; set; } = "yyyy-MM-dd HH:mm:ss.fff";
|
||||||
|
}
|
||||||
|
}
|
||||||
27
EonaCat.LogStack/DllInfo.cs
Normal file
27
EonaCat.LogStack/DllInfo.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using EonaCat.Versioning.Helpers;
|
||||||
|
using System.Reflection;
|
||||||
|
namespace EonaCat.LogStack;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public static class DllInfo
|
||||||
|
{
|
||||||
|
public const string NAME = "EonaCatLogStack";
|
||||||
|
public const string VERSION = "0.0.1";
|
||||||
|
|
||||||
|
static DllInfo()
|
||||||
|
{
|
||||||
|
var isDebug = false;
|
||||||
|
#if DEBUG
|
||||||
|
isDebug = true;
|
||||||
|
#endif
|
||||||
|
VersionName = isDebug ? "DEBUG" : "RELEASE";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string VersionName { get; }
|
||||||
|
|
||||||
|
public static string ApplicationName { get; set; } = "EonaCatLogStack";
|
||||||
|
|
||||||
|
public static string EonaCatVersion => VersionHelper.GetEonaCatVersion(Assembly.GetExecutingAssembly());
|
||||||
|
}
|
||||||
199
EonaCat.LogStack/Enums.cs
Normal file
199
EonaCat.LogStack/Enums.cs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack;
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public static class LogTypeConverter
|
||||||
|
{
|
||||||
|
public static Core.LogLevel FromLogLevel(this LogLevel logLevel)
|
||||||
|
{
|
||||||
|
switch (logLevel)
|
||||||
|
{
|
||||||
|
case LogLevel.None:
|
||||||
|
return Core.LogLevel.None;
|
||||||
|
case LogLevel.Error:
|
||||||
|
return Core.LogLevel.Error;
|
||||||
|
case LogLevel.Debug:
|
||||||
|
return Core.LogLevel.Debug;
|
||||||
|
case LogLevel.Critical:
|
||||||
|
return Core.LogLevel.Critical;
|
||||||
|
case LogLevel.Warning:
|
||||||
|
return Core.LogLevel.Warning;
|
||||||
|
case LogLevel.Trace:
|
||||||
|
return Core.LogLevel.Trace;
|
||||||
|
case LogLevel.Information:
|
||||||
|
return Core.LogLevel.Information;
|
||||||
|
default:
|
||||||
|
return Core.LogLevel.Trace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LogLevel ToLogLevel(this Core.LogLevel logLevel)
|
||||||
|
{
|
||||||
|
switch (logLevel)
|
||||||
|
{
|
||||||
|
case Core.LogLevel.None:
|
||||||
|
return LogLevel.None;
|
||||||
|
case Core.LogLevel.Error:
|
||||||
|
return LogLevel.Error;
|
||||||
|
case Core.LogLevel.Debug:
|
||||||
|
return LogLevel.Debug;
|
||||||
|
case Core.LogLevel.Critical:
|
||||||
|
return LogLevel.Critical;
|
||||||
|
case Core.LogLevel.Warning:
|
||||||
|
return LogLevel.Warning;
|
||||||
|
case Core.LogLevel.Trace:
|
||||||
|
return LogLevel.Trace;
|
||||||
|
case Core.LogLevel.Information:
|
||||||
|
return LogLevel.Information;
|
||||||
|
default:
|
||||||
|
return LogLevel.Information;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToString(this Core.LogLevel logLevel)
|
||||||
|
{
|
||||||
|
switch (logLevel)
|
||||||
|
{
|
||||||
|
case Core.LogLevel.None:
|
||||||
|
return "NONE";
|
||||||
|
case Core.LogLevel.Error:
|
||||||
|
return "ERROR";
|
||||||
|
case Core.LogLevel.Debug:
|
||||||
|
return "DEBUG";
|
||||||
|
case Core.LogLevel.Critical:
|
||||||
|
return "CRITICAL";
|
||||||
|
case Core.LogLevel.Warning:
|
||||||
|
return "WARNING";
|
||||||
|
case Core.LogLevel.Trace:
|
||||||
|
return "TRACE";
|
||||||
|
case Core.LogLevel.Information:
|
||||||
|
return "INFO";
|
||||||
|
default:
|
||||||
|
return "INFO";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Core.LogLevel FromSeverity(this ESeverity logLevel)
|
||||||
|
{
|
||||||
|
switch (logLevel)
|
||||||
|
{
|
||||||
|
case ESeverity.Debug:
|
||||||
|
return Core.LogLevel.Debug;
|
||||||
|
case ESeverity.Warn:
|
||||||
|
return Core.LogLevel.Warning;
|
||||||
|
case ESeverity.Emergency:
|
||||||
|
return Core.LogLevel.Trace;
|
||||||
|
case ESeverity.Critical:
|
||||||
|
return Core.LogLevel.Critical;
|
||||||
|
case ESeverity.Error:
|
||||||
|
return Core.LogLevel.Error;
|
||||||
|
default:
|
||||||
|
return Core.LogLevel.Information;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int ToGrayLogLevel(this Core.LogLevel logLevel)
|
||||||
|
{
|
||||||
|
// Loglevel to GELF format
|
||||||
|
switch (logLevel.ToString())
|
||||||
|
{
|
||||||
|
case "TRACE": return 7;
|
||||||
|
case "DEBUG": return 7;
|
||||||
|
case "INFO": return 6;
|
||||||
|
case "WARNING": return 4;
|
||||||
|
case "ERROR": return 3;
|
||||||
|
case "CRITICAL": return 2;
|
||||||
|
default: return 6; // Default to INFO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ESeverity ToSeverity(this Core.LogLevel logLevel)
|
||||||
|
{
|
||||||
|
switch (logLevel)
|
||||||
|
{
|
||||||
|
case Core.LogLevel.Debug:
|
||||||
|
return ESeverity.Debug;
|
||||||
|
case Core.LogLevel.Warning:
|
||||||
|
return ESeverity.Warn;
|
||||||
|
case Core.LogLevel.Critical:
|
||||||
|
return ESeverity.Critical;
|
||||||
|
case Core.LogLevel.Trace:
|
||||||
|
return ESeverity.Emergency;
|
||||||
|
case Core.LogLevel.Error:
|
||||||
|
return ESeverity.Error;
|
||||||
|
default:
|
||||||
|
return ESeverity.Info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SyslogFacility
|
||||||
|
{
|
||||||
|
Kernel = 0, // 0 - Kernel messages
|
||||||
|
UserLevel = 1, // 1 - User-level messages
|
||||||
|
MailSystem = 2, // 2 - Mail system
|
||||||
|
Daemon = 3, // 3 - Daemon messages
|
||||||
|
Auth = 4, // 4 - Security/authorization messages
|
||||||
|
Syslog = 5, // 5 - Messages generated by syslogd
|
||||||
|
Lpr = 6, // 6 - Line printer subsystem
|
||||||
|
News = 7, // 7 - Network news subsystem
|
||||||
|
UUCP = 8, // 8 - UUCP subsystem
|
||||||
|
Clock = 9, // 9 - Clock daemon
|
||||||
|
AuthPriv = 10, // 10 - Security/authorization messages (privileged)
|
||||||
|
Ftp = 11, // 11 - FTP daemon
|
||||||
|
Ntp = 12, // 12 - NTP subsystem
|
||||||
|
Audit = 13, // 13 - Audit messages
|
||||||
|
Alert = 14, // 14 - Log alert messages
|
||||||
|
Cron = 15, // 15 - Cron daemon
|
||||||
|
Local0 = 16, // 16 - Local use 0 (custom usage)
|
||||||
|
Local1 = 17, // 17 - Local use 1 (custom usage)
|
||||||
|
Local2 = 18, // 18 - Local use 2 (custom usage)
|
||||||
|
Local3 = 19, // 19 - Local use 3 (custom usage)
|
||||||
|
Local4 = 20, // 20 - Local use 4 (custom usage)
|
||||||
|
Local5 = 21, // 21 - Local use 5 (custom usage)
|
||||||
|
Local6 = 22, // 22 - Local use 6 (custom usage)
|
||||||
|
Local7 = 23 // 23 - Local use 7 (custom usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Message severity.
|
||||||
|
/// </summary>
|
||||||
|
public enum ESeverity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Debug messages.
|
||||||
|
/// </summary>
|
||||||
|
Debug = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Informational messages.
|
||||||
|
/// </summary>
|
||||||
|
Info = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warning messages.
|
||||||
|
/// </summary>
|
||||||
|
Warn = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error messages.
|
||||||
|
/// </summary>
|
||||||
|
Error = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alert messages.
|
||||||
|
/// </summary>
|
||||||
|
Alert = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Critical messages.
|
||||||
|
/// </summary>
|
||||||
|
Critical = 5,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emergency messages.
|
||||||
|
/// </summary>
|
||||||
|
Emergency = 6
|
||||||
|
}
|
||||||
92
EonaCat.LogStack/EonaCat.LogStack.csproj
Normal file
92
EonaCat.LogStack/EonaCat.LogStack.csproj
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>.netstandard2.1; net8.0; net4.8;</TargetFrameworks>
|
||||||
|
<ApplicationIcon>icon.ico</ApplicationIcon>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Authors>EonaCat (Jeroen Saey)</Authors>
|
||||||
|
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
|
||||||
|
<Company>EonaCat (Jeroen Saey)</Company>
|
||||||
|
<PackageIcon>icon.png</PackageIcon>
|
||||||
|
<PackageProjectUrl>https://www.nuget.org/packages/EonaCat.LogStack/</PackageProjectUrl>
|
||||||
|
<Description>flow-based logging library for .NET, designed for zero-allocation logging paths and superior memory efficiency.
|
||||||
|
It features a rich fluent API for routing log events to dozens of destinations from console and file to Slack, Discord, Redis, Elasticsearch, and beyond.</Description>
|
||||||
|
<PackageReleaseNotes>Public release version</PackageReleaseNotes>
|
||||||
|
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||||
|
<PackageTags>EonaCat;Logger;EonaCatLogStack;Log;Writer;Flows;LogStack;Memory;Speed;Jeroen;Saey</PackageTags>
|
||||||
|
<PackageIconUrl />
|
||||||
|
<FileVersion>0.0.3</FileVersion>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||||
|
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||||
|
|
||||||
|
<PackageRequireLicenseAcceptance>True</PackageRequireLicenseAcceptance>
|
||||||
|
<Title>EonaCat.LogStack</Title>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<EVRevisionFormat>0.0.3+{chash:10}.{c:ymd}</EVRevisionFormat>
|
||||||
|
<EVDefault>true</EVDefault>
|
||||||
|
<EVInfo>true</EVInfo>
|
||||||
|
<EVTagMatch>v[0-9]*</EVTagMatch>
|
||||||
|
<EVRemoveTagV>true</EVRemoveTagV>
|
||||||
|
<EVVcs>git</EVVcs>
|
||||||
|
<EVCheckAllAttributes>true</EVCheckAllAttributes>
|
||||||
|
<EVShowRevision>true</EVShowRevision>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Version>0.0.3</Version>
|
||||||
|
<PackageId>EonaCat.LogStack</PackageId>
|
||||||
|
<Product>EonaCat.LogStack</Product>
|
||||||
|
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.LogStack</RepositoryUrl>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Target Name="EVPack" BeforeTargets="Pack">
|
||||||
|
<Message Text="EVPack: Forcing NuGet Version = $(GeneratedVersion)" Importance="High" />
|
||||||
|
<PropertyGroup>
|
||||||
|
<Version>$(GeneratedVersion)</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="icon.png" />
|
||||||
|
<None Include="..\LICENSE">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
<None Include="..\README.md">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
<None Include="icon.png">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>
|
||||||
|
</PackagePath>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="EonaCat.Json" Version="2.2.0" />
|
||||||
|
<PackageReference Include="EonaCat.Versioning" Version="1.2.8">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="EonaCat.Versioning.Helpers" Version="1.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.3" />
|
||||||
|
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||||
|
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="LICENSE.md">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
<None Update="README.md">
|
||||||
|
<Pack>True</Pack>
|
||||||
|
<PackagePath>\</PackagePath>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
291
EonaCat.LogStack/EonaCatLogger.cs
Normal file
291
EonaCat.LogStack/EonaCatLogger.cs
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
using EonaCat.LogStack.Boosters;
|
||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using EonaCat.LogStack.Flows;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// EonaCat logger with flow-based architecture, booster, and pre-build modifier hook.
|
||||||
|
/// Designed for zero-allocation logging paths and superior memory efficiency.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EonaCatLogStack : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly string _category;
|
||||||
|
private readonly List<IFlow> _flows = new List<IFlow>();
|
||||||
|
private readonly List<IBooster> _boosters = new List<IBooster>();
|
||||||
|
private readonly ConcurrentBag<IFlow> _concurrentFlows = new ConcurrentBag<IFlow>();
|
||||||
|
private readonly LogLevel _minimumLevel;
|
||||||
|
private readonly TimestampMode _timestampMode;
|
||||||
|
|
||||||
|
private volatile bool _isDisposed;
|
||||||
|
private long _totalLoggedCount;
|
||||||
|
private long _totalDroppedCount;
|
||||||
|
|
||||||
|
private readonly List<ActionRef<LogEventBuilder>> _modifiers = new List<ActionRef<LogEventBuilder>>();
|
||||||
|
public delegate void ActionRef<T>(ref T item);
|
||||||
|
|
||||||
|
private readonly object _modifiersLock = new object();
|
||||||
|
|
||||||
|
public event EventHandler<LogMessage> OnLog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new logger instance
|
||||||
|
/// </summary>
|
||||||
|
public EonaCatLogStack(string category = "Application",
|
||||||
|
LogLevel minimumLevel = LogLevel.Trace,
|
||||||
|
TimestampMode timestampMode = TimestampMode.Utc)
|
||||||
|
{
|
||||||
|
_category = category ?? throw new ArgumentNullException(nameof(category));
|
||||||
|
_minimumLevel = minimumLevel;
|
||||||
|
_timestampMode = timestampMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a flow (output destination) to this logger
|
||||||
|
/// </summary>
|
||||||
|
public EonaCatLogStack AddFlow(IFlow flow)
|
||||||
|
{
|
||||||
|
if (flow == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(flow));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_flows) { _flows.Add(flow); }
|
||||||
|
_concurrentFlows.Add(flow);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a booster to this logger
|
||||||
|
/// </summary>
|
||||||
|
public EonaCatLogStack AddBooster(IBooster booster)
|
||||||
|
{
|
||||||
|
if (booster == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(booster));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_boosters) { _boosters.Add(booster); }
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a flow by name
|
||||||
|
/// </summary>
|
||||||
|
public EonaCatLogStack RemoveFlow(string name)
|
||||||
|
{
|
||||||
|
lock (_flows) { _flows.RemoveAll(f => f.Name == name); }
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a booster by name
|
||||||
|
/// </summary>
|
||||||
|
public EonaCatLogStack RemoveBooster(string name)
|
||||||
|
{
|
||||||
|
lock (_boosters) { _boosters.RemoveAll(b => b.Name == name); }
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a modifier to run before building the LogEvent.
|
||||||
|
/// Return false to cancel logging.
|
||||||
|
/// </summary>
|
||||||
|
public EonaCatLogStack AddModifier(ActionRef<LogEventBuilder> modifier)
|
||||||
|
{
|
||||||
|
if (modifier == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(modifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_modifiersLock) { _modifiers.Add(modifier); }
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Log(string message, LogLevel level = LogLevel.Information)
|
||||||
|
{
|
||||||
|
if (_isDisposed || level < _minimumLevel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new LogEventBuilder()
|
||||||
|
.WithLevel(level)
|
||||||
|
.WithCategory(_category)
|
||||||
|
.WithMessage(message)
|
||||||
|
.WithTimestamp(GetTimestamp());
|
||||||
|
|
||||||
|
ProcessLogEvent(ref builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Log(LogLevel level, Exception exception, string message)
|
||||||
|
{
|
||||||
|
if (_isDisposed || level < _minimumLevel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new LogEventBuilder()
|
||||||
|
.WithLevel(level)
|
||||||
|
.WithCategory(_category)
|
||||||
|
.WithMessage(message)
|
||||||
|
.WithException(exception)
|
||||||
|
.WithTimestamp(GetTimestamp());
|
||||||
|
|
||||||
|
ProcessLogEvent(ref builder);
|
||||||
|
|
||||||
|
OnLog?.Invoke(this, new LogMessage
|
||||||
|
{
|
||||||
|
Level = level,
|
||||||
|
Exception = exception,
|
||||||
|
Message = message,
|
||||||
|
Category = _category,
|
||||||
|
Origin = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Log(LogLevel level, string message, params (string Key, object Value)[] properties)
|
||||||
|
{
|
||||||
|
if (_isDisposed || level < _minimumLevel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new LogEventBuilder()
|
||||||
|
.WithLevel(level)
|
||||||
|
.WithCategory(_category)
|
||||||
|
.WithMessage(message)
|
||||||
|
.WithTimestamp(GetTimestamp());
|
||||||
|
|
||||||
|
foreach (var (key, value) in properties)
|
||||||
|
{
|
||||||
|
builder.WithProperty(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessLogEvent(ref builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Trace(string message) => Log(LogLevel.Trace, message);
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Debug(string message) => Log(LogLevel.Debug, message);
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Information(string message) => Log(LogLevel.Information, message);
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Warning(string message) => Log(LogLevel.Warning, message);
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Warning(Exception ex, string message) => Log(LogLevel.Warning, ex, message);
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Error(string message) => Log(LogLevel.Error, message);
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Error(Exception ex, string message) => Log(LogLevel.Error, ex, message);
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Critical(string message) => Log(LogLevel.Critical, message);
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Critical(Exception ex, string message) => Log(LogLevel.Critical, ex, message);
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private void ProcessLogEvent(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
// Apply boosters
|
||||||
|
lock (_boosters)
|
||||||
|
{
|
||||||
|
foreach (var booster in _boosters)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!booster.Boost(ref builder))
|
||||||
|
{
|
||||||
|
return; // filtered out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply modifiers
|
||||||
|
foreach (var mod in _modifiers)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mod(ref builder);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
var logEvent = builder.Build();
|
||||||
|
Interlocked.Increment(ref _totalLoggedCount);
|
||||||
|
|
||||||
|
// Blast to flows
|
||||||
|
foreach (var flow in _concurrentFlows)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = flow.BlastAsync(logEvent).GetAwaiter().GetResult();
|
||||||
|
if (result == WriteResult.Dropped)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _totalDroppedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private long GetTimestamp()
|
||||||
|
{
|
||||||
|
switch (_timestampMode)
|
||||||
|
{
|
||||||
|
case TimestampMode.Local: return DateTime.Now.Ticks;
|
||||||
|
case TimestampMode.HighPrecision: return System.Diagnostics.Stopwatch.GetTimestamp();
|
||||||
|
default: return DateTime.UtcNow.Ticks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task FlushAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var tasks = _concurrentFlows.Select(f => f.FlushAsync(cancellationToken));
|
||||||
|
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoggerDiagnostics GetDiagnostics()
|
||||||
|
{
|
||||||
|
var flowDiagnostics = _concurrentFlows
|
||||||
|
.Select(f => f is FlowBase fb ? fb.GetDiagnostics() : null)
|
||||||
|
.Where(d => d != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new LoggerDiagnostics
|
||||||
|
{
|
||||||
|
Category = _category,
|
||||||
|
MinimumLevel = _minimumLevel,
|
||||||
|
TotalLogged = Interlocked.Read(ref _totalLoggedCount),
|
||||||
|
TotalDropped = Interlocked.Read(ref _totalDroppedCount),
|
||||||
|
FlowCount = _flows.Count,
|
||||||
|
BoosterCount = _boosters.Count,
|
||||||
|
Flows = flowDiagnostics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDisposed = true;
|
||||||
|
|
||||||
|
await FlushAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var disposeTasks = _concurrentFlows.Select(f => f.DisposeAsync().AsTask());
|
||||||
|
await Task.WhenAll(disposeTasks).ConfigureAwait(false);
|
||||||
|
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
EonaCat.LogStack/EonaCatLoggerCore/BoosterBase.cs
Normal file
22
EonaCat.LogStack/EonaCatLoggerCore/BoosterBase.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for boosters that need configuration
|
||||||
|
/// </summary>
|
||||||
|
public abstract class BoosterBase : IBooster
|
||||||
|
{
|
||||||
|
protected BoosterBase(string name)
|
||||||
|
{
|
||||||
|
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public abstract bool Boost(ref LogEventBuilder builder);
|
||||||
|
}
|
||||||
25
EonaCat.LogStack/EonaCatLoggerCore/Boosters/AppBooster.cs
Normal file
25
EonaCat.LogStack/EonaCatLoggerCore/Boosters/AppBooster.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
public sealed class AppBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private static readonly string AppName = AppDomain.CurrentDomain.FriendlyName;
|
||||||
|
private static readonly string AppBase = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
|
||||||
|
public AppBooster() : base("App") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("App", AppName);
|
||||||
|
builder.WithProperty("AppBase", AppBase);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds application name and version to log events
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApplicationBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private readonly string _applicationName;
|
||||||
|
private readonly string? _version;
|
||||||
|
|
||||||
|
public ApplicationBooster(string applicationName, string? version = null) : base("Application")
|
||||||
|
{
|
||||||
|
_applicationName = applicationName ?? throw new ArgumentNullException(nameof(applicationName));
|
||||||
|
_version = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("Application", _applicationName);
|
||||||
|
if (_version != null)
|
||||||
|
{
|
||||||
|
builder.WithProperty("Version", _version);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds custom properties from a callback
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CallbackBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private readonly Func<Dictionary<string, object?>> _propertiesCallback;
|
||||||
|
|
||||||
|
public CallbackBooster(string name, Func<Dictionary<string, object?>> propertiesCallback) : base(name)
|
||||||
|
{
|
||||||
|
_propertiesCallback = propertiesCallback ?? throw new ArgumentNullException(nameof(propertiesCallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var properties = _propertiesCallback();
|
||||||
|
if (properties != null)
|
||||||
|
{
|
||||||
|
foreach (var kvp in properties)
|
||||||
|
{
|
||||||
|
builder.WithProperty(kvp.Key, kvp.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Swallow exceptions in boosters to prevent logging failures
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds correlation ID from Activity or custom source
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CorrelationIdBooster : BoosterBase
|
||||||
|
{
|
||||||
|
public CorrelationIdBooster() : base("CorrelationId") { }
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
var activity = Activity.Current;
|
||||||
|
if (activity != null)
|
||||||
|
{
|
||||||
|
builder.WithProperty("CorrelationId", activity.Id ?? activity.TraceId.ToString());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a custom text property to log events
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomTextBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private readonly string _propertyName;
|
||||||
|
private readonly string _text;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new booster that adds a custom text property to logs
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="propertyName">The name of the property to add</param>
|
||||||
|
/// <param name="text">The text value to set</param>
|
||||||
|
public CustomTextBooster(string propertyName, string text)
|
||||||
|
: base("CustomText")
|
||||||
|
{
|
||||||
|
_propertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName));
|
||||||
|
_text = text ?? throw new ArgumentNullException(nameof(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty(_propertyName, _text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
EonaCat.LogStack/EonaCatLoggerCore/Boosters/DateBooster.cs
Normal file
21
EonaCat.LogStack/EonaCatLoggerCore/Boosters/DateBooster.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class DateBooster : BoosterBase
|
||||||
|
{
|
||||||
|
public DateBooster() : base("Date") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("Date", DateTime.UtcNow.ToString("yyyy-MM-dd"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds environment name to log events
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EnvironmentBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private readonly string _environmentName;
|
||||||
|
|
||||||
|
public EnvironmentBooster(string environmentName) : base("Environment")
|
||||||
|
{
|
||||||
|
_environmentName = environmentName ?? "Production";
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("Environment", _environmentName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class FrameworkBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private static readonly string FrameworkDesc = RuntimeInformation.FrameworkDescription;
|
||||||
|
|
||||||
|
public FrameworkBooster() : base("Framework") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("Framework", FrameworkDesc);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filters log events based on level
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LevelFilterBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private readonly LogLevel _minimumLevel;
|
||||||
|
|
||||||
|
public LevelFilterBooster(LogLevel minimumLevel) : base("LevelFilter")
|
||||||
|
{
|
||||||
|
_minimumLevel = minimumLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
// Filter will be handled by the pipeline, this is a no-op booster
|
||||||
|
// Actual filtering happens in the logger pipeline based on configuration
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds machine name to log events
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MachineNameBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private static readonly string MachineName = Environment.MachineName;
|
||||||
|
|
||||||
|
public MachineNameBooster() : base("MachineName") { }
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("MachineName", MachineName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
EonaCat.LogStack/EonaCatLoggerCore/Boosters/MemoryBooster.cs
Normal file
22
EonaCat.LogStack/EonaCatLoggerCore/Boosters/MemoryBooster.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class MemoryBooster : BoosterBase
|
||||||
|
{
|
||||||
|
public MemoryBooster() : base("Memory") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
var memoryMB = GC.GetTotalMemory(false) / 1024 / 1024;
|
||||||
|
builder.WithProperty("Memory", memoryMB);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
EonaCat.LogStack/EonaCatLoggerCore/Boosters/OSBooster.cs
Normal file
23
EonaCat.LogStack/EonaCatLoggerCore/Boosters/OSBooster.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class OSBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private static readonly string OSDesc = RuntimeInformation.OSDescription;
|
||||||
|
|
||||||
|
public OSBooster() : base("OS") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("OS", OSDesc);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class ProcStartBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private static readonly DateTime ProcessStart = Process.GetCurrentProcess().StartTime;
|
||||||
|
|
||||||
|
public ProcStartBooster() : base("ProcStart") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("ProcStart", ProcessStart);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds process ID to log events
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProcessIdBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private static readonly int ProcessId = Process.GetCurrentProcess().Id;
|
||||||
|
|
||||||
|
public ProcessIdBooster() : base("ProcessId") { }
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("ProcessId", ProcessId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class ThreadIdBooster : BoosterBase
|
||||||
|
{
|
||||||
|
public ThreadIdBooster() : base("ThreadId") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("ThreadId", Environment.CurrentManagedThreadId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class ThreadNameBooster : BoosterBase
|
||||||
|
{
|
||||||
|
public ThreadNameBooster() : base("ThreadName") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("ThreadName", Thread.CurrentThread.Name ?? "n/a");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
EonaCat.LogStack/EonaCatLoggerCore/Boosters/TicksBooster.cs
Normal file
21
EonaCat.LogStack/EonaCatLoggerCore/Boosters/TicksBooster.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class TicksBooster : BoosterBase
|
||||||
|
{
|
||||||
|
public TicksBooster() : base("Ticks") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("Ticks", DateTime.UtcNow.Ticks);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
EonaCat.LogStack/EonaCatLoggerCore/Boosters/TimeBooster.cs
Normal file
21
EonaCat.LogStack/EonaCatLoggerCore/Boosters/TimeBooster.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class TimeBooster : BoosterBase
|
||||||
|
{
|
||||||
|
public TimeBooster() : base("Time") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("Time", DateTime.UtcNow.ToString("HH:mm:ss.fff"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds timestamp in multiple formats
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TimestampBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private readonly TimestampMode _mode;
|
||||||
|
|
||||||
|
public TimestampBooster(TimestampMode mode = TimestampMode.Utc) : base("Timestamp")
|
||||||
|
{
|
||||||
|
_mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
var timestamp = _mode switch
|
||||||
|
{
|
||||||
|
TimestampMode.Local => DateTime.Now.Ticks,
|
||||||
|
TimestampMode.HighPrecision => Stopwatch.GetTimestamp(),
|
||||||
|
_ => DateTime.UtcNow.Ticks
|
||||||
|
};
|
||||||
|
|
||||||
|
builder.WithTimestamp(timestamp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
EonaCat.LogStack/EonaCatLoggerCore/Boosters/UptimeBooster.cs
Normal file
25
EonaCat.LogStack/EonaCatLoggerCore/Boosters/UptimeBooster.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class UptimeBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private static readonly DateTime ProcessStart = Process.GetCurrentProcess().StartTime;
|
||||||
|
|
||||||
|
public UptimeBooster() : base("Uptime") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
var uptime = (DateTime.Now - ProcessStart).TotalSeconds;
|
||||||
|
builder.WithProperty("Uptime", uptime);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
EonaCat.LogStack/EonaCatLoggerCore/Boosters/UserBooster.cs
Normal file
23
EonaCat.LogStack/EonaCatLoggerCore/Boosters/UserBooster.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using EonaCat.LogStack.Core;
|
||||||
|
using System;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.Boosters
|
||||||
|
{
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
public sealed class UserBooster : BoosterBase
|
||||||
|
{
|
||||||
|
private static readonly string UserName = Environment.UserName;
|
||||||
|
|
||||||
|
public UserBooster() : base("User") { }
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public override bool Boost(ref LogEventBuilder builder)
|
||||||
|
{
|
||||||
|
builder.WithProperty("User", UserName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
EonaCat.LogStack/EonaCatLoggerCore/ColorSchema.cs
Normal file
74
EonaCat.LogStack/EonaCatLoggerCore/ColorSchema.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Colors to use when writing to the console.
|
||||||
|
/// </summary>
|
||||||
|
public class ColorSchema
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The color to use for critical messages.
|
||||||
|
/// </summary>
|
||||||
|
public ColorScheme Critical = new(ConsoleColor.DarkRed, ConsoleColor.Black);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color to use for debug messages.
|
||||||
|
/// </summary>
|
||||||
|
public ColorScheme Debug = new(ConsoleColor.Green, ConsoleColor.Black);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color to use for error messages.
|
||||||
|
/// </summary>
|
||||||
|
public ColorScheme Error = new(ConsoleColor.Red, ConsoleColor.Black);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color to use for informational messages.
|
||||||
|
/// </summary>
|
||||||
|
public ColorScheme Info = new(ConsoleColor.Blue, ConsoleColor.Black);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color to use for emergency messages.
|
||||||
|
/// </summary>
|
||||||
|
public ColorScheme Trace = new(ConsoleColor.Cyan, ConsoleColor.Black);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color to use for alert messages.
|
||||||
|
/// </summary>
|
||||||
|
public ColorScheme Traffic = new(ConsoleColor.DarkMagenta, ConsoleColor.Black);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color to use for warning messages.
|
||||||
|
/// </summary>
|
||||||
|
public ColorScheme Warning = new(ConsoleColor.DarkYellow, ConsoleColor.Black);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Color scheme for logging messages.
|
||||||
|
/// </summary>
|
||||||
|
public class ColorScheme
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Background color.
|
||||||
|
/// </summary>
|
||||||
|
public ConsoleColor Background = Console.BackgroundColor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreground color.
|
||||||
|
/// </summary>
|
||||||
|
public ConsoleColor Foreground = Console.ForegroundColor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates a new color scheme.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="foreground">Foreground color.</param>
|
||||||
|
/// <param name="background">Background color.</param>
|
||||||
|
public ColorScheme(ConsoleColor foreground, ConsoleColor background)
|
||||||
|
{
|
||||||
|
Foreground = foreground;
|
||||||
|
Background = background;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
EonaCat.LogStack/EonaCatLoggerCore/CompressionFormat.cs
Normal file
11
EonaCat.LogStack/EonaCatLoggerCore/CompressionFormat.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.EonaCatLogStackCore
|
||||||
|
{
|
||||||
|
public enum CompressionFormat
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
GZip,
|
||||||
|
}
|
||||||
|
}
|
||||||
59
EonaCat.LogStack/EonaCatLoggerCore/Enums.cs
Normal file
59
EonaCat.LogStack/EonaCatLoggerCore/Enums.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
namespace EonaCat.LogStack.Core;
|
||||||
|
|
||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the severity level of log entries
|
||||||
|
/// </summary>
|
||||||
|
public enum LogLevel : byte
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Trace = 1,
|
||||||
|
Debug = 2,
|
||||||
|
Information = 3,
|
||||||
|
Warning = 4,
|
||||||
|
Error = 5,
|
||||||
|
Critical = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a log write operation
|
||||||
|
/// </summary>
|
||||||
|
public enum WriteResult : byte
|
||||||
|
{
|
||||||
|
Success = 0,
|
||||||
|
Dropped = 1,
|
||||||
|
Failed = 2,
|
||||||
|
FlowDisabled = 3,
|
||||||
|
LevelFiltered = 4,
|
||||||
|
NoBlastZone = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strategy for handling backpressure in flows
|
||||||
|
/// </summary>
|
||||||
|
public enum BackpressureStrategy : byte
|
||||||
|
{
|
||||||
|
/// <summary>Wait for capacity to become available</summary>
|
||||||
|
Wait = 0,
|
||||||
|
|
||||||
|
/// <summary>Drop the newest incoming message</summary>
|
||||||
|
DropNewest = 1,
|
||||||
|
|
||||||
|
/// <summary>Drop the oldest message in the queue</summary>
|
||||||
|
DropOldest = 2,
|
||||||
|
|
||||||
|
/// <summary>Block until space is available (may impact performance)</summary>
|
||||||
|
Block = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Options for timestamp generation
|
||||||
|
/// </summary>
|
||||||
|
public enum TimestampMode : byte
|
||||||
|
{
|
||||||
|
Utc = 0,
|
||||||
|
Local = 1,
|
||||||
|
HighPrecision = 2
|
||||||
|
}
|
||||||
14
EonaCat.LogStack/EonaCatLoggerCore/FileOutputFormat.cs
Normal file
14
EonaCat.LogStack/EonaCatLoggerCore/FileOutputFormat.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||||
|
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||||
|
|
||||||
|
namespace EonaCat.LogStack.EonaCatLogStackCore
|
||||||
|
{
|
||||||
|
public enum FileOutputFormat
|
||||||
|
{
|
||||||
|
Text,
|
||||||
|
Json,
|
||||||
|
Xml,
|
||||||
|
Csv, // RFC-4180 CSV
|
||||||
|
StructuredJson, // Machine-readable JSON with correlation IDs
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user