using Azure.Identity; using Azure.Monitor.Query; using Azure.Monitor.Query.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; /// /// SuperAdmin-only viewer for structured application logs. /// /// In production (when ApplicationInsights:WorkspaceId is configured) logs are queried /// from the Azure Monitor / Application Insights workspace via LogsQueryClient using /// DefaultAzureCredential (Managed Identity on App Service; developer credentials locally). /// /// In development (or when the AI workspace is not configured) the controller falls back to /// querying the Serilog SystemLogs SQL table via raw ADO.NET, exactly as before. /// /// Both paths populate the same model so the view is unchanged. /// [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class SystemLogsController : Controller { private readonly ApplicationDbContext _db; private readonly IConfiguration _configuration; private readonly ILogger _logger; public SystemLogsController( ApplicationDbContext db, IConfiguration configuration, ILogger logger) { _db = db; _configuration = configuration; _logger = logger; } /// /// Renders a paginated, filterable view of application logs. /// Routes to when an AI workspace is configured, /// or otherwise. /// public async Task Index( string? search, string? level, string? source, DateTime? from, DateTime? to, int page = 1, int pageSize = 50) { pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50; page = Math.Max(1, page); // Prefer resource-based querying (works for both classic and workspace-based AI resources). // Fall back to workspace-based querying if only WorkspaceId is configured (legacy). var resourceId = _configuration["ApplicationInsights:ResourceId"]; var workspaceId = _configuration["ApplicationInsights:WorkspaceId"]; var useAi = !string.IsNullOrWhiteSpace(resourceId) || !string.IsNullOrWhiteSpace(workspaceId); ViewBag.UseAi = useAi; ViewBag.Search = search; ViewBag.Level = level; ViewBag.Source = source; ViewBag.From = from?.ToString("yyyy-MM-dd"); ViewBag.To = to?.ToString("yyyy-MM-dd"); ViewBag.Page = page; ViewBag.PageSize = pageSize; List items; int totalCount; if (useAi) (items, totalCount) = await QueryApplicationInsightsAsync(resourceId, workspaceId, search, level, source, from, to, page, pageSize); else (items, totalCount) = await QuerySqlTableAsync(search, level, source, from, to, page, pageSize); ViewBag.TotalCount = totalCount; ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize); return View(items); } // ── Application Insights path ───────────────────────────────────────────── /// /// Queries the Application Insights traces table via KQL using /// DefaultAzureCredential. On Azure App Service this resolves to Managed Identity /// automatically; in local dev it uses Azure CLI / Visual Studio credentials. /// /// Prefers QueryResourceAsync with the AI resource ID (works for both classic and /// workspace-based resources). Falls back to QueryWorkspaceAsync if only a workspace /// ID is configured. /// /// Serilog writes all structured properties (SourceContext, UserName, CompanyId, Exception) /// into the customDimensions bag. The KQL query extends them into named columns so /// they map cleanly onto . /// /// Pagination is approximated: a separate count query runs first (capped at 10 000 to /// avoid expensive full scans), then the data query uses | skip / | take. /// private async Task<(List items, int totalCount)> QueryApplicationInsightsAsync( string? resourceId, string? workspaceId, string? search, string? level, string? source, DateTime? from, DateTime? to, int page, int pageSize) { var items = new List(); var totalCount = 0; try { var client = new LogsQueryClient(new DefaultAzureCredential()); var timeRange = BuildTimeRange(from, to); var filters = BuildKqlFilters(search, level, source); // Count query (capped — full scans on large workspaces are expensive) var countKql = $""" traces | where severityLevel >= 2 {filters} | count """; // Data query — fetch offset+pageSize rows and skip client-side. // The Application Insights query API does not support the KQL `skip` operator, // so we over-fetch and discard the leading rows in C#. var offset = (page - 1) * pageSize; var dataKql = $""" traces | where severityLevel >= 2 {filters} | extend Level = case(severityLevel == 2, "Warning", severityLevel == 3, "Error", "Fatal"), SourceContext = tostring(customDimensions["SourceContext"]), UserName = tostring(customDimensions["UserName"]), CompanyIdStr = tostring(customDimensions["CompanyId"]), ExceptionText = tostring(customDimensions["Exception"]) | project Timestamp = timestamp, Level, SourceContext, Message = message, ExceptionText, UserName, CompanyIdStr | order by Timestamp desc | take {offset + pageSize} """; if (!string.IsNullOrWhiteSpace(resourceId)) { // Resource-based query — works for both classic and workspace-based AI resources. var rid = new Azure.Core.ResourceIdentifier(resourceId); var countResult = await client.QueryResourceAsync(rid, countKql, timeRange); totalCount = (int)Math.Min(countResult.Value.FirstOrDefault(), 10_000); var dataResult = await client.QueryResourceAsync(rid, dataKql, timeRange); foreach (var row in dataResult.Value.Skip(offset)) { items.Add(MapAiRow(row)); } } else { // Workspace-based query (legacy fallback). var countResult = await client.QueryWorkspaceAsync(workspaceId!, countKql, timeRange); totalCount = (int)Math.Min(countResult.Value.FirstOrDefault(), 10_000); var dataResult = await client.QueryWorkspaceAsync(workspaceId!, dataKql, timeRange); foreach (var row in dataResult.Value.Skip(offset)) { items.Add(MapAiRow(row)); } } } catch (Exception ex) { // SEM0100 specifically means the "traces" table doesn't exist yet in the workspace — // it is created automatically on first ingestion, typically within a few minutes. // Any other exception (auth failures, wrong workspace ID, network errors) shows the // real message so it can be diagnosed rather than silently swallowed. if (ex.Message.Contains("SEM0100")) { ViewBag.QueryError = "No log data yet — the Application Insights traces table is created automatically " + "after the first Warning or Error is written. Check back in a few minutes."; } else { _logger.LogError(ex, "Error querying Application Insights workspace {WorkspaceId}", workspaceId); ViewBag.QueryError = $"Error loading logs from Application Insights: {ex.Message}"; } } return (items, totalCount); } /// /// Builds a KQL time range from optional date filters. /// Defaults to the last 7 days when no date is specified, matching the AI portal default. /// private static QueryTimeRange BuildTimeRange(DateTime? from, DateTime? to) { if (from.HasValue && to.HasValue) return new QueryTimeRange(from.Value.ToUniversalTime(), to.Value.ToUniversalTime().AddDays(1)); if (from.HasValue) return new QueryTimeRange(from.Value.ToUniversalTime(), DateTimeOffset.UtcNow); if (to.HasValue) return new QueryTimeRange(DateTimeOffset.UtcNow.AddDays(-90), to.Value.ToUniversalTime().AddDays(1)); return new QueryTimeRange(TimeSpan.FromDays(7)); } /// /// Builds KQL filter lines from the optional search/level/source parameters. /// Each line is a separate | where clause appended to the base query. /// private static string BuildKqlFilters(string? search, string? level, string? source) { var parts = new List(); if (!string.IsNullOrWhiteSpace(search)) parts.Add($"| where message contains \"{EscapeKql(search)}\" or tostring(customDimensions[\"Exception\"]) contains \"{EscapeKql(search)}\""); if (!string.IsNullOrWhiteSpace(level)) { var severity = level switch { "Warning" => "2", "Error" => "3", "Fatal" => "4", _ => "2" }; parts.Add($"| where severityLevel == {severity}"); } if (!string.IsNullOrWhiteSpace(source)) parts.Add($"| where tostring(customDimensions[\"SourceContext\"]) contains \"{EscapeKql(source)}\""); return string.Join("\n", parts); } /// /// Escapes a user-supplied string for safe embedding inside a KQL string literal. /// Backslashes and double-quotes are the only special characters in KQL string literals. /// private static string EscapeKql(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); private static SystemLogRow MapAiRow(AiTraceRow row) => new() { Id = 0, Timestamp = row.Timestamp.UtcDateTime, Level = row.Level ?? "Information", SourceContext = NullIfEmpty(row.SourceContext), Message = row.Message ?? "", Exception = NullIfEmpty(row.ExceptionText), UserName = NullIfEmpty(row.UserName), CompanyId = int.TryParse(row.CompanyIdStr, out var cid) ? cid : null, RemoteIP = null }; private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; // ── SQL fallback path (dev / no AI configured) ──────────────────────────── /// /// Queries the Serilog SystemLogs SQL table via raw ADO.NET. /// Used in development or when no Application Insights workspace ID is configured. /// An idempotent ALTER TABLE ensures the SourceContext column exists on /// databases created before that column was added to the sink configuration. /// private async Task<(List items, int totalCount)> QuerySqlTableAsync( string? search, string? level, string? source, DateTime? from, DateTime? to, int page, int pageSize) { var items = new List(); var totalCount = 0; try { var connStr = _db.Database.GetConnectionString()!; // Ensure SourceContext column exists (added after initial deployment) await using (var ensureConn = new SqlConnection(connStr)) { await ensureConn.OpenAsync(); await using var ensureCmd = new SqlCommand(""" IF NOT EXISTS ( SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('SystemLogs') AND name = 'SourceContext' ) ALTER TABLE SystemLogs ADD SourceContext NVARCHAR(512) NULL """, ensureConn); await ensureCmd.ExecuteNonQueryAsync(); } var conditions = new List(); var sqlParams = new List(); if (!string.IsNullOrWhiteSpace(search)) { conditions.Add("(Message LIKE @search OR Exception LIKE @search)"); sqlParams.Add(new SqlParameter("@search", $"%{search}%")); } if (!string.IsNullOrWhiteSpace(level)) { conditions.Add("Level = @level"); sqlParams.Add(new SqlParameter("@level", level)); } if (!string.IsNullOrWhiteSpace(source)) { conditions.Add("SourceContext LIKE @source"); sqlParams.Add(new SqlParameter("@source", $"%{source}%")); } if (from.HasValue) { conditions.Add("Timestamp >= @from"); sqlParams.Add(new SqlParameter("@from", from.Value.ToUniversalTime())); } if (to.HasValue) { conditions.Add("Timestamp < @to"); sqlParams.Add(new SqlParameter("@to", to.Value.ToUniversalTime().AddDays(1))); } var where = conditions.Count > 0 ? "WHERE " + string.Join(" AND ", conditions) : ""; await using var conn = new SqlConnection(connStr); await conn.OpenAsync(); await using (var countCmd = new SqlCommand($"SELECT COUNT(*) FROM SystemLogs {where}", conn)) { foreach (var p in sqlParams) countCmd.Parameters.Add(CloneParam(p)); totalCount = (int)await countCmd.ExecuteScalarAsync(); } var offset = (page - 1) * pageSize; var dataSql = $""" SELECT Id, Timestamp, Level, SourceContext, Message, Exception, UserName, CompanyId, RemoteIP FROM SystemLogs {where} ORDER BY Timestamp DESC OFFSET {offset} ROWS FETCH NEXT {pageSize} ROWS ONLY """; await using var dataCmd = new SqlCommand(dataSql, conn); foreach (var p in sqlParams) dataCmd.Parameters.Add(CloneParam(p)); await using var reader = await dataCmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { items.Add(new SystemLogRow { Id = reader.GetInt32(0), Timestamp = reader.GetDateTime(1), Level = reader.IsDBNull(2) ? "" : reader.GetString(2), SourceContext = reader.IsDBNull(3) ? null : reader.GetString(3), Message = reader.IsDBNull(4) ? "" : reader.GetString(4), Exception = reader.IsDBNull(5) ? null : reader.GetString(5), UserName = reader.IsDBNull(6) ? null : reader.GetString(6), CompanyId = reader.IsDBNull(7) ? null : reader.GetInt32(7), RemoteIP = reader.IsDBNull(8) ? null : reader.GetString(8), }); } } catch (Exception ex) when (ex.Message.Contains("Invalid object name 'SystemLogs'")) { ViewBag.TableMissing = true; } catch (Exception ex) { _logger.LogError(ex, "Error querying SystemLogs SQL table"); ViewBag.QueryError = "Error loading logs from SQL table."; } return (items, totalCount); } /// /// Clones a so the same logical parameter can be added to both /// the COUNT and data SELECT commands. A single instance cannot be reused across commands. /// private static SqlParameter CloneParam(SqlParameter p) => new(p.ParameterName, p.SqlDbType, p.Size) { Value = p.Value }; } /// /// Unified log row used by the view regardless of whether data came from AI or SQL. /// public class SystemLogRow { public int Id { get; set; } public DateTime Timestamp { get; set; } public string Level { get; set; } = ""; public string? SourceContext { get; set; } public string Message { get; set; } = ""; public string? Exception { get; set; } public string? UserName { get; set; } public int? CompanyId { get; set; } public string? RemoteIP { get; set; } } /// /// Strongly-typed projection used by LogsQueryClient.QueryWorkspaceAsync<T> /// to deserialize each row from the KQL result. Property names must match the KQL column /// names exactly (case-insensitive). The Message column comes from the Serilog sink's /// message field; the others are extended from customDimensions in the query. /// internal class AiTraceRow { public DateTimeOffset Timestamp { get; set; } public string? Level { get; set; } public string? SourceContext { get; set; } public string? Message { get; set; } public string? ExceptionText { get; set; } public string? UserName { get; set; } public string? CompanyIdStr { get; set; } }