Added EonaCat.LogStack.Status

Updated EonaCat.LogStack.LogClient to support EonaCat.LogStack.Status
This commit is contained in:
2026-03-12 21:15:33 +01:00
parent 776cc624bd
commit 977374ce02
41 changed files with 3412 additions and 180 deletions

View 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>

View 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." });
}
}

View File

@@ -0,0 +1,99 @@
@page
@model Status.Pages.Admin.IngestModel
@{
ViewData["Title"] = "Log Ingestion";
ViewData["Page"] = "admin-ingest";
var host = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
}
<div class="section-title mb-2">Log Ingestion API</div>
<p style="color:var(--text-muted);margin-bottom:20px;font-size:13px">
Status accepts logs from any application via HTTP POST. Compatible with custom HTTP sinks from Serilog, NLog, log4net, and any HTTP client.
</p>
<div class="two-col" style="align-items:start">
<div>
<div class="card mb-2">
<div class="card-header"><span class="card-title">Single Log Entry</span></div>
<div class="card-body">
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">POST @host/api/logs/ingest
Content-Type: application/json
{
"source": "my-app",
"level": "error",
"message": "Something went wrong",
"exception": "System.Exception: ...",
"properties": "{\"userId\": 42}",
"host": "prod-server-01",
"traceId": "abc123"
}</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><span class="card-title">Batch Ingestion</span></div>
<div class="card-body">
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">POST @host/api/logs/batch
Content-Type: application/json
[
{"source":"app","level":"info","message":"Started"},
{"source":"app","level":"warn","message":"Slow query","properties":"{\"ms\":2400}"}
]</div>
</div>
</div>
</div>
<div>
<div class="card mb-2">
<div class="card-header"><span class="card-title">EonaCat.LogStack HTTP Flow (.NET)</span></div>
<div class="card-body">
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">// Install: EonaCat.LogStack
var logger = new LogBuilder().WriteToHttp("@host/api/logs/eonacat").Build();
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><span class="card-title">Serilog HTTP Sink (.NET)</span></div>
<div class="card-body">
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">
// Install: Serilog.Sinks.Http
Log.Logger = new LoggerConfiguration()
.WriteTo.Http(
requestUri: "@host/api/logs/serilog",
queueLimitBytes: null)
.CreateLogger();
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><span class="card-title">Python (requests)</span></div>
<div class="card-body">
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:14px;border-radius:4px;line-height:1.8;white-space:pre-wrap;overflow-x:auto">import requests
requests.post("@host/api/logs/ingest", json={
"source": "my-python-app",
"level": "info",
"message": "App started",
"host": socket.gethostname()
})</div>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Log Levels</span></div>
<div class="card-body">
<table class="data-table">
<tr><td><span class="badge badge-unknown">DEBUG</span></td><td style="font-size:12px">Verbose diagnostic info</td></tr>
<tr><td><span class="badge badge-info">INFO</span></td><td style="font-size:12px">Normal operation events</td></tr>
<tr><td><span class="badge badge-warn">WARNING</span></td><td style="font-size:12px">Warning requires attention</td></tr>
<tr><td><span class="badge badge-down">ERROR</span></td><td style="font-size:12px">Errors requiring attention</td></tr>
<tr><td><span class="badge badge-down">CRITICAL</span></td><td style="font-size:12px">System critical failure / crash</td></tr>
</table>
</div>
</div>
</div>
</div>

View 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();
}
}

View 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>

View 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();
}
}

View File

@@ -0,0 +1,2 @@
@page
@model Status.Pages.Admin.LogoutModel

View 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");
}
}

View File

@@ -0,0 +1,195 @@
@page
@model Status.Pages.Admin.MonitorsModel
@{
ViewData["Title"] = "Manage Monitors";
ViewData["Page"] = "admin-monitors";
}
@if (!string.IsNullOrEmpty(Model.Message))
{
<div class="alert alert-success">✓ @Model.Message</div>
}
<div class="section-header">
<span class="section-title">Monitors</span>
<button class="btn btn-primary" onclick="openModal('add-monitor-modal')">+ Add Monitor</button>
</div>
<div class="card">
<table class="data-table">
<thead><tr>
<th>Name</th>
<th>Type</th>
<th>Host / URL</th>
<th>Group</th>
<th>Interval</th>
<th>Status</th>
<th>Visibility</th>
<th>Actions</th>
</tr></thead>
<tbody>
@foreach (var m in Model.Monitors)
{
var badgeClass = m.LastStatus switch {
MonitorStatus.Up => "badge-up",
MonitorStatus.Down => "badge-down",
MonitorStatus.Warning or MonitorStatus.Degraded => "badge-warn",
_ => "badge-unknown"
};
<tr>
<td style="color:var(--text-primary);font-weight:500">@m.Name
@if (!m.IsActive) { <span class="badge badge-unknown" style="font-size:8px">PAUSED</span> }
</td>
<td class="mono" style="font-size:11px">@m.Type</td>
<td class="mono" style="font-size:11px;color:var(--text-muted)">@(m.Url ?? (m.Host + (m.Port.HasValue ? ":" + m.Port : "")))</td>
<td style="font-size:12px">@(m.GroupName ?? "—")</td>
<td class="mono" style="font-size:11px">@m.IntervalSeconds s</td>
<td><span class="badge @badgeClass">@m.LastStatus</span></td>
<td>@(m.IsPublic ? "🌐 Public" : "🔒 Private")</td>
<td>
<div class="flex gap-2">
<form method="post" asp-page-handler="CheckNow" style="display:inline">
<input type="hidden" name="id" value="@m.Id" />
<button type="submit" class="btn btn-outline btn-sm" title="Check Now">▶</button>
</form>
<button class="btn btn-outline btn-sm" onclick="editMonitor(@m.Id,'@m.Name','@m.Description','@m.Type','@m.Host','@(m.Port?.ToString() ?? "")','@m.Url','@m.ProcessName','@m.IntervalSeconds','@m.TimeoutMs','@m.IsActive'.toLowerCase(),'@m.IsPublic'.toLowerCase(),'@m.Tags','@m.GroupName')">Edit</button>
<form method="post" asp-page-handler="Delete" style="display:inline" onsubmit="return confirm('Delete @m.Name?')">
<input type="hidden" name="id" value="@m.Id" />
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</td>
</tr>
}
@if (!Model.Monitors.Any())
{
<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--text-muted)">No monitors yet. Add one above.</td></tr>
}
</tbody>
</table>
</div>
<!-- Add/Edit Monitor Modal -->
<div class="modal-overlay" id="add-monitor-modal">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="modal-title">Add Monitor</span>
<button class="modal-close" onclick="closeModal('add-monitor-modal')">✕</button>
</div>
<form method="post" asp-page-handler="Save">
<div class="modal-body">
<input type="hidden" name="EditMonitor.Id" id="edit-id" value="0" />
<div class="two-col">
<div class="form-group">
<label class="form-label">Name *</label>
<input type="text" name="EditMonitor.Name" id="edit-name" class="form-control" required />
</div>
<div class="form-group">
<label class="form-label">Type *</label>
<select name="EditMonitor.Type" id="edit-type" class="form-control" onchange="updateTypeFields()">
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
<option value="HTTP">HTTP</option>
<option value="HTTPS">HTTPS</option>
<option value="AppLocal">App (Local)</option>
<option value="AppRemote">App (Remote)</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input type="text" name="EditMonitor.Description" id="edit-desc" class="form-control" />
</div>
<div class="two-col" id="host-row">
<div class="form-group">
<label class="form-label">Host</label>
<input type="text" name="EditMonitor.Host" id="edit-host" class="form-control" placeholder="hostname or IP" />
</div>
<div class="form-group">
<label class="form-label">Port</label>
<input type="number" name="EditMonitor.Port" id="edit-port" class="form-control" placeholder="e.g. 443" />
</div>
</div>
<div class="form-group" id="url-row" style="display:none">
<label class="form-label">URL</label>
<input type="text" name="EditMonitor.Url" id="edit-url" class="form-control" placeholder="https://example.com" />
</div>
<div class="form-group" id="process-row" style="display:none">
<label class="form-label">Process Name</label>
<input type="text" name="EditMonitor.ProcessName" id="edit-process" class="form-control" placeholder="nginx" />
</div>
<div class="two-col">
<div class="form-group">
<label class="form-label">Check Interval (seconds)</label>
<input type="number" name="EditMonitor.IntervalSeconds" id="edit-interval" class="form-control" value="60" min="10" />
</div>
<div class="form-group">
<label class="form-label">Timeout (ms)</label>
<input type="number" name="EditMonitor.TimeoutMs" id="edit-timeout" class="form-control" value="5000" min="500" />
</div>
</div>
<div class="two-col">
<div class="form-group">
<label class="form-label">Group Name</label>
<input type="text" name="EditMonitor.GroupName" id="edit-group" class="form-control" placeholder="Production" />
</div>
<div class="form-group">
<label class="form-label">Tags (comma-separated)</label>
<input type="text" name="EditMonitor.Tags" id="edit-tags" class="form-control" placeholder="web, api" />
</div>
</div>
<div class="flex gap-3 align-center mt-1">
<label class="flex align-center gap-2" style="cursor:pointer">
<label class="toggle">
<input type="checkbox" name="EditMonitor.IsActive" id="edit-active" checked />
<span class="toggle-slider"></span>
</label>
<span style="font-size:13px">Active</span>
</label>
<label class="flex align-center gap-2" style="cursor:pointer">
<label class="toggle">
<input type="checkbox" name="EditMonitor.IsPublic" id="edit-public" checked />
<span class="toggle-slider"></span>
</label>
<span style="font-size:13px">Public (visible to unauthenticated users)</span>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" onclick="closeModal('add-monitor-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Monitor</button>
</div>
</form>
</div>
</div>
@section Scripts {
<script>
function updateTypeFields() {
const type = document.getElementById('edit-type').value;
document.getElementById('host-row').style.display = ['HTTP','HTTPS'].includes(type) ? 'none' : 'grid';
document.getElementById('url-row').style.display = ['HTTP','HTTPS'].includes(type) ? 'block' : 'none';
document.getElementById('process-row').style.display = type === 'AppLocal' ? 'block' : 'none';
}
function editMonitor(id,name,desc,type,host,port,url,process,interval,timeout,active,pub,tags,group) {
document.getElementById('modal-title').textContent = 'Edit Monitor';
document.getElementById('edit-id').value = id;
document.getElementById('edit-name').value = name;
document.getElementById('edit-desc').value = desc;
document.getElementById('edit-type').value = type;
document.getElementById('edit-host').value = host;
document.getElementById('edit-port').value = port;
document.getElementById('edit-url').value = url;
document.getElementById('edit-process').value = process;
document.getElementById('edit-interval').value = interval;
document.getElementById('edit-timeout').value = timeout;
document.getElementById('edit-active').checked = active === 'true';
document.getElementById('edit-public').checked = pub === 'true';
document.getElementById('edit-tags').value = tags;
document.getElementById('edit-group').value = group;
updateTypeFields();
openModal('add-monitor-modal');
}
</script>
}

View File

@@ -0,0 +1,121 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using EonaCat.LogStack.Status.Data;
using EonaCat.LogStack.Status.Models;
using EonaCat.LogStack.Status.Services;
using Monitor = EonaCat.LogStack.Status.Models.Monitor;
namespace EonaCat.LogStack.Status.Pages.Admin;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public class MonitorsModel : PageModel
{
private readonly DatabaseContext _db;
private readonly MonitoringService _monSvc;
public MonitorsModel(DatabaseContext db, MonitoringService monSvc) { _db = db; _monSvc = monSvc; }
public List<Monitor> Monitors { get; set; } = new();
[BindProperty] public Monitor EditMonitor { get; set; } = new();
public string? Message { get; set; }
public async Task<IActionResult> OnGetAsync(string? msg)
{
if (HttpContext.Session.GetString("IsAdmin") != "true")
{
return RedirectToPage("/Admin/Login");
}
Monitors = await _db.Monitors.OrderBy(m => m.GroupName).ThenBy(m => m.Name).ToListAsync();
Message = msg;
return Page();
}
public async Task<IActionResult> OnPostSaveAsync()
{
if (HttpContext.Session.GetString("IsAdmin") != "true")
{
return RedirectToPage("/Admin/Login");
}
// if we dont have a host, use the url to extract it
if (string.IsNullOrWhiteSpace(EditMonitor.Host) && !string.IsNullOrEmpty(EditMonitor.Url))
{
try
{
var uri = new Uri(EditMonitor.Url);
EditMonitor.Host = uri.Host;
if (EditMonitor.Port == null || EditMonitor.Port == 0)
{
EditMonitor.Port = uri.Port;
}
}
catch
{
}
}
if (EditMonitor.Id == 0)
{
_db.Monitors.Add(EditMonitor);
}
else
{
var existing = await _db.Monitors.FindAsync(EditMonitor.Id);
if (existing == null)
{
return RedirectToPage(new { msg = "Monitor not found." });
}
existing.Name = EditMonitor.Name;
existing.Description = EditMonitor.Description;
existing.Type = EditMonitor.Type;
existing.Host = EditMonitor.Host;
existing.Port = EditMonitor.Port;
existing.Url = EditMonitor.Url;
existing.ProcessName = EditMonitor.ProcessName;
existing.IntervalSeconds = EditMonitor.IntervalSeconds;
existing.TimeoutMs = EditMonitor.TimeoutMs;
existing.IsActive = EditMonitor.IsActive;
existing.IsPublic = EditMonitor.IsPublic;
existing.Tags = EditMonitor.Tags;
existing.GroupName = EditMonitor.GroupName;
}
await _db.SaveChangesAsync();
return RedirectToPage(new { msg = "Monitor saved." });
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
if (HttpContext.Session.GetString("IsAdmin") != "true")
{
return RedirectToPage("/Admin/Login");
}
var m = await _db.Monitors.FindAsync(id);
if (m != null) { _db.Monitors.Remove(m); await _db.SaveChangesAsync(); }
return RedirectToPage(new { msg = "Monitor deleted." });
}
public async Task<IActionResult> OnPostCheckNowAsync(int id)
{
if (HttpContext.Session.GetString("IsAdmin") != "true")
{
return RedirectToPage("/Admin/Login");
}
var m = await _db.Monitors.FindAsync(id);
if (m != null)
{
await _monSvc.CheckMonitorAsync(m);
}
return RedirectToPage(new { msg = "Check completed." });
}
}

View File

@@ -0,0 +1,98 @@
@page
@model Status.Pages.Admin.SettingsModel
@{
ViewData["Title"] = "Settings";
ViewData["Page"] = "admin-settings";
var pwParts = (Model.PasswordMessage ?? "").Split(':', 2);
var pwType = pwParts.Length > 1 ? pwParts[0] : "";
var pwMsg = pwParts.Length > 1 ? pwParts[1] : pwParts.ElementAtOrDefault(0) ?? "";
}
@if (!string.IsNullOrEmpty(Model.Message))
{
<div class="alert alert-success">✓ @Model.Message</div>
}
<div class="two-col" style="align-items:start">
<div>
<div class="section-title mb-2">General Settings</div>
<div class="card">
<div class="card-body">
<form method="post" asp-page-handler="SaveSettings">
<div class="form-group">
<label class="form-label">Site Name</label>
<input type="text" name="SiteName" value="@Model.SiteName" class="form-control" />
</div>
<div class="form-group">
<label class="form-label">Alert Email</label>
<input type="email" name="AlertEmail" value="@Model.AlertEmail" class="form-control" placeholder="alerts@example.com" />
</div>
<div class="form-group">
<label class="form-label">Log Retention (days)</label>
<input type="number" name="MaxLogRetentionDays" value="@Model.MaxLogRetentionDays" class="form-control" min="1" max="365" />
</div>
<div class="form-group">
<label class="flex align-center gap-2" style="cursor:pointer;margin-bottom:10px">
<label class="toggle">
<input type="checkbox" name="ShowUptimePublicly" @(Model.ShowUptimePublicly ? "checked" : "") />
<span class="toggle-slider"></span>
</label>
<span>Show uptime % publicly</span>
</label>
<label class="flex align-center gap-2" style="cursor:pointer">
<label class="toggle">
<input type="checkbox" name="ShowLogsPublicly" @(Model.ShowLogsPublicly ? "checked" : "") />
<span class="toggle-slider"></span>
</label>
<span>Show logs publicly (not recommended)</span>
</label>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
</div>
<div>
<div class="section-title mb-2">Change Password</div>
<div class="card">
<div class="card-body">
@if (!string.IsNullOrEmpty(pwMsg))
{
<div class="alert @(pwType == "success" ? "alert-success" : "alert-danger")">@pwMsg</div>
}
<form method="post" asp-page-handler="ChangePassword">
<div class="form-group">
<label class="form-label">Current Password</label>
<input type="password" name="CurrentPassword" class="form-control" required />
</div>
<div class="form-group">
<label class="form-label">New Password</label>
<input type="password" name="NewPassword" class="form-control" required minlength="6" />
</div>
<div class="form-group">
<label class="form-label">Confirm New Password</label>
<input type="password" name="ConfirmPassword" class="form-control" required />
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
</div>
<div class="section-title mb-2 mt-3">API Endpoints</div>
<div class="card">
<div class="card-body">
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">Use these endpoints to ingest logs or query status from external applications.</p>
<div style="font-family:var(--font-mono);font-size:11px;background:var(--bg-base);padding:12px;border-radius:4px;line-height:2">
<div><span style="color:var(--accent)">POST</span> <span style="color:var(--text-primary)">/api/logs/ingest</span></div>
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/logs/ingest</span> <span style="color:var(--text-muted)">(batch via GET body)</span></div>
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/status/summary</span></div>
<div><span style="color:var(--info)">GET</span> <span style="color:var(--text-primary)">/api/monitors/{id}/check</span></div>
</div>
<a href="/admin/ingest" class="btn btn-outline btn-sm mt-2">View Ingest Docs →</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using EonaCat.LogStack.Status.Services;
namespace EonaCat.LogStack.Status.Pages.Admin;
public class SettingsModel : PageModel
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
private readonly AuthenticationService _authenticationService;
public SettingsModel(AuthenticationService authentication) => _authenticationService = authentication;
[BindProperty] public string SiteName { get; set; } = "";
[BindProperty] public bool ShowLogsPublicly { get; set; }
[BindProperty] public bool ShowUptimePublicly { get; set; }
[BindProperty] public int MaxLogRetentionDays { get; set; } = 30;
[BindProperty] public string AlertEmail { get; set; } = "";
[BindProperty] public string CurrentPassword { get; set; } = "";
[BindProperty] public string NewPassword { get; set; } = "";
[BindProperty] public string ConfirmPassword { get; set; } = "";
public string? Message { get; set; }
public string? PasswordMessage { get; set; }
public async Task<IActionResult> OnGetAsync()
{
if (HttpContext.Session.GetString("IsAdmin") != "true")
{
return RedirectToPage("/Admin/Login");
}
SiteName = await _authenticationService.GetSettingAsync("SiteName", "Status");
ShowLogsPublicly = (await _authenticationService.GetSettingAsync("ShowLogsPublicly", "false")) == "true";
ShowUptimePublicly = (await _authenticationService.GetSettingAsync("ShowUptimePublicly", "true")) == "true";
MaxLogRetentionDays = int.TryParse(await _authenticationService.GetSettingAsync("MaxLogRetentionDays", "30"), out var d) ? d : 30;
AlertEmail = await _authenticationService.GetSettingAsync("AlertEmail", "");
return Page();
}
public async Task<IActionResult> OnPostSaveSettingsAsync()
{
if (HttpContext.Session.GetString("IsAdmin") != "true")
{
return RedirectToPage("/Admin/Login");
}
await _authenticationService.SetSettingAsync("SiteName", SiteName ?? "Status");
await _authenticationService.SetSettingAsync("ShowLogsPublicly", ShowLogsPublicly ? "true" : "false");
await _authenticationService.SetSettingAsync("ShowUptimePublicly", ShowUptimePublicly ? "true" : "false");
await _authenticationService.SetSettingAsync("MaxLogRetentionDays", MaxLogRetentionDays.ToString());
await _authenticationService.SetSettingAsync("AlertEmail", AlertEmail ?? "");
Message = "Settings saved.";
return await OnGetAsync();
}
public async Task<IActionResult> OnPostChangePasswordAsync()
{
if (HttpContext.Session.GetString("IsAdmin") != "true")
{
return RedirectToPage("/Admin/Login");
}
if (NewPassword != ConfirmPassword) { PasswordMessage = "error:Passwords do not match."; return await OnGetAsync(); }
if (NewPassword.Length < 6) { PasswordMessage = "error:Password must be at least 6 characters."; return await OnGetAsync(); }
if (await _authenticationService.ChangePasswordAsync(CurrentPassword, NewPassword))
{
PasswordMessage = "success:Password changed successfully.";
}
else
{
PasswordMessage = "error:Current password is incorrect.";
}
return await OnGetAsync();
}
}