Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/SystemLogsController.cs
T
2026-04-23 21:38:24 -04:00

414 lines
18 KiB
C#

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;
/// <summary>
/// SuperAdmin-only viewer for structured application logs.
///
/// In production (when <c>ApplicationInsights:WorkspaceId</c> is configured) logs are queried
/// from the Azure Monitor / Application Insights workspace via <c>LogsQueryClient</c> using
/// <c>DefaultAzureCredential</c> (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 <c>SystemLogs</c> SQL table via raw ADO.NET, exactly as before.
///
/// Both paths populate the same <see cref="SystemLogRow"/> model so the view is unchanged.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class SystemLogsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IConfiguration _configuration;
private readonly ILogger<SystemLogsController> _logger;
public SystemLogsController(
ApplicationDbContext db,
IConfiguration configuration,
ILogger<SystemLogsController> logger)
{
_db = db;
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Renders a paginated, filterable view of application logs.
/// Routes to <see cref="QueryApplicationInsightsAsync"/> when an AI workspace is configured,
/// or <see cref="QuerySqlTableAsync"/> otherwise.
/// </summary>
public async Task<IActionResult> 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<SystemLogRow> 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 ─────────────────────────────────────────────
/// <summary>
/// Queries the Application Insights <c>traces</c> table via KQL using
/// <c>DefaultAzureCredential</c>. On Azure App Service this resolves to Managed Identity
/// automatically; in local dev it uses Azure CLI / Visual Studio credentials.
///
/// Prefers <c>QueryResourceAsync</c> with the AI resource ID (works for both classic and
/// workspace-based resources). Falls back to <c>QueryWorkspaceAsync</c> if only a workspace
/// ID is configured.
///
/// Serilog writes all structured properties (SourceContext, UserName, CompanyId, Exception)
/// into the <c>customDimensions</c> bag. The KQL query extends them into named columns so
/// they map cleanly onto <see cref="SystemLogRow"/>.
///
/// Pagination is approximated: a separate count query runs first (capped at 10 000 to
/// avoid expensive full scans), then the data query uses <c>| skip / | take</c>.
/// </summary>
private async Task<(List<SystemLogRow> 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<SystemLogRow>();
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<long>(rid, countKql, timeRange);
totalCount = (int)Math.Min(countResult.Value.FirstOrDefault(), 10_000);
var dataResult = await client.QueryResourceAsync<AiTraceRow>(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<long>(workspaceId!, countKql, timeRange);
totalCount = (int)Math.Min(countResult.Value.FirstOrDefault(), 10_000);
var dataResult = await client.QueryWorkspaceAsync<AiTraceRow>(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);
}
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>
/// Builds KQL filter lines from the optional search/level/source parameters.
/// Each line is a separate <c>| where</c> clause appended to the base query.
/// </summary>
private static string BuildKqlFilters(string? search, string? level, string? source)
{
var parts = new List<string>();
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);
}
/// <summary>
/// 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.
/// </summary>
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) ────────────────────────────
/// <summary>
/// Queries the Serilog <c>SystemLogs</c> SQL table via raw ADO.NET.
/// Used in development or when no Application Insights workspace ID is configured.
/// An idempotent <c>ALTER TABLE</c> ensures the <c>SourceContext</c> column exists on
/// databases created before that column was added to the sink configuration.
/// </summary>
private async Task<(List<SystemLogRow> items, int totalCount)> QuerySqlTableAsync(
string? search, string? level, string? source,
DateTime? from, DateTime? to, int page, int pageSize)
{
var items = new List<SystemLogRow>();
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<string>();
var sqlParams = new List<SqlParameter>();
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);
}
/// <summary>
/// Clones a <see cref="SqlParameter"/> so the same logical parameter can be added to both
/// the COUNT and data SELECT commands. A single instance cannot be reused across commands.
/// </summary>
private static SqlParameter CloneParam(SqlParameter p) =>
new(p.ParameterName, p.SqlDbType, p.Size) { Value = p.Value };
}
/// <summary>
/// Unified log row used by the view regardless of whether data came from AI or SQL.
/// </summary>
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; }
}
/// <summary>
/// Strongly-typed projection used by <c>LogsQueryClient.QueryWorkspaceAsync&lt;T&gt;</c>
/// to deserialize each row from the KQL result. Property names must match the KQL column
/// names exactly (case-insensitive). The <c>Message</c> column comes from the Serilog sink's
/// <c>message</c> field; the others are extended from <c>customDimensions</c> in the query.
/// </summary>
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; }
}