merge wt/wp2 into v3/batch2-raw-ui (Wave A)

This commit is contained in:
Joseph Doherty
2026-07-16 02:35:17 -04:00
4 changed files with 419 additions and 0 deletions
@@ -16,6 +16,7 @@
<NavRailItem Href="/hosts" Text="Host status" /> <NavRailItem Href="/hosts" Text="Host status" />
<NavRailItem Href="/clusters" Text="Clusters" /> <NavRailItem Href="/clusters" Text="Clusters" />
<NavRailItem Href="/uns" Text="UNS" /> <NavRailItem Href="/uns" Text="UNS" />
<NavRailItem Href="/raw" Text="Raw" />
<NavRailItem Href="/reservations" Text="Reservations" /> <NavRailItem Href="/reservations" Text="Reservations" />
<NavRailItem Href="/certificates" Text="Certificates" /> <NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" /> <NavRailItem Href="/role-grants" Text="Role grants" />
@@ -0,0 +1,68 @@
@page "/raw"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@inject IRawTreeService Svc
@* Peer of GlobalUns (/uns) for the v3 Raw project tree — the cluster-rooted
Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy.
Loads the eager roots on init, then hands off to RawTree, which lazily expands
each container via IRawTreeService.LoadChildrenAsync and surfaces per-node
context menus (real create/configure/tag modals arrive in later Batch-2 waves). *@
<PageTitle>Raw</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Raw</h4>
<span class="text-muted small">Changes apply on the next deployment.</span>
</div>
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Project tree</span>
</div>
@if (_loading)
{
<div style="padding:1rem" class="text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
</div>
}
else if (_loadError is not null)
{
<div style="padding:1rem" class="text-danger">@_loadError</div>
}
else if (_roots.Count == 0)
{
<div style="padding:1rem" class="text-muted">No clusters yet.</div>
}
else
{
<div style="padding:.5rem 1rem">
<RawTree Roots="_roots" />
</div>
}
</section>
@code {
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
private bool _loading = true;
private string? _loadError;
protected override async Task OnInitializedAsync()
{
try
{
_roots = await Svc.LoadRootsAsync();
}
catch (Exception ex)
{
_loadError = $"Failed to load the Raw tree: {ex.Message}";
}
finally
{
_loading = false;
}
}
}
@@ -0,0 +1,49 @@
@* Placeholder modal for the /raw context-menu actions whose real dialogs
(Configure / Device / TagModal / CSV import-export / Browse device / Rename)
land in later Batch-2 waves (B/C). This wave wires every menu action to a
named handler that opens this "coming soon" placeholder, so the tree + lazy
expansion + menus can be live-verified without the real modals existing yet.
Wave B/C replaces each handler body with the real modal invocation and can
then delete this component. Markup mirrors the GlobalUns delete-confirm modal. *@
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="OnClose"></button>
</div>
<div class="modal-body">
<p>@Message</p>
@if (!string.IsNullOrWhiteSpace(NodeName))
{
<p class="text-muted small mb-0">Target: <span class="mono">@NodeName</span></p>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="OnClose">Close</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the placeholder is shown.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Modal title — the action name (e.g. "Configure driver").</summary>
[Parameter] public string Title { get; set; } = "";
/// <summary>"Coming soon" body message.</summary>
[Parameter] public string Message { get; set; } = "";
/// <summary>The target node's display name, shown as context.</summary>
[Parameter] public string? NodeName { get; set; }
/// <summary>Raised when the placeholder is dismissed.</summary>
[Parameter] public EventCallback OnClose { get; set; }
}
@@ -0,0 +1,301 @@
@* Recursive, lazily-expanding renderer for the v3 Raw project tree (/raw).
Unlike UnsTree (which eager-loads the whole structural tree and never touches
the RawNode lazy fields), THIS component actually exercises the lazy plumbing:
expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends
the returned level into node.Children and flips Loaded/Loading, showing a spinner
in flight and an error row on failure. Per-node context menus (right-click surface
+ "⋯" fallback) are built per RawNodeKind; every action opens a placeholder
(RawStubModal) in this wave — Wave B/C replaces each named handler's body with the
real Configure / Device / TagModal / CSV / Browse modal. This component is a single
instance that recurses via the RenderNode RenderFragment, so it owns one stub modal
and one injected service for the whole tree. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@inject IRawTreeService Svc
@foreach (var root in Roots)
{
@RenderNode(root, 0)
}
<RawStubModal Visible="_stubVisible" Title="@_stubTitle" Message="@_stubMessage"
NodeName="@_stubNodeName" OnClose="CloseStub" />
@code {
/// <summary>The top-level Raw nodes to render (the enterprise roots from <c>LoadRootsAsync</c>).</summary>
[Parameter, EditorRequired] public IReadOnlyList<RawNode> Roots { get; set; } = default!;
// --- Stub-placeholder state (one instance for the whole recursive tree) ---
private bool _stubVisible;
private string _stubTitle = "";
private string _stubMessage = "";
private string? _stubNodeName;
// ---------------------------------------------------------------------------
// Row rendering
// ---------------------------------------------------------------------------
private RenderFragment RenderNode(RawNode node, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device;
var items = MenuFor(node);
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
@if (hasExpander)
{
<button type="button" class="btn btn-sm btn-link p-0" style="width:18px"
@onclick="@(() => ToggleAsync(node))">
@(node.Expanded ? "▼" : "▶")
</button>
}
else
{
<span style="width:18px"></span>
}
@if (items.Count > 0)
{
<ContextMenu Items="items" ShowEllipsis="true">
@RenderRowBody(node, muted)
</ContextMenu>
}
else
{
@RenderRowBody(node, muted)
}
</div>
@if (node.Expanded && node.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (node.Expanded && node.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
@node.Error
</div>
}
else if (node.Expanded)
{
@foreach (var child in node.Children)
{
@RenderNode(child, depth + 1)
}
}
};
// The icon + label + badge shown for a node — the ContextMenu right-click surface.
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
{
<span class="d-inline-flex align-items-center gap-1">
<span aria-hidden="true">@KindIcon(node.Kind)</span>
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
@if (muted)
{
<span class="chip chip-idle ms-1" style="font-size:0.65rem">disabled</span>
}
@if (node.ChildCount > 0)
{
<span class="chip chip-idle ms-1" style="font-size:0.7rem">@node.ChildCount</span>
}
</span>
};
private static string KindIcon(RawNodeKind kind) => kind switch
{
RawNodeKind.Enterprise => "🏢",
RawNodeKind.Cluster => "🖥️",
RawNodeKind.Folder => "📁",
RawNodeKind.Driver => "🔌",
RawNodeKind.Device => "📟",
RawNodeKind.TagGroup => "🗂️",
RawNodeKind.Tag => "🏷️",
_ => "•",
};
// ---------------------------------------------------------------------------
// Lazy expansion — the whole point of WP2 (UNS never did this).
// ---------------------------------------------------------------------------
/// <summary>
/// Toggles a node's expansion. On first expand of an unloaded container this sets
/// <c>Loading</c>, awaits <see cref="IRawTreeService.LoadChildrenAsync"/>, appends the
/// returned level into <c>Children</c>, flips <c>Loaded</c>, and re-renders — showing a
/// spinner in flight and an error row on failure. A leaf (Tag) has no expander so never
/// reaches here.
/// </summary>
private async Task ToggleAsync(RawNode node)
{
if (node.Loading) { return; }
node.Expanded = !node.Expanded;
if (node.Expanded && node.HasLazyChildren && !node.Loaded)
{
node.Loading = true;
node.Error = null;
StateHasChanged();
try
{
var children = await Svc.LoadChildrenAsync(node);
node.Children.Clear();
node.Children.AddRange(children);
node.ChildCount = node.Children.Count;
node.Loaded = true;
}
catch (Exception ex)
{
node.Error = ex.Message;
}
finally
{
node.Loading = false;
}
StateHasChanged();
}
}
// ---------------------------------------------------------------------------
// Per-kind context menus. Every OnClick opens a placeholder in this wave; the
// named handlers below are the seam Wave B/C replaces with the real modals.
// ---------------------------------------------------------------------------
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) => node.Kind switch
{
RawNodeKind.Cluster => ClusterMenu(node),
RawNodeKind.Folder => FolderMenu(node),
RawNodeKind.Driver => DriverMenu(node),
RawNodeKind.Device => DeviceMenu(node),
RawNodeKind.TagGroup => TagGroupMenu(node),
RawNodeKind.Tag => TagMenu(node),
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
};
private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
{
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
};
private List<ContextMenuItem> FolderMenu(RawNode node) => new()
{
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
ContextMenuItem.Separator(),
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameFolder(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteFolder(node) },
};
private List<ContextMenuItem> DriverMenu(RawNode node) => new()
{
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDriver(node) },
new() { Label = "Test connect", Icon = "🔎", OnClick = () => OnTestConnect(node) },
new() { Label = "New device", Icon = "📟", OnClick = () => OnNewDevice(node) },
ContextMenuItem.Separator(),
new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "▶",
OnClick = () => OnToggleDriverEnabled(node) },
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameDriver(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDriver(node) },
};
private List<ContextMenuItem> DeviceMenu(RawNode node) => new()
{
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDevice(node) },
new() { Label = "New tag-group", Icon = "🗂️", OnClick = () => OnNewTagGroup(node) },
ContextMenuItem.Separator(),
// "Add tags ▸" flattened — ContextMenu is single-level, so the submenu is a labelled group.
new() { Label = "Add tags", Disabled = true },
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
ContextMenuItem.Separator(),
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDevice(node) },
};
private List<ContextMenuItem> TagGroupMenu(RawNode node) => new()
{
new() { Label = "New group", Icon = "🗂️", OnClick = () => OnNewGroup(node) },
ContextMenuItem.Separator(),
new() { Label = "Add tags", Disabled = true },
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
ContextMenuItem.Separator(),
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameTagGroup(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTagGroup(node) },
};
private List<ContextMenuItem> TagMenu(RawNode node) => new()
{
new() { Label = "Edit", Icon = "✏️", OnClick = () => OnEditTag(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTag(node) },
};
// ---------------------------------------------------------------------------
// Named action handlers — the Wave B/C replacement seam. Each opens a placeholder
// today; a later wave swaps the body for the real modal invocation.
// ---------------------------------------------------------------------------
// Folder / Cluster
private Task OnNewFolder(RawNode node) => ShowStub("New folder", node);
private Task OnNewDriver(RawNode node) => ShowStub("New driver", node);
private Task OnRenameFolder(RawNode node) => ShowStub("Rename folder", node);
private Task OnDeleteFolder(RawNode node) => ShowStub("Delete folder", node);
// Driver
private Task OnConfigureDriver(RawNode node) => ShowStub("Configure driver", node);
private Task OnTestConnect(RawNode node) => ShowStub("Test connect", node);
private Task OnNewDevice(RawNode node) => ShowStub("New device", node);
private Task OnToggleDriverEnabled(RawNode node) => ShowStub(node.Enabled ? "Disable driver" : "Enable driver", node);
private Task OnRenameDriver(RawNode node) => ShowStub("Rename driver", node);
private Task OnDeleteDriver(RawNode node) => ShowStub("Delete driver", node);
// Device
private Task OnConfigureDevice(RawNode node) => ShowStub("Configure device", node);
private Task OnNewTagGroup(RawNode node) => ShowStub("New tag-group", node);
private Task OnDeleteDevice(RawNode node) => ShowStub("Delete device", node);
// Add-tags submenu (shared by Device + TagGroup)
private Task OnAddManualTags(RawNode node) => ShowStub("Add tags — manual entry", node);
private Task OnImportCsv(RawNode node) => ShowStub("Import tags CSV", node);
private Task OnExportCsv(RawNode node) => ShowStub("Export tags CSV", node);
private Task OnBrowseDevice(RawNode node) => ShowStub("Browse device", node);
// TagGroup
private Task OnNewGroup(RawNode node) => ShowStub("New group", node);
private Task OnRenameTagGroup(RawNode node) => ShowStub("Rename tag-group", node);
private Task OnDeleteTagGroup(RawNode node) => ShowStub("Delete tag-group", node);
// Tag
private Task OnEditTag(RawNode node) => ShowStub("Edit tag", node);
private Task OnDeleteTag(RawNode node) => ShowStub("Delete tag", node);
// ---------------------------------------------------------------------------
// Placeholder plumbing (removed once every handler above has its real modal).
// ---------------------------------------------------------------------------
private Task ShowStub(string action, RawNode node)
{
_stubTitle = action;
_stubMessage = $"{action} — coming in a later wave.";
_stubNodeName = node.DisplayName;
_stubVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private void CloseStub()
{
_stubVisible = false;
_stubTitle = "";
_stubMessage = "";
_stubNodeName = null;
}
}