refactor(ui): rename AuditLog viewer to ConfigurationAuditLog under /audit/configuration (#23 M7)
The pre-M1 IAuditService config-change viewer moves out of the Monitoring nav group to make room for the new Audit nav group (issue #23 M7). The old route /monitoring/audit-log returns 404 (no redirect, per plan) — the viewer is now reachable at /audit/configuration and labelled "Configuration Audit Log" to disambiguate from the new Audit Log page (arriving in #23 M7-T9). Inbound references in NavMenu, Dashboard, and the Playwright nav tests are updated to the new route and label.
This commit is contained in:
@@ -1,330 +0,0 @@
|
||||
@page "/monitoring/audit-log"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.CentralUI.Components
|
||||
@using ScadaLink.Commons.Entities.Audit
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject ICentralUiRepository CentralUiRepository
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h4 class="mb-3">Audit Log</h4>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
<div class="row mb-3 g-2 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small" for="audit-filter-user">User</label>
|
||||
<input id="audit-filter-user"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
aria-label="User"
|
||||
@bind="_filterUser"
|
||||
placeholder="Username" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small" for="audit-filter-entity-type">Entity Type</label>
|
||||
<input id="audit-filter-entity-type"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
aria-label="Entity type"
|
||||
@bind="_filterEntityType"
|
||||
placeholder="e.g. Template" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small" for="audit-filter-action">Action</label>
|
||||
<input id="audit-filter-action"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
aria-label="Action"
|
||||
@bind="_filterAction"
|
||||
placeholder="e.g. Create" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small" for="audit-filter-from">From</label>
|
||||
<input id="audit-filter-from"
|
||||
type="datetime-local"
|
||||
class="form-control form-control-sm"
|
||||
aria-label="From timestamp"
|
||||
@bind="_filterFrom" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small" for="audit-filter-to">To</label>
|
||||
<input id="audit-filter-to"
|
||||
type="datetime-local"
|
||||
class="form-control form-control-sm"
|
||||
aria-label="To timestamp"
|
||||
@bind="_filterTo" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
|
||||
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||
Search
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters" disabled="@_searching">
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
|
||||
@if (_entries != null)
|
||||
{
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Entity Type</th>
|
||||
<th>Entity ID</th>
|
||||
<th>Entity Name</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_entries.Count == 0)
|
||||
{
|
||||
<tr><td colspan="7" class="text-muted text-center">No audit entries found.</td></tr>
|
||||
}
|
||||
@foreach (var entry in _entries)
|
||||
{
|
||||
var entityIdShort = entry.EntityId is { Length: > 0 }
|
||||
? entry.EntityId[..Math.Min(12, entry.EntityId.Length)]
|
||||
: "";
|
||||
var hasState = !string.IsNullOrWhiteSpace(entry.AfterStateJson);
|
||||
var isLarge = hasState && entry.AfterStateJson!.Length > 1024;
|
||||
<tr>
|
||||
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
|
||||
<td class="small">@entry.User</td>
|
||||
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
|
||||
<td class="small">@entry.EntityType</td>
|
||||
<td class="small">
|
||||
@if (!string.IsNullOrEmpty(entry.EntityId))
|
||||
{
|
||||
<code>@entityIdShort…</code>
|
||||
<button class="btn btn-link btn-sm p-0 ms-1"
|
||||
@onclick="() => CopyAsync(entry.EntityId)"
|
||||
title="Copy entity ID"
|
||||
aria-label="Copy entity ID @entry.EntityId">📋</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@entry.EntityName</td>
|
||||
<td>
|
||||
@if (hasState)
|
||||
{
|
||||
if (isLarge)
|
||||
{
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||
@onclick="() => ShowStateModal(entry)"
|
||||
aria-label="Open state details in modal for audit entry @entry.Id">
|
||||
View in modal
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||
@onclick="() => ToggleStateView(entry.Id)"
|
||||
aria-label="Toggle state details for audit entry @entry.Id">
|
||||
@(_expandedEntryId == entry.Id ? "Hide" : "View")
|
||||
</button>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (hasState && !isLarge && _expandedEntryId == entry.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<pre class="bg-light p-2 rounded small mb-0" style="max-height: 200px; overflow: auto;">@FormatJson(entry.AfterStateJson!)</pre>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted small">Page @_page of @TotalPages (@_totalCount total)</span>
|
||||
<div>
|
||||
<button class="btn btn-outline-secondary btn-sm me-1" @onclick="PrevPage" disabled="@(_page <= 1)">Previous</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="NextPage" disabled="@(!HasMore)">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_modalEntry != null)
|
||||
{
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
Audit entry @_modalEntry.Id — @_modalEntry.EntityType state
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre class="bg-light p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? _filterUser;
|
||||
private string? _filterEntityType;
|
||||
private string? _filterAction;
|
||||
private DateTime? _filterFrom;
|
||||
private DateTime? _filterTo;
|
||||
|
||||
// The datetime-local filter inputs are in the browser's local time zone.
|
||||
// This holds new Date().getTimezoneOffset() so the values are converted to
|
||||
// UTC (CentralUI-008) rather than relabelled. Until JS interop runs it is 0
|
||||
// (UTC), which is a safe default for a UTC server/browser.
|
||||
private int _browserUtcOffsetMinutes;
|
||||
|
||||
private List<AuditLogEntry>? _entries;
|
||||
private int _totalCount;
|
||||
private int _page = 1;
|
||||
private int _pageSize = 50;
|
||||
private bool _searching;
|
||||
private string? _errorMessage;
|
||||
private int? _expandedEntryId;
|
||||
private AuditLogEntry? _modalEntry;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
private int TotalPages => _pageSize > 0 ? Math.Max(1, (_totalCount + _pageSize - 1) / _pageSize) : 1;
|
||||
private bool HasMore => _page * _pageSize < _totalCount;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
try
|
||||
{
|
||||
// Date.getTimezoneOffset() returns (UTC - local) in minutes.
|
||||
_browserUtcOffsetMinutes = await JS.InvokeAsync<int>(
|
||||
"eval", "new Date().getTimezoneOffset()");
|
||||
}
|
||||
catch (Exception ex) when (ex is JSException or JSDisconnectedException
|
||||
or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
// Prerender or a disconnected circuit: fall back to UTC (offset 0).
|
||||
_browserUtcOffsetMinutes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Search()
|
||||
{
|
||||
_page = 1;
|
||||
await FetchPage();
|
||||
}
|
||||
|
||||
private async Task ClearFilters()
|
||||
{
|
||||
_filterUser = null;
|
||||
_filterEntityType = null;
|
||||
_filterAction = null;
|
||||
_filterFrom = null;
|
||||
_filterTo = null;
|
||||
_page = 1;
|
||||
await FetchPage();
|
||||
}
|
||||
|
||||
private async Task PrevPage() { _page--; await FetchPage(); }
|
||||
private async Task NextPage() { _page++; await FetchPage(); }
|
||||
|
||||
private async Task FetchPage()
|
||||
{
|
||||
_searching = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
var (entries, totalCount) = await CentralUiRepository.GetAuditLogEntriesAsync(
|
||||
user: string.IsNullOrWhiteSpace(_filterUser) ? null : _filterUser.Trim(),
|
||||
entityType: string.IsNullOrWhiteSpace(_filterEntityType) ? null : _filterEntityType.Trim(),
|
||||
action: string.IsNullOrWhiteSpace(_filterAction) ? null : _filterAction.Trim(),
|
||||
from: BrowserTime.LocalInputToUtc(_filterFrom, _browserUtcOffsetMinutes),
|
||||
to: BrowserTime.LocalInputToUtc(_filterTo, _browserUtcOffsetMinutes),
|
||||
page: _page,
|
||||
pageSize: _pageSize);
|
||||
|
||||
_entries = entries.ToList();
|
||||
_totalCount = totalCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Query failed: {ex.Message}";
|
||||
}
|
||||
_searching = false;
|
||||
}
|
||||
|
||||
private void ToggleStateView(int entryId)
|
||||
{
|
||||
_expandedEntryId = _expandedEntryId == entryId ? null : entryId;
|
||||
}
|
||||
|
||||
private void ShowStateModal(AuditLogEntry entry)
|
||||
{
|
||||
_modalEntry = entry;
|
||||
}
|
||||
|
||||
private void CloseStateModal()
|
||||
{
|
||||
_modalEntry = null;
|
||||
}
|
||||
|
||||
private async Task CopyAsync(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
||||
_toast.ShowSuccess("Copied to clipboard.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_toast.ShowError("Copy failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetActionBadge(string action) => action switch
|
||||
{
|
||||
"Create" => "bg-success",
|
||||
"Update" => "bg-primary",
|
||||
"Delete" => "bg-danger",
|
||||
"Deploy" => "bg-info text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private static string FormatJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
return System.Text.Json.JsonSerializer.Serialize(doc, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return json;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user