Files
PowderCoatingLogix/src/PowderCoating.Web/Views/SystemLogs/Index.cshtml
T
spouliot a0bdd2b5b4 Sweep all .cshtml files for encoding corruption; add pre-commit guard
Replace all corruption variants with HTML entities across 226 view files:
- 3-char UTF-8-as-Win1252 sequences (ae-corruption)
- Standalone smart/curly quotes that break C# Razor expressions
- Partially re-corrupted variants where the 3rd byte was normalised to ASCII

tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the
script itself never contains a literal non-ASCII character; supports -DryRun

.githooks/pre-commit: blocks commits containing the ae-corruption byte
signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the
hook is repo-committed and active for all future work on this machine.

Build clean; 225 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:37:10 -04:00

322 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@model List<PowderCoating.Web.Controllers.SystemLogRow>
@{
ViewData["Title"] = "System Logs";
int page = ViewBag.Page;
int totalPages = ViewBag.TotalPages;
int totalCount = ViewBag.TotalCount;
int pageSize = ViewBag.PageSize;
string PageLink(int p) => Url.Action("Index", new {
search = ViewBag.Search, level = ViewBag.Level, source = ViewBag.Source,
from = ViewBag.From, to = ViewBag.To, page = p, pageSize
})!;
string LevelBadge(string level) => level switch {
"Warning" => "bg-warning text-dark",
"Error" => "bg-danger",
"Fatal" => "bg-dark",
"Information" => "bg-info text-dark",
_ => "bg-secondary"
};
}
@section Styles {
<style>
/* Dark mode: bg-warning/bg-info badges with text-dark are unreadable */
[data-bs-theme="dark"] .badge.bg-warning.text-dark,
[data-bs-theme="dark"] .badge.bg-info.text-dark {
color: #fff !important;
}
/* Dark mode: bg-light pre in modal becomes unreadable */
[data-bs-theme="dark"] pre.bg-light {
background-color: var(--bs-tertiary-bg) !important;
color: var(--bs-body-color) !important;
}
/* Dark mode: table-dark thead needs contrast against the dark body bg */
[data-bs-theme="dark"] thead.table-dark th {
background-color: #343a40 !important;
border-bottom-color: #4d5154 !important;
}
</style>
}
<div class="container-fluid py-3">
<div class="mb-2">
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Observability
</a>
</div>
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-database-exclamation me-2 text-danger"></i>System Logs</h4>
<small class="text-muted">
@if ((bool)ViewBag.UseAi)
{
<i class="bi bi-cloud me-1 text-primary"></i><span class="text-primary fw-medium">Application Insights</span><span class="ms-1">&mdash; Warning+ events &mdash;</span>
}
else
{
<i class="bi bi-database me-1"></i><span>SQL table &mdash; Warning+ events &mdash;</span>
}
@if (totalCount > 0) { <span>@totalCount.ToString("N0") entries</span> }
else { <span>No entries yet</span> }
</small>
</div>
@if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")))
{
<a asp-controller="Diagnostics" asp-action="ViewLogs" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-text me-1"></i>Raw Log Files
</a>
}
</div>
@if (ViewBag.TableMissing == true)
{
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>
The <code>SystemLogs</code> table hasn't been created yet &mdash; it will appear automatically after the first Warning or Error is logged.
</div>
}
else if (ViewBag.QueryError != null)
{
<div class="alert alert-danger alert-permanent">@ViewBag.QueryError</div>
}
@* Filters *@
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<input name="search" value="@ViewBag.Search" class="form-control form-control-sm"
placeholder="Search message or exception&hellip;" />
</div>
<div class="col-md-2">
<select name="level" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All levels</option>
@foreach (var l in new[] { "Warning", "Error", "Fatal" })
{
<option value="@l" selected="@(ViewBag.Level == l)">@l</option>
}
</select>
</div>
<div class="col-md-3">
<input name="source" value="@ViewBag.Source" class="form-control form-control-sm"
placeholder="Source context&hellip;" />
</div>
<div class="col-md-1">
<input name="from" type="date" value="@ViewBag.From" class="form-control form-control-sm" title="From date" />
</div>
<div class="col-md-1">
<input name="to" type="date" value="@ViewBag.To" class="form-control form-control-sm" title="To date" />
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm flex-grow-1">Filter</button>
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">Clear</a>
</div>
<input type="hidden" name="pageSize" value="@pageSize" />
</form>
</div>
</div>
@if (Model.Count == 0 && ViewBag.TableMissing != true)
{
<div class="alert alert-info alert-permanent"><i class="bi bi-check-circle me-2"></i>No log entries match your filters.</div>
}
else if (Model.Count > 0)
{
@* Desktop table *@
<div class="card border-0 shadow-sm d-none d-lg-block">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead class="table-dark">
<tr>
<th style="width:150px">Timestamp</th>
<th style="width:80px">Level</th>
<th style="width:180px">Source</th>
<th>Message</th>
<th style="width:130px">User</th>
<th style="width:50px">Co.</th>
<th style="width:30px" class="text-center"><i class="bi bi-zoom-in"></i></th>
</tr>
</thead>
<tbody>
@foreach (var row in Model)
{
<tr class="@(row.Level == "Error" || row.Level == "Fatal" ? "table-danger" : row.Level == "Warning" ? "table-warning" : "") log-row"
style="cursor:pointer"
data-bs-toggle="modal" data-bs-target="#logModal"
data-message="@System.Text.Json.JsonSerializer.Serialize(row.Message)"
data-exception="@System.Text.Json.JsonSerializer.Serialize(row.Exception ?? "")"
data-source="@row.SourceContext"
data-timestamp="@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("yyyy-MM-dd HH:mm:ss")"
data-level="@row.Level">
<td class="text-nowrap small text-muted">@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm:ss")</td>
<td><span class="badge @LevelBadge(row.Level)">@row.Level</span></td>
<td class="small text-truncate" style="max-width:180px" title="@row.SourceContext">
@(row.SourceContext?.Split('.').LastOrDefault() ?? "&mdash;")
</td>
<td class="small text-truncate" style="max-width:400px">
@row.Message
@if (!string.IsNullOrEmpty(row.Exception))
{
<i class="bi bi-bug text-danger ms-1" title="Has exception"></i>
}
</td>
<td class="small text-muted">@(row.UserName ?? "&mdash;")</td>
<td class="small text-muted text-center">@(row.CompanyId?.ToString() ?? "&mdash;")</td>
<td class="text-center"><i class="bi bi-zoom-in text-muted small"></i></td>
</tr>
}
</tbody>
</table>
</div>
</div>
@* Mobile card view &mdash; shown on screens < 992px *@
<div class="mobile-card-view d-lg-none">
<div class="mobile-card-list">
@foreach (var row in Model)
{
var cardIconBg = row.Level == "Error" || row.Level == "Fatal" ? "bg-danger"
: row.Level == "Warning" ? "bg-warning"
: "bg-secondary";
var cardIcon = row.Level == "Error" || row.Level == "Fatal" ? "bi-x-circle"
: row.Level == "Warning" ? "bi-exclamation-triangle"
: "bi-info-circle";
var msgTruncated = row.Message?.Length > 80 ? row.Message.Substring(0, 80) + "&hellip;" : row.Message ?? "&mdash;";
<div class="mobile-data-card log-row"
style="cursor:pointer"
data-bs-toggle="modal" data-bs-target="#logModal"
data-message="@System.Text.Json.JsonSerializer.Serialize(row.Message)"
data-exception="@System.Text.Json.JsonSerializer.Serialize(row.Exception ?? "")"
data-source="@row.SourceContext"
data-timestamp="@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("yyyy-MM-dd HH:mm:ss")"
data-level="@row.Level">
<div class="mobile-card-header">
<div class="mobile-card-icon @cardIconBg"><i class="bi @cardIcon"></i></div>
<div class="mobile-card-title">
<h6>@msgTruncated</h6>
<small class="text-muted">@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm:ss")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Level</span>
<span class="mobile-card-value">
<span class="badge @LevelBadge(row.Level)">@row.Level</span>
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Source</span>
<span class="mobile-card-value small">@(row.SourceContext?.Split('.').LastOrDefault() ?? "&mdash;")</span>
</div>
@if (row.UserName != null)
{
<div class="mobile-card-row">
<span class="mobile-card-label">User</span>
<span class="mobile-card-value">@row.UserName</span>
</div>
}
@if (!string.IsNullOrEmpty(row.Exception))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Exception</span>
<span class="mobile-card-value"><i class="bi bi-bug text-danger"></i> Yes</span>
</div>
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-secondary">View Details →</span>
</div>
</div>
}
</div>
</div>
@* Pagination *@
@if (totalPages > 1)
{
<nav class="mt-3 d-flex justify-content-between align-items-center">
<small class="text-muted">
Showing @((page - 1) * pageSize + 1)&ndash;@Math.Min(page * pageSize, totalCount) of @totalCount.ToString("N0")
</small>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(page <= 1 ? "disabled" : "")">
<a class="page-link" href="@PageLink(page - 1)"></a>
</li>
@for (int i = Math.Max(1, page - 2); i <= Math.Min(totalPages, page + 2); i++)
{
<li class="page-item @(i == page ? "active" : "")">
<a class="page-link" href="@PageLink(i)">@i</a>
</li>
}
<li class="page-item @(page >= totalPages ? "disabled" : "")">
<a class="page-link" href="@PageLink(page + 1)"></a>
</li>
</ul>
<div class="d-flex gap-1">
@foreach (var ps in new[] { 25, 50, 100 })
{
<a href="@PageLink(1)&pageSize=@ps" class="btn btn-sm @(pageSize == ps ? "btn-secondary" : "btn-outline-secondary")">@ps</a>
}
</div>
</nav>
}
}
</div>
@* Detail modal *@
<div class="modal fade" id="logModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title">
<span id="modalLevel" class="badge me-2"></span>
<span id="modalTimestamp" class="text-muted small"></span>
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="small text-muted mb-1" id="modalSource"></p>
<h6>Message</h6>
<pre class="bg-light p-2 rounded small" id="modalMessage" style="white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto"></pre>
<div id="modalExceptionSection" class="d-none">
<h6>Exception</h6>
<pre class="bg-danger bg-opacity-10 p-2 rounded small text-danger" id="modalException" style="white-space:pre-wrap;word-break:break-all;max-height:300px;overflow-y:auto"></pre>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
document.getElementById('logModal').addEventListener('show.bs.modal', function (e) {
const btn = e.relatedTarget;
const msg = JSON.parse(btn.dataset.message);
const exception = JSON.parse(btn.dataset.exception);
const level = btn.dataset.level;
document.getElementById('modalMessage').textContent = msg;
document.getElementById('modalTimestamp').textContent = btn.dataset.timestamp;
document.getElementById('modalSource').textContent = btn.dataset.source || '';
const badge = document.getElementById('modalLevel');
badge.textContent = level;
badge.className = 'badge me-2 ' + ({
Warning: 'bg-warning text-dark',
Error: 'bg-danger',
Fatal: 'bg-dark'
}[level] || 'bg-secondary');
const excSection = document.getElementById('modalExceptionSection');
if (exception) {
document.getElementById('modalException').textContent = exception;
excSection.classList.remove('d-none');
} else {
excSection.classList.add('d-none');
}
});
</script>
}