Initial version
This commit is contained in:
118
EonaCat.LogStack.Status/Pages/Admin/AlertRules.cshtml
Normal file
118
EonaCat.LogStack.Status/Pages/Admin/AlertRules.cshtml
Normal file
@@ -0,0 +1,118 @@
|
||||
@page
|
||||
@model Status.Pages.Admin.AlertRulesModel
|
||||
@{
|
||||
ViewData["Title"] = "Alert Rules";
|
||||
ViewData["Page"] = "admin-alerts";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Message))
|
||||
{
|
||||
<div class="alert alert-success">✓ @Model.Message</div>
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">Alert Rules</span>
|
||||
<button class="btn btn-primary" onclick="openModal('add-rule-modal')">+ Add Rule</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Monitor</th>
|
||||
<th>Condition</th>
|
||||
<th>Threshold</th>
|
||||
<th>Webhook</th>
|
||||
<th>Cooldown</th>
|
||||
<th>Last Fired</th>
|
||||
<th>Enabled</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in Model.Rules)
|
||||
{
|
||||
<tr>
|
||||
<td style="color:var(--text-primary)">@(r.Monitor?.Name ?? "All Monitors")</td>
|
||||
<td class="mono" style="font-size:11px">@r.Condition</td>
|
||||
<td class="mono" style="font-size:11px">@(r.ThresholdValue?.ToString() ?? "-")</td>
|
||||
<td style="font-size:11px;color:var(--text-muted)">@(string.IsNullOrEmpty(r.WebhookUrl) ? "-" : "✓ configured")</td>
|
||||
<td class="mono" style="font-size:11px">@r.CooldownMinutes min</td>
|
||||
<td class="mono" style="font-size:11px">@(r.LastFiredAt?.ToString("yyyy-MM-dd HH:mm") ?? "never")</td>
|
||||
<td>
|
||||
<form method="post" asp-page-handler="Toggle" style="display:inline">
|
||||
<input type="hidden" name="id" value="@r.Id" />
|
||||
<button type="submit" class="btn btn-outline btn-sm">
|
||||
@(r.IsEnabled ? "🟢 On" : "⚫ Off")
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" asp-page-handler="Delete" style="display:inline" onsubmit="return confirm('Delete rule?')">
|
||||
<input type="hidden" name="id" value="@r.Id" />
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!Model.Rules.Any())
|
||||
{
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-muted)">No alert rules configured.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add Rule Modal -->
|
||||
<div class="modal-overlay" id="add-rule-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Add Alert Rule</span>
|
||||
<button class="modal-close" onclick="closeModal('add-rule-modal')">✕</button>
|
||||
</div>
|
||||
<form method="post" asp-page-handler="Save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Monitor (leave blank to apply to all)</label>
|
||||
<select name="NewRule.MonitorId" class="form-control">
|
||||
<option value="">All Monitors</option>
|
||||
@foreach (var m in Model.Monitors)
|
||||
{ <option value="@m.Id">@m.Name</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Condition *</label>
|
||||
<select name="NewRule.Condition" class="form-control" onchange="updateThresholdVisibility(this.value)">
|
||||
@foreach (var c in Enum.GetValues<AlertRuleCondition>())
|
||||
{ <option value="@c">@c</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="threshold-group">
|
||||
<label class="form-label">Threshold Value</label>
|
||||
<input type="number" name="NewRule.ThresholdValue" class="form-control" step="any" placeholder="e.g. 500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Webhook URL (optional)</label>
|
||||
<input type="url" name="NewRule.WebhookUrl" class="form-control" placeholder="https://hooks.slack.com/..." />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Cooldown (minutes)</label>
|
||||
<input type="number" name="NewRule.CooldownMinutes" class="form-control" value="10" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-rule-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Rule</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function updateThresholdVisibility(condition) {
|
||||
const needsThreshold = ['ResponseAboveMs', 'CertExpiresWithinDays'].includes(condition);
|
||||
document.getElementById('threshold-group').style.opacity = needsThreshold ? '1' : '0.4';
|
||||
}
|
||||
</script>
|
||||
}
|
||||
89
EonaCat.LogStack.Status/Pages/Admin/AlertRules.cshtml.cs
Normal file
89
EonaCat.LogStack.Status/Pages/Admin/AlertRules.cshtml.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class AlertRulesModel : PageModel
|
||||
{
|
||||
private readonly DatabaseContext _database;
|
||||
public AlertRulesModel(DatabaseContext database) => _database = database;
|
||||
|
||||
public List<AlertRule> Rules { get; set; } = new();
|
||||
public List<Monitor> Monitors { get; set; } = new();
|
||||
public string? Message { get; set; }
|
||||
|
||||
[BindProperty] public AlertRule NewRule { get; set; } = new();
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
Rules = await _database.AlertRules
|
||||
.Include(r => r.Monitor)
|
||||
.OrderBy(r => r.MonitorId)
|
||||
.ToListAsync();
|
||||
|
||||
Monitors = await _database.Monitors.Where(m => m.IsActive).OrderBy(m => m.Name).ToListAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSaveAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
NewRule.IsEnabled = true;
|
||||
_database.AlertRules.Add(NewRule);
|
||||
await _database.SaveChangesAsync();
|
||||
Message = "Alert rule saved.";
|
||||
return await OnGetAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostToggleAsync(int id)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var rule = await _database.AlertRules.FindAsync(id);
|
||||
if (rule != null)
|
||||
{
|
||||
rule.IsEnabled = !rule.IsEnabled;
|
||||
await _database.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Message = "Rule updated.";
|
||||
return await OnGetAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var rule = await _database.AlertRules.FindAsync(id);
|
||||
if (rule != null)
|
||||
{
|
||||
_database.AlertRules.Remove(rule);
|
||||
await _database.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Message = "Rule deleted.";
|
||||
return await OnGetAsync();
|
||||
}
|
||||
}
|
||||
126
EonaCat.LogStack.Status/Pages/Admin/Certificates.cshtml
Normal file
126
EonaCat.LogStack.Status/Pages/Admin/Certificates.cshtml
Normal file
@@ -0,0 +1,126 @@
|
||||
@page
|
||||
@model Status.Pages.Admin.CertificatesModel
|
||||
@{
|
||||
ViewData["Title"] = "Manage Certificates";
|
||||
ViewData["Page"] = "admin-certs";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Message))
|
||||
{
|
||||
<div class="alert alert-success">✓ @Model.Message</div>
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">SSL Certificates</span>
|
||||
<button class="btn btn-primary" onclick="openModal('add-cert-modal')">+ Add Certificate</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>Domain</th>
|
||||
<th>Issuer</th>
|
||||
<th>Issued</th>
|
||||
<th>Expires</th>
|
||||
<th>Days Left</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in Model.Certificates)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var days = c.ExpiresAt.HasValue ? (int)(c.ExpiresAt.Value - now).TotalDays : (int?)null;
|
||||
var cls = days == null ? "" : days <= 0 ? "cert-expiry-expired" : days <= 7 ? "cert-expiry-critical" : days <= 30 ? "cert-expiry-warn" : "cert-expiry-ok";
|
||||
<tr>
|
||||
<td style="font-weight:500;color:var(--text-primary)">@c.Name</td>
|
||||
<td class="mono" style="font-size:11px">@c.Domain:@c.Port</td>
|
||||
<td style="font-size:11px;color:var(--text-muted);max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="@c.Issuer">@(c.Issuer?.Split(',')[0] ?? "-")</td>
|
||||
<td class="mono" style="font-size:11px">@(c.IssuedAt?.ToString("yyyy-MM-dd") ?? "-")</td>
|
||||
<td class="mono @cls" style="font-size:11px">@(c.ExpiresAt?.ToString("yyyy-MM-dd") ?? "-")</td>
|
||||
<td class="mono @cls" style="font-size:11px;font-weight:700">@(days.HasValue? days +"d" : "-")</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(c.LastError)) { <span class="badge badge-down" title="@c.LastError">ERROR</span> }
|
||||
else if (days == null) { <span class="badge badge-unknown">Unchecked</span> }
|
||||
else if (days <= 0) { <span class="badge badge-down">EXPIRED</span> }
|
||||
else if (days <= 7) { <span class="badge badge-down">CRITICAL</span> }
|
||||
else if (days <= 30) { <span class="badge badge-warn">EXPIRING</span> }
|
||||
else { <span class="badge badge-up">VALID</span> }
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<form method="post" asp-page-handler="CheckNow">
|
||||
<input type="hidden" name="id" value="@c.Id" />
|
||||
<button type="submit" class="btn btn-outline btn-sm">▶ Check</button>
|
||||
</form>
|
||||
<form method="post" asp-page-handler="Delete" onsubmit="return confirm('Delete @c.Name?')">
|
||||
<input type="hidden" name="id" value="@c.Id" />
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!Model.Certificates.Any())
|
||||
{
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-muted)">No certificates tracked yet.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add Cert Modal -->
|
||||
<div class="modal-overlay" id="add-cert-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Add Certificate</span>
|
||||
<button class="modal-close" onclick="closeModal('add-cert-modal')">✕</button>
|
||||
</div>
|
||||
<form method="post" asp-page-handler="Save">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="EditCert.Id" value="0" />
|
||||
<div class="two-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name *</label>
|
||||
<input type="text" name="EditCert.Name" class="form-control" required placeholder="My Site" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Domain *</label>
|
||||
<input type="text" name="EditCert.Domain" class="form-control" required placeholder="example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" name="EditCert.Port" class="form-control" value="443" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alert Days Before Expiry</label>
|
||||
<input type="number" name="EditCert.AlertDaysBeforeExpiry" class="form-control" value="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 align-center">
|
||||
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="EditCert.IsPublic" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span style="font-size:13px">Public</span>
|
||||
</label>
|
||||
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="EditCert.AlertOnExpiry" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span style="font-size:13px">Alert on Expiry</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-cert-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
90
EonaCat.LogStack.Status/Pages/Admin/Certificates.cshtml.cs
Normal file
90
EonaCat.LogStack.Status/Pages/Admin/Certificates.cshtml.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
using EonaCat.LogStack.Status.Services;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||
|
||||
public class CertificatesModel : PageModel
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
private readonly DatabaseContext _db;
|
||||
private readonly MonitoringService _monSvc;
|
||||
public CertificatesModel(DatabaseContext db, MonitoringService monSvc) { _db = db; _monSvc = monSvc; }
|
||||
|
||||
public List<CertificateEntry> Certificates { get; set; } = new();
|
||||
[BindProperty] public CertificateEntry EditCert { get; set; } = new();
|
||||
public string? Message { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string? msg)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
Certificates = await _db.Certificates.OrderBy(c => c.ExpiresAt).ToListAsync();
|
||||
Message = msg;
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSaveAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
if (EditCert.Id == 0)
|
||||
{
|
||||
_db.Certificates.Add(EditCert);
|
||||
}
|
||||
else
|
||||
{
|
||||
var e = await _db.Certificates.FindAsync(EditCert.Id);
|
||||
if (e != null)
|
||||
{
|
||||
e.Name = EditCert.Name;
|
||||
e.Domain = EditCert.Domain;
|
||||
e.Port = EditCert.Port;
|
||||
e.IsPublic = EditCert.IsPublic;
|
||||
e.AlertOnExpiry = EditCert.AlertOnExpiry;
|
||||
e.AlertDaysBeforeExpiry = EditCert.AlertDaysBeforeExpiry;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
return RedirectToPage(new { msg = "Certificate saved." });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var c = await _db.Certificates.FindAsync(id);
|
||||
if (c != null) { _db.Certificates.Remove(c); await _db.SaveChangesAsync(); }
|
||||
return RedirectToPage(new { msg = "Certificate deleted." });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostCheckNowAsync(int id)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var c = await _db.Certificates.FindAsync(id);
|
||||
if (c != null)
|
||||
{
|
||||
await _monSvc.CheckCertificateAsync(c);
|
||||
}
|
||||
|
||||
return RedirectToPage(new { msg = "Certificate checked." });
|
||||
}
|
||||
}
|
||||
165
EonaCat.LogStack.Status/Pages/Admin/Incidents.cshtml
Normal file
165
EonaCat.LogStack.Status/Pages/Admin/Incidents.cshtml
Normal file
@@ -0,0 +1,165 @@
|
||||
@page
|
||||
@model Status.Pages.Admin.IncidentsModel
|
||||
@{
|
||||
ViewData["Title"] = "Manage Incidents";
|
||||
ViewData["Page"] = "admin-incidents";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Message))
|
||||
{
|
||||
<div class="alert alert-success">✓ @Model.Message</div>
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">Incidents</span>
|
||||
<button class="btn btn-primary" onclick="openModal('add-incident-modal')">+ New Incident</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Title</th>
|
||||
<th>Severity</th>
|
||||
<th>Status</th>
|
||||
<th>Monitor</th>
|
||||
<th>Created</th>
|
||||
<th>Resolved</th>
|
||||
<th>Visibility</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@foreach (var i in Model.Incidents)
|
||||
{
|
||||
var severityBadge = i.Severity switch {
|
||||
IncidentSeverity.Critical => "badge-down",
|
||||
IncidentSeverity.Major => "badge-warn",
|
||||
_ => "badge-info"
|
||||
};
|
||||
var statusBadge = i.Status == IncidentStatus.Resolved ? "badge-up" : "badge-warn";
|
||||
<tr>
|
||||
<td style="color:var(--text-primary);font-weight:500">@i.Title</td>
|
||||
<td><span class="badge @severityBadge">@i.Severity</span></td>
|
||||
<td><span class="badge @statusBadge">@i.Status</span></td>
|
||||
<td style="font-size:12px">@(i.Monitor?.Name ?? "-")</td>
|
||||
<td class="mono" style="font-size:11px">@i.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="mono" style="font-size:11px">@(i.ResolvedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-")</td>
|
||||
<td>@(i.IsPublic ? "🌐 Public" : "🔒 Private")</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-outline btn-sm" onclick="openUpdate(@i.Id,'@i.Status')">Update</button>
|
||||
@if (i.Status != IncidentStatus.Resolved)
|
||||
{
|
||||
<form method="post" asp-page-handler="Resolve" style="display:inline">
|
||||
<input type="hidden" name="id" value="@i.Id" />
|
||||
<button type="submit" class="btn btn-outline btn-sm">Resolve</button>
|
||||
</form>
|
||||
}
|
||||
<form method="post" asp-page-handler="Delete" style="display:inline" onsubmit="return confirm('Delete incident?')">
|
||||
<input type="hidden" name="id" value="@i.Id" />
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!Model.Incidents.Any())
|
||||
{
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-muted)">No incidents recorded.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- New Incident Modal -->
|
||||
<div class="modal-overlay" id="add-incident-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">New Incident</span>
|
||||
<button class="modal-close" onclick="closeModal('add-incident-modal')">✕</button>
|
||||
</div>
|
||||
<form method="post" asp-page-handler="Create">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Title *</label>
|
||||
<input type="text" name="NewIncident.Title" class="form-control" required />
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Severity</label>
|
||||
<select name="NewIncident.Severity" class="form-control">
|
||||
@foreach (var s in Enum.GetValues<IncidentSeverity>())
|
||||
{ <option value="@s">@s</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="NewIncident.Status" class="form-control">
|
||||
@foreach (var s in Enum.GetValues<IncidentStatus>())
|
||||
{ <option value="@s">@s</option> }
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Related Monitor</label>
|
||||
<select name="NewIncident.MonitorId" class="form-control">
|
||||
<option value="">None</option>
|
||||
@foreach (var m in Model.Monitors)
|
||||
{ <option value="@m.Id">@m.Name</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="NewIncident.Body" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2 align-center">
|
||||
<label class="toggle"><input type="checkbox" name="NewIncident.IsPublic" checked /><span class="toggle-slider"></span></label>
|
||||
<span style="font-size:13px">Visible on public status page</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-incident-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Incident</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Update Modal -->
|
||||
<div class="modal-overlay" id="update-incident-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Post Update</span>
|
||||
<button class="modal-close" onclick="closeModal('update-incident-modal')">✕</button>
|
||||
</div>
|
||||
<form method="post" asp-page-handler="PostUpdate">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="UpdateDto.IncidentId" id="update-incident-id" />
|
||||
<div class="form-group">
|
||||
<label class="form-label">New Status</label>
|
||||
<select name="UpdateDto.Status" id="update-status" class="form-control">
|
||||
@foreach (var s in Enum.GetValues<IncidentStatus>())
|
||||
{ <option value="@s">@s</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Update Message *</label>
|
||||
<textarea name="UpdateDto.Message" class="form-control" rows="3" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('update-incident-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Post Update</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function openUpdate(id, status) {
|
||||
document.getElementById('update-incident-id').value = id;
|
||||
document.getElementById('update-status').value = status;
|
||||
openModal('update-incident-modal');
|
||||
}
|
||||
</script>
|
||||
}
|
||||
138
EonaCat.LogStack.Status/Pages/Admin/Incidents.cshtml.cs
Normal file
138
EonaCat.LogStack.Status/Pages/Admin/Incidents.cshtml.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class IncidentsModel : PageModel
|
||||
{
|
||||
private readonly DatabaseContext _database;
|
||||
public IncidentsModel(DatabaseContext database) => _database = database;
|
||||
|
||||
public List<Incident> Incidents { get; set; } = new();
|
||||
public List<Monitor> Monitors { get; set; } = new();
|
||||
public string? Message { get; set; }
|
||||
|
||||
[BindProperty] public Incident NewIncident { get; set; } = new();
|
||||
|
||||
public class UpdateDto
|
||||
{
|
||||
public int IncidentId { get; set; }
|
||||
public IncidentStatus Status { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
[BindProperty] public UpdateDto UpdateDtoModel { get; set; } = new();
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
Incidents = await _database.Incidents
|
||||
.Include(i => i.Updates)
|
||||
.Include(i => i.Monitor)
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
Monitors = await _database.Monitors.Where(m => m.IsActive).ToListAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostCreateAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
NewIncident.CreatedAt = DateTime.UtcNow;
|
||||
NewIncident.UpdatedAt = DateTime.UtcNow;
|
||||
_database.Incidents.Add(NewIncident);
|
||||
await _database.SaveChangesAsync();
|
||||
Message = "Incident created.";
|
||||
return await OnGetAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostPostUpdateAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var incident = await _database.Incidents.FindAsync(UpdateDtoModel.IncidentId);
|
||||
if (incident != null)
|
||||
{
|
||||
incident.Status = UpdateDtoModel.Status;
|
||||
incident.UpdatedAt = DateTime.UtcNow;
|
||||
if (UpdateDtoModel.Status == IncidentStatus.Resolved)
|
||||
{
|
||||
incident.ResolvedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
_database.IncidentUpdates.Add(new IncidentUpdate
|
||||
{
|
||||
IncidentId = incident.Id,
|
||||
Message = UpdateDtoModel.Message,
|
||||
Status = UpdateDtoModel.Status
|
||||
});
|
||||
await _database.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Message = "Update posted.";
|
||||
return await OnGetAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostResolveAsync(int id)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var incident = await _database.Incidents.FindAsync(id);
|
||||
if (incident != null)
|
||||
{
|
||||
incident.Status = IncidentStatus.Resolved;
|
||||
incident.ResolvedAt = DateTime.UtcNow;
|
||||
incident.UpdatedAt = DateTime.UtcNow;
|
||||
_database.IncidentUpdates.Add(new IncidentUpdate
|
||||
{
|
||||
IncidentId = id,
|
||||
Message = "Incident resolved.",
|
||||
Status = IncidentStatus.Resolved
|
||||
});
|
||||
await _database.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Message = "Incident resolved.";
|
||||
return await OnGetAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var incident = await _database.Incidents.FindAsync(id);
|
||||
if (incident != null)
|
||||
{
|
||||
_database.Incidents.Remove(incident);
|
||||
await _database.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Message = "Incident deleted.";
|
||||
return await OnGetAsync();
|
||||
}
|
||||
}
|
||||
133
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml
Normal file
133
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml
Normal file
@@ -0,0 +1,133 @@
|
||||
@page
|
||||
@model Status.Pages.Admin.IngestModel
|
||||
@{
|
||||
ViewData["Title"] = "Log Ingestion";
|
||||
ViewData["Page"] = "admin-ingest";
|
||||
var host = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
}
|
||||
|
||||
<div class="section-title mb-2">Log Ingestion API</div>
|
||||
<p style="color:var(--text-muted);margin-bottom:20px;font-size:13px">
|
||||
Status accepts logs from any application via HTTP POST. Compatible with custom HTTP sinks from Serilog, NLog, log4net, and any HTTP client.
|
||||
</p>
|
||||
|
||||
<div class="two-col" style="align-items:start">
|
||||
<div>
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><span class="card-title">Single Log Entry</span></div>
|
||||
<div class="card-body">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">POST @host/api/logs/ingest
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"source": "my-app",
|
||||
"level": "error",
|
||||
"message": "Something went wrong",
|
||||
"exception": "System.Exception: ...",
|
||||
"properties": "{\"userId\": 42}",
|
||||
"host": "prod-server-01",
|
||||
"traceId": "abc123"
|
||||
}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><span class="card-title">Batch Ingestion</span></div>
|
||||
<div class="card-body">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">POST @host/api/logs/batch
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{"source":"app","level":"info","message":"Started"},
|
||||
{"source":"app","level":"warn","message":"Slow query","properties":"{\"ms\":2400}"}
|
||||
]</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><span class="card-title">EonaCat.LogStack HTTP Flow (.NET)</span></div>
|
||||
<div class="card-body">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">// Install: EonaCat.LogStack
|
||||
var logger = new LogBuilder().WriteToHttp("@host/api/logs/eonacat").Build();
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><span class="card-title">Serilog HTTP Sink (.NET)</span></div>
|
||||
<div class="card-body">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">
|
||||
// Install: Serilog.Sinks.Http
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.Http(
|
||||
requestUri: "@host/api/logs/serilog",
|
||||
queueLimitBytes: null)
|
||||
.CreateLogger();
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Syslog (UDP)</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">
|
||||
# Send a syslog message using netcat (Linux/macOS)
|
||||
|
||||
echo "<14>1 $(date -u +%Y-%m-%dT%H:%M:%SZ) my-host my-app - - - Hello from syslog" \
|
||||
| nc -u -w0 YOUR_HOST 514
|
||||
|
||||
# Example using logger (Linux)
|
||||
|
||||
logger -n YOUR_HOST -P 514 "Hello from syslog"
|
||||
|
||||
|
||||
# Example raw syslog message (RFC5424)
|
||||
|
||||
<14>1 2026-03-28T12:00:00Z my-host my-app - - - Something happened
|
||||
|
||||
|
||||
# Example JSON over syslog (auto-detected)
|
||||
|
||||
{
|
||||
"source": "my-app",
|
||||
"level": "error",
|
||||
"message": "Something failed",
|
||||
"host": "server-01",
|
||||
"traceId": "abc123"
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><span class="card-title">Python (requests)</span></div>
|
||||
<div class="card-body">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">import requests
|
||||
|
||||
requests.post("@host/api/logs/ingest", json={
|
||||
"source": "my-python-app",
|
||||
"level": "info",
|
||||
"message": "App started",
|
||||
"host": socket.gethostname()
|
||||
})</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Log Levels</span></div>
|
||||
<div class="card-body">
|
||||
<table class="data-table">
|
||||
<tr><td><span class="badge badge-unknown">DEBUG</span></td><td style="font-size:12px">Verbose diagnostic info</td></tr>
|
||||
<tr><td><span class="badge badge-info">INFO</span></td><td style="font-size:12px">Normal operation events</td></tr>
|
||||
<tr><td><span class="badge badge-warn">WARNING</span></td><td style="font-size:12px">Warning requires attention</td></tr>
|
||||
<tr><td><span class="badge badge-down">ERROR</span></td><td style="font-size:12px">Errors requiring attention</td></tr>
|
||||
<tr><td><span class="badge badge-down">CRITICAL</span></td><td style="font-size:12px">System critical failure / crash</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
20
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml.cs
Normal file
20
EonaCat.LogStack.Status/Pages/Admin/Ingest.cshtml.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||
|
||||
public class IngestModel : PageModel
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public IActionResult OnGet()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
52
EonaCat.LogStack.Status/Pages/Admin/Login.cshtml
Normal file
52
EonaCat.LogStack.Status/Pages/Admin/Login.cshtml
Normal file
@@ -0,0 +1,52 @@
|
||||
@page
|
||||
@model Status.Pages.Admin.LoginModel
|
||||
@{
|
||||
Layout = null;
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Login - Status</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="~/css/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div style="text-align:center;margin-bottom:28px">
|
||||
<div style="font-size:32px;color:var(--accent);margin-bottom:8px">◈</div>
|
||||
<div class="login-title">Status</div>
|
||||
<div class="login-sub">Admin authentication required</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Error))
|
||||
{
|
||||
<div class="alert alert-danger">@Model.Error</div>
|
||||
}
|
||||
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="Password" class="form-control" placeholder="Enter admin password" autofocus />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;padding:10px">
|
||||
Authenticate →
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style="margin-top:16px;text-align:center">
|
||||
<a href="/" style="color:var(--text-muted);font-size:12px">← Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;padding-top:16px;border-top:1px solid var(--border);text-align:center">
|
||||
<span style="font-family:var(--font-mono);font-size:9px;color:var(--text-muted);letter-spacing:1px">
|
||||
DEFAULT PASSWORD: adminEonaCat
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
30
EonaCat.LogStack.Status/Pages/Admin/Login.cshtml.cs
Normal file
30
EonaCat.LogStack.Status/Pages/Admin/Login.cshtml.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using EonaCat.LogStack.Status.Services;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class LoginModel : PageModel
|
||||
{
|
||||
private readonly AuthenticationService _auth;
|
||||
public LoginModel(AuthenticationService auth) => _auth = auth;
|
||||
|
||||
[BindProperty] public string Password { get; set; } = "";
|
||||
public string? Error { get; set; }
|
||||
|
||||
public void OnGet() { }
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Password) && await _auth.ValidatePasswordAsync(Password))
|
||||
{
|
||||
HttpContext.Session.SetString("IsAdmin", "true");
|
||||
return RedirectToPage("/Index");
|
||||
}
|
||||
Error = "Invalid password.";
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
2
EonaCat.LogStack.Status/Pages/Admin/Logout.cshtml
Normal file
2
EonaCat.LogStack.Status/Pages/Admin/Logout.cshtml
Normal file
@@ -0,0 +1,2 @@
|
||||
@page
|
||||
@model Status.Pages.Admin.LogoutModel
|
||||
16
EonaCat.LogStack.Status/Pages/Admin/Logout.cshtml.cs
Normal file
16
EonaCat.LogStack.Status/Pages/Admin/Logout.cshtml.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class LogoutModel : PageModel
|
||||
{
|
||||
public IActionResult OnGet()
|
||||
{
|
||||
HttpContext.Session.Clear();
|
||||
return RedirectToPage("/Index");
|
||||
}
|
||||
}
|
||||
229
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml
Normal file
229
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml
Normal file
@@ -0,0 +1,229 @@
|
||||
@page
|
||||
@model EonaCat.LogStack.Status.Pages.Admin.MonitorsModel
|
||||
@{
|
||||
ViewData["Title"] = "Manage Monitors";
|
||||
ViewData["Page"] = "admin-monitors";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Message))
|
||||
{
|
||||
<div class="alert alert-success">✓ @Model.Message</div>
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">Monitors</span>
|
||||
<button class="btn btn-primary" onclick="openModal('add-monitor-modal')">+ Add Monitor</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Host / URL</th>
|
||||
<th>Group</th>
|
||||
<th>Interval</th>
|
||||
<th>Status</th>
|
||||
<th>Visibility</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@foreach (var m in Model.Monitors)
|
||||
{
|
||||
var badgeClass = m.LastStatus switch {
|
||||
MonitorStatus.Up => "badge-up",
|
||||
MonitorStatus.Down => "badge-down",
|
||||
MonitorStatus.Warning or MonitorStatus.Degraded => "badge-warn",
|
||||
_ => "badge-unknown"
|
||||
};
|
||||
<tr>
|
||||
<td style="color:var(--text-primary);font-weight:500">@m.Name
|
||||
@if (!m.IsActive) { <span class="badge badge-unknown" style="font-size:8px">PAUSED</span> }
|
||||
</td>
|
||||
<td class="mono" style="font-size:11px">@m.Type</td>
|
||||
<td class="mono" style="font-size:11px;color:var(--text-muted)">@(m.Url ?? (m.Host + (m.Port.HasValue ? ":" + m.Port : "")))</td>
|
||||
<td style="font-size:12px">@(m.GroupName ?? "-")</td>
|
||||
<td class="mono" style="font-size:11px">@m.IntervalSeconds s</td>
|
||||
<td>
|
||||
<span class="badge @badgeClass">@m.LastStatus</span>
|
||||
@if (m.ConsecutiveFailures > 0 && m.LastStatus != MonitorStatus.Down)
|
||||
{
|
||||
<span class="mono" style="font-size:9px;color:var(--warn)" title="Consecutive failures">(@m.ConsecutiveFailures/@m.FailureThreshold)</span>
|
||||
}
|
||||
</td>
|
||||
<td>@(m.IsPublic ? "🌐 Public" : "🔒 Private")</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<form method="post" asp-page-handler="CheckNow" style="display:inline">
|
||||
<input type="hidden" name="id" value="@m.Id" />
|
||||
<button type="submit" class="btn btn-outline btn-sm" title="Check Now">▶</button>
|
||||
</form>
|
||||
<button class="btn btn-outline btn-sm" onclick="editMonitor(@m.Id,'@m.Name','@(m.Description ?? "")','@m.Type','@m.Host','@(m.Port?.ToString() ?? "")','@(m.Url ?? "")','@(m.ProcessName ?? "")','@m.IntervalSeconds','@m.TimeoutMs','@m.IsActive'.toLowerCase(),'@m.IsPublic'.toLowerCase(),'@(m.Tags ?? "")','@(m.GroupName ?? "")','@m.FailureThreshold','@(m.ExpectedKeyword ?? "")','@(m.ExpectedStatusCode?.ToString() ?? "")')">Edit</button>
|
||||
<form method="post" asp-page-handler="Delete" style="display:inline" onsubmit="return confirm('Delete @m.Name?')">
|
||||
<input type="hidden" name="id" value="@m.Id" />
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!Model.Monitors.Any())
|
||||
{
|
||||
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-muted)">No monitors yet. Add one above.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Monitor Modal -->
|
||||
<div class="modal-overlay" id="add-monitor-modal">
|
||||
<div class="modal" style="max-width:640px">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modal-title">Add Monitor</span>
|
||||
<button class="modal-close" onclick="closeModal('add-monitor-modal')">✕</button>
|
||||
</div>
|
||||
<form method="post" asp-page-handler="Save">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="EditMonitor.Id" id="edit-id" value="0" />
|
||||
<div class="two-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name *</label>
|
||||
<input type="text" name="EditMonitor.Name" id="edit-name" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Type *</label>
|
||||
<select name="EditMonitor.Type" id="edit-type" class="form-control" onchange="updateTypeFields()">
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
<option value="HTTP">HTTP</option>
|
||||
<option value="HTTPS">HTTPS</option>
|
||||
<option value="Ping">Ping (ICMP)</option>
|
||||
<option value="AppLocal">App (Local)</option>
|
||||
<option value="AppRemote">App (Remote)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" name="EditMonitor.Description" id="edit-desc" class="form-control" />
|
||||
</div>
|
||||
<div class="two-col" id="host-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" name="EditMonitor.Host" id="edit-host" class="form-control" placeholder="hostname or IP" />
|
||||
</div>
|
||||
<div class="form-group" id="port-col">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" name="EditMonitor.Port" id="edit-port" class="form-control" placeholder="e.g. 443" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="url-row" style="display:none">
|
||||
<label class="form-label">URL</label>
|
||||
<input type="text" name="EditMonitor.Url" id="edit-url" class="form-control" placeholder="https://example.com" />
|
||||
</div>
|
||||
<div class="form-group" id="process-row" style="display:none">
|
||||
<label class="form-label">Process Name</label>
|
||||
<input type="text" name="EditMonitor.ProcessName" id="edit-process" class="form-control" placeholder="nginx" />
|
||||
</div>
|
||||
<!-- HTTP-specific assertions -->
|
||||
<div id="http-assertions" style="display:none">
|
||||
<div class="two-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Expected Keyword (optional)</label>
|
||||
<input type="text" name="EditMonitor.ExpectedKeyword" id="edit-keyword" class="form-control" placeholder="OK" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Expected Status Code (optional)</label>
|
||||
<input type="number" name="EditMonitor.ExpectedStatusCode" id="edit-statuscode" class="form-control" placeholder="200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Check Interval (seconds)</label>
|
||||
<input type="number" name="EditMonitor.IntervalSeconds" id="edit-interval" class="form-control" value="60" min="10" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Timeout (ms)</label>
|
||||
<input type="number" name="EditMonitor.TimeoutMs" id="edit-timeout" class="form-control" value="5000" min="500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Failure Threshold</label>
|
||||
<input type="number" name="EditMonitor.FailureThreshold" id="edit-threshold" class="form-control" value="1" min="1" max="10" />
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:3px">Consecutive failures before marking as down.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Group Name</label>
|
||||
<input type="text" name="EditMonitor.GroupName" id="edit-group" class="form-control" placeholder="Production" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tags (comma-separated)</label>
|
||||
<input type="text" name="EditMonitor.Tags" id="edit-tags" class="form-control" placeholder="web, api" />
|
||||
</div>
|
||||
<div class="flex gap-3 align-center mt-1">
|
||||
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="EditMonitor.IsActive" id="edit-active" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span style="font-size:13px">Active</span>
|
||||
</label>
|
||||
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="EditMonitor.IsPublic" id="edit-public" checked />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span style="font-size:13px">Public</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('add-monitor-modal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Monitor</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function updateTypeFields() {
|
||||
const type = document.getElementById('edit-type').value;
|
||||
const isHttp = ['HTTP', 'HTTPS'].includes(type);
|
||||
const isPing = type === 'Ping';
|
||||
const isLocal = type === 'AppLocal';
|
||||
|
||||
document.getElementById('host-row').style.display = isHttp ? 'none' : 'grid';
|
||||
document.getElementById('port-col').style.display = (isPing || isLocal) ? 'none' : 'block';
|
||||
document.getElementById('url-row').style.display = isHttp ? 'block' : 'none';
|
||||
document.getElementById('process-row').style.display = isLocal ? 'block' : 'none';
|
||||
document.getElementById('http-assertions').style.display = isHttp ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function editMonitor(id,name,desc,type,host,port,url,process,interval,timeout,active,pub,tags,group,threshold,keyword,statuscode) {
|
||||
document.getElementById('modal-title').textContent = 'Edit Monitor';
|
||||
document.getElementById('edit-id').value = id;
|
||||
document.getElementById('edit-name').value = name;
|
||||
document.getElementById('edit-desc').value = desc;
|
||||
document.getElementById('edit-type').value = type;
|
||||
document.getElementById('edit-host').value = host;
|
||||
document.getElementById('edit-port').value = port;
|
||||
document.getElementById('edit-url').value = url;
|
||||
document.getElementById('edit-process').value = process;
|
||||
document.getElementById('edit-interval').value = interval;
|
||||
document.getElementById('edit-timeout').value = timeout;
|
||||
document.getElementById('edit-active').checked = active === 'true';
|
||||
document.getElementById('edit-public').checked = pub === 'true';
|
||||
document.getElementById('edit-tags').value = tags;
|
||||
document.getElementById('edit-group').value = group;
|
||||
document.getElementById('edit-threshold').value = threshold;
|
||||
document.getElementById('edit-keyword').value = keyword;
|
||||
document.getElementById('edit-statuscode').value = statuscode;
|
||||
updateTypeFields();
|
||||
openModal('add-monitor-modal');
|
||||
}
|
||||
</script>
|
||||
}
|
||||
121
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml.cs
Normal file
121
EonaCat.LogStack.Status/Pages/Admin/Monitors.cshtml.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
using EonaCat.LogStack.Status.Services;
|
||||
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class MonitorsModel : PageModel
|
||||
{
|
||||
private readonly DatabaseContext _db;
|
||||
private readonly MonitoringService _monSvc;
|
||||
public MonitorsModel(DatabaseContext db, MonitoringService monSvc) { _db = db; _monSvc = monSvc; }
|
||||
|
||||
public List<Monitor> Monitors { get; set; } = new();
|
||||
|
||||
[BindProperty] public Monitor EditMonitor { get; set; } = new();
|
||||
public string? Message { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string? msg)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
Monitors = await _db.Monitors.OrderBy(m => m.GroupName).ThenBy(m => m.Name).ToListAsync();
|
||||
Message = msg;
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSaveAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
// If no host, extract from URL
|
||||
if (string.IsNullOrWhiteSpace(EditMonitor.Host) && !string.IsNullOrEmpty(EditMonitor.Url))
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(EditMonitor.Url);
|
||||
EditMonitor.Host = uri.Host;
|
||||
if (EditMonitor.Port == null || EditMonitor.Port == 0)
|
||||
{
|
||||
EditMonitor.Port = uri.Port;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (EditMonitor.Id == 0)
|
||||
{
|
||||
_db.Monitors.Add(EditMonitor);
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await _db.Monitors.FindAsync(EditMonitor.Id);
|
||||
if (existing == null)
|
||||
{
|
||||
return RedirectToPage(new { msg = "Monitor not found." });
|
||||
}
|
||||
|
||||
existing.Name = EditMonitor.Name;
|
||||
existing.Description = EditMonitor.Description;
|
||||
existing.Type = EditMonitor.Type;
|
||||
existing.Host = EditMonitor.Host;
|
||||
existing.Port = EditMonitor.Port;
|
||||
existing.Url = EditMonitor.Url;
|
||||
existing.ProcessName = EditMonitor.ProcessName;
|
||||
existing.IntervalSeconds = EditMonitor.IntervalSeconds;
|
||||
existing.TimeoutMs = EditMonitor.TimeoutMs;
|
||||
existing.IsActive = EditMonitor.IsActive;
|
||||
existing.IsPublic = EditMonitor.IsPublic;
|
||||
existing.Tags = EditMonitor.Tags;
|
||||
existing.GroupName = EditMonitor.GroupName;
|
||||
// new fields
|
||||
existing.FailureThreshold = Math.Max(1, EditMonitor.FailureThreshold);
|
||||
existing.ExpectedKeyword = EditMonitor.ExpectedKeyword;
|
||||
existing.ExpectedStatusCode = EditMonitor.ExpectedStatusCode;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return RedirectToPage(new { msg = "Monitor saved." });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var m = await _db.Monitors.FindAsync(id);
|
||||
if (m != null) { _db.Monitors.Remove(m); await _db.SaveChangesAsync(); }
|
||||
return RedirectToPage(new { msg = "Monitor deleted." });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostCheckNowAsync(int id)
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var m = await _db.Monitors.FindAsync(id);
|
||||
if (m != null)
|
||||
{
|
||||
await _monSvc.CheckMonitorAsync(m);
|
||||
}
|
||||
|
||||
return RedirectToPage(new { msg = "Check completed." });
|
||||
}
|
||||
}
|
||||
129
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml
Normal file
129
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml
Normal file
@@ -0,0 +1,129 @@
|
||||
@page
|
||||
@model EonaCat.LogStack.Status.Pages.Admin.SettingsModel
|
||||
@{
|
||||
ViewData["Title"] = "Settings";
|
||||
ViewData["Page"] = "admin-settings";
|
||||
var pwParts = (Model.PasswordMessage ?? "").Split(':', 2);
|
||||
var pwType = pwParts.Length > 1 ? pwParts[0] : "";
|
||||
var pwMsg = pwParts.Length > 1 ? pwParts[1] : pwParts.ElementAtOrDefault(0) ?? "";
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Message))
|
||||
{
|
||||
<div class="alert alert-success">✓ @Model.Message</div>
|
||||
}
|
||||
|
||||
<div class="two-col" style="align-items:start">
|
||||
|
||||
<div>
|
||||
<div class="section-title mb-2">General Settings</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" asp-page-handler="SaveSettings">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Site Name</label>
|
||||
<input type="text" name="SiteName" value="@Model.SiteName" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alert Email</label>
|
||||
<input type="email" name="AlertEmail" value="@Model.AlertEmail" class="form-control" placeholder="alerts@example.com" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alert Webhook URL</label>
|
||||
<input type="url" name="AlertWebhookUrl" value="@Model.AlertWebhookUrl" class="form-control" placeholder="https://hooks.slack.com/..." />
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Global fallback for alert rules without a specific webhook.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Log Retention (days)</label>
|
||||
<input type="number" name="MaxLogRetentionDays" value="@Model.MaxLogRetentionDays" class="form-control" min="1" max="365" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="flex align-center gap-2" style="cursor:pointer;margin-bottom:10px">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="ShowUptimePublicly" @(Model.ShowUptimePublicly ? "checked" : "") />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span>Show uptime % publicly</span>
|
||||
</label>
|
||||
<label class="flex align-center gap-2" style="cursor:pointer;margin-bottom:10px">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="ShowIncidentsPublicly" @(Model.ShowIncidentsPublicly ? "checked" : "") />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span>Show incidents on public page</span>
|
||||
</label>
|
||||
<label class="flex align-center gap-2" style="cursor:pointer;margin-bottom:10px">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="AutoCreateIncidents" @(Model.AutoCreateIncidents ? "checked" : "") />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span>Auto-create incidents when monitors go down</span>
|
||||
</label>
|
||||
<label class="flex align-center gap-2" style="cursor:pointer">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="ShowLogsPublicly" @(Model.ShowLogsPublicly ? "checked" : "") />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span>Show logs publicly (not recommended)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="section-title mb-2">Change Password</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@if (!string.IsNullOrEmpty(pwMsg))
|
||||
{
|
||||
<div class="alert @(pwType == "success" ? "alert-success" : "alert-danger")">@pwMsg</div>
|
||||
}
|
||||
<form method="post" asp-page-handler="ChangePassword">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Current Password</label>
|
||||
<input type="password" name="CurrentPassword" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" name="NewPassword" class="form-control" required minlength="6" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<input type="password" name="ConfirmPassword" class="form-control" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title mb-2 mt-3">API Endpoints</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">Use these endpoints to ingest logs or query status from external applications.</p>
|
||||
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:12px;border-radius:4px;line-height:2">
|
||||
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/logs/ingest</span></div>
|
||||
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/logs/batch</span></div>
|
||||
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/logs/serilog</span></div>
|
||||
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/logs/eonacat</span></div>
|
||||
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/logs</span> <span style="color:var(--text-muted)">?level=&source=&search=&from=&to=&page=&pageSize=</span></div>
|
||||
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/logs/stats</span> <span style="color:var(--text-muted)">?hours=24</span></div>
|
||||
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/status/summary</span></div>
|
||||
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/monitors</span></div>
|
||||
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/monitors/{id}/check</span></div>
|
||||
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/monitors/{id}/history</span> <span style="color:var(--text-muted)">?limit=100</span></div>
|
||||
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/monitors/{id}/uptime</span></div>
|
||||
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/monitors/{id}/pause</span></div>
|
||||
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/monitors/{id}/resume</span></div>
|
||||
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/incidents</span> <span style="color:var(--text-muted)">?activeOnly=true</span></div>
|
||||
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/incidents</span></div>
|
||||
<div><span style="color:var(--warn)">PATCH</span> <span style="color:var(--text-primary)">/api/incidents/{id}</span></div>
|
||||
</div>
|
||||
<a href="/admin/ingest" class="btn btn-outline btn-sm mt-2">View Ingest Docs →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
86
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml.cs
Normal file
86
EonaCat.LogStack.Status/Pages/Admin/Settings.cshtml.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using EonaCat.LogStack.Status.Services;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages.Admin;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class SettingsModel : PageModel
|
||||
{
|
||||
private readonly AuthenticationService _authenticationService;
|
||||
public SettingsModel(AuthenticationService authentication) => _authenticationService = authentication;
|
||||
|
||||
[BindProperty] public string SiteName { get; set; } = "";
|
||||
[BindProperty] public bool ShowLogsPublicly { get; set; }
|
||||
[BindProperty] public bool ShowUptimePublicly { get; set; }
|
||||
[BindProperty] public bool ShowIncidentsPublicly { get; set; }
|
||||
[BindProperty] public bool AutoCreateIncidents { get; set; }
|
||||
[BindProperty] public int MaxLogRetentionDays { get; set; } = 30;
|
||||
[BindProperty] public string AlertEmail { get; set; } = "";
|
||||
[BindProperty] public string AlertWebhookUrl { get; set; } = "";
|
||||
[BindProperty] public string CurrentPassword { get; set; } = "";
|
||||
[BindProperty] public string NewPassword { get; set; } = "";
|
||||
[BindProperty] public string ConfirmPassword { get; set; } = "";
|
||||
public string? Message { get; set; }
|
||||
public string? PasswordMessage { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
SiteName = await _authenticationService.GetSettingAsync("SiteName", "Status");
|
||||
ShowLogsPublicly = (await _authenticationService.GetSettingAsync("ShowLogsPublicly", "false")) == "true";
|
||||
ShowUptimePublicly = (await _authenticationService.GetSettingAsync("ShowUptimePublicly", "true")) == "true";
|
||||
ShowIncidentsPublicly = (await _authenticationService.GetSettingAsync("ShowIncidentsPublicly", "true")) == "true";
|
||||
AutoCreateIncidents = (await _authenticationService.GetSettingAsync("AutoCreateIncidents", "false")) == "true";
|
||||
MaxLogRetentionDays = int.TryParse(await _authenticationService.GetSettingAsync("MaxLogRetentionDays", "30"), out var d) ? d : 30;
|
||||
AlertEmail = await _authenticationService.GetSettingAsync("AlertEmail", "");
|
||||
AlertWebhookUrl = await _authenticationService.GetSettingAsync("AlertWebhookUrl", "");
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSaveSettingsAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
await _authenticationService.SetSettingAsync("SiteName", SiteName ?? "Status");
|
||||
await _authenticationService.SetSettingAsync("ShowLogsPublicly", ShowLogsPublicly ? "true" : "false");
|
||||
await _authenticationService.SetSettingAsync("ShowUptimePublicly", ShowUptimePublicly ? "true" : "false");
|
||||
await _authenticationService.SetSettingAsync("ShowIncidentsPublicly", ShowIncidentsPublicly ? "true" : "false");
|
||||
await _authenticationService.SetSettingAsync("AutoCreateIncidents", AutoCreateIncidents ? "true" : "false");
|
||||
await _authenticationService.SetSettingAsync("MaxLogRetentionDays", MaxLogRetentionDays.ToString());
|
||||
await _authenticationService.SetSettingAsync("AlertEmail", AlertEmail ?? "");
|
||||
await _authenticationService.SetSettingAsync("AlertWebhookUrl", AlertWebhookUrl ?? "");
|
||||
Message = "Settings saved.";
|
||||
return await OnGetAsync();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostChangePasswordAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
if (NewPassword != ConfirmPassword) { PasswordMessage = "error:Passwords do not match."; return await OnGetAsync(); }
|
||||
if (NewPassword.Length < 6) { PasswordMessage = "error:Password must be at least 6 characters."; return await OnGetAsync(); }
|
||||
if (await _authenticationService.ChangePasswordAsync(CurrentPassword, NewPassword))
|
||||
{
|
||||
PasswordMessage = "success:Password changed successfully.";
|
||||
}
|
||||
else
|
||||
{
|
||||
PasswordMessage = "error:Current password is incorrect.";
|
||||
}
|
||||
|
||||
return await OnGetAsync();
|
||||
}
|
||||
}
|
||||
80
EonaCat.LogStack.Status/Pages/Analytics.cshtml
Normal file
80
EonaCat.LogStack.Status/Pages/Analytics.cshtml
Normal file
@@ -0,0 +1,80 @@
|
||||
@page
|
||||
@model EonaCat.LogStack.Status.Pages.AnalyticsModel
|
||||
@{
|
||||
ViewData["Title"] = "Analytics";
|
||||
ViewData["Page"] = "analytics";
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">Uptime & Performance</span>
|
||||
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.Reports.Count monitors</span>
|
||||
</div>
|
||||
|
||||
@if (!Model.Reports.Any())
|
||||
{
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">◎</div>
|
||||
<div class="empty-state-text">No check history yet</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Monitor</th>
|
||||
<th>24h Uptime</th>
|
||||
<th>7d Uptime</th>
|
||||
<th>30d Uptime</th>
|
||||
<th>Avg Response</th>
|
||||
<th>Checks (30d)</th>
|
||||
<th>Response Trend</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in Model.Reports)
|
||||
{
|
||||
string UptimeCls(double pct) => pct >= 99 ? "rt-good" : pct >= 95 ? "rt-ok" : "rt-slow";
|
||||
<tr>
|
||||
<td style="font-weight:500;color:var(--text-primary)">@r.MonitorName</td>
|
||||
<td class="mono @UptimeCls(r.Uptime24h)" style="font-size:12px">@r.Uptime24h.ToString("F1")%</td>
|
||||
<td class="mono @UptimeCls(r.Uptime7d)" style="font-size:12px">@r.Uptime7d.ToString("F1")%</td>
|
||||
<td class="mono @UptimeCls(r.Uptime30d)" style="font-size:12px">@r.Uptime30d.ToString("F1")%</td>
|
||||
<td class="mono" style="font-size:12px">@((int)r.AvgResponseMs)ms</td>
|
||||
<td class="mono" style="font-size:12px">
|
||||
<span style="color:var(--up)">@r.UpChecks ↑</span>
|
||||
<span style="color:var(--down);margin-left:6px">@r.DownChecks ↓</span>
|
||||
</td>
|
||||
<td>
|
||||
<canvas id="spark-@r.MonitorId" width="90" height="28"
|
||||
data-values="@Model.SparklineData[r.MonitorId]"
|
||||
class="sparkline-canvas"></canvas>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Log error rate chart -->
|
||||
<div class="chart-wrap mt-2">
|
||||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);letter-spacing:1px;margin-bottom:10px">LOG ERROR RATE - LAST 24H</div>
|
||||
<canvas id="log-stats-chart" style="height:180px"></canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Render response-time sparklines
|
||||
document.querySelectorAll('.sparkline-canvas').forEach(canvas => {
|
||||
const raw = canvas.dataset.values;
|
||||
if (!raw) return;
|
||||
const values = raw.split(',').map(Number).filter(n => !isNaN(n));
|
||||
drawSparkline(canvas, values, 'rgba(0,212,170,0.7)');
|
||||
});
|
||||
|
||||
loadLogStatsChart('log-stats-chart');
|
||||
});
|
||||
</script>
|
||||
}
|
||||
56
EonaCat.LogStack.Status/Pages/Analytics.cshtml.cs
Normal file
56
EonaCat.LogStack.Status/Pages/Analytics.cshtml.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
using EonaCat.LogStack.Status.Services;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class AnalyticsModel : PageModel
|
||||
{
|
||||
private readonly DatabaseContext _db;
|
||||
private readonly MonitoringService _monSvc;
|
||||
|
||||
public AnalyticsModel(DatabaseContext db, MonitoringService monSvc)
|
||||
{
|
||||
_db = db;
|
||||
_monSvc = monSvc;
|
||||
}
|
||||
|
||||
public List<UptimeReport> Reports { get; set; } = new();
|
||||
/// <summary>Comma-separated response times (ms) for the last 30 checks - keyed by MonitorId.</summary>
|
||||
public Dictionary<int, string> SparklineData { get; set; } = new();
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
var monitors = await _db.Monitors.Where(m => m.IsActive).OrderBy(m => m.Name).ToListAsync();
|
||||
|
||||
foreach (var m in monitors)
|
||||
{
|
||||
var report = await _monSvc.GetUptimeReportAsync(m.Id);
|
||||
Reports.Add(report);
|
||||
|
||||
// Last 30 response times for sparkline
|
||||
var recent = await _db.MonitorChecks
|
||||
.Where(c => c.MonitorId == m.Id)
|
||||
.OrderByDescending(c => c.CheckedAt)
|
||||
.Take(30)
|
||||
.Select(c => c.ResponseMs)
|
||||
.ToListAsync();
|
||||
|
||||
recent.Reverse();
|
||||
SparklineData[m.Id] = string.Join(",", recent.Select(v => ((int)v).ToString()));
|
||||
}
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
72
EonaCat.LogStack.Status/Pages/Certificates.cshtml
Normal file
72
EonaCat.LogStack.Status/Pages/Certificates.cshtml
Normal file
@@ -0,0 +1,72 @@
|
||||
@page
|
||||
@model Status.Pages.CertificatesModel
|
||||
@{
|
||||
ViewData["Title"] = "Certificates";
|
||||
ViewData["Page"] = "certs";
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">SSL/TLS Certificates</span>
|
||||
@if (Model.IsAdmin) { <a href="/admin/certificates" class="btn btn-outline btn-sm">Manage →</a> }
|
||||
</div>
|
||||
|
||||
@if (!Model.Certificates.Any())
|
||||
{
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">◧</div>
|
||||
<div class="empty-state-text">No certificates tracked</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="stats-grid">
|
||||
@{
|
||||
var now = DateTime.UtcNow;
|
||||
var valid = Model.Certificates.Count(c => c.ExpiresAt.HasValue && (c.ExpiresAt.Value - now).TotalDays > 30);
|
||||
var expiringSoon = Model.Certificates.Count(c => c.ExpiresAt.HasValue && (c.ExpiresAt.Value - now).TotalDays is > 0 and <= 30);
|
||||
var expired = Model.Certificates.Count(c => c.ExpiresAt.HasValue && c.ExpiresAt.Value <= now);
|
||||
}
|
||||
<div class="stat-card up"><div class="stat-label">Valid</div><div class="stat-value">@valid</div></div>
|
||||
<div class="stat-card warn"><div class="stat-label">Expiring <30d</div><div class="stat-value">@expiringSoon</div></div>
|
||||
<div class="stat-card down"><div class="stat-label">Expired</div><div class="stat-value">@expired</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>Domain</th>
|
||||
<th>Issuer</th>
|
||||
<th>Valid From</th>
|
||||
<th>Expires</th>
|
||||
<th>Days Left</th>
|
||||
<th>Fingerprint</th>
|
||||
<th>Status</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@foreach (var certificate in Model.Certificates)
|
||||
{
|
||||
var days = certificate.ExpiresAt.HasValue ? (int)(certificate.ExpiresAt.Value - now).TotalDays : (int?)null;
|
||||
var cls = days == null ? "" : days <= 0 ? "cert-expiry-expired" : days <= 7 ? "cert-expiry-critical" : days <= 30 ? "cert-expiry-warn" : "cert-expiry-ok";
|
||||
<tr>
|
||||
<td style="font-weight:500;color:var(--text-primary)">@certificate.Name</td>
|
||||
<td class="mono" style="font-size:11px">@certificate.Domain</td>
|
||||
<td style="font-size:11px;color:var(--text-muted);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">@(certificate.Issuer?.Split(',').FirstOrDefault()?.Replace("CN=", "") ?? "-")</td>
|
||||
<td class="mono" style="font-size:11px">@(certificate.IssuedAt?.ToString("yyyy-MM-dd") ?? "-")</td>
|
||||
<td class="mono @cls" style="font-size:11px">@(certificate.ExpiresAt?.ToString("yyyy-MM-dd") ?? "-")</td>
|
||||
<td class="mono @cls" style="font-weight:700">@(days.HasValue? days +"d" : "-")</td>
|
||||
<td class="mono" style="font-size:10px;color:var(--text-muted)">@(certificate.Thumbprint?[..16] ?? "-")…</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(certificate.LastError)) { <span class="badge badge-down" title="@certificate.LastError">ERROR</span> }
|
||||
else if (days == null) { <span class="badge badge-unknown">Unchecked</span> }
|
||||
else if (days <= 0) { <span class="badge badge-down">EXPIRED</span> }
|
||||
else if (days <= 7) { <span class="badge badge-down">CRITICAL</span> }
|
||||
else if (days <= 30) { <span class="badge badge-warn">EXPIRING</span> }
|
||||
else { <span class="badge badge-up">VALID</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
20
EonaCat.LogStack.Status/Pages/Certificates.cshtml.cs
Normal file
20
EonaCat.LogStack.Status/Pages/Certificates.cshtml.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages;
|
||||
|
||||
public class CertificatesModel : PageModel
|
||||
{
|
||||
private readonly DatabaseContext _db;
|
||||
public CertificatesModel(DatabaseContext db) => _db = db;
|
||||
public List<CertificateEntry> Certificates { get; set; } = new List<CertificateEntry>();
|
||||
public bool IsAdmin { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
IsAdmin = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||
Certificates = await _db.Certificates.OrderBy(c => c.ExpiresAt).ToListAsync();
|
||||
}
|
||||
}
|
||||
70
EonaCat.LogStack.Status/Pages/Incidents.cshtml
Normal file
70
EonaCat.LogStack.Status/Pages/Incidents.cshtml
Normal file
@@ -0,0 +1,70 @@
|
||||
@page
|
||||
@model EonaCat.LogStack.Status.Pages.IncidentsModel
|
||||
@{
|
||||
ViewData["Title"] = "Incidents";
|
||||
ViewData["Page"] = "incidents";
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">Incident History</span>
|
||||
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.Incidents.Count total</span>
|
||||
</div>
|
||||
|
||||
@if (!Model.Incidents.Any())
|
||||
{
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">✓</div>
|
||||
<div class="empty-state-text">No incidents recorded - all systems nominal.</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var inc in Model.Incidents)
|
||||
{
|
||||
var headerClass = inc.Severity switch {
|
||||
IncidentSeverity.Critical => "down",
|
||||
IncidentSeverity.Major => "warn",
|
||||
_ => "info"
|
||||
};
|
||||
var statusBadge = inc.Status == IncidentStatus.Resolved ? "badge-up" : "badge-warn";
|
||||
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">@inc.Title</span>
|
||||
<span class="badge @statusBadge" style="margin-left:8px">@inc.Status</span>
|
||||
<span class="badge badge-@headerClass" style="margin-left:4px">@inc.Severity</span>
|
||||
</div>
|
||||
<span class="mono" style="font-size:11px;color:var(--text-muted)">
|
||||
@inc.CreatedAt.ToString("yyyy-MM-dd HH:mm") UTC
|
||||
@if (inc.ResolvedAt.HasValue) { <span> - resolved @inc.ResolvedAt.Value.ToString("yyyy-MM-dd HH:mm")</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div style="padding:12px 16px">
|
||||
@if (!string.IsNullOrEmpty(inc.Body))
|
||||
{
|
||||
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">@inc.Body</p>
|
||||
}
|
||||
@if (inc.Monitor != null)
|
||||
{
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">
|
||||
Affected service: <strong style="color:var(--text-primary)">@inc.Monitor.Name</strong>
|
||||
</div>
|
||||
}
|
||||
@if (inc.Updates.Any())
|
||||
{
|
||||
<div style="border-left:2px solid var(--border);padding-left:12px;margin-top:8px">
|
||||
@foreach (var u in inc.Updates.OrderByDescending(u => u.PostedAt))
|
||||
{
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px">
|
||||
<span class="mono" style="font-size:10px;color:var(--text-muted)">@u.PostedAt.ToString("yyyy-MM-dd HH:mm")</span>
|
||||
<span class="badge badge-info" style="font-size:9px">@u.Status</span>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--text-secondary)">@u.Message</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
48
EonaCat.LogStack.Status/Pages/Incidents.cshtml.cs
Normal file
48
EonaCat.LogStack.Status/Pages/Incidents.cshtml.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
using EonaCat.LogStack.Status.Services;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class IncidentsModel : PageModel
|
||||
{
|
||||
private readonly DatabaseContext _db;
|
||||
private readonly AuthenticationService _authSvc;
|
||||
|
||||
public IncidentsModel(DatabaseContext db, AuthenticationService authSvc)
|
||||
{
|
||||
_db = db;
|
||||
_authSvc = authSvc;
|
||||
}
|
||||
|
||||
public List<Incident> Incidents { get; set; } = new();
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var isAdmin = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||
var showIncidents = (await _authSvc.GetSettingAsync("ShowIncidentsPublicly", "true")) == "true";
|
||||
|
||||
if (!isAdmin && !showIncidents)
|
||||
{
|
||||
Incidents = new List<Incident>();
|
||||
return;
|
||||
}
|
||||
|
||||
var query = _db.Incidents
|
||||
.Include(i => i.Updates)
|
||||
.Include(i => i.Monitor)
|
||||
.AsQueryable();
|
||||
|
||||
if (!isAdmin)
|
||||
{
|
||||
query = query.Where(i => i.IsPublic);
|
||||
}
|
||||
|
||||
Incidents = await query.OrderByDescending(i => i.CreatedAt).ToListAsync();
|
||||
}
|
||||
}
|
||||
198
EonaCat.LogStack.Status/Pages/Index.cshtml
Normal file
198
EonaCat.LogStack.Status/Pages/Index.cshtml
Normal file
@@ -0,0 +1,198 @@
|
||||
@page
|
||||
@model IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Dashboard";
|
||||
ViewData["Page"] = "dashboard";
|
||||
}
|
||||
<div data-autorefresh="true">
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card @(Model.Stats.DownCount > 0 ? "down" : Model.Stats.WarnCount > 0 ? "warn" : Model.Stats.UpCount > 0 ? "up" : "neutral")">
|
||||
<div class="stat-label">Overall</div>
|
||||
<div class="stat-value" style="font-size:22px">
|
||||
@if (Model.Stats.DownCount > 0) { <span style="color:var(--down)">DEGRADED</span> }
|
||||
else if (Model.Stats.WarnCount > 0) { <span style="color:var(--warn)">WARNING</span> }
|
||||
else if (Model.Stats.UpCount > 0) { <span style="color:var(--up)">OPERATIONAL</span> }
|
||||
else { <span style="color:var(--unknown)">UNKNOWN</span> }
|
||||
</div>
|
||||
<div class="stat-sub">@Model.Stats.TotalMonitors monitor(s) active</div>
|
||||
</div>
|
||||
<div class="stat-card up">
|
||||
<div class="stat-label">Online</div>
|
||||
<div class="stat-value">@Model.Stats.UpCount</div>
|
||||
<div class="stat-sub">monitors up</div>
|
||||
</div>
|
||||
<div class="stat-card down">
|
||||
<div class="stat-label">Offline</div>
|
||||
<div class="stat-value">@Model.Stats.DownCount</div>
|
||||
<div class="stat-sub">monitors down</div>
|
||||
</div>
|
||||
<div class="stat-card warn">
|
||||
<div class="stat-label">Warnings</div>
|
||||
<div class="stat-value">@Model.Stats.WarnCount</div>
|
||||
<div class="stat-sub">monitors degraded</div>
|
||||
</div>
|
||||
@if (Model.ShowUptime)
|
||||
{
|
||||
<div class="stat-card info">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value">@Model.Stats.OverallUptime.ToString("F1")%</div>
|
||||
<div class="stat-sub">overall availability</div>
|
||||
</div>
|
||||
}
|
||||
<div class="stat-card @(Model.Stats.CertExpired > 0 ? "down" : Model.Stats.CertExpiringSoon > 0 ? "warn" : "neutral")">
|
||||
<div class="stat-label">Certificates</div>
|
||||
<div class="stat-value">@Model.Stats.CertCount</div>
|
||||
<div class="stat-sub">
|
||||
@if (Model.Stats.CertExpired > 0) { <span class="text-down">@Model.Stats.CertExpired expired</span> }
|
||||
else if (Model.Stats.CertExpiringSoon > 0) { <span class="text-warn">@Model.Stats.CertExpiringSoon expiring soon</span> }
|
||||
else { <span>all valid</span> }
|
||||
</div>
|
||||
</div>
|
||||
@if (Model.IsAdmin)
|
||||
{
|
||||
<div class="stat-card @(Model.Stats.ErrorLogs > 0 ? "warn" : "neutral")">
|
||||
<div class="stat-label">Log Errors</div>
|
||||
<div class="stat-value">@Model.Stats.ErrorLogs</div>
|
||||
<div class="stat-sub">@Model.Stats.TotalLogs total entries</div>
|
||||
</div>
|
||||
<div class="stat-card @(Model.Stats.ActiveIncidents > 0 ? "down" : "neutral")">
|
||||
<div class="stat-label">Incidents</div>
|
||||
<div class="stat-value">@Model.Stats.ActiveIncidents</div>
|
||||
<div class="stat-sub">@Model.Stats.ResolvedIncidents resolved</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Active Incidents Banner *@
|
||||
@if (Model.ShowIncidents && Model.ActiveIncidents.Any())
|
||||
{
|
||||
foreach (var inc in Model.ActiveIncidents)
|
||||
{
|
||||
var incClass = inc.Severity switch {
|
||||
IncidentSeverity.Critical => "alert-danger",
|
||||
IncidentSeverity.Major => "alert-warning",
|
||||
_ => "alert-info"
|
||||
};
|
||||
<div class="alert @incClass mt-2" style="border-left:4px solid currentColor">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>⚠ @inc.Title</strong>
|
||||
<span class="mono" style="font-size:11px">@inc.Status • @inc.Severity</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(inc.Body))
|
||||
{
|
||||
<div style="margin-top:4px;font-size:12px">@inc.Body</div>
|
||||
}
|
||||
@if (inc.Updates.Any())
|
||||
{
|
||||
var latest = inc.Updates.OrderByDescending(u => u.PostedAt).First();
|
||||
<div style="margin-top:4px;font-size:11px;color:var(--text-muted)">
|
||||
Latest update (@latest.PostedAt.ToString("yyyy-MM-dd HH:mm")): @latest.Message
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (Model.Monitors.Any())
|
||||
{
|
||||
var groups = Model.Monitors.GroupBy(m => m.GroupName ?? "General");
|
||||
foreach (var group in groups)
|
||||
{
|
||||
<div class="card mb-2 mt-2">
|
||||
<div class="card-header">
|
||||
<span class="card-title">@group.Key</span>
|
||||
<span class="mono" style="font-size:11px;color:var(--text-muted)">@group.Count() services</span>
|
||||
</div>
|
||||
<div style="padding: 8px;">
|
||||
@foreach (var m in group)
|
||||
{
|
||||
var badgeClass = m.LastStatus switch {
|
||||
MonitorStatus.Up => "badge-up",
|
||||
MonitorStatus.Down => "badge-down",
|
||||
MonitorStatus.Warning or MonitorStatus.Degraded => "badge-warn",
|
||||
_ => "badge-unknown"
|
||||
};
|
||||
var checks = Model.RecentChecks.ContainsKey(m.Id) ? Model.RecentChecks[m.Id] : new();
|
||||
<div class="monitor-row">
|
||||
<div>
|
||||
<div class="monitor-name">@m.Name
|
||||
@if (!m.IsPublic && Model.IsAdmin) { <span class="badge badge-info" style="font-size:8px;padding:1px 5px">PRIVATE</span> }
|
||||
@if (!m.IsActive) { <span class="badge badge-unknown" style="font-size:8px;padding:1px 5px">PAUSED</span> }
|
||||
</div>
|
||||
<div class="monitor-host">@(m.Url ?? (m.Host + (m.Port.HasValue ? ":" + m.Port : "")))</div>
|
||||
</div>
|
||||
<div class="monitor-type">@m.Type.ToString().ToUpper()</div>
|
||||
<div>
|
||||
@if (Model.ShowUptime && checks.Any())
|
||||
{
|
||||
<div class="uptime-bar" title="Last 7 days">
|
||||
@foreach (var c in checks)
|
||||
{
|
||||
var cls = c.Status switch { MonitorStatus.Up => "up", MonitorStatus.Down => "down", MonitorStatus.Warning or MonitorStatus.Degraded => "warn", _ => "unknown" };
|
||||
<div class="uptime-block @cls" title="@c.CheckedAt.ToString("g"): @c.Status"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="monitor-latency">
|
||||
@if (m.LastResponseMs.HasValue) { <span>@((int)m.LastResponseMs.Value)ms</span> }
|
||||
</div>
|
||||
<div><span class="badge @badgeClass">@m.LastStatus</span></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">◎</div>
|
||||
<div class="empty-state-text">No monitors have been configured</div>
|
||||
@if (Model.IsAdmin) { <div class="mt-2"><a href="/admin/monitors" class="btn btn-primary">Add Monitor</a></div> }
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Certificates.Any())
|
||||
{
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<span class="card-title">SSL Certificates</span>
|
||||
</div>
|
||||
<div style="padding: 0 4px;">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Domain</th>
|
||||
<th>Name</th>
|
||||
<th>Expires</th>
|
||||
<th>Days Left</th>
|
||||
<th>Status</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in Model.Certificates)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var daysLeft = c.ExpiresAt.HasValue ? (int)(c.ExpiresAt.Value - now).TotalDays : (int?)null;
|
||||
var expiryClass = daysLeft == null ? "" : daysLeft <= 0 ? "cert-expiry-expired" : daysLeft <= 7 ? "cert-expiry-critical" : daysLeft <= 30 ? "cert-expiry-warn" : "cert-expiry-ok";
|
||||
<tr>
|
||||
<td class="mono" style="color:var(--text-primary)">@c.Domain:@c.Port</td>
|
||||
<td>@c.Name</td>
|
||||
<td class="mono @expiryClass">@(c.ExpiresAt?.ToString("yyyy-MM-dd") ?? "unknown")</td>
|
||||
<td class="mono @expiryClass">@(daysLeft.HasValue ? daysLeft + "d" : "-")</td>
|
||||
<td>
|
||||
@if (daysLeft == null) { <span class="badge badge-unknown">Unknown</span> }
|
||||
else if (daysLeft <= 0) { <span class="badge badge-down">EXPIRED</span> }
|
||||
else if (daysLeft <= 7) { <span class="badge badge-down">CRITICAL</span> }
|
||||
else if (daysLeft <= 30) { <span class="badge badge-warn">EXPIRING</span> }
|
||||
else { <span class="badge badge-up">VALID</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
78
EonaCat.LogStack.Status/Pages/Index.cshtml.cs
Normal file
78
EonaCat.LogStack.Status/Pages/Index.cshtml.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
using EonaCat.LogStack.Status.Services;
|
||||
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly DatabaseContext _db;
|
||||
private readonly MonitoringService _monSvc;
|
||||
private readonly AuthenticationService _authSvc;
|
||||
|
||||
public IndexModel(DatabaseContext db, MonitoringService monSvc, AuthenticationService authSvc)
|
||||
{
|
||||
_db = db;
|
||||
_monSvc = monSvc;
|
||||
_authSvc = authSvc;
|
||||
}
|
||||
|
||||
public DashboardStats Stats { get; set; } = new();
|
||||
public List<Monitor> Monitors { get; set; } = new();
|
||||
public List<CertificateEntry> Certificates { get; set; } = new();
|
||||
public List<Incident> ActiveIncidents { get; set; } = new();
|
||||
public bool IsAdmin { get; set; }
|
||||
public bool ShowUptime { get; set; }
|
||||
public bool ShowIncidents { get; set; }
|
||||
public string SiteName { get; set; } = "Status";
|
||||
public Dictionary<int, List<MonitorCheck>> RecentChecks { get; set; } = new();
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
IsAdmin = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||
ShowUptime = (await _authSvc.GetSettingAsync("ShowUptimePublicly", "true")) == "true";
|
||||
ShowIncidents = (await _authSvc.GetSettingAsync("ShowIncidentsPublicly", "true")) == "true";
|
||||
SiteName = await _authSvc.GetSettingAsync("SiteName", "Status");
|
||||
Stats = await _monSvc.GetStatsAsync(IsAdmin);
|
||||
|
||||
var query = _db.Monitors.Where(m => m.IsActive);
|
||||
if (!IsAdmin)
|
||||
{
|
||||
query = query.Where(m => m.IsPublic);
|
||||
}
|
||||
|
||||
Monitors = await query.OrderBy(m => m.GroupName).ThenBy(m => m.Name).ToListAsync();
|
||||
|
||||
Certificates = await _db.Certificates.OrderBy(c => c.ExpiresAt).ToListAsync();
|
||||
|
||||
// Active incidents (public or admin)
|
||||
var incidentQuery = _db.Incidents
|
||||
.Include(i => i.Updates)
|
||||
.Where(i => i.Status != IncidentStatus.Resolved);
|
||||
if (!IsAdmin)
|
||||
{
|
||||
incidentQuery = incidentQuery.Where(i => i.IsPublic);
|
||||
}
|
||||
|
||||
ActiveIncidents = await incidentQuery.OrderByDescending(i => i.CreatedAt).ToListAsync();
|
||||
|
||||
// Recent checks for uptime bars (last 7 days, up to 90 per monitor)
|
||||
var monitorIds = Monitors.Select(m => m.Id).ToList();
|
||||
var cutoff = DateTime.UtcNow.AddDays(-7);
|
||||
var checks = await _db.MonitorChecks
|
||||
.Where(c => monitorIds.Contains(c.MonitorId) && c.CheckedAt >= cutoff)
|
||||
.OrderByDescending(c => c.CheckedAt)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var m in Monitors)
|
||||
{
|
||||
RecentChecks[m.Id] = checks.Where(c => c.MonitorId == m.Id).Take(90).Reverse().ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
133
EonaCat.LogStack.Status/Pages/Logs.cshtml
Normal file
133
EonaCat.LogStack.Status/Pages/Logs.cshtml
Normal file
@@ -0,0 +1,133 @@
|
||||
@page
|
||||
@model LogsModel
|
||||
@{
|
||||
ViewData["Title"] = "Log Stream";
|
||||
ViewData["Page"] = "logs";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
.chart-wrap canvas { height: 180px !important; }
|
||||
</style>
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">
|
||||
Log Stream
|
||||
<span class="live-count" id="live-count"></span>
|
||||
</span>
|
||||
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.TotalCount entries</span>
|
||||
</div>
|
||||
|
||||
<!-- Log volume chart (last 24 h) -->
|
||||
<div class="chart-wrap">
|
||||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);letter-spacing:1px;margin-bottom:10px">LOG VOLUME - LAST 24H</div>
|
||||
<canvas id="log-stats-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<form method="get" class="filter-bar mt-2">
|
||||
<select name="Level" class="form-control" onchange="this.form.submit()">
|
||||
<option value="">All Levels</option>
|
||||
@foreach (var lvl in new[] { "debug", "info", "warn", "error", "critical" })
|
||||
{
|
||||
<option value="@lvl" selected="@(Model.Level?.ToLower() == lvl)">@lvl.ToUpper()</option>
|
||||
}
|
||||
</select>
|
||||
<select name="Source" class="form-control" onchange="this.form.submit()">
|
||||
<option value="">All Sources</option>
|
||||
@foreach (var s in Model.Sources)
|
||||
{
|
||||
<option value="@s" selected="@(Model.Source == s)">@s</option>
|
||||
}
|
||||
</select>
|
||||
<input id="log-search" type="text" name="Search" value="@Model.Search"
|
||||
placeholder="Search messages… (/ to focus)" class="form-control" style="max-width:280px" />
|
||||
<input type="date" name="FromDate" value="@Model.FromDate?.ToString("yyyy-MM-dd")" class="form-control" style="max-width:145px" title="From date" />
|
||||
<input type="date" name="ToDate" value="@Model.ToDate?.ToString("yyyy-MM-dd")" class="form-control" style="max-width:145px" title="To date" />
|
||||
<button type="submit" class="btn btn-outline btn-sm">Apply</button>
|
||||
<a href="/logs" class="btn btn-outline btn-sm">Clear</a>
|
||||
<button type="button" class="btn btn-outline btn-sm" onclick="exportLogs()" title="Export visible entries as JSON">⬇ Export</button>
|
||||
</form>
|
||||
|
||||
<!-- Log toolbar -->
|
||||
<div class="log-toolbar">
|
||||
<span class="mono" style="font-size:10px;color:var(--text-muted)">ENTRIES</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||
<span class="kbd" title="Keyboard shortcut">/</span> <span style="font-size:11px;color:var(--text-muted)">search</span>
|
||||
<button class="btn btn-outline btn-sm" onclick="scrollLogsToBottom()" title="Jump to bottom">↓ Bottom</button>
|
||||
<button class="btn btn-outline btn-sm" id="btn-scroll-top" onclick="document.getElementById('log-stream').scrollTop=0" title="Jump to top">↑ Top</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="log-stream" id="log-stream" data-last-id="@(Model.Entries.FirstOrDefault()?.Id ?? 0)">
|
||||
@if (!Model.Entries.Any())
|
||||
{
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">▦</div>
|
||||
<div class="empty-state-text">No log entries match your filters</div>
|
||||
</div>
|
||||
}
|
||||
@foreach (var e in Model.Entries.AsEnumerable().Reverse())
|
||||
{
|
||||
<div class="log-entry">
|
||||
<span class="log-ts">@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</span>
|
||||
<span class="log-level @e.Level.ToLower()">@e.Level.ToUpper()</span>
|
||||
<span class="log-source" title="@e.Source">@e.Source</span>
|
||||
<span class="log-message">
|
||||
@e.Message
|
||||
@if (!string.IsNullOrEmpty(e.TraceId))
|
||||
{
|
||||
<span class="mono" style="font-size:9px;color:var(--text-muted);margin-left:6px">trace=@e.TraceId</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(e.Exception))
|
||||
{
|
||||
<details style="margin-top:3px">
|
||||
<summary style="color:var(--down);cursor:pointer;font-size:10px">Exception ▾</summary>
|
||||
<pre style="font-size:10px;color:var(--text-muted);margin-top:4px;white-space:pre-wrap">@e.Exception</pre>
|
||||
</details>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Model.TotalPages > 1)
|
||||
{
|
||||
<div class="flex gap-2 mt-2 align-center">
|
||||
@if (Model.PageIndex > 1)
|
||||
{
|
||||
<a href="@Url.Page("/Logs", null, new { PageIndex = Model.PageIndex - 1, Level = Model.Level, Source = Model.Source, Search = Model.Search }, null)" class="btn btn-outline btn-sm">← Prev</a>
|
||||
}
|
||||
<span class="mono" style="font-size:11px;color:var(--text-muted)">Page @Model.PageIndex of @Model.TotalPages</span>
|
||||
@if (Model.PageIndex < Model.TotalPages)
|
||||
{
|
||||
<a href="@Url.Page("/Logs", null, new { PageIndex = Model.PageIndex + 1, Level = Model.Level, Source = Model.Source, Search = Model.Search }, null)" class="btn btn-outline btn-sm">Next →</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadLogStatsChart('log-stats-chart');
|
||||
});
|
||||
|
||||
function exportLogs() {
|
||||
const entries = [];
|
||||
document.querySelectorAll('.log-entry').forEach(row => {
|
||||
entries.push({
|
||||
timestamp: row.querySelector('.log-ts')?.textContent?.trim(),
|
||||
level: row.querySelector('.log-level')?.textContent?.trim(),
|
||||
source: row.querySelector('.log-source')?.textContent?.trim(),
|
||||
message: row.querySelector('.log-message')?.childNodes[0]?.textContent?.trim()
|
||||
});
|
||||
});
|
||||
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
a.download = `logs_${new Date().toISOString().slice(0,10)}.json`;
|
||||
a.click();
|
||||
showToast('Exported ' + entries.length + ' entries', 'success');
|
||||
}
|
||||
</script>
|
||||
}
|
||||
78
EonaCat.LogStack.Status/Pages/Logs.cshtml.cs
Normal file
78
EonaCat.LogStack.Status/Pages/Logs.cshtml.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages;
|
||||
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
public class LogsModel : PageModel
|
||||
{
|
||||
private readonly DatabaseContext _db;
|
||||
public LogsModel(DatabaseContext db) => _db = db;
|
||||
|
||||
public List<LogEntry> Entries { get; set; } = new();
|
||||
public List<string> Sources { get; set; } = new();
|
||||
public int TotalCount { get; set; }
|
||||
public int TotalPages { get; set; }
|
||||
|
||||
[BindProperty(SupportsGet = true)] public string? Level { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public string? Source { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public string? Search { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public DateTime? FromDate { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public DateTime? ToDate { get; set; }
|
||||
[BindProperty(SupportsGet = true)] public int PageIndex { get; set; } = 1;
|
||||
|
||||
private const int PageSize = 100;
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
if (HttpContext.Session.GetString("IsAdmin") != "true")
|
||||
{
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
|
||||
Sources = await _db.Logs.Select(l => l.Source).Distinct().OrderBy(s => s).ToListAsync();
|
||||
|
||||
var q = _db.Logs.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Level))
|
||||
{
|
||||
q = q.Where(l => l.Level == Level.ToLower());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Source))
|
||||
{
|
||||
q = q.Where(l => l.Source == Source);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Search))
|
||||
{
|
||||
q = q.Where(l => l.Message.Contains(Search) || (l.Exception != null && l.Exception.Contains(Search)));
|
||||
}
|
||||
|
||||
if (FromDate.HasValue)
|
||||
{
|
||||
q = q.Where(l => l.Timestamp >= FromDate.Value.ToUniversalTime());
|
||||
}
|
||||
|
||||
if (ToDate.HasValue)
|
||||
{
|
||||
q = q.Where(l => l.Timestamp <= ToDate.Value.AddDays(1).ToUniversalTime());
|
||||
}
|
||||
|
||||
TotalCount = await q.CountAsync();
|
||||
TotalPages = (int)Math.Ceiling((double)TotalCount / PageSize);
|
||||
|
||||
Entries = await q
|
||||
.OrderByDescending(l => l.Timestamp)
|
||||
.Skip((PageIndex - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
83
EonaCat.LogStack.Status/Pages/Monitors.cshtml
Normal file
83
EonaCat.LogStack.Status/Pages/Monitors.cshtml
Normal file
@@ -0,0 +1,83 @@
|
||||
@page
|
||||
@model EonaCat.LogStack.Status.Pages.MonitorsModel
|
||||
@{
|
||||
ViewData["Title"] = "Monitors";
|
||||
ViewData["Page"] = "monitors";
|
||||
var groups = Model.Monitors.GroupBy(m => m.GroupName ?? "General");
|
||||
}
|
||||
|
||||
<div class="section-header">
|
||||
<span class="section-title">All Monitors</span>
|
||||
<span class="mono" style="font-size:11px;color:var(--text-muted)">@Model.Monitors.Count services</span>
|
||||
</div>
|
||||
|
||||
@foreach (var group in groups)
|
||||
{
|
||||
<div class="card mb-2">
|
||||
<div class="card-header">
|
||||
<span class="card-title">@group.Key</span>
|
||||
<span style="font-size:11px;color:var(--text-muted)">
|
||||
@group.Count(m => m.LastStatus == MonitorStatus.Up) / @group.Count() up
|
||||
</span>
|
||||
</div>
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Monitor</th>
|
||||
<th>Type</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Response</th>
|
||||
<th>30d Uptime</th>
|
||||
<th>Last Checked</th>
|
||||
<th>Status</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
@foreach (var monitor in group)
|
||||
{
|
||||
var badgeClass = monitor.LastStatus switch
|
||||
{
|
||||
MonitorStatus.Up => "badge-up",
|
||||
MonitorStatus.Down => "badge-down",
|
||||
MonitorStatus.Warning or MonitorStatus.Degraded => "badge-warn",
|
||||
_ => "badge-unknown"
|
||||
};
|
||||
|
||||
var uptime = Model.UptimePercent.ContainsKey(monitor.Id) ? Model.UptimePercent[monitor.Id] : 0;
|
||||
var uptimeColor = uptime >= 99 ? "var(--up)" : uptime >= 95 ? "var(--warn)" : "var(--down)";
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight:500;color:var(--text-primary)">@monitor.Name</div>
|
||||
@if (!string.IsNullOrEmpty(monitor.Description)) { <div style="font-size:11px;color:var(--text-muted)">@monitor.Description</div> }
|
||||
</td>
|
||||
<td><span class="badge badge-info" style="font-size:9px">@monitor.Type</span></td>
|
||||
<td class="mono" style="font-size:11px;color:var(--text-muted)">
|
||||
@(monitor.Type is MonitorType.HTTP or MonitorType.HTTPS ? monitor.Url : $"{monitor.Host}{(monitor.Port.HasValue ? ":" + monitor.Port : "")}")
|
||||
</td>
|
||||
<td class="mono" style="font-size:11px">
|
||||
@if (monitor.LastResponseMs.HasValue) { <span>@((int)monitor.LastResponseMs.Value)ms</span> }
|
||||
else
|
||||
{
|
||||
|
||||
<span style="color:var(--text-muted)">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span style="font-family:var(--font-mono);font-size:12px;color:@uptimeColor">@uptime.ToString("F1")%</span>
|
||||
</td>
|
||||
<td style="font-size:11px;color:var(--text-muted)">
|
||||
@(monitor.LastChecked.HasValue ? monitor.LastChecked.Value.ToString("HH:mm:ss") + " UTC" : "Never")
|
||||
</td>
|
||||
<td><span class="badge @badgeClass">@monitor.LastStatus</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Model.Monitors.Any())
|
||||
{
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">◎</div>
|
||||
<div class="empty-state-text">No monitors configured</div>
|
||||
</div>
|
||||
}
|
||||
37
EonaCat.LogStack.Status/Pages/Monitors.cshtml.cs
Normal file
37
EonaCat.LogStack.Status/Pages/Monitors.cshtml.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EonaCat.LogStack.Status.Data;
|
||||
using EonaCat.LogStack.Status.Models;
|
||||
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
|
||||
|
||||
namespace EonaCat.LogStack.Status.Pages;
|
||||
|
||||
public class MonitorsModel : PageModel
|
||||
{
|
||||
private readonly DatabaseContext _db;
|
||||
public MonitorsModel(DatabaseContext db) => _db = db;
|
||||
public List<Monitor> Monitors { get; set; } = new();
|
||||
public bool IsAdmin { get; set; }
|
||||
public Dictionary<int, double> UptimePercent { get; set; } = new();
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
IsAdmin = HttpContext.Session.GetString("IsAdmin") == "true";
|
||||
var q = _db.Monitors.Where(m => m.IsActive);
|
||||
if (!IsAdmin)
|
||||
{
|
||||
q = q.Where(m => m.IsPublic);
|
||||
}
|
||||
|
||||
Monitors = await q.OrderBy(m => m.GroupName).ThenBy(m => m.Name).ToListAsync();
|
||||
|
||||
var ids = Monitors.Select(m => m.Id).ToList();
|
||||
var cutoff = DateTime.UtcNow.AddDays(-30);
|
||||
var checks = await _db.MonitorChecks.Where(c => ids.Contains(c.MonitorId) && c.CheckedAt >= cutoff).ToListAsync();
|
||||
foreach (var m in Monitors)
|
||||
{
|
||||
var mc = checks.Where(c => c.MonitorId == m.Id).ToList();
|
||||
UptimePercent[m.Id] = mc.Any() ? (double)mc.Count(c => c.Status == MonitorStatus.Up) / mc.Count * 100 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
162
EonaCat.LogStack.Status/Pages/Shared/_Layout.cshtml
Normal file
162
EonaCat.LogStack.Status/Pages/Shared/_Layout.cshtml
Normal file
@@ -0,0 +1,162 @@
|
||||
<!--
|
||||
This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] - Status</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="~/css/site.css" />
|
||||
@RenderSection("Styles", required: false)
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">◈</span>
|
||||
<span class="logo-text">EonaCat LogStack<br /><strong>Status</strong></span>
|
||||
</div>
|
||||
<button class="sidebar-toggle" onclick="toggleSidebar()" title="Toggle sidebar">⟨</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<span class="nav-label">MONITORING</span>
|
||||
<a href="/" data-label="Dashboard" class="nav-item @(ViewData["Page"]?.ToString() == "dashboard" ? "active" : "")">
|
||||
<span class="nav-icon">⬡</span>
|
||||
<span class="nav-text">Dashboard</span>
|
||||
<span class="status-dot" id="overall-dot"></span>
|
||||
</a>
|
||||
<a href="/monitors" data-label="Monitors" class="nav-item @(ViewData["Page"]?.ToString() == "monitors" ? "active" : "")">
|
||||
<span class="nav-icon">◎</span>
|
||||
<span class="nav-text">Monitors</span>
|
||||
</a>
|
||||
<a href="/certificates" data-label="Certificates" class="nav-item @(ViewData["Page"]?.ToString() == "certs" ? "active" : "")">
|
||||
<span class="nav-icon">◧</span>
|
||||
<span class="nav-text">Certificates</span>
|
||||
</a>
|
||||
<a href="/incidents" data-label="Incidents" class="nav-item @(ViewData["Page"]?.ToString() == "incidents" ? "active" : "")">
|
||||
<span class="nav-icon">⚠</span>
|
||||
<span class="nav-text">Incidents</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (Context.Session.GetString("IsAdmin") == "true")
|
||||
{
|
||||
<div class="nav-section">
|
||||
<span class="nav-label">ANALYTICS</span>
|
||||
<a href="/logs" data-label="Log Stream" class="nav-item @(ViewData["Page"]?.ToString() == "logs" ? "active" : "")">
|
||||
<span class="nav-icon">▦</span>
|
||||
<span class="nav-text">Log Stream</span>
|
||||
</a>
|
||||
<a href="/analytics" data-label="Analytics" class="nav-item @(ViewData["Page"]?.ToString() == "analytics" ? "active" : "")">
|
||||
<span class="nav-icon">⬡</span>
|
||||
<span class="nav-text">Analytics</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<span class="nav-label">ADMIN</span>
|
||||
<a href="/admin/monitors" data-label="Monitors" class="nav-item @(ViewData["Page"]?.ToString() == "admin-monitors" ? "active" : "")">
|
||||
<span class="nav-icon">⊞</span>
|
||||
<span class="nav-text">Manage Monitors</span>
|
||||
</a>
|
||||
<a href="/admin/certificates" data-label="Certificates" class="nav-item @(ViewData["Page"]?.ToString() == "admin-certs" ? "active" : "")">
|
||||
<span class="nav-icon">⊟</span>
|
||||
<span class="nav-text">Manage Certificates</span>
|
||||
</a>
|
||||
<a href="/admin/incidents" data-label="Incidents" class="nav-item @(ViewData["Page"]?.ToString() == "admin-incidents" ? "active" : "")">
|
||||
<span class="nav-icon">⚑</span>
|
||||
<span class="nav-text">Manage Incidents</span>
|
||||
</a>
|
||||
<a href="/admin/alertrules" data-label="Alert Rules" class="nav-item @(ViewData["Page"]?.ToString() == "admin-alerts" ? "active" : "")">
|
||||
<span class="nav-icon">◉</span>
|
||||
<span class="nav-text">Alert Rules</span>
|
||||
</a>
|
||||
<a href="/admin/settings" data-label="Settings" class="nav-item @(ViewData["Page"]?.ToString() == "admin-settings" ? "active" : "")">
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span class="nav-text">Settings</span>
|
||||
</a>
|
||||
<a href="/admin/ingest" data-label="Log Ingest" class="nav-item @(ViewData["Page"]?.ToString() == "admin-ingest" ? "active" : "")">
|
||||
<span class="nav-icon">⊕</span>
|
||||
<span class="nav-text">Log Ingest</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<a href="/admin/logout" data-label="Logout" class="nav-item nav-item--danger">
|
||||
<span class="nav-icon">⊘</span>
|
||||
<span class="nav-text">Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="nav-section" style="margin-top:auto">
|
||||
<a href="/admin/login" data-label="Admin Login" class="nav-item">
|
||||
<span class="nav-icon">⊛</span>
|
||||
<span class="nav-text">Admin Login</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<span class="clock" id="clock">--:--:--</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content" id="main-content">
|
||||
<div class="topbar">
|
||||
<div class="breadcrumb">@ViewData["Title"]</div>
|
||||
<div class="topbar-right">
|
||||
<!-- Refresh countdown ring -->
|
||||
<div class="refresh-ring" id="refresh-ring" title="Auto refresh" style="display:none">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18">
|
||||
<circle class="refresh-ring-track" cx="9" cy="9" r="8" />
|
||||
<circle class="refresh-ring-fill" id="refresh-ring-fill" cx="9" cy="9" r="8" />
|
||||
</svg>
|
||||
<span class="refresh-ring-label" id="refresh-ring-label"></span>
|
||||
</div>
|
||||
|
||||
<div class="live-indicator" title="Online">
|
||||
<span class="pulse-dot"></span>
|
||||
</div>
|
||||
|
||||
@if (SyslogUdpService.IsRunning)
|
||||
{
|
||||
<div class="live-indicator" title="Syslog">
|
||||
<span class="pulse-dot"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Context.Session.GetString("IsAdmin") == "true")
|
||||
{
|
||||
<span class="admin-badge">ADMIN</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
@RenderBody()
|
||||
</div>
|
||||
|
||||
<div class="page-footer">
|
||||
© @DateTime.Now.Year
|
||||
<strong>
|
||||
<a href="https://EonaCat.com" target="_blank">EonaCat (Jeroen Saey)</a>
|
||||
</strong>
|
||||
All rights reserved.
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Global toast container -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="~/js/site.js"></script>
|
||||
@RenderSection("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
5
EonaCat.LogStack.Status/Pages/_ViewImports.cshtml
Normal file
5
EonaCat.LogStack.Status/Pages/_ViewImports.cshtml
Normal file
@@ -0,0 +1,5 @@
|
||||
@using EonaCat.LogStack.Status
|
||||
@using EonaCat.LogStack.Status.Models
|
||||
@using EonaCat.LogStack.Status.Services
|
||||
@namespace EonaCat.LogStack.Status.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
3
EonaCat.LogStack.Status/Pages/_ViewStart.cshtml
Normal file
3
EonaCat.LogStack.Status/Pages/_ViewStart.cshtml
Normal file
@@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
||||
Reference in New Issue
Block a user