Phases 4-6: Complete Central UI — Admin, Design, Deployment, and Operations pages
Phase 4 — Operator/Admin UI: - Sites, DataConnections, Areas (hierarchical), API Keys (auto-generated) CRUD - Health Dashboard (live refresh, per-site metrics from CentralHealthAggregator) - Instance list with filtering/staleness/lifecycle actions - Deployment status tracking with auto-refresh Phase 5 — Authoring UI: - Template authoring with inheritance tree, tabs (attrs/alarms/scripts/compositions) - Lock indicators, on-demand validation, collision detection - Shared scripts with syntax check - External systems, DB connections, notification lists, Inbound API methods Phase 6 — Deployment Operations UI: - Staleness indicators, validation gating - Debug view (instance selection, attribute/alarm live tables) - Site event log viewer (filters, keyword search, keyset pagination) - Parked message management, Audit log viewer with JSON state Shared components: DataTable, ConfirmDialog, ToastNotification, LoadingSpinner, TimestampDisplay 623 tests pass, zero warnings. All Bootstrap 5, clean corporate design.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
@page "/monitoring/audit-log"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Audit
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject ICentralUiRepository CentralUiRepository
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h4 class="mb-3">Audit Log</h4>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
<div class="row mb-3 g-2">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">User</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_filterUser" placeholder="Username" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Entity Type</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_filterEntityType" placeholder="e.g. Template" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Action</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_filterAction" placeholder="e.g. Create" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">From</label>
|
||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterFrom" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">To</label>
|
||||
<input type="datetime-local" class="form-control form-control-sm" @bind="_filterTo" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
|
||||
@if (_searching) { <span class="spinner-border spinner-border-sm"></span> }
|
||||
Search
|
||||
</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)
|
||||
{
|
||||
<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"><code>@entry.EntityId</code></td>
|
||||
<td class="small">@entry.EntityName</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrWhiteSpace(entry.AfterStateJson))
|
||||
{
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||
@onclick="() => ToggleStateView(entry.Id)">
|
||||
@(_expandedEntryId == entry.Id ? "Hide" : "View")
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (_expandedEntryId == entry.Id && !string.IsNullOrWhiteSpace(entry.AfterStateJson))
|
||||
{
|
||||
<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 @((_totalCount + _pageSize - 1) / _pageSize) (@_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="@(_entries.Count < _pageSize)">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? _filterUser;
|
||||
private string? _filterEntityType;
|
||||
private string? _filterAction;
|
||||
private DateTime? _filterFrom;
|
||||
private DateTime? _filterTo;
|
||||
|
||||
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 ToastNotification _toast = default!;
|
||||
|
||||
private async Task Search()
|
||||
{
|
||||
_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: _filterFrom.HasValue ? new DateTimeOffset(_filterFrom.Value, TimeSpan.Zero) : null,
|
||||
to: _filterTo.HasValue ? new DateTimeOffset(_filterTo.Value, TimeSpan.Zero) : null,
|
||||
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 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