Files
EonaCat.LogStack/EonaCat.LogStack.Status/Controllers/ApiController.cs
2026-04-06 08:15:54 +02:00

555 lines
16 KiB
C#

using EonaCat.LogStack.Status.Data;
using EonaCat.LogStack.Status.Models;
using EonaCat.LogStack.Status.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace EonaCat.LogStack.Status.Controllers;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
[ApiController]
[Route("api")]
public class ApiController : ControllerBase
{
private readonly DatabaseContext _database;
private readonly MonitoringService _monitorService;
private readonly IngestionService _ingestionService;
public ApiController(DatabaseContext database, MonitoringService monitorService, IngestionService ingestionService)
{
_database = database;
_monitorService = monitorService;
_ingestionService = ingestionService;
}
[HttpGet("status/summary")]
public async Task<IActionResult> GetSummary()
{
var isAdmin = IsAdmin();
var stats = await _monitorService.GetStatsAsync(isAdmin);
return Ok(stats);
}
[HttpGet("monitors")]
public async Task<IActionResult> GetMonitors()
{
var isAdmin = IsAdmin();
var query = _database.Monitors.Where(m => m.IsActive);
if (!isAdmin)
{
query = query.Where(m => m.IsPublic);
}
return Ok(await query.ToListAsync());
}
[HttpGet("monitors/{id}")]
public async Task<IActionResult> GetMonitor(int id)
{
var monitor = await _database.Monitors.FindAsync(id);
if (monitor == null)
{
return NotFound();
}
if (!IsAdmin() && !monitor.IsPublic)
{
return Unauthorized();
}
return Ok(monitor);
}
[HttpGet("monitors/{id}/check")]
public async Task<IActionResult> CheckMonitor(int id)
{
var monitor = await _database.Monitors.FindAsync(id);
if (monitor == null)
{
return NotFound();
}
var check = await _monitorService.CheckMonitorAsync(monitor);
return Ok(check);
}
/// <summary>Returns the last N checks for a monitor (default 100).</summary>
[HttpGet("monitors/{id}/history")]
public async Task<IActionResult> GetMonitorHistory(int id, [FromQuery] int limit = 100)
{
var monitor = await _database.Monitors.FindAsync(id);
if (monitor == null)
{
return NotFound();
}
if (!IsAdmin() && !monitor.IsPublic)
{
return Unauthorized();
}
var checks = await _database.MonitorChecks
.Where(c => c.MonitorId == id)
.OrderByDescending(c => c.CheckedAt)
.Take(Math.Clamp(limit, 1, 1000))
.ToListAsync();
return Ok(checks);
}
/// <summary>Returns uptime percentages over 24h / 7d / 30d windows.</summary>
[HttpGet("monitors/{id}/uptime")]
public async Task<IActionResult> GetMonitorUptime(int id)
{
var monitor = await _database.Monitors.FindAsync(id);
if (monitor == null)
{
return NotFound();
}
if (!IsAdmin() && !monitor.IsPublic)
{
return Unauthorized();
}
var report = await _monitorService.GetUptimeReportAsync(id);
return Ok(report);
}
/// <summary>Pause or resume a monitor (admin only).</summary>
[HttpPost("monitors/{id}/pause")]
public async Task<IActionResult> PauseMonitor(int id)
{
if (!IsAdmin())
{
return Unauthorized();
}
var monitor = await _database.Monitors.FindAsync(id);
if (monitor == null)
{
return NotFound();
}
monitor.IsActive = false;
await _database.SaveChangesAsync();
return Ok(new { id, active = false });
}
[HttpPost("monitors/{id}/resume")]
public async Task<IActionResult> ResumeMonitor(int id)
{
if (!IsAdmin())
{
return Unauthorized();
}
var monitor = await _database.Monitors.FindAsync(id);
if (monitor == null)
{
return NotFound();
}
monitor.IsActive = true;
monitor.LastChecked = null; // force immediate re-check
await _database.SaveChangesAsync();
return Ok(new { id, active = true });
}
[HttpGet("monitors/{id}/alerts")]
public async Task<IActionResult> GetAlertRules(int id)
{
if (!IsAdmin())
{
return Unauthorized();
}
var rules = await _database.AlertRules.Where(r => r.MonitorId == id).ToListAsync();
return Ok(rules);
}
[HttpPost("monitors/{id}/alerts")]
public async Task<IActionResult> CreateAlertRule(int id, [FromBody] AlertRule rule)
{
if (!IsAdmin())
{
return Unauthorized();
}
var monitor = await _database.Monitors.FindAsync(id);
if (monitor == null)
{
return NotFound();
}
rule.MonitorId = id;
_database.AlertRules.Add(rule);
await _database.SaveChangesAsync();
return Ok(rule);
}
[HttpDelete("alerts/{ruleId}")]
public async Task<IActionResult> DeleteAlertRule(int ruleId)
{
if (!IsAdmin())
{
return Unauthorized();
}
var rule = await _database.AlertRules.FindAsync(ruleId);
if (rule == null)
{
return NotFound();
}
_database.AlertRules.Remove(rule);
await _database.SaveChangesAsync();
return Ok(new { deleted = ruleId });
}
/// <summary>List incidents (admin sees all; public sees only IsPublic incidents).</summary>
[HttpGet("incidents")]
public async Task<IActionResult> GetIncidents([FromQuery] bool activeOnly = false)
{
var isAdmin = IsAdmin();
var query = _database.Incidents.Include(i => i.Updates).AsQueryable();
if (!isAdmin)
{
query = query.Where(i => i.IsPublic);
}
if (activeOnly)
{
query = query.Where(i => i.Status != IncidentStatus.Resolved);
}
var list = await query.OrderByDescending(i => i.CreatedAt).ToListAsync();
return Ok(list);
}
[HttpGet("incidents/{id}")]
public async Task<IActionResult> GetIncident(int id)
{
var incident = await _database.Incidents.Include(i => i.Updates).FirstOrDefaultAsync(i => i.Id == id);
if (incident == null)
{
return NotFound();
}
if (!IsAdmin() && !incident.IsPublic)
{
return Unauthorized();
}
return Ok(incident);
}
[HttpPost("incidents")]
public async Task<IActionResult> CreateIncident([FromBody] Incident incident)
{
if (!IsAdmin())
{
return Unauthorized();
}
incident.CreatedAt = DateTime.UtcNow;
incident.UpdatedAt = DateTime.UtcNow;
_database.Incidents.Add(incident);
await _database.SaveChangesAsync();
return Ok(incident);
}
[HttpPatch("incidents/{id}")]
public async Task<IActionResult> UpdateIncident(int id, [FromBody] IncidentPatchDto patch)
{
if (!IsAdmin())
{
return Unauthorized();
}
var incident = await _database.Incidents.FindAsync(id);
if (incident == null)
{
return NotFound();
}
if (patch.Status.HasValue)
{
incident.Status = patch.Status.Value;
if (patch.Status == IncidentStatus.Resolved)
{
incident.ResolvedAt = DateTime.UtcNow;
}
}
if (patch.Severity.HasValue)
{
incident.Severity = patch.Severity.Value;
}
if (patch.Body != null)
{
incident.Body = patch.Body;
}
incident.UpdatedAt = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(patch.UpdateMessage))
{
_database.IncidentUpdates.Add(new IncidentUpdate
{
IncidentId = id,
Message = patch.UpdateMessage,
Status = patch.Status ?? incident.Status
});
}
await _database.SaveChangesAsync();
return Ok(incident);
}
[HttpDelete("incidents/{id}")]
public async Task<IActionResult> DeleteIncident(int id)
{
if (!IsAdmin())
{
return Unauthorized();
}
var incident = await _database.Incidents.FindAsync(id);
if (incident == null)
{
return NotFound();
}
_database.Incidents.Remove(incident);
await _database.SaveChangesAsync();
return Ok(new { deleted = id });
}
[HttpGet("logs")]
public async Task<IActionResult> QueryLogs(
[FromQuery] string? level,
[FromQuery] string? source,
[FromQuery] string? search,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 100)
{
if (!IsAdmin())
{
return Unauthorized();
}
var query = _database.Logs.AsQueryable();
if (!string.IsNullOrWhiteSpace(level))
{
query = query.Where(x => x.Level == level.ToLower());
}
if (!string.IsNullOrWhiteSpace(source))
{
query = query.Where(x => x.Source == source);
}
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(x => x.Message.Contains(search));
}
if (from.HasValue)
{
query = query.Where(x => x.Timestamp >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.Timestamp <= to.Value);
}
var total = await query.CountAsync();
var entries = await query
.OrderByDescending(x => x.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return Ok(new { total, page, pageSize, entries });
}
/// <summary>Returns hourly log volume buckets for charting.</summary>
[HttpGet("logs/stats")]
public async Task<IActionResult> GetLogStats([FromQuery] int hours = 24)
{
if (!IsAdmin())
{
return Unauthorized();
}
var buckets = await _monitorService.GetLogStatsAsync(Math.Clamp(hours, 1, 168));
return Ok(buckets);
}
[HttpPost("logs/ingest")]
public async Task<IActionResult> IngestLog([FromBody] LogEntry entry)
{
if (string.IsNullOrWhiteSpace(entry.Message))
{
return BadRequest("message required");
}
entry.Level = (entry.Level ?? "info").ToLower();
entry.Timestamp = DateTime.UtcNow;
await _ingestionService.IngestAsync(entry);
return Ok(new { success = true });
}
[HttpPost("logs/batch")]
public async Task<IActionResult> IngestBatch([FromBody] List<LogEntry> entries)
{
if (entries == null || !entries.Any())
{
return BadRequest("entries required");
}
foreach (var entry in entries)
{
entry.Level = (entry.Level ?? "info").ToLower();
if (entry.Timestamp == default)
{
entry.Timestamp = DateTime.UtcNow;
}
}
await _ingestionService.IngestBatchAsync(entries);
return Ok(new { success = true, count = entries.Count });
}
[HttpPost("logs/eonacat")]
public async Task<IActionResult> IngestEonaCat([FromBody] object[] events)
{
var entries = new List<LogEntry>();
foreach (var evtObj in events)
{
var json = JsonSerializer.Serialize(evtObj);
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!;
var logEntry = new LogEntry
{
Source = dict.TryGetValue("properties", out var props) &&
props.ValueKind == JsonValueKind.Object &&
props.Deserialize<Dictionary<string, object?>>()!.TryGetValue("Application", out var appObj)
? appObj?.ToString() ?? "EonaCat.LogStack" : "EonaCat.LogStack",
Level = dict.TryGetValue("level", out var level) ? level.GetString() ?? "Info" : "Info",
Message = dict.TryGetValue("message", out var msg) ? msg.GetString() ?? "" : "",
Exception = dict.TryGetValue("exception", out var ex) ? ex.ToString() : null,
Host = dict.TryGetValue("host", out var host) ? host.GetString() : null,
TraceId = dict.TryGetValue("traceId", out var traceId) ? traceId.GetString() : null,
Properties = dict.TryGetValue("properties", out var properties) ? properties.GetRawText() : null,
Timestamp = dict.TryGetValue("timestamp", out var ts) && DateTime.TryParse(ts.GetString(), out var dt) ? dt : DateTime.UtcNow
};
logEntry.Level = MapEonaCatLevel(logEntry.Level);
entries.Add(logEntry);
}
if (entries.Any())
{
await _ingestionService.IngestBatchAsync(entries);
}
return Ok(new { success = true, count = entries.Count });
}
[HttpPost("logs/serilog")]
public async Task<IActionResult> IngestSerilog([FromBody] SerilogPayload payload)
{
var entries = payload.Events?.Select(e => new LogEntry
{
Source = e.Properties?.TryGetValue("Application", out var app) == true ? app?.ToString() ?? "serilog" : "serilog",
Level = MapSerilogLevel(e.Level),
Message = e.RenderedMessage ?? e.MessageTemplate ?? "",
Exception = e.Exception,
Properties = e.Properties != null ? JsonSerializer.Serialize(e.Properties) : null,
Timestamp = e.Timestamp == default ? DateTime.UtcNow : e.Timestamp
}).ToList() ?? new List<LogEntry>();
if (entries.Any())
{
await _ingestionService.IngestBatchAsync(entries);
}
return Ok(new { success = true, count = entries.Count });
}
private bool IsAdmin() => HttpContext.Session.GetString("IsAdmin") == "true";
private static string MapSerilogLevel(string? l) => l?.ToLower() switch
{
"verbose" or "debug" => "debug",
"information" => "info",
"warning" => "warn",
"error" => "error",
"fatal" => "critical",
_ => "info"
};
private static string MapEonaCatLevel(string? l) => l?.ToLower() switch
{
"trace" or "debug" => "debug",
"information" => "info",
"warning" => "warn",
"error" => "error",
"critical" => "critical",
_ => "info"
};
}
public class IncidentPatchDto
{
public IncidentStatus? Status { get; set; }
public IncidentSeverity? Severity { get; set; }
public string? Body { get; set; }
/// <summary>If provided, a new IncidentUpdate is appended with this message.</summary>
public string? UpdateMessage { get; set; }
}
public class EonaCatPayLoad
{
public List<EonaCatLogEvent>? Events { get; set; }
}
public class EonaCatLogEvent
{
public string Timestamp { get; set; } = default!;
public string Level { get; set; } = default!;
public string Message { get; set; } = default!;
public string Category { get; set; } = default!;
public int ThreadId { get; set; }
public string? TraceId { get; set; }
public string? SpanId { get; set; }
public ExceptionDto? Exception { get; set; }
public Dictionary<string, object?>? Properties { get; set; }
}
public class ExceptionDto
{
public string Type { get; set; } = default!;
public string Message { get; set; } = default!;
public string? StackTrace { get; set; }
}
public class SerilogPayload
{
public List<SerilogEvent>? Events { get; set; }
}
public class SerilogEvent
{
public DateTime Timestamp { get; set; }
public string? Level { get; set; }
public string? MessageTemplate { get; set; }
public string? RenderedMessage { get; set; }
public string? Exception { get; set; }
public Dictionary<string, object?>? Properties { get; set; }
}