Files
scadalink-design/src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor

308 lines
12 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.
@page "/monitoring/event-logs"
@attribute [Authorize]
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.RemoteQuery
@using ScadaLink.Communication
@inject ISiteRepository SiteRepository
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
@inject CommunicationService CommunicationService
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Event Logs</h4>
<button class="btn btn-outline-secondary btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#event-logs-filters"
aria-expanded="true"
aria-controls="event-logs-filters">
Filter options (@ActiveFilterCount active)
</button>
</div>
<ToastNotification @ref="_toast" />
<div class="collapse show" id="event-logs-filters">
<div class="row mb-3 g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small" for="filter-site">Site</label>
<select id="filter-site" class="form-select form-select-sm" aria-label="Site" @bind="_selectedSiteId">
<option value="">Select site...</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-event-type">Event Type</label>
<input id="filter-event-type"
type="text"
class="form-control form-control-sm"
aria-label="Event type"
@bind="_filterEventType"
placeholder="e.g. ScriptError" />
</div>
<div class="col-md-1">
<label class="form-label small" for="filter-severity">Severity</label>
<select id="filter-severity"
class="form-select form-select-sm"
aria-label="Severity"
@bind="_filterSeverity">
<option value="">All</option>
<option>Info</option>
<option>Warning</option>
<option>Error</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-from">From</label>
<input id="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="filter-to">To</label>
<input id="filter-to"
type="datetime-local"
class="form-control form-control-sm"
aria-label="To timestamp"
@bind="_filterTo" />
</div>
<div class="col-md-1">
<label class="form-label small" for="filter-keyword">Message contains</label>
<input id="filter-keyword"
type="text"
class="form-control form-control-sm"
aria-label="Message contains"
@bind="_filterKeyword" />
</div>
<div class="col-md-2">
<label class="form-label small" for="filter-instance">Instance</label>
<input id="filter-instance"
type="text"
class="form-control form-control-sm"
aria-label="Instance name"
@bind="_filterInstanceName"
placeholder="Instance name" />
</div>
<div class="col-md-12 d-flex gap-2">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Search
</button>
</div>
</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 style="width: 1%;"></th>
<th>Timestamp</th>
<th>Type</th>
<th>Severity</th>
<th>Instance</th>
<th>Source</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@if (_entries.Count == 0)
{
<tr><td colspan="7" class="text-muted text-center">No events found.</td></tr>
}
@for (int i = 0; i < _entries.Count; i++)
{
var idx = i;
var entry = _entries[idx];
var rowClass = entry.Severity == "Error" ? "table-danger"
: entry.Severity == "Warning" ? "table-warning"
: "";
var expanded = _expandedRows.Contains(idx);
<tr class="@rowClass">
<td>
<button class="btn btn-link btn-sm p-0"
@onclick="() => ToggleRow(idx)"
aria-label="@(expanded ? "Hide full message" : "View full message")">
@(expanded ? "Hide" : "View")
</button>
</td>
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
<td class="small">@entry.EventType</td>
<td>
<span class="badge @GetSeverityBadge(entry.Severity)" aria-label="Severity: @entry.Severity">
@SeverityGlyph(entry.Severity) @entry.Severity
</span>
</td>
<td class="small">@(entry.InstanceId ?? "—")</td>
<td class="small">@entry.Source</td>
<td class="small text-truncate" style="max-width: 380px;">@entry.Message</td>
</tr>
@if (expanded)
{
<tr class="@rowClass">
<td colspan="7">
<pre class="small mb-0">@entry.Message</pre>
</td>
</tr>
}
}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Showing @_entries.Count entries</span>
<div>
@if (_hasMore)
{
<button class="btn btn-outline-primary btn-sm" @onclick="LoadMore" disabled="@_searching">Load more</button>
}
else if (_entries.Count > 0)
{
<span class="text-muted small">End of results</span>
}
</div>
</div>
}
</div>
@code {
private List<Site> _sites = new();
private string _selectedSiteId = string.Empty;
private string? _filterEventType;
private string _filterSeverity = string.Empty;
private DateTime? _filterFrom;
private DateTime? _filterTo;
private string? _filterKeyword;
private string? _filterInstanceName;
private List<EventLogEntry>? _entries;
private bool _hasMore;
private long? _continuationToken;
private bool _searching;
private string? _errorMessage;
private ToastNotification _toast = default!;
private readonly HashSet<int> _expandedRows = new();
private int ActiveFilterCount
{
get
{
var n = 0;
if (!string.IsNullOrEmpty(_selectedSiteId)) n++;
if (!string.IsNullOrWhiteSpace(_filterEventType)) n++;
if (!string.IsNullOrEmpty(_filterSeverity)) n++;
if (_filterFrom.HasValue) n++;
if (_filterTo.HasValue) n++;
if (!string.IsNullOrWhiteSpace(_filterKeyword)) n++;
if (!string.IsNullOrWhiteSpace(_filterInstanceName)) n++;
return n;
}
}
protected override async Task OnInitializedAsync()
{
// Site scoping (CentralUI-002): a scoped Deployment user may only query
// event logs for the sites they are permitted on.
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
}
// _sites is already filtered, so membership IS the scope check.
private bool SelectedSiteIsPermitted =>
!string.IsNullOrEmpty(_selectedSiteId)
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
private async Task Search()
{
_entries = new();
_continuationToken = null;
_expandedRows.Clear();
await FetchPage();
}
private async Task LoadMore() => await FetchPage();
private void ToggleRow(int idx)
{
if (!_expandedRows.Add(idx))
{
_expandedRows.Remove(idx);
}
}
private async Task FetchPage()
{
_searching = true;
_errorMessage = null;
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
// filtered, but the selection must not be trusted on its own.
if (!SelectedSiteIsPermitted)
{
_errorMessage = "You are not permitted to view event logs for that site.";
_searching = false;
return;
}
try
{
var request = new EventLogQueryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
SiteId: _selectedSiteId,
From: _filterFrom.HasValue ? new DateTimeOffset(_filterFrom.Value, TimeSpan.Zero) : null,
To: _filterTo.HasValue ? new DateTimeOffset(_filterTo.Value, TimeSpan.Zero) : null,
EventType: string.IsNullOrWhiteSpace(_filterEventType) ? null : _filterEventType.Trim(),
Severity: string.IsNullOrWhiteSpace(_filterSeverity) ? null : _filterSeverity,
InstanceId: string.IsNullOrWhiteSpace(_filterInstanceName) ? null : _filterInstanceName.Trim(),
KeywordFilter: string.IsNullOrWhiteSpace(_filterKeyword) ? null : _filterKeyword.Trim(),
ContinuationToken: _continuationToken,
PageSize: 50,
Timestamp: DateTimeOffset.UtcNow);
var response = await CommunicationService.QueryEventLogsAsync(_selectedSiteId, request);
if (response.Success)
{
_entries ??= new();
_entries.AddRange(response.Entries);
_hasMore = response.HasMore;
_continuationToken = response.ContinuationToken;
}
else
{
_errorMessage = response.ErrorMessage ?? "Query failed.";
}
}
catch (Exception ex)
{
_errorMessage = $"Query failed: {ex.Message}";
}
_searching = false;
}
private static string GetSeverityBadge(string severity) => severity switch
{
"Error" => "bg-danger",
"Warning" => "bg-warning text-dark",
"Info" => "bg-info text-dark",
_ => "bg-secondary"
};
private static string SeverityGlyph(string severity) => severity switch
{
"Error" => "⛔",
"Warning" => "⚠",
"Info" => "",
_ => "•"
};
}