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:
Joseph Doherty
2026-03-16 21:47:37 -04:00
parent 6ea38faa6f
commit 3b2320bd35
22 changed files with 4821 additions and 32 deletions

View File

@@ -0,0 +1,264 @@
@page "/admin/api-keys"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.InboundApi
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">API Key Management</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add API Key</button>
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingKey == null ? "Add New API Key" : "Edit API Key")</h6>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="col-md-4">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
@if (_newlyCreatedKeyValue != null)
{
<div class="alert alert-success alert-dismissible fade show">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
<button type="button" class="btn-close" @onclick="() => _newlyCreatedKeyValue = null"></button>
</div>
}
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Key Value</th>
<th>Status</th>
<th style="width: 240px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_keys.Count == 0)
{
<tr>
<td colspan="5" class="text-muted text-center">No API keys configured.</td>
</tr>
}
@foreach (var key in _keys)
{
<tr>
<td>@key.Id</td>
<td>@key.Name</td>
<td><code>@MaskKeyValue(key.KeyValue)</code></td>
<td>
@if (key.IsEnabled)
{
<span class="badge bg-success">Enabled</span>
}
else
{
<span class="badge bg-secondary">Disabled</span>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditKey(key)">Edit</button>
@if (key.IsEnabled)
{
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
@onclick="() => ToggleKey(key)">Disable</button>
}
else
{
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
@onclick="() => ToggleKey(key)">Enable</button>
}
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteKey(key)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
@code {
private List<ApiKey> _keys = new();
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private ApiKey? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _newlyCreatedKeyValue;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
_keys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load API keys: {ex.Message}";
}
_loading = false;
}
private static string MaskKeyValue(string keyValue)
{
if (keyValue.Length <= 8) return new string('*', keyValue.Length);
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
}
private void ShowAddForm()
{
_editingKey = null;
_formName = string.Empty;
_formError = null;
_showForm = true;
}
private void EditKey(ApiKey key)
{
_editingKey = key;
_formName = key.Name;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingKey = null;
_formError = null;
}
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue)
{
IsEnabled = true
};
await InboundApiRepository.AddApiKeyAsync(key);
_newlyCreatedKeyValue = keyValue;
}
await InboundApiRepository.SaveChangesAsync();
_showForm = false;
_editingKey = null;
_toast.ShowSuccess("API key saved.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task ToggleKey(ApiKey key)
{
try
{
key.IsEnabled = !key.IsEnabled;
await InboundApiRepository.UpdateApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
}
catch (Exception ex)
{
_toast.ShowError($"Toggle failed: {ex.Message}");
}
}
private async Task DeleteKey(ApiKey key)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete API key '{key.Name}'? This cannot be undone.", "Delete API Key");
if (!confirmed) return;
try
{
await InboundApiRepository.DeleteApiKeyAsync(key.Id);
await InboundApiRepository.SaveChangesAsync();
_toast.ShowSuccess($"API key '{key.Name}' deleted.");
await LoadDataAsync();
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
private void CopyKeyToClipboard()
{
// Note: JS interop for clipboard would be needed for actual copy.
// For now the key is displayed for manual copy.
_toast.ShowInfo("Key displayed above. Select and copy manually.");
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}