403 lines
21 KiB
Plaintext
403 lines
21 KiB
Plaintext
@using Microsoft.Extensions.DependencyInjection
|
|
@inject IServiceProvider MutationServices
|
|
@page "/deployment/instances/create"
|
|
@using ZB.MOM.WW.ScadaBridge.Security
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
|
|
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
|
|
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
|
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
|
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
|
@inject ISiteRepository SiteRepository
|
|
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
|
|
@inject InstanceService InstanceService
|
|
@inject IFlatteningPipeline FlatteningPipeline
|
|
@inject AuthenticationStateProvider AuthStateProvider
|
|
@inject NavigationManager NavigationManager
|
|
@inject IDialogService Dialog
|
|
|
|
<div class="container-fluid mt-3">
|
|
<div class="d-flex align-items-center mb-3">
|
|
<a href="/deployment/topology" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
|
<h4 class="mb-0">Create Instance</h4>
|
|
</div>
|
|
|
|
@if (_loading)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
}
|
|
else
|
|
{
|
|
<div class="card mb-3">
|
|
<div class="card-body">
|
|
<div class="mb-2">
|
|
<label class="form-label small">Instance Name</label>
|
|
<input type="text" class="form-control form-control-sm" @bind="_createName" placeholder="e.g. Motor-1" />
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label small">Template</label>
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" class="form-control" data-test="create-template" readonly
|
|
placeholder="Choose a template…" value="@TemplatePathLabel"
|
|
style="cursor: pointer; background-color: var(--bs-body-bg);"
|
|
@onclick="OpenTemplatePickerAsync" />
|
|
<button class="btn btn-outline-secondary" type="button" data-test="create-template-choose"
|
|
disabled="@(_templates.Count == 0)" @onclick="OpenTemplatePickerAsync">Choose…</button>
|
|
</div>
|
|
@if (_templates.Count == 0)
|
|
{
|
|
<div class="form-text">No templates exist yet — <a href="/design/templates">create one on the Templates page</a>.</div>
|
|
}
|
|
else if (_templateHint is not null)
|
|
{
|
|
<div class="form-text text-warning-emphasis">@_templateHint</div>
|
|
}
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label small">Site</label>
|
|
<select class="form-select form-select-sm" data-test="create-site"
|
|
value="@_createSiteId"
|
|
@onchange="async e => { _createSiteId = int.TryParse(e.Value?.ToString(), out var v) ? v : 0; _createAreaId = 0; await OnTemplateOrSiteChangedAsync(); }">
|
|
<option value="0">Select site...</option>
|
|
@foreach (var s in _sites)
|
|
{
|
|
<option value="@s.Id">@s.Name</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label small">Area</label>
|
|
<select class="form-select form-select-sm" @bind="_createAreaId">
|
|
<option value="0">No area</option>
|
|
@foreach (var a in _allAreas.Where(a => a.SiteId == _createSiteId))
|
|
{
|
|
<option value="@a.Id">@a.Name</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
@if (_formError != null)
|
|
{
|
|
<div class="text-danger small mt-2">@_formError</div>
|
|
}
|
|
@if (!BindingsCardVisible)
|
|
{
|
|
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Create instance actions">
|
|
<button class="btn btn-success" @onclick="CreateInstance">Create</button>
|
|
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@* Connection Bindings — the same module groups the Configure page shows, built
|
|
from a template+site PREVIEW flatten (no instance row exists yet). Group
|
|
headers only: per-member editing stays on Configure. Nothing is saved until
|
|
Create. Design: docs/plans/2026-08-21-create-instance-bindings-design.md *@
|
|
@if (BindingsCardVisible)
|
|
{
|
|
<div class="card mb-3" data-test="create-bindings-card">
|
|
<div class="card-header py-2 d-flex align-items-center gap-3 flex-wrap">
|
|
<strong>Connection Bindings</strong>
|
|
@if (_previewLoading)
|
|
{
|
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted small">@_rowCount data-sourced attribute(s) in @_groups.Count group(s)</span>
|
|
}
|
|
<span class="ms-auto"></span>
|
|
@if (UnboundGroups > 0 && !_skipBindings)
|
|
{
|
|
<span class="badge text-bg-warning" data-test="create-unbound-groups">@UnboundGroups group(s) unbound</span>
|
|
}
|
|
<span class="badge border text-muted">preview — nothing saved yet</span>
|
|
</div>
|
|
@if (_previewError is not null)
|
|
{
|
|
<div class="alert alert-warning rounded-0 mb-0 py-2 small" data-test="create-bindings-preview-error">
|
|
Could not preview this template at the selected site (@_previewError). You can create the instance and bind on Configure.
|
|
</div>
|
|
}
|
|
else if (!_previewLoading && _rowCount == 0)
|
|
{
|
|
<div class="card-body text-muted small">Nothing to bind in this template.</div>
|
|
}
|
|
else if (_siteConnections.Count == 0)
|
|
{
|
|
<div class="card-body text-muted small">This site has no data connections — add one under Sites, or create now and bind later.</div>
|
|
}
|
|
else if (!_skipBindings)
|
|
{
|
|
<div class="card-body border-bottom py-2 d-flex align-items-center gap-2 flex-wrap small">
|
|
<span>Bind all to</span>
|
|
<select class="form-select form-select-sm" style="width: auto;" data-test="create-root-connection" @bind="_rootConnectionId">
|
|
<option value="0">Select connection...</option>
|
|
@foreach (var c in _siteConnections)
|
|
{
|
|
<option value="@c.Id">@c.Name (@c.Protocol)</option>
|
|
}
|
|
</select>
|
|
<input class="form-control form-control-sm font-monospace" style="max-width: 260px;" data-test="create-root-object"
|
|
@bind="_rootObject" placeholder="root object, e.g. Z28063" />
|
|
<button class="btn btn-outline-primary btn-sm" data-test="create-root-browse"
|
|
disabled="@(!IsBrowsable(_rootConnectionId))" @onclick="() => OpenBrowser(null)">Browse…</button>
|
|
<button class="btn btn-outline-primary btn-sm" data-test="create-prefill"
|
|
disabled="@(_rootConnectionId == 0)" @onclick="PrefillGroups">Prefill groups</button>
|
|
<span class="text-muted">Fills the connection on every group and suggests an Object for blanks; never overwrites an Object you typed.</span>
|
|
</div>
|
|
@foreach (var g in _groups)
|
|
{
|
|
var input = _inputs.GetValueOrDefault(g.Key);
|
|
var source = CreateBindingPlanner.SourceOf(g, input);
|
|
<div class="binding-group-header d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-test="create-binding-group">
|
|
<span class="fw-semibold binding-group-name">@g.Key
|
|
@if (g.IsInstanceGroup)
|
|
{
|
|
<small class="text-muted fw-normal ms-1">direct attributes</small>
|
|
}
|
|
else if (g.NestedModules.Count > 0)
|
|
{
|
|
<small class="text-muted fw-normal ms-1">incl. @string.Join(", ", g.NestedModules)</small>
|
|
}
|
|
</span>
|
|
<span class="text-muted small">@g.Members.Count</span>
|
|
<select class="form-select form-select-sm" style="width: 160px;" data-test="create-group-connection"
|
|
value="@(input?.ConnectionId ?? 0)"
|
|
@onchange="e => SetGroupConnection(g, int.TryParse(e.Value?.ToString(), out var v) ? v : 0)">
|
|
<option value="0">— none —</option>
|
|
@foreach (var c in _siteConnections)
|
|
{
|
|
<option value="@c.Id">@c.Name</option>
|
|
}
|
|
</select>
|
|
<input class="form-control form-control-sm font-monospace" style="max-width: 260px;" data-test="create-group-object"
|
|
value="@input?.Object"
|
|
placeholder="@(g.IsInstanceGroup ? "object, e.g. Z28063" : $"object, e.g. {g.Key}")"
|
|
title="Object path this module's members hang off; every member binds as <object>.<member>."
|
|
@onchange="e => SetGroupObject(g, e.Value?.ToString())" />
|
|
<button class="btn btn-outline-primary btn-sm" data-test="create-group-browse"
|
|
disabled="@(!IsBrowsable(input?.ConnectionId ?? 0))" @onclick="() => OpenBrowser(g)">Browse…</button>
|
|
@if (source == CreateBindingPlanner.ObjectSource.Suggested)
|
|
{
|
|
<span class="badge text-bg-secondary" title="Suggested from the root object; edit if the object is a sibling">suggested</span>
|
|
}
|
|
else if (source == CreateBindingPlanner.ObjectSource.FromTemplate)
|
|
{
|
|
<span class="badge text-bg-secondary" title="The composed template's defaults already carry this object">from template</span>
|
|
}
|
|
@if (IsGroupBound(g))
|
|
{
|
|
<span class="badge text-bg-success">bound</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge text-bg-danger">@g.Members.Count unbound</span>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="Create instance actions">
|
|
<button class="btn btn-success" @onclick="CreateInstance" disabled="@_previewLoading">Create instance</button>
|
|
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
|
</div>
|
|
<label class="form-check form-check-inline mb-0 small ms-2">
|
|
<input class="form-check-input" type="checkbox" data-test="create-skip-bindings" @bind="_skipBindings" />
|
|
Skip bindings — I'll bind on Configure
|
|
</label>
|
|
<span class="text-muted small ms-auto">
|
|
@if (_skipBindings)
|
|
{
|
|
<text>Creates the instance without bindings.</text>
|
|
}
|
|
else
|
|
{
|
|
<text>Creates the instance, saves @BoundRowCount binding(s), then opens Configure.@(UnboundGroups > 0 ? $" {UnboundGroups} group(s) stay unbound — Deploy is rejected until they are bound." : "")</text>
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
@* Address-space picker. SelectableNodeClasses adds Object so a module's
|
|
container node can be chosen, not just a leaf tag. *@
|
|
<NodeBrowserDialog @ref="_browserRef"
|
|
SiteId="@(_siteIdentifier ?? "")"
|
|
ConnectionName="@_browserConnectionName"
|
|
InitialNodeId="@_browserInitial"
|
|
SelectableNodeClasses="ObjectAndVariable"
|
|
OnSelected="OnBrowserSelected" />
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
/// <summary>Query preselect: the template the operator picked elsewhere (e.g. the Templates tree's "New Instance…").</summary>
|
|
[SupplyParameterFromQuery] public int? TemplateId { get; set; }
|
|
[SupplyParameterFromQuery] public int? SiteId { get; set; }
|
|
[SupplyParameterFromQuery] public int? AreaId { get; set; }
|
|
|
|
private List<Site> _sites = new();
|
|
private List<Template> _templates = new();
|
|
private List<TemplateFolder> _folders = new();
|
|
private Dictionary<int, TemplateFolder> _foldersById = new();
|
|
private string? _templateHint;
|
|
|
|
/// <summary>Folder-qualified label shown in the read-only template field; blank when nothing is chosen.</summary>
|
|
private string TemplatePathLabel => _templates.FirstOrDefault(t => t.Id == _createTemplateId) is { } t
|
|
? TemplatePathFormatter.Format(t, _foldersById)
|
|
: string.Empty;
|
|
private List<Area> _allAreas = new();
|
|
private bool _loading = true;
|
|
|
|
private string _createName = string.Empty;
|
|
private int _createTemplateId;
|
|
private int _createSiteId;
|
|
private int _createAreaId;
|
|
private string? _formError;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
try
|
|
{
|
|
_templates = (await TemplateEngineRepository.GetTemplateStubsAsync()).ToShellTemplates();
|
|
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
|
|
_foldersById = _folders.ToDictionary(f => f.Id);
|
|
if (TemplateId is int tid)
|
|
{
|
|
if (_templates.Any(t => t.Id == tid)) _createTemplateId = tid;
|
|
else _templateHint = $"Template {tid} no longer exists — choose another.";
|
|
}
|
|
// Site scoping (CentralUI-002): a scoped Deployment user may only
|
|
// create instances on sites they are permitted on.
|
|
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
|
|
|
_allAreas.Clear();
|
|
foreach (var site in _sites)
|
|
{
|
|
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
|
_allAreas.AddRange(areas);
|
|
}
|
|
|
|
if (SiteId is int sid && _sites.Any(s => s.Id == sid))
|
|
{
|
|
_createSiteId = sid;
|
|
}
|
|
if (AreaId is int aid && _allAreas.Any(a => a.Id == aid && a.SiteId == _createSiteId))
|
|
{
|
|
_createAreaId = aid;
|
|
}
|
|
|
|
|
|
// Query-string preselects (siteId + templateId) make this a real preview
|
|
// when both are present; it no-ops when either is missing.
|
|
await OnTemplateOrSiteChangedAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_formError = $"Failed to load data: {ex.Message}";
|
|
}
|
|
_loading = false;
|
|
}
|
|
|
|
private async Task CreateInstance()
|
|
{
|
|
_formError = null;
|
|
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Instance name is required."; return; }
|
|
if (_createTemplateId == 0) { _formError = "Select a template."; return; }
|
|
if (_createSiteId == 0) { _formError = "Select a site."; return; }
|
|
// Site scoping (CentralUI-002): re-check server-side before the mutating
|
|
// command, independent of what the site dropdown was populated with.
|
|
if (!await SiteScope.IsSiteAllowedAsync(_createSiteId))
|
|
{
|
|
_formError = "You are not permitted to create instances on the selected site.";
|
|
return;
|
|
}
|
|
|
|
string user;
|
|
int id;
|
|
try
|
|
{
|
|
user = await GetCurrentUserAsync();
|
|
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(null, _createSiteId, (instanceService, currentUser) => instanceService.CreateInstanceAsync(
|
|
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, currentUser));
|
|
if (result.IsFailure)
|
|
{
|
|
_formError = result.Error;
|
|
return;
|
|
}
|
|
id = result.Value.Id;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_formError = $"Create failed: {ex.Message}";
|
|
return;
|
|
}
|
|
|
|
// From here the instance EXISTS. Whatever the binding save does, land on
|
|
// Configure rather than leaving the operator on a stale create form —
|
|
// carrying any binding error in the query so it is visible there.
|
|
var bindings = _skipBindings || _previewError is not null
|
|
? (IReadOnlyList<ConnectionBinding>)Array.Empty<ConnectionBinding>()
|
|
: CreateBindingPlanner.BuildBindings(_groups, _inputs);
|
|
|
|
if (bindings.Count == 0)
|
|
{
|
|
NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?created=1");
|
|
return;
|
|
}
|
|
|
|
string? bindError = null;
|
|
try
|
|
{
|
|
var bind = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(id, null, (instanceService, currentUser) => instanceService.SetConnectionBindingsAsync(id, bindings, currentUser));
|
|
if (bind.IsFailure) bindError = bind.Error;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Network / actor timeout AFTER the instance was created.
|
|
bindError = ex.Message;
|
|
}
|
|
|
|
var query = bindError is null
|
|
? $"created=1&bound={bindings.Count}"
|
|
: $"created=1&bindError={Uri.EscapeDataString(Truncate(bindError, 300))}";
|
|
NavigationManager.NavigateTo($"/deployment/instances/{id}/configure?{query}");
|
|
}
|
|
|
|
/// <summary>Keeps a failure message short enough to ride in the query string.</summary>
|
|
private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n] + "…";
|
|
|
|
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
|
|
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
|
private Task<string> GetCurrentUserAsync()
|
|
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
|
|
|
|
/// <summary>
|
|
/// Opens the folder-tree template picker. A pick clears any dangling-id hint and
|
|
/// re-runs the bindings preview; Cancel (or re-picking the same template) is a no-op.
|
|
/// </summary>
|
|
private async Task OpenTemplatePickerAsync()
|
|
{
|
|
if (_templates.Count == 0) return;
|
|
var picked = await Dialog.ShowAsync<TemplatePickerDialog.TemplatePickResult>(
|
|
"Choose template",
|
|
ctx => @<TemplatePickerDialog Context="ctx" Folders="_folders" Templates="_templates"
|
|
InitialTemplateId="@(_createTemplateId > 0 ? _createTemplateId : null)" />,
|
|
size: "modal-lg");
|
|
if (picked is null || picked.TemplateId == _createTemplateId) return;
|
|
_createTemplateId = picked.TemplateId;
|
|
_templateHint = null;
|
|
await OnTemplateOrSiteChangedAsync();
|
|
}
|
|
}
|