diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor index 5c12aab..395c7d9 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor @@ -1,15 +1,32 @@ -@* ScadaLink-parity sidebar layout per decision #102 (Bootstrap 5, dark sidebar, main content area). *@ @inherits LayoutComponentBase
@Body diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters.razor deleted file mode 100644 index d04e751..0000000 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters.razor +++ /dev/null @@ -1,42 +0,0 @@ -@page "/clusters" -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@inject ClusterService ClusterSvc - -

Clusters

- -@if (_clusters is null) -{ -

Loading…

-} -else if (_clusters.Count == 0) -{ -

No clusters yet. Use the stored-proc sp_PublishGeneration workflow to bootstrap.

-} -else -{ - - - - @foreach (var c in _clusters) - { - - - - - - - - } - -
ClusterIdNameEnterprise/SiteRedundancyModeEnabled
@c.ClusterId@c.Name@c.Enterprise / @c.Site@c.RedundancyMode@(c.Enabled ? "Yes" : "No")
-} - -@code { - private List? _clusters; - - protected override async Task OnInitializedAsync() - { - _clusters = await ClusterSvc.ListAsync(CancellationToken.None); - } -} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor new file mode 100644 index 0000000..3d7311d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor @@ -0,0 +1,126 @@ +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject NodeAclService AclSvc + +
+

Access-control grants

+ +
+ +@if (_acls is null) {

Loading…

} +else if (_acls.Count == 0) {

No ACL grants in this draft. Publish will result in a cluster with no external access.

} +else +{ + + + + @foreach (var a in _acls) + { + + + + + + + + } + +
LDAP groupScopeScope IDPermissions
@a.LdapGroup@a.ScopeKind@(a.ScopeId ?? "-")@a.PermissionFlags
+} + +@if (_showForm) +{ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ @if (_error is not null) {
@_error
} +
+ + +
+
+
+} + +@code { + [Parameter] public long GenerationId { get; set; } + [Parameter] public string ClusterId { get; set; } = string.Empty; + + private List? _acls; + private bool _showForm; + private string _group = string.Empty; + private NodeAclScopeKind _scopeKind = NodeAclScopeKind.Cluster; + private string _scopeId = string.Empty; + private string _preset = "Read"; + private string? _error; + + protected override async Task OnParametersSetAsync() => + _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); + + private NodePermissions ResolvePreset() => _preset switch + { + "Read" => NodePermissions.Browse | NodePermissions.Read, + "WriteOperate" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate, + "Engineer" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune | NodePermissions.WriteConfigure, + "AlarmAck" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge, + "Full" => unchecked((NodePermissions)(-1)), + _ => NodePermissions.Browse | NodePermissions.Read, + }; + + private async Task SaveAsync() + { + _error = null; + if (string.IsNullOrWhiteSpace(_group)) { _error = "LDAP group is required"; return; } + + var scopeId = _scopeKind == NodeAclScopeKind.Cluster ? null + : string.IsNullOrWhiteSpace(_scopeId) ? null : _scopeId; + + if (_scopeKind != NodeAclScopeKind.Cluster && scopeId is null) + { + _error = $"ScopeId required for {_scopeKind}"; + return; + } + + try + { + await AclSvc.GrantAsync(GenerationId, ClusterId, _group, _scopeKind, scopeId, + ResolvePreset(), notes: null, CancellationToken.None); + _group = string.Empty; _scopeId = string.Empty; + _showForm = false; + _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); + } + catch (Exception ex) { _error = ex.Message; } + } + + private async Task RevokeAsync(Guid rowId) + { + await AclSvc.RevokeAsync(rowId, CancellationToken.None); + _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AuditTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AuditTab.razor new file mode 100644 index 0000000..94b841c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AuditTab.razor @@ -0,0 +1,35 @@ +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject AuditLogService AuditSvc + +

Recent audit log

+ +@if (_entries is null) {

Loading…

} +else if (_entries.Count == 0) {

No audit entries for this cluster yet.

} +else +{ + + + + @foreach (var a in _entries) + { + + + + + + + + + } + +
WhenPrincipalEventNodeGenerationDetails
@a.Timestamp.ToString("u")@a.Principal@a.EventType@a.NodeId@a.GenerationId@a.DetailsJson
+} + +@code { + [Parameter] public string ClusterId { get; set; } = string.Empty; + private List? _entries; + + protected override async Task OnParametersSetAsync() => + _entries = await AuditSvc.ListRecentAsync(ClusterId, limit: 100, CancellationToken.None); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor new file mode 100644 index 0000000..7b5ad59 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor @@ -0,0 +1,123 @@ +@page "/clusters/{ClusterId}" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject ClusterService ClusterSvc +@inject GenerationService GenerationSvc +@inject NavigationManager Nav + +@if (_cluster is null) +{ +

Loading…

+} +else +{ +
+
+

@_cluster.Name

+ @_cluster.ClusterId + @if (!_cluster.Enabled) { Disabled } +
+
+ @if (_currentDraft is not null) + { + + Edit current draft (gen @_currentDraft.GenerationId) + + } + else + { + + } +
+
+ + + + @if (_tab == "overview") + { +
+
Enterprise / Site
@_cluster.Enterprise / @_cluster.Site
+
Redundancy
@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))
+
Current published
+
+ @if (_currentPublished is not null) { @_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u")) } + else { none published yet } +
+
Created
@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy
+
+ } + else if (_tab == "generations") + { + + } + else if (_tab == "equipment" && _currentDraft is not null) + { + + } + else if (_tab == "uns" && _currentDraft is not null) + { + + } + else if (_tab == "namespaces" && _currentDraft is not null) + { + + } + else if (_tab == "drivers" && _currentDraft is not null) + { + + } + else if (_tab == "acls" && _currentDraft is not null) + { + + } + else if (_tab == "audit") + { + + } + else + { +

Open a draft to edit this cluster's content.

+ } +} + +@code { + [Parameter] public string ClusterId { get; set; } = string.Empty; + private ServerCluster? _cluster; + private ConfigGeneration? _currentDraft; + private ConfigGeneration? _currentPublished; + private string _tab = "overview"; + private bool _busy; + + private string Tab(string key) => _tab == key ? "active" : string.Empty; + + protected override async Task OnInitializedAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _cluster = await ClusterSvc.FindAsync(ClusterId, CancellationToken.None); + var gens = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None); + _currentDraft = gens.FirstOrDefault(g => g.Status == GenerationStatus.Draft); + _currentPublished = gens.FirstOrDefault(g => g.Status == GenerationStatus.Published); + } + + private async Task CreateDraftAsync() + { + _busy = true; + try + { + var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: "admin-ui", CancellationToken.None); + Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}"); + } + finally { _busy = false; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor new file mode 100644 index 0000000..8448328 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor @@ -0,0 +1,56 @@ +@page "/clusters" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject ClusterService ClusterSvc + +
+

Clusters

+ New cluster +
+ +@if (_clusters is null) +{ +

Loading…

+} +else if (_clusters.Count == 0) +{ +

No clusters yet. Create the first one.

+} +else +{ + + + + + + + + + @foreach (var c in _clusters) + { + + + + + + + + + + + } + +
ClusterIdNameEnterpriseSiteRedundancyModeNodeCountEnabled
@c.ClusterId@c.Name@c.Enterprise@c.Site@c.RedundancyMode@c.NodeCount + @if (c.Enabled) { Active } + else { Disabled } + Open
+} + +@code { + private List? _clusters; + + protected override async Task OnInitializedAsync() + { + _clusters = await ClusterSvc.ListAsync(CancellationToken.None); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor new file mode 100644 index 0000000..a9633c0 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor @@ -0,0 +1,73 @@ +@page "/clusters/{ClusterId}/draft/{GenerationId:long}/diff" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject GenerationService GenerationSvc + +
+
+

Draft diff

+ + Cluster @ClusterId — from last published (@(_fromLabel)) → to draft @GenerationId + +
+ Back to editor +
+ +@if (_rows is null) +{ +

Computing diff…

+} +else if (_error is not null) +{ +
@_error
+} +else if (_rows.Count == 0) +{ +

No differences — draft is structurally identical to the last published generation.

+} +else +{ + + + + @foreach (var r in _rows) + { + + + + + + } + +
TableLogicalIdChangeKind
@r.TableName@r.LogicalId + @switch (r.ChangeKind) + { + case "Added": @r.ChangeKind break; + case "Removed": @r.ChangeKind break; + case "Modified": @r.ChangeKind break; + default: @r.ChangeKind break; + } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = string.Empty; + [Parameter] public long GenerationId { get; set; } + + private List? _rows; + private string _fromLabel = "(empty)"; + private string? _error; + + protected override async Task OnParametersSetAsync() + { + try + { + var all = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None); + var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published); + _fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}"; + _rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None); + } + catch (Exception ex) { _error = ex.Message; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor new file mode 100644 index 0000000..49d90eb --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor @@ -0,0 +1,103 @@ +@page "/clusters/{ClusterId}/draft/{GenerationId:long}" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Validation +@inject GenerationService GenerationSvc +@inject DraftValidationService ValidationSvc +@inject NavigationManager Nav + +
+
+

Draft editor

+ Cluster @ClusterId · generation @GenerationId +
+
+ Back to cluster + View diff + +
+
+ + + +
+
+ @if (_tab == "equipment") { } + else if (_tab == "uns") { } + else if (_tab == "namespaces") { } + else if (_tab == "drivers") { } + else if (_tab == "acls") { } +
+
+
+
+ Validation + +
+
+ @if (_validating) {

Checking…

} + else if (_errors.Count == 0) {
No validation errors — safe to publish.
} + else + { +
@_errors.Count error@(_errors.Count == 1 ? "" : "s")
+
    + @foreach (var e in _errors) + { +
  • + @e.Code + @e.Message + @if (!string.IsNullOrEmpty(e.Context)) {
    @e.Context
    } +
  • + } +
+ } +
+
+ + @if (_publishError is not null) {
@_publishError
} +
+
+ +@code { + [Parameter] public string ClusterId { get; set; } = string.Empty; + [Parameter] public long GenerationId { get; set; } + + private string _tab = "equipment"; + private List _errors = []; + private bool _validating; + private bool _busy; + private string? _publishError; + + private string Active(string k) => _tab == k ? "active" : string.Empty; + + protected override async Task OnParametersSetAsync() => await RevalidateAsync(); + + private async Task RevalidateAsync() + { + _validating = true; + try + { + var errors = await ValidationSvc.ValidateAsync(GenerationId, CancellationToken.None); + _errors = errors.ToList(); + } + finally { _validating = false; } + } + + private async Task PublishAsync() + { + _busy = true; + _publishError = null; + try + { + await GenerationSvc.PublishAsync(ClusterId, GenerationId, notes: "Published via Admin UI", CancellationToken.None); + Nav.NavigateTo($"/clusters/{ClusterId}"); + } + catch (Exception ex) { _publishError = ex.Message; } + finally { _busy = false; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor new file mode 100644 index 0000000..901e77b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor @@ -0,0 +1,107 @@ +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject DriverInstanceService DriverSvc +@inject NamespaceService NsSvc + +
+

DriverInstances

+ +
+ +@if (_drivers is null) {

Loading…

} +else if (_drivers.Count == 0) {

No drivers configured in this draft.

} +else +{ + + + + @foreach (var d in _drivers) + { + + } + +
DriverInstanceIdNameTypeNamespace
@d.DriverInstanceId@d.Name@d.DriverType@d.NamespaceId
+} + +@if (_showForm && _namespaces is not null) +{ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).
+
+
+ @if (_error is not null) {
@_error
} +
+ + +
+
+
+} + +@code { + [Parameter] public long GenerationId { get; set; } + [Parameter] public string ClusterId { get; set; } = string.Empty; + + private List? _drivers; + private List? _namespaces; + private bool _showForm; + private string _name = string.Empty; + private string _type = "ModbusTcp"; + private string _nsId = string.Empty; + private string _config = "{}"; + private string? _error; + + protected override async Task OnParametersSetAsync() => await ReloadAsync(); + + private async Task ReloadAsync() + { + _drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None); + _namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None); + _nsId = _namespaces.FirstOrDefault()?.NamespaceId ?? string.Empty; + } + + private async Task SaveAsync() + { + _error = null; + if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_nsId)) + { + _error = "Name and Namespace are required"; + return; + } + try + { + await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, _config, CancellationToken.None); + _name = string.Empty; _config = "{}"; + _showForm = false; + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor new file mode 100644 index 0000000..fdc28f1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor @@ -0,0 +1,152 @@ +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Validation +@inject EquipmentService EquipmentSvc + +
+

Equipment (draft gen @GenerationId)

+ +
+ +@if (_equipment is null) +{ +

Loading…

+} +else if (_equipment.Count == 0 && !_showForm) +{ +

No equipment in this draft yet.

+} +else if (_equipment.Count > 0) +{ + + + + + + + + + @foreach (var e in _equipment) + { + + + + + + + + + + + } + +
EquipmentIdNameMachineCodeZTagSAPIDManufacturer / ModelSerial
@e.EquipmentId@e.Name@e.MachineCode@e.ZTag@e.SAPID@e.Manufacturer / @e.Model@e.SerialNumber
+} + +@if (_showForm) +{ +
+
+
New equipment
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
OPC 40010 Identification
+
+
+
+
+
+
+
+ + +
+
+ + @if (_error is not null) {
@_error
} + +
+ + +
+
+
+
+} + +@code { + [Parameter] public long GenerationId { get; set; } + private List? _equipment; + private bool _showForm; + private Equipment _draft = NewBlankDraft(); + private string? _error; + + private static Equipment NewBlankDraft() => new() + { + EquipmentId = string.Empty, DriverInstanceId = string.Empty, + UnsLineId = string.Empty, Name = string.Empty, MachineCode = string.Empty, + }; + + protected override async Task OnParametersSetAsync() => await ReloadAsync(); + + private async Task ReloadAsync() + { + _equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None); + } + + private void StartAdd() + { + _draft = NewBlankDraft(); + _error = null; + _showForm = true; + } + + private async Task SaveAsync() + { + _error = null; + _draft.EquipmentUuid = Guid.NewGuid(); + _draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid); + _draft.GenerationId = GenerationId; + try + { + await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None); + _showForm = false; + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } + } + + private async Task DeleteAsync(Guid id) + { + await EquipmentSvc.DeleteAsync(id, CancellationToken.None); + await ReloadAsync(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/Generations.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/Generations.razor new file mode 100644 index 0000000..55a226c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/Generations.razor @@ -0,0 +1,73 @@ +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject GenerationService GenerationSvc +@inject NavigationManager Nav + +

Generations

+ +@if (_generations is null) {

Loading…

} +else if (_generations.Count == 0) {

No generations in this cluster yet.

} +else +{ + + + + + + @foreach (var g in _generations) + { + + + + + + + + + + } + +
IDStatusCreatedPublishedPublishedByNotes
@g.GenerationId@StatusBadge(g.Status)@g.CreatedAt.ToString("u") by @g.CreatedBy@(g.PublishedAt?.ToString("u") ?? "-")@g.PublishedBy@g.Notes + @if (g.Status == GenerationStatus.Draft) + { + Open + } + else if (g.Status is GenerationStatus.Published or GenerationStatus.Superseded) + { + + } +
+} + +@if (_error is not null) {
@_error
} + +@code { + [Parameter] public string ClusterId { get; set; } = string.Empty; + private List? _generations; + private string? _error; + + protected override async Task OnParametersSetAsync() => await ReloadAsync(); + + private async Task ReloadAsync() => + _generations = await GenerationSvc.ListRecentAsync(ClusterId, 100, CancellationToken.None); + + private async Task RollbackAsync(long targetId) + { + _error = null; + try + { + await GenerationSvc.RollbackAsync(ClusterId, targetId, notes: $"Rollback via Admin UI", CancellationToken.None); + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } + } + + private static MarkupString StatusBadge(GenerationStatus s) => s switch + { + GenerationStatus.Draft => new MarkupString("Draft"), + GenerationStatus.Published => new MarkupString("Published"), + GenerationStatus.Superseded => new MarkupString("Superseded"), + _ => new MarkupString($"{s}"), + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NamespacesTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NamespacesTab.razor new file mode 100644 index 0000000..d3ecd61 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NamespacesTab.razor @@ -0,0 +1,69 @@ +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject NamespaceService NsSvc + +
+

Namespaces

+ +
+ +@if (_namespaces is null) {

Loading…

} +else if (_namespaces.Count == 0) {

No namespaces defined in this draft.

} +else +{ + + + + @foreach (var n in _namespaces) + { + + } + +
NamespaceIdKindURIEnabled
@n.NamespaceId@n.Kind@n.NamespaceUri@(n.Enabled ? "yes" : "no")
+} + +@if (_showForm) +{ +
+
+
+
+
+ + +
+
+
+ + +
+
+
+} + +@code { + [Parameter] public long GenerationId { get; set; } + [Parameter] public string ClusterId { get; set; } = string.Empty; + private List? _namespaces; + private bool _showForm; + private string _uri = string.Empty; + private NamespaceKind _kind = NamespaceKind.Equipment; + + protected override async Task OnParametersSetAsync() => await ReloadAsync(); + + private async Task ReloadAsync() => + _namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None); + + private async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(_uri)) return; + await NsSvc.AddAsync(GenerationId, ClusterId, _uri, _kind, CancellationToken.None); + _uri = string.Empty; + _showForm = false; + await ReloadAsync(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor new file mode 100644 index 0000000..1c2c6f7 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/NewCluster.razor @@ -0,0 +1,104 @@ +@page "/clusters/new" +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject ClusterService ClusterSvc +@inject GenerationService GenerationSvc +@inject NavigationManager Nav + +

New cluster

+ + + + +
+
+ + +
Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.
+ +
+
+ + + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + +
+ + Cancel +
+
+ +@code { + private sealed class Input + { + [Required, RegularExpression("^[a-z0-9-]{1,64}$", ErrorMessage = "Lowercase alphanumeric + hyphens only")] + public string ClusterId { get; set; } = string.Empty; + + [Required, StringLength(128)] + public string Name { get; set; } = string.Empty; + + [StringLength(32)] public string Enterprise { get; set; } = "zb"; + [StringLength(32)] public string Site { get; set; } = "dev"; + public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None; + } + + private Input _input = new(); + private bool _submitting; + private string? _error; + + private async Task CreateAsync() + { + _submitting = true; + _error = null; + + try + { + var cluster = new ServerCluster + { + ClusterId = _input.ClusterId, + Name = _input.Name, + Enterprise = _input.Enterprise, + Site = _input.Site, + RedundancyMode = _input.RedundancyMode, + NodeCount = _input.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2, + Enabled = true, + CreatedBy = "admin-ui", + }; + + await ClusterSvc.CreateAsync(cluster, createdBy: "admin-ui", CancellationToken.None); + await GenerationSvc.CreateDraftAsync(cluster.ClusterId, createdBy: "admin-ui", CancellationToken.None); + + Nav.NavigateTo($"/clusters/{cluster.ClusterId}"); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally { _submitting = false; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor new file mode 100644 index 0000000..6c29a82 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor @@ -0,0 +1,115 @@ +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject UnsService UnsSvc + +
+
+
+

UNS Areas

+ +
+ + @if (_areas is null) {

Loading…

} + else if (_areas.Count == 0) {

No areas yet.

} + else + { + + + + @foreach (var a in _areas) + { + + } + +
AreaIdName
@a.UnsAreaId@a.Name
+ } + + @if (_showAreaForm) + { +
+
+
+ + +
+
+ } +
+
+
+

UNS Lines

+ +
+ + @if (_lines is null) {

Loading…

} + else if (_lines.Count == 0) {

No lines yet.

} + else + { + + + + @foreach (var l in _lines) + { + + } + +
LineIdAreaName
@l.UnsLineId@l.UnsAreaId@l.Name
+ } + + @if (_showLineForm && _areas is not null) + { +
+
+
+ + +
+
+ + +
+
+ } +
+
+ +@code { + [Parameter] public long GenerationId { get; set; } + [Parameter] public string ClusterId { get; set; } = string.Empty; + + private List? _areas; + private List? _lines; + private bool _showAreaForm; + private bool _showLineForm; + private string _newAreaName = string.Empty; + private string _newLineName = string.Empty; + private string _newLineAreaId = string.Empty; + + protected override async Task OnParametersSetAsync() => await ReloadAsync(); + + private async Task ReloadAsync() + { + _areas = await UnsSvc.ListAreasAsync(GenerationId, CancellationToken.None); + _lines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None); + } + + private async Task AddAreaAsync() + { + if (string.IsNullOrWhiteSpace(_newAreaName)) return; + await UnsSvc.AddAreaAsync(GenerationId, ClusterId, _newAreaName, notes: null, CancellationToken.None); + _newAreaName = string.Empty; + _showAreaForm = false; + await ReloadAsync(); + } + + private async Task AddLineAsync() + { + if (string.IsNullOrWhiteSpace(_newLineName) || string.IsNullOrWhiteSpace(_newLineAreaId)) return; + await UnsSvc.AddLineAsync(GenerationId, _newLineAreaId, _newLineName, notes: null, CancellationToken.None); + _newLineName = string.Empty; + _showLineForm = false; + await ReloadAsync(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor index 111c238..6ef2ff7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor @@ -1,16 +1,72 @@ @page "/" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject ClusterService ClusterSvc +@inject GenerationService GenerationSvc +@inject NavigationManager Nav -

OtOpcUa fleet overview

-

Phase 1 scaffold — full dashboard lands in Phase 1 Stream E completion.

+

Fleet overview

-
-
-
Clusters
Manage
+@if (_clusters is null) +{ +

Loading…

+} +else if (_clusters.Count == 0) +{ +
+ No clusters configured yet. Create the first cluster.
-
-
Generations
Manage
+} +else +{ +
+
+
Clusters
@_clusters.Count
+
+
+
Active drafts
@_activeDraftCount
+
+
+
Published generations
@_publishedCount
+
+
+
Disabled clusters
@_clusters.Count(c => !c.Enabled)
+
-
-
Equipment
Manage
-
-
+ +

Clusters

+ + + + @foreach (var c in _clusters) + { + + + + + + + + + } + +
ClusterIdNameEnterprise / SiteRedundancyEnabled
@c.ClusterId@c.Name@c.Enterprise / @c.Site@c.RedundancyMode@(c.Enabled ? "Yes" : "No")Open
+} + +@code { + private List? _clusters; + private int _activeDraftCount; + private int _publishedCount; + + protected override async Task OnInitializedAsync() + { + _clusters = await ClusterSvc.ListAsync(CancellationToken.None); + + foreach (var c in _clusters) + { + var gens = await GenerationSvc.ListRecentAsync(c.ClusterId, 50, CancellationToken.None); + _activeDraftCount += gens.Count(g => g.Status.ToString() == "Draft"); + _publishedCount += gens.Count(g => g.Status.ToString() == "Published"); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor new file mode 100644 index 0000000..18f4408 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor @@ -0,0 +1,74 @@ +@page "/login" +@using System.Security.Claims +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Authentication.Cookies +@using Microsoft.AspNetCore.Components.Authorization +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@inject IHttpContextAccessor Http + +
+
+
+
+

OtOpcUa Admin — sign in

+ + +
+ + +
+
+ + +
+ + @if (_error is not null) {
@_error
} + + +
+ +
+ + Phase 1 note: real LDAP bind is deferred. This scaffold accepts + any non-empty credentials and issues a FleetAdmin cookie. Replace the + LdapAuthService stub with the ScadaLink-parity implementation before + production deployment. + +
+
+
+
+ +@code { + private sealed class Input + { + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + } + + private Input _input = new(); + private string? _error; + + private async Task SignInAsync() + { + if (string.IsNullOrWhiteSpace(_input.Username) || string.IsNullOrWhiteSpace(_input.Password)) + { + _error = "Username and password are required"; + return; + } + + var ctx = Http.HttpContext + ?? throw new InvalidOperationException("HttpContext unavailable for sign-in"); + + var claims = new List + { + new(ClaimTypes.Name, _input.Username), + new(ClaimTypes.Role, AdminRoles.FleetAdmin), + }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity)); + + ctx.Response.Redirect("/"); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor new file mode 100644 index 0000000..4e79ea8 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor @@ -0,0 +1,114 @@ +@page "/reservations" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using Microsoft.AspNetCore.Authorization +@attribute [Authorize(Policy = "CanPublish")] +@inject ReservationService ReservationSvc + +

External-ID reservations

+

+ Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a + FleetAdmin-only audit-logged action — only release when the physical asset is permanently + retired and its ID needs to be reused by a different equipment. +

+ +

Active

+@if (_active is null) {

Loading…

} +else if (_active.Count == 0) {

No active reservations.

} +else +{ + + + + @foreach (var r in _active) + { + + + + + + + + + + } + +
KindValueEquipmentUuidClusterFirst publishedLast published
@r.Kind@r.Value@r.EquipmentUuid@r.ClusterId@r.FirstPublishedAt.ToString("u") by @r.FirstPublishedBy@r.LastPublishedAt.ToString("u")
+} + +

Released (most recent 100)

+@if (_released is null) {

Loading…

} +else if (_released.Count == 0) {

No released reservations yet.

} +else +{ + + + + @foreach (var r in _released) + { + + } + +
KindValueReleased atByReason
@r.Kind@r.Value@r.ReleasedAt?.ToString("u")@r.ReleasedBy@r.ReleaseReason
+} + +@if (_releasing is not null) +{ + +} + +@code { + private List? _active; + private List? _released; + private ExternalIdReservation? _releasing; + private string _reason = string.Empty; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() => await ReloadAsync(); + + private async Task ReloadAsync() + { + _active = await ReservationSvc.ListActiveAsync(CancellationToken.None); + _released = await ReservationSvc.ListReleasedAsync(CancellationToken.None); + } + + private void OpenReleaseDialog(ExternalIdReservation r) + { + _releasing = r; + _reason = string.Empty; + _error = null; + } + + private async Task ReleaseAsync() + { + if (_releasing is null || string.IsNullOrWhiteSpace(_reason)) { _error = "Reason is required"; return; } + _busy = true; + try + { + await ReservationSvc.ReleaseAsync(_releasing.Kind.ToString(), _releasing.Value, _reason, CancellationToken.None); + _releasing = null; + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor index d17869a..10288f9 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor @@ -4,7 +4,11 @@ @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Http @using Microsoft.JSInterop @using ZB.MOM.WW.OtOpcUa.Admin @using ZB.MOM.WW.OtOpcUa.Admin.Components @using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout +@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages +@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs index 9c9fec6..bcd9e70 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; using Serilog; @@ -13,6 +14,7 @@ builder.Host.UseSerilog((ctx, cfg) => cfg .WriteTo.File("logs/otopcua-admin-.log", rollingInterval: RollingInterval.Day)); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddHttpContextAccessor(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(o => @@ -26,12 +28,22 @@ builder.Services.AddAuthorizationBuilder() .AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin)) .AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin)); +builder.Services.AddCascadingAuthenticationState(); + builder.Services.AddDbContext(opt => opt.UseSqlServer(builder.Configuration.GetConnectionString("ConfigDb") ?? throw new InvalidOperationException("ConnectionStrings:ConfigDb not configured"))); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -41,9 +53,14 @@ app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); +app.MapPost("/auth/logout", async (HttpContext ctx) => +{ + await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + ctx.Response.Redirect("/"); +}); + app.MapRazorComponents().AddInteractiveServerRenderMode(); await app.RunAsync(); -// Public for WebApplicationFactory testability. public partial class Program; diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AuditLogService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AuditLogService.cs new file mode 100644 index 0000000..384f2fd --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AuditLogService.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +public sealed class AuditLogService(OtOpcUaConfigDbContext db) +{ + public Task> ListRecentAsync(string? clusterId, int limit, CancellationToken ct) + { + var q = db.ConfigAuditLogs.AsNoTracking(); + if (clusterId is not null) q = q.Where(a => a.ClusterId == clusterId); + return q.OrderByDescending(a => a.Timestamp).Take(limit).ToListAsync(ct); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DraftValidationService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DraftValidationService.cs new file mode 100644 index 0000000..1aba5ba --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DraftValidationService.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Runs the managed against a draft's snapshot loaded from the +/// Configuration DB. Used by the draft editor's inline validation panel and by the publish +/// dialog's pre-check. Structural-only SQL checks live in sp_ValidateDraft; this layer +/// owns the content / cross-generation / regex rules. +/// +public sealed class DraftValidationService(OtOpcUaConfigDbContext db) +{ + public async Task> ValidateAsync(long draftId, CancellationToken ct) + { + var draft = await db.ConfigGenerations.AsNoTracking() + .FirstOrDefaultAsync(g => g.GenerationId == draftId, ct) + ?? throw new InvalidOperationException($"Draft {draftId} not found"); + + var snapshot = new DraftSnapshot + { + GenerationId = draft.GenerationId, + ClusterId = draft.ClusterId, + Namespaces = await db.Namespaces.AsNoTracking().Where(n => n.GenerationId == draftId).ToListAsync(ct), + DriverInstances = await db.DriverInstances.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct), + Devices = await db.Devices.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct), + UnsAreas = await db.UnsAreas.AsNoTracking().Where(a => a.GenerationId == draftId).ToListAsync(ct), + UnsLines = await db.UnsLines.AsNoTracking().Where(l => l.GenerationId == draftId).ToListAsync(ct), + Equipment = await db.Equipment.AsNoTracking().Where(e => e.GenerationId == draftId).ToListAsync(ct), + Tags = await db.Tags.AsNoTracking().Where(t => t.GenerationId == draftId).ToListAsync(ct), + PollGroups = await db.PollGroups.AsNoTracking().Where(p => p.GenerationId == draftId).ToListAsync(ct), + + PriorEquipment = await db.Equipment.AsNoTracking() + .Where(e => e.GenerationId != draftId + && db.ConfigGenerations.Any(g => g.GenerationId == e.GenerationId && g.ClusterId == draft.ClusterId)) + .ToListAsync(ct), + ActiveReservations = await db.ExternalIdReservations.AsNoTracking() + .Where(r => r.ReleasedAt == null) + .ToListAsync(ct), + }; + + return DraftValidator.Validate(snapshot); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DriverInstanceService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DriverInstanceService.cs new file mode 100644 index 0000000..75833bb --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/DriverInstanceService.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +public sealed class DriverInstanceService(OtOpcUaConfigDbContext db) +{ + public Task> ListAsync(long generationId, CancellationToken ct) => + db.DriverInstances.AsNoTracking() + .Where(d => d.GenerationId == generationId) + .OrderBy(d => d.DriverInstanceId) + .ToListAsync(ct); + + public async Task AddAsync( + long draftId, string clusterId, string namespaceId, string name, string driverType, + string driverConfigJson, CancellationToken ct) + { + var di = new DriverInstance + { + GenerationId = draftId, + DriverInstanceId = $"drv-{Guid.NewGuid():N}"[..20], + ClusterId = clusterId, + NamespaceId = namespaceId, + Name = name, + DriverType = driverType, + DriverConfig = driverConfigJson, + }; + db.DriverInstances.Add(di); + await db.SaveChangesAsync(ct); + return di; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs new file mode 100644 index 0000000..ee93822 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Equipment CRUD scoped to a generation. The Admin app writes against Draft generations only; +/// Published generations are read-only (to create changes, clone to a new draft via +/// ). +/// +public sealed class EquipmentService(OtOpcUaConfigDbContext db) +{ + public Task> ListAsync(long generationId, CancellationToken ct) => + db.Equipment.AsNoTracking() + .Where(e => e.GenerationId == generationId) + .OrderBy(e => e.Name) + .ToListAsync(ct); + + public Task FindAsync(long generationId, string equipmentId, CancellationToken ct) => + db.Equipment.AsNoTracking() + .FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct); + + /// + /// Creates a new equipment row in the given draft. The EquipmentId is auto-derived from + /// a fresh EquipmentUuid per decision #125; operator-supplied IDs are rejected upstream. + /// + public async Task CreateAsync(long draftId, Equipment input, CancellationToken ct) + { + input.GenerationId = draftId; + input.EquipmentUuid = input.EquipmentUuid == Guid.Empty ? Guid.NewGuid() : input.EquipmentUuid; + input.EquipmentId = DraftValidator.DeriveEquipmentId(input.EquipmentUuid); + db.Equipment.Add(input); + await db.SaveChangesAsync(ct); + return input; + } + + public async Task UpdateAsync(Equipment updated, CancellationToken ct) + { + // Only editable fields are persisted; EquipmentId + EquipmentUuid are immutable once set. + var existing = await db.Equipment + .FirstOrDefaultAsync(e => e.EquipmentRowId == updated.EquipmentRowId, ct) + ?? throw new InvalidOperationException($"Equipment row {updated.EquipmentRowId} not found"); + + existing.Name = updated.Name; + existing.MachineCode = updated.MachineCode; + existing.ZTag = updated.ZTag; + existing.SAPID = updated.SAPID; + existing.Manufacturer = updated.Manufacturer; + existing.Model = updated.Model; + existing.SerialNumber = updated.SerialNumber; + existing.HardwareRevision = updated.HardwareRevision; + existing.SoftwareRevision = updated.SoftwareRevision; + existing.YearOfConstruction = updated.YearOfConstruction; + existing.AssetLocation = updated.AssetLocation; + existing.ManufacturerUri = updated.ManufacturerUri; + existing.DeviceManualUri = updated.DeviceManualUri; + existing.DriverInstanceId = updated.DriverInstanceId; + existing.DeviceId = updated.DeviceId; + existing.UnsLineId = updated.UnsLineId; + existing.EquipmentClassRef = updated.EquipmentClassRef; + existing.Enabled = updated.Enabled; + + await db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(Guid equipmentRowId, CancellationToken ct) + { + var row = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentRowId == equipmentRowId, ct); + if (row is null) return; + db.Equipment.Remove(row); + await db.SaveChangesAsync(ct); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NamespaceService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NamespaceService.cs new file mode 100644 index 0000000..fbb7a90 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NamespaceService.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +public sealed class NamespaceService(OtOpcUaConfigDbContext db) +{ + public Task> ListAsync(long generationId, CancellationToken ct) => + db.Namespaces.AsNoTracking() + .Where(n => n.GenerationId == generationId) + .OrderBy(n => n.NamespaceId) + .ToListAsync(ct); + + public async Task AddAsync( + long draftId, string clusterId, string namespaceUri, NamespaceKind kind, CancellationToken ct) + { + var ns = new Namespace + { + GenerationId = draftId, + NamespaceId = $"ns-{Guid.NewGuid():N}"[..20], + ClusterId = clusterId, + NamespaceUri = namespaceUri, + Kind = kind, + }; + db.Namespaces.Add(ns); + await db.SaveChangesAsync(ct); + return ns; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs new file mode 100644 index 0000000..7835055 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +public sealed class NodeAclService(OtOpcUaConfigDbContext db) +{ + public Task> ListAsync(long generationId, CancellationToken ct) => + db.NodeAcls.AsNoTracking() + .Where(a => a.GenerationId == generationId) + .OrderBy(a => a.LdapGroup) + .ThenBy(a => a.ScopeKind) + .ToListAsync(ct); + + public async Task GrantAsync( + long draftId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId, + NodePermissions permissions, string? notes, CancellationToken ct) + { + var acl = new NodeAcl + { + GenerationId = draftId, + NodeAclId = $"acl-{Guid.NewGuid():N}"[..20], + ClusterId = clusterId, + LdapGroup = ldapGroup, + ScopeKind = scopeKind, + ScopeId = scopeId, + PermissionFlags = permissions, + Notes = notes, + }; + db.NodeAcls.Add(acl); + await db.SaveChangesAsync(ct); + return acl; + } + + public async Task RevokeAsync(Guid nodeAclRowId, CancellationToken ct) + { + var row = await db.NodeAcls.FirstOrDefaultAsync(a => a.NodeAclRowId == nodeAclRowId, ct); + if (row is null) return; + db.NodeAcls.Remove(row); + await db.SaveChangesAsync(ct); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/ReservationService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/ReservationService.cs new file mode 100644 index 0000000..955dde2 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/ReservationService.cs @@ -0,0 +1,38 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Fleet-wide external-ID reservation inspector + FleetAdmin-only release flow per +/// admin-ui.md §"Release an external-ID reservation". Release is audit-logged +/// () via sp_ReleaseExternalIdReservation. +/// +public sealed class ReservationService(OtOpcUaConfigDbContext db) +{ + public Task> ListActiveAsync(CancellationToken ct) => + db.ExternalIdReservations.AsNoTracking() + .Where(r => r.ReleasedAt == null) + .OrderBy(r => r.Kind).ThenBy(r => r.Value) + .ToListAsync(ct); + + public Task> ListReleasedAsync(CancellationToken ct) => + db.ExternalIdReservations.AsNoTracking() + .Where(r => r.ReleasedAt != null) + .OrderByDescending(r => r.ReleasedAt) + .Take(100) + .ToListAsync(ct); + + public async Task ReleaseAsync(string kind, string value, string reason, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("ReleaseReason is required (audit invariant)", nameof(reason)); + + await db.Database.ExecuteSqlRawAsync( + "EXEC dbo.sp_ReleaseExternalIdReservation @Kind = {0}, @Value = {1}, @ReleaseReason = {2}", + [kind, value, reason], + ct); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs new file mode 100644 index 0000000..c66ff17 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +public sealed class UnsService(OtOpcUaConfigDbContext db) +{ + public Task> ListAreasAsync(long generationId, CancellationToken ct) => + db.UnsAreas.AsNoTracking() + .Where(a => a.GenerationId == generationId) + .OrderBy(a => a.Name) + .ToListAsync(ct); + + public Task> ListLinesAsync(long generationId, CancellationToken ct) => + db.UnsLines.AsNoTracking() + .Where(l => l.GenerationId == generationId) + .OrderBy(l => l.Name) + .ToListAsync(ct); + + public async Task AddAreaAsync(long draftId, string clusterId, string name, string? notes, CancellationToken ct) + { + var area = new UnsArea + { + GenerationId = draftId, + UnsAreaId = $"area-{Guid.NewGuid():N}"[..20], + ClusterId = clusterId, + Name = name, + Notes = notes, + }; + db.UnsAreas.Add(area); + await db.SaveChangesAsync(ct); + return area; + } + + public async Task AddLineAsync(long draftId, string unsAreaId, string name, string? notes, CancellationToken ct) + { + var line = new UnsLine + { + GenerationId = draftId, + UnsLineId = $"line-{Guid.NewGuid():N}"[..20], + UnsAreaId = unsAreaId, + Name = name, + Notes = notes, + }; + db.UnsLines.Add(line); + await db.SaveChangesAsync(ct); + return line; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json b/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json index 9ae83f6..0cd1da8 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;" + "ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;" }, "Serilog": { "MinimumLevel": "Information" diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/AdminServicesIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/AdminServicesIntegrationTests.cs new file mode 100644 index 0000000..50340c9 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/AdminServicesIntegrationTests.cs @@ -0,0 +1,192 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +/// +/// Ties Admin services end-to-end against a throwaway per-run database — mirrors the +/// Configuration fixture pattern. Spins up a fresh DB, applies migrations, exercises the +/// create-cluster → add-equipment → validate → publish → rollback happy path, then drops the +/// DB in Dispose. Confirms the stored procedures and managed validators agree with the UI +/// services. +/// +[Trait("Category", "Integration")] +public sealed class AdminServicesIntegrationTests : IDisposable +{ + private const string DefaultServer = "localhost,14330"; + private const string DefaultSaPassword = "OtOpcUaDev_2026!"; + + private readonly string _databaseName = $"OtOpcUaAdminTest_{Guid.NewGuid():N}"; + private readonly string _connectionString; + + public AdminServicesIntegrationTests() + { + var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; + var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword; + _connectionString = + $"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;"; + + using var ctx = NewContext(); + ctx.Database.Migrate(); + } + + public void Dispose() + { + using var conn = new Microsoft.Data.SqlClient.SqlConnection( + new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString) + { InitialCatalog = "master" }.ConnectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" +IF DB_ID(N'{_databaseName}') IS NOT NULL +BEGIN + ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{_databaseName}]; +END"; + cmd.ExecuteNonQuery(); + } + + private OtOpcUaConfigDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlServer(_connectionString) + .Options; + return new OtOpcUaConfigDbContext(opts); + } + + [Fact] + public async Task Create_cluster_add_equipment_validate_publish_roundtrips_the_full_admin_flow() + { + // 1. Create cluster + draft. + await using (var ctx = NewContext()) + { + var clusterSvc = new ClusterService(ctx); + await clusterSvc.CreateAsync(new ServerCluster + { + ClusterId = "flow-1", Name = "Flow test", Enterprise = "zb", Site = "dev", + NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, + CreatedBy = "test", + }, createdBy: "test", CancellationToken.None); + } + + long draftId; + await using (var ctx = NewContext()) + { + var genSvc = new GenerationService(ctx); + var draft = await genSvc.CreateDraftAsync("flow-1", "test", CancellationToken.None); + draftId = draft.GenerationId; + } + + // 2. Add namespace + UNS + driver + equipment. + await using (var ctx = NewContext()) + { + var nsSvc = new NamespaceService(ctx); + var unsSvc = new UnsService(ctx); + var drvSvc = new DriverInstanceService(ctx); + var eqSvc = new EquipmentService(ctx); + + var ns = await nsSvc.AddAsync(draftId, "flow-1", "urn:flow:ns", NamespaceKind.Equipment, CancellationToken.None); + var area = await unsSvc.AddAreaAsync(draftId, "flow-1", "line-a", null, CancellationToken.None); + var line = await unsSvc.AddLineAsync(draftId, area.UnsAreaId, "cell-1", null, CancellationToken.None); + var driver = await drvSvc.AddAsync(draftId, "flow-1", ns.NamespaceId, "modbus", "ModbusTcp", "{}", CancellationToken.None); + + await eqSvc.CreateAsync(draftId, new Equipment + { + EquipmentUuid = Guid.NewGuid(), + EquipmentId = string.Empty, + DriverInstanceId = driver.DriverInstanceId, + UnsLineId = line.UnsLineId, + Name = "eq-1", + MachineCode = "M001", + }, CancellationToken.None); + } + + // 3. Validate — should be error-free. + await using (var ctx = NewContext()) + { + var validationSvc = new DraftValidationService(ctx); + var errors = await validationSvc.ValidateAsync(draftId, CancellationToken.None); + errors.ShouldBeEmpty("draft with matched namespace/driver should validate clean"); + } + + // 4. Publish + verify status flipped. + await using (var ctx = NewContext()) + { + var genSvc = new GenerationService(ctx); + await genSvc.PublishAsync("flow-1", draftId, "first publish", CancellationToken.None); + } + + await using (var ctx = NewContext()) + { + var status = await ctx.ConfigGenerations + .Where(g => g.GenerationId == draftId) + .Select(g => g.Status) + .FirstAsync(); + status.ShouldBe(GenerationStatus.Published); + } + + // 5. Rollback creates a new Published generation cloned from the target. + await using (var ctx = NewContext()) + { + var genSvc = new GenerationService(ctx); + await genSvc.RollbackAsync("flow-1", draftId, "rollback test", CancellationToken.None); + } + + await using (var ctx = NewContext()) + { + var publishedCount = await ctx.ConfigGenerations + .CountAsync(g => g.ClusterId == "flow-1" && g.Status == GenerationStatus.Published); + publishedCount.ShouldBe(1, "rollback supersedes the prior publish with a new one"); + + var supersededCount = await ctx.ConfigGenerations + .CountAsync(g => g.ClusterId == "flow-1" && g.Status == GenerationStatus.Superseded); + supersededCount.ShouldBeGreaterThanOrEqualTo(1); + } + } + + [Fact] + public async Task Validate_draft_surfaces_cross_cluster_namespace_binding_violation() + { + await using (var ctx = NewContext()) + { + await new ClusterService(ctx).CreateAsync(new ServerCluster + { + ClusterId = "c-A", Name = "A", Enterprise = "zb", Site = "dev", + NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t", + }, "t", CancellationToken.None); + + await new ClusterService(ctx).CreateAsync(new ServerCluster + { + ClusterId = "c-B", Name = "B", Enterprise = "zb", Site = "dev", + NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t", + }, "t", CancellationToken.None); + } + + long draftId; + await using (var ctx = NewContext()) + { + var draft = await new GenerationService(ctx).CreateDraftAsync("c-A", "t", CancellationToken.None); + draftId = draft.GenerationId; + } + + await using (var ctx = NewContext()) + { + // Namespace rooted in c-B, driver in c-A — decision #122 violation. + var ns = await new NamespaceService(ctx) + .AddAsync(draftId, "c-B", "urn:cross", NamespaceKind.Equipment, CancellationToken.None); + await new DriverInstanceService(ctx) + .AddAsync(draftId, "c-A", ns.NamespaceId, "drv", "ModbusTcp", "{}", CancellationToken.None); + } + + await using (var ctx = NewContext()) + { + var errors = await new DraftValidationService(ctx).ValidateAsync(draftId, CancellationToken.None); + errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding"); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj index 7956b2d..5c8d455 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj @@ -21,6 +21,7 @@ +