Added EonaCat.LogStack.Status
Updated EonaCat.LogStack.LogClient to support EonaCat.LogStack.Status
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using EonaCat.LogStack.LogClient.Models;
|
||||
using EonaCat.Json;
|
||||
using EonaCat.LogStack.Extensions;
|
||||
using EonaCat.LogStack.LogClient.Models;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
@@ -9,11 +11,30 @@ using System.Text;
|
||||
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.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;
|
||||
@@ -37,14 +58,16 @@ namespace EonaCat.LogStack.LogClient
|
||||
|
||||
public async Task LogAsync(LogEntry entry)
|
||||
{
|
||||
entry.ApplicationName = _options.ApplicationName;
|
||||
entry.ApplicationVersion = _options.ApplicationVersion;
|
||||
entry.Environment = _options.Environment;
|
||||
entry.Source = _options.ApplicationName;
|
||||
entry.Timestamp = DateTime.UtcNow;
|
||||
entry.Message ??= "";
|
||||
|
||||
entry.MachineName ??= Environment.MachineName;
|
||||
entry.Category ??= entry.Category ?? "Default";
|
||||
entry.Message ??= 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);
|
||||
|
||||
@@ -54,50 +77,9 @@ namespace EonaCat.LogStack.LogClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogExceptionAsync(Exception ex, string message = "",
|
||||
Dictionary<string, object>? properties = null)
|
||||
{
|
||||
await LogAsync(new LogEntry
|
||||
{
|
||||
Level = (int)LogLevel.Error,
|
||||
Category = "Exception",
|
||||
Message = message,
|
||||
Exception = ex.ToString(),
|
||||
StackTrace = ex.StackTrace,
|
||||
Properties = properties
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LogSecurityEventAsync(string eventType, string message,
|
||||
Dictionary<string, object>? properties = null)
|
||||
{
|
||||
await LogAsync(new LogEntry
|
||||
{
|
||||
Level = (int)LogLevel.Security,
|
||||
Category = "Security",
|
||||
Message = $"[{eventType}] {message}",
|
||||
Properties = properties
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LogAnalyticsAsync(string eventName,
|
||||
Dictionary<string, object>? properties = null)
|
||||
{
|
||||
await LogAsync(new LogEntry
|
||||
{
|
||||
Level = (int)LogLevel.Analytics,
|
||||
Category = "Analytics",
|
||||
Message = eventName,
|
||||
Properties = properties
|
||||
});
|
||||
}
|
||||
|
||||
private async Task FlushAsync()
|
||||
{
|
||||
if (_logQueue.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_logQueue.IsEmpty) return;
|
||||
|
||||
await _flushSemaphore.WaitAsync();
|
||||
try
|
||||
@@ -110,7 +92,7 @@ namespace EonaCat.LogStack.LogClient
|
||||
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await SendBatchAsync(batch);
|
||||
await SendBatchToEonaCatAsync(batch);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -119,49 +101,48 @@ namespace EonaCat.LogStack.LogClient
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendBatchAsync(List<LogEntry> entries)
|
||||
private async Task SendBatchToEonaCatAsync(List<LogEntry> batch)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Map EF entities to DTOs for API
|
||||
var dtos = entries.Select(e => new LogEntryDto
|
||||
var eventsArray = batch.Select(e => new
|
||||
{
|
||||
Id = e.Id,
|
||||
Timestamp = e.Timestamp,
|
||||
ApplicationName = e.ApplicationName,
|
||||
ApplicationVersion = e.ApplicationVersion,
|
||||
Environment = e.Environment,
|
||||
MachineName = e.MachineName,
|
||||
Level = e.Level,
|
||||
Category = e.Category,
|
||||
Message = e.Message,
|
||||
Exception = e.Exception,
|
||||
StackTrace = e.StackTrace,
|
||||
Properties = e.Properties,
|
||||
UserId = e.UserId,
|
||||
SessionId = e.SessionId,
|
||||
RequestId = e.RequestId,
|
||||
CorrelationId = e.CorrelationId
|
||||
}).ToList();
|
||||
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 response = await _httpClient.PostAsJsonAsync("/api/logs/batch", dtos);
|
||||
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: {ex.Message}");
|
||||
Console.WriteLine($"[LogCentral] Failed to send logs to EonaCat: {ex.Message}");
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
foreach (var entry in batch)
|
||||
{
|
||||
_logQueue.Enqueue(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task FlushAndDisposeAsync()
|
||||
{
|
||||
await FlushAsync();
|
||||
@@ -170,10 +151,7 @@ namespace EonaCat.LogStack.LogClient
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_disposed) return;
|
||||
|
||||
_flushTimer?.Dispose();
|
||||
FlushAsync().GetAwaiter().GetResult();
|
||||
@@ -183,25 +161,4 @@ namespace EonaCat.LogStack.LogClient
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
public class LogEntryDto
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string ApplicationName { get; set; } = default!;
|
||||
public string ApplicationVersion { get; set; } = default!;
|
||||
public string Environment { get; set; } = default!;
|
||||
public string MachineName { get; set; } = default!;
|
||||
public int Level { get; set; }
|
||||
public string Category { get; set; } = default!;
|
||||
public string Message { get; set; } = default!;
|
||||
public string? Exception { get; set; }
|
||||
public string? StackTrace { get; set; }
|
||||
public Dictionary<string, object>? Properties { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? SessionId { get; set; }
|
||||
public string? RequestId { get; set; }
|
||||
public string? CorrelationId { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using EonaCat.LogStack.Configuration;
|
||||
using EonaCat.LogStack.Extensions;
|
||||
using EonaCat.LogStack.LogClient.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -23,13 +24,13 @@ namespace EonaCat.LogStack.LogClient
|
||||
{
|
||||
var entry = new LogEntry
|
||||
{
|
||||
Level = (int)MapLogLevel(e.Level),
|
||||
Category = e.Category ?? "General",
|
||||
Level = e.Level.ToString().ToLower(),
|
||||
Message = e.Message,
|
||||
Properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "Source", e.Origin ?? "Unknown" }
|
||||
}
|
||||
{
|
||||
{ "Source", e.Origin ?? "Unknown" },
|
||||
{ "Category", e.Category ?? "General" }
|
||||
}.ToJson()
|
||||
};
|
||||
|
||||
if (e.Exception != null)
|
||||
|
||||
@@ -9,12 +9,12 @@ namespace EonaCat.LogStack.LogClient
|
||||
{
|
||||
public class LogCentralOptions
|
||||
{
|
||||
public string ServerUrl { get; set; } = "http://localhost:5000";
|
||||
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; } = 50;
|
||||
public int BatchSize { get; set; } = 1;
|
||||
public int FlushIntervalSeconds { get; set; } = 5;
|
||||
public bool EnableFallbackLogging { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
using EonaCat.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text;
|
||||
using System;
|
||||
|
||||
namespace EonaCat.LogStack.LogClient.Models
|
||||
{
|
||||
@@ -11,57 +7,15 @@ namespace EonaCat.LogStack.LogClient.Models
|
||||
|
||||
public class LogEntry
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
public string ApplicationName { get; set; } = default!;
|
||||
public string ApplicationVersion { get; set; } = default!;
|
||||
public string Environment { get; set; } = default!;
|
||||
public string MachineName { get; set; } = default!;
|
||||
public int Level { get; set; }
|
||||
public string Category { get; set; } = default!;
|
||||
public string Message { get; set; } = default!;
|
||||
|
||||
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; }
|
||||
|
||||
[Column(TypeName = "TEXT")]
|
||||
public string? PropertiesJson { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public Dictionary<string, object>? Properties
|
||||
{
|
||||
get => string.IsNullOrEmpty(PropertiesJson)
|
||||
? null
|
||||
: JsonHelper.ToObject<Dictionary<string, object>>(PropertiesJson);
|
||||
set => PropertiesJson = value == null ? null : JsonHelper.ToJson(value);
|
||||
}
|
||||
|
||||
public string? UserId { get; set; }
|
||||
public string? SessionId { get; set; }
|
||||
public string? RequestId { get; set; }
|
||||
public string? CorrelationId { get; set; }
|
||||
|
||||
|
||||
public static LogEntryDto ToDto(LogEntry entry) => new LogEntryDto()
|
||||
{
|
||||
Id = entry.Id,
|
||||
Timestamp = entry.Timestamp,
|
||||
ApplicationName = entry.ApplicationName,
|
||||
ApplicationVersion = entry.ApplicationVersion,
|
||||
Environment = entry.Environment,
|
||||
MachineName = entry.MachineName,
|
||||
Level = entry.Level,
|
||||
Category = entry.Category,
|
||||
Message = entry.Message,
|
||||
Exception = entry.Exception,
|
||||
StackTrace = entry.StackTrace,
|
||||
Properties = entry.Properties,
|
||||
UserId = entry.UserId,
|
||||
SessionId = entry.SessionId,
|
||||
RequestId = entry.RequestId,
|
||||
CorrelationId = entry.CorrelationId
|
||||
};
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public string? TraceId { get; set; }
|
||||
public string? Host { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
251
EonaCat.LogStack.Status/Controllers/ApiController.cs
Normal file
251
EonaCat.LogStack.Status/Controllers/ApiController.cs
Normal file
@@ -0,0 +1,251 @@
|
||||
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 = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||
var stats = await _monitorService.GetStatsAsync(isAdmin);
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[HttpGet("monitors")]
|
||||
public async Task<IActionResult> GetMonitors()
|
||||
{
|
||||
var isAdmin = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||
var query = _database.Monitors.Where(m => m.IsActive);
|
||||
|
||||
if (!isAdmin)
|
||||
{
|
||||
query = query.Where(m => m.IsPublic);
|
||||
}
|
||||
|
||||
return Ok(await query.ToListAsync());
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
// Use System.Text.Json to parse the dictionary dynamically
|
||||
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 ? System.Text.Json.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 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"
|
||||
};
|
||||
|
||||
[HttpGet("logs")]
|
||||
public async Task<IActionResult> QueryLogs([FromQuery] string? level, [FromQuery] string? source, [FromQuery] string? search, [FromQuery] int page = 1, [FromQuery] int pageSize = 100)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
37
EonaCat.LogStack.Status/Data/DatabaseContext.cs
Normal file
37
EonaCat.LogStack.Status/Data/DatabaseContext.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
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>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
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();
|
||||
|
||||
// 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 = "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
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>
|
||||
107
EonaCat.LogStack.Status/Models/Models.cs
Normal file
107
EonaCat.LogStack.Status/Models/Models.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
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 }
|
||||
public enum MonitorStatus { Unknown, Up, Down, Warning, Degraded }
|
||||
|
||||
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; }
|
||||
public ICollection<MonitorCheck> Checks { get; set; } = new List<MonitorCheck>();
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
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." });
|
||||
}
|
||||
}
|
||||
99
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml
Normal file
99
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml
Normal file
@@ -0,0 +1,99 @@
|
||||
@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">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");
|
||||
}
|
||||
}
|
||||
195
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml
Normal file
195
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml
Normal file
@@ -0,0 +1,195 @@
|
||||
@page
|
||||
@model 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></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')">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">
|
||||
<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="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">
|
||||
<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>
|
||||
<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">Group Name</label>
|
||||
<input type="text" name="EditMonitor.GroupName" id="edit-group" class="form-control" placeholder="Production" />
|
||||
</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>
|
||||
<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 (visible to unauthenticated users)</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;
|
||||
document.getElementById('host-row').style.display = ['HTTP','HTTPS'].includes(type) ? 'none' : 'grid';
|
||||
document.getElementById('url-row').style.display = ['HTTP','HTTPS'].includes(type) ? 'block' : 'none';
|
||||
document.getElementById('process-row').style.display = type === 'AppLocal' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function editMonitor(id,name,desc,type,host,port,url,process,interval,timeout,active,pub,tags,group) {
|
||||
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;
|
||||
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 we dont have a host, use the url to extract it
|
||||
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;
|
||||
}
|
||||
|
||||
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." });
|
||||
}
|
||||
}
|
||||
98
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml
Normal file
98
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml
Normal file
@@ -0,0 +1,98 @@
|
||||
@page
|
||||
@model 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">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">
|
||||
<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(--info)">GET</span> <span style="color:var(--text-primary)">/api/logs/ingest</span> <span style="color:var(--text-muted)">(batch via GET body)</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/{id}/check</span></div>
|
||||
</div>
|
||||
<a href="/admin/ingest" class="btn btn-outline btn-sm mt-2">View Ingest Docs →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
77
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml.cs
Normal file
77
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using EonaCat.LogStack.Status.Services;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||
|
||||
public class SettingsModel : 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 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 int MaxLogRetentionDays { get; set; } = 30;
|
||||
[BindProperty] public string AlertEmail { 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";
|
||||
MaxLogRetentionDays = int.TryParse(await _authenticationService.GetSettingAsync("MaxLogRetentionDays", "30"), out var d) ? d : 30;
|
||||
AlertEmail = await _authenticationService.GetSettingAsync("AlertEmail", "");
|
||||
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("MaxLogRetentionDays", MaxLogRetentionDays.ToString());
|
||||
await _authenticationService.SetSettingAsync("AlertEmail", AlertEmail ?? "");
|
||||
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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
162
EonaCat.LogStack.Status/Pages/Index.cshtml
Normal file
162
EonaCat.LogStack.Status/Pages/Index.cshtml
Normal file
@@ -0,0 +1,162 @@
|
||||
@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.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>
|
||||
|
||||
@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> }
|
||||
</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>
|
||||
63
EonaCat.LogStack.Status/Pages/Index.cshtml.cs
Normal file
63
EonaCat.LogStack.Status/Pages/Index.cshtml.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
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;
|
||||
|
||||
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 bool IsAdmin { get; set; }
|
||||
public bool ShowUptime { 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";
|
||||
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();
|
||||
|
||||
// Get last 90 checks per monitor for uptime bars
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
73
EonaCat.LogStack.Status/Pages/Logs.cshtml
Normal file
73
EonaCat.LogStack.Status/Pages/Logs.cshtml
Normal file
@@ -0,0 +1,73 @@
|
||||
@page
|
||||
@model LogsModel
|
||||
@{
|
||||
ViewData["Title"] = "Log Stream";
|
||||
ViewData["Page"] = "logs";
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">Log Stream</span>
|
||||
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.TotalCount entries</span>
|
||||
</div>
|
||||
|
||||
<form method="get" class="filter-bar">
|
||||
<select name="Level" class="form-control" onchange="this.form.submit()">
|
||||
<option value="">All Levels</option>
|
||||
@foreach (var lvl in new[] { "debug", "info", "warning", "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..." class="form-control" style="max-width:300px" />
|
||||
<a href="/logs" class="btn btn-outline btn-sm">Clear</a>
|
||||
</form>
|
||||
|
||||
<div class="log-stream" id="log-stream">
|
||||
@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">@e.Source</span>
|
||||
<span class="log-message">
|
||||
@e.Message
|
||||
@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>
|
||||
}
|
||||
66
EonaCat.LogStack.Status/Pages/Logs.cshtml.cs
Normal file
66
EonaCat.LogStack.Status/Pages/Logs.cshtml.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
|
||||
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 int PageIndex { get; set; } = 1;
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
TotalCount = await q.CountAsync();
|
||||
TotalPages = (int)Math.Ceiling((double)TotalCount / 100);
|
||||
|
||||
Entries = await q.OrderByDescending(l => l.Timestamp)
|
||||
.Skip((PageIndex - 1) * 100)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
79
EonaCat.LogStack.Status/Pages/Monitors.cshtml
Normal file
79
EonaCat.LogStack.Status/Pages/Monitors.cshtml
Normal file
@@ -0,0 +1,79 @@
|
||||
@page
|
||||
@model 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
116
EonaCat.LogStack.Status/Pages/Shared/_Layout.cshtml
Normal file
116
EonaCat.LogStack.Status/Pages/Shared/_Layout.cshtml
Normal file
@@ -0,0 +1,116 @@
|
||||
<!--
|
||||
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()">⟨</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<span class="nav-label">MONITORING</span>
|
||||
<a href="/" class="nav-item @(ViewData["Page"]?.ToString() == "dashboard" ? "active" : "")">
|
||||
<span class="nav-icon">⬡</span> Dashboard
|
||||
<span class="status-dot" id="overall-dot"></span>
|
||||
</a>
|
||||
<a href="/monitors" class="nav-item @(ViewData["Page"]?.ToString() == "monitors" ? "active" : "")">
|
||||
<span class="nav-icon">◎</span> Monitors
|
||||
</a>
|
||||
<a href="/certificates" class="nav-item @(ViewData["Page"]?.ToString() == "certs" ? "active" : "")">
|
||||
<span class="nav-icon">◧</span> Certificates
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (Context.Session.GetString("IsAdmin") == "true")
|
||||
{
|
||||
<div class="nav-section">
|
||||
<span class="nav-label">ANALYTICS</span>
|
||||
<a href="/logs" class="nav-item @(ViewData["Page"]?.ToString() == "logs" ? "active" : "")">
|
||||
<span class="nav-icon">▦</span> Log Stream
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<span class="nav-label">ADMIN</span>
|
||||
<a href="/admin/monitors" class="nav-item @(ViewData["Page"]?.ToString() == "admin-monitors" ? "active" : "")">
|
||||
<span class="nav-icon">⊞</span> Manage Monitors
|
||||
</a>
|
||||
<a href="/admin/certificates" class="nav-item @(ViewData["Page"]?.ToString() == "admin-certs" ? "active" : "")">
|
||||
<span class="nav-icon">⊟</span> Manage Certificates
|
||||
</a>
|
||||
<a href="/admin/settings" class="nav-item @(ViewData["Page"]?.ToString() == "admin-settings" ? "active" : "")">
|
||||
<span class="nav-icon">◈</span> Settings
|
||||
</a>
|
||||
<a href="/admin/ingest" class="nav-item @(ViewData["Page"]?.ToString() == "admin-ingest" ? "active" : "")">
|
||||
<span class="nav-icon">⊕</span> Log Ingest
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<a href="/admin/logout" class="nav-item nav-item--danger">
|
||||
<span class="nav-icon">⊘</span> Logout
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="nav-section" style="margin-top:auto">
|
||||
<a href="/admin/login" class="nav-item">
|
||||
<span class="nav-icon">⊛</span> Admin Login
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<span class="clock" id="clock">--:--:--</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="topbar">
|
||||
<div class="breadcrumb">@ViewData["Title"]</div>
|
||||
<div class="topbar-right">
|
||||
<div class="live-indicator">
|
||||
<span class="pulse-dot"></span> LIVE
|
||||
</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>
|
||||
|
||||
<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";
|
||||
}
|
||||
40
EonaCat.LogStack.Status/Program.cs
Normal file
40
EonaCat.LogStack.Status/Program.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
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}"));
|
||||
builder.Services.AddScoped<MonitoringService>();
|
||||
builder.Services.AddScoped<AuthenticationService>();
|
||||
builder.Services.AddScoped<IngestionService>();
|
||||
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:62299;http://localhost:62300"
|
||||
}
|
||||
}
|
||||
}
|
||||
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"]
|
||||
```
|
||||
71
EonaCat.LogStack.Status/Services/AuthenticationService.cs
Normal file
71
EonaCat.LogStack.Status/Services/AuthenticationService.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
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 s = await db.Settings.FirstOrDefaultAsync(x => x.Key == key);
|
||||
if (s == null) { db.Settings.Add(new Models.AppSettings { Key = key, Value = value }); }
|
||||
else { s.Value = value; }
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
303
EonaCat.LogStack.Status/Services/MonitoringService.cs
Normal file
303
EonaCat.LogStack.Status/Services/MonitoringService.cs
Normal file
@@ -0,0 +1,303 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Diagnostics;
|
||||
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;
|
||||
}
|
||||
|
||||
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.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),
|
||||
MonitorType.HTTPS => await CheckHttpAsync(monitor.Url ?? $"https://{monitor.Host}", monitor.TimeoutMs),
|
||||
_ => (MonitorStatus.Unknown, "Unknown monitor type")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status = MonitorStatus.Down;
|
||||
message = ex.Message;
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
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();
|
||||
db.MonitorChecks.Add(check);
|
||||
monitor.LastChecked = DateTime.UtcNow;
|
||||
monitor.LastStatus = status;
|
||||
monitor.LastResponseMs = check.ResponseMs;
|
||||
db.Monitors.Update(monitor);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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>>();
|
||||
var monitorSvc = scope.ServiceProvider.GetRequiredService<MonitoringService>();
|
||||
|
||||
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)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var checkScope = _scopeFactory.CreateScope();
|
||||
var svc = checkScope.ServiceProvider.GetRequiredService<MonitoringService>();
|
||||
await svc.CheckMonitorAsync(m);
|
||||
}, 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)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var certScope = _scopeFactory.CreateScope();
|
||||
var svc = certScope.ServiceProvider.GetRequiredService<MonitoringService>();
|
||||
await svc.CheckCertificateAsync(c);
|
||||
}, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Error in monitor loop");
|
||||
}
|
||||
|
||||
await Task.Delay(10000, 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);
|
||||
var old = await db.Logs.Where(l => l.Timestamp < cutoff).ToListAsync();
|
||||
db.Logs.RemoveRange(old);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
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": "*"
|
||||
}
|
||||
638
EonaCat.LogStack.Status/wwwroot/css/site.css
Normal file
638
EonaCat.LogStack.Status/wwwroot/css/site.css
Normal file
@@ -0,0 +1,638 @@
|
||||
/* 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;
|
||||
}
|
||||
.sidebar.collapsed { width: 56px; }
|
||||
.sidebar.collapsed .logo-text,
|
||||
.sidebar.collapsed .nav-label,
|
||||
.sidebar.collapsed .nav-item span:not(.nav-icon),
|
||||
.sidebar.collapsed .sidebar-footer { display: none; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 { background: rgba(255,181,71,0.1); border: 1px solid rgba(255,181,71,0.25); color: var(--warn); }
|
||||
|
||||
/* 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-item span:not(.nav-icon),
|
||||
.sidebar .sidebar-footer { display: none; }
|
||||
.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; }
|
||||
70
EonaCat.LogStack.Status/wwwroot/js/site.js
Normal file
70
EonaCat.LogStack.Status/wwwroot/js/site.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// 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.
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
const h = String(now.getHours()).padStart(2, '0');
|
||||
const m = String(now.getMinutes()).padStart(2, '0');
|
||||
const s = String(now.getSeconds()).padStart(2, '0');
|
||||
const el = document.getElementById('clock');
|
||||
if (el) el.textContent = `${h}:${m}:${s}`;
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
function toggleSidebar() {
|
||||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
function openModal(id) {
|
||||
document.getElementById(id).classList.add('open');
|
||||
}
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('open');
|
||||
}
|
||||
|
||||
// Refresh dashboard every 30s
|
||||
if (document.body.dataset.autorefresh) {
|
||||
setTimeout(() => location.reload(), 30000);
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
function scrollLogsToBottom() {
|
||||
const el = document.getElementById('log-stream');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', scrollLogsToBottom);
|
||||
|
||||
// Filtering debounce
|
||||
(function() {
|
||||
const searchInput = document.getElementById('log-search');
|
||||
if (!searchInput) return;
|
||||
let timer;
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const form = searchInput.closest('form');
|
||||
if (form) form.submit();
|
||||
}, 400);
|
||||
});
|
||||
})();
|
||||
|
||||
// Summary
|
||||
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)';
|
||||
}).catch(() => {});
|
||||
|
||||
// Notifications
|
||||
function showToast(msg, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${type}`;
|
||||
toast.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:9999;min-width:250px;animation:slideIn 0.3s ease';
|
||||
toast.textContent = msg;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
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
|
||||
@@ -116,6 +118,18 @@ Global
|
||||
{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
|
||||
|
||||
@@ -7,6 +7,8 @@ using EonaCat.LogStack.LogClient;
|
||||
using EonaCat.Web.RateLimiter;
|
||||
using EonaCat.Web.RateLimiter.Endpoints.Extensions;
|
||||
using EonaCat.LogStack.Flows.WindowsEventLog;
|
||||
using EonaCat.LogStack.LogClient.Models;
|
||||
using EonaCat.LogStack.Extensions;
|
||||
|
||||
namespace EonaCat.LogStack.Test.Web
|
||||
{
|
||||
@@ -14,6 +16,31 @@ namespace EonaCat.LogStack.Test.Web
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
|
||||
// Configure the client
|
||||
var centralOptions = new LogCentralOptions
|
||||
{
|
||||
ServerUrl = "https://localhost:62299",
|
||||
ApiKey = "716a964de381979df4303bf93fc091d3",
|
||||
ApplicationName = "MyApp",
|
||||
ApplicationVersion = "1.0.0",
|
||||
Environment = "Production",
|
||||
FlushIntervalSeconds = 5
|
||||
};
|
||||
|
||||
var logClient = new LogCentralClient(centralOptions);
|
||||
logClient.LogAsync(new LogEntry
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Level = Core.LogLevel.Critical.ToString(),
|
||||
Message = "Hello, LogCentral!",
|
||||
Properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "UserId", 123 },
|
||||
{ "Operation", "TestLogging" }
|
||||
}.ToJson()
|
||||
}).ConfigureAwait(false);
|
||||
Console.ReadKey();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var logBuilder = new LogBuilder();
|
||||
@@ -35,7 +62,7 @@ namespace EonaCat.LogStack.Test.Web
|
||||
//logBuilder.WriteToSignalR();
|
||||
//logBuilder.WriteToEmailFlow();
|
||||
//logBuilder.WriteToWindowsEventLog();
|
||||
|
||||
logBuilder.WriteToHttp("https://localhost:62299/api/logs/eonacat");
|
||||
var logger = logBuilder.Build();
|
||||
|
||||
logger.AddModifier((ref LogEventBuilder b) =>
|
||||
@@ -142,20 +169,6 @@ namespace EonaCat.LogStack.Test.Web
|
||||
//logger.LoggerSettings.HeaderTokens.AddCustomToken("AppName", x => "[ALL YOUR BASE ARE BELONG TO US!]");
|
||||
//logger.LoggerSettings.HeaderFormat = "{AppName} {logtype} {ts}";
|
||||
|
||||
// Configure the client
|
||||
var centralOptions = new LogCentralOptions
|
||||
{
|
||||
ServerUrl = "https://localhost:7282",
|
||||
ApiKey = "716a964de381979df4303bf93fc091d3",
|
||||
ApplicationName = "MyApp",
|
||||
ApplicationVersion = "1.0.0",
|
||||
Environment = "Production",
|
||||
BatchSize = 50,
|
||||
FlushIntervalSeconds = 5
|
||||
};
|
||||
|
||||
var logClient = new LogCentralClient(centralOptions);
|
||||
|
||||
// Create the adapter
|
||||
//var adapter = new LogCentralEonaCatAdapter(logger.LoggerSettings, logClient);
|
||||
//await LogManager.Instance.WriteAsync("LogCentral adapter initialized", ELogType.INFO).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user