414 lines
18 KiB
C#
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<T></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; }
|
|
}
|