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 GetSummary() { var isAdmin = IsAdmin(); var stats = await _monitorService.GetStatsAsync(isAdmin); return Ok(stats); } [HttpGet("monitors")] public async Task 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 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 CheckMonitor(int id) { var monitor = await _database.Monitors.FindAsync(id); if (monitor == null) { return NotFound(); } var check = await _monitorService.CheckMonitorAsync(monitor); return Ok(check); } /// Returns the last N checks for a monitor (default 100). [HttpGet("monitors/{id}/history")] public async Task 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); } /// Returns uptime percentages over 24h / 7d / 30d windows. [HttpGet("monitors/{id}/uptime")] public async Task 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); } /// Pause or resume a monitor (admin only). [HttpPost("monitors/{id}/pause")] public async Task 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 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 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 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 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 }); } /// List incidents (admin sees all; public sees only IsPublic incidents). [HttpGet("incidents")] public async Task 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 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 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 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 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 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 }); } /// Returns hourly log volume buckets for charting. [HttpGet("logs/stats")] public async Task 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 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 IngestBatch([FromBody] List 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 IngestEonaCat([FromBody] object[] events) { var entries = new List(); foreach (var evtObj in events) { var json = JsonSerializer.Serialize(evtObj); var dict = JsonSerializer.Deserialize>(json)!; var logEntry = new LogEntry { Source = dict.TryGetValue("properties", out var props) && props.ValueKind == JsonValueKind.Object && props.Deserialize>()!.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 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(); 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; } /// If provided, a new IncidentUpdate is appended with this message. public string? UpdateMessage { get; set; } } public class EonaCatPayLoad { public List? 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? 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? 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? Properties { get; set; } }