diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor index 5b0b00ae..dff73ec0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -85,7 +85,7 @@ + Disabled="@(_connected || _instancesLoading)" DataTestPrefix="debug-instance" />
@if (!_connected) @@ -263,6 +263,12 @@ private bool _loading = true; private bool _connected; private bool _connecting; + + // True for the whole of LoadInstancesForSite. Blazor renders as soon as that load hits its + // first await, and the page is not _connected then — so without this the picker is enabled + // while it is still showing the PREVIOUS site's tree, and anything typed into it is wiped + // when the new lists land (the picker reads a new list object as "the site changed"). + private bool _instancesLoading; private bool _connectedFromStorage; // Which tab pane is visible — "attributes" (default) or "alarms". @@ -415,43 +421,74 @@ { _selectedSiteId = siteId; await LoadInstancesForSite(); + + // Membership guard, mirroring the ?instanceId= path above. A stored instance that + // has since been disabled (or deleted, or moved to another site) is not in the list + // the picker was handed, so it renders a blank field while the page holds a non-zero + // id and auto-connects to it anyway. Forget the stored instance instead — the site + // stays selected and loaded, so the operator just picks again. + var stored = _siteInstances.FirstOrDefault(i => i.Id == instanceId); + if (stored == null) + { + await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId"); + _toast.ShowInfo( + "The instance from your previous session is no longer available for debug streaming.", + autoDismissMs: 8000); + StateHasChanged(); + return; + } + _selectedInstanceId = instanceId; _connectedFromStorage = true; StateHasChanged(); await Connect(); // Auto-reconnect notice — the user didn't initiate this connection. - var inst = _siteInstances.FirstOrDefault(i => i.Id == instanceId); _toast.ShowInfo( - $"Auto-reconnected to {inst?.UniqueName ?? "instance"} from previous session.", + $"Auto-reconnected to {stored.UniqueName} from previous session.", autoDismissMs: 8000); } } + /// + /// Loads the selected site's instances and areas into the picker's two lists, holding + /// _instancesLoading for the whole round trip so the field cannot be typed into + /// while it is still showing the previous site's tree. The flag is cleared in a + /// finally: a scope refusal or a repository fault must not leave the picker + /// disabled forever. + /// private async Task LoadInstancesForSite() { - _siteInstances = new(); - _siteAreas = new(); - _selectedInstanceId = 0; - if (_selectedSiteId == 0) return; - // Site scoping (CentralUI-002): re-check the claim server-side — a query - // string or stale localStorage value could name a site outside the grant. - if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId)) - { - _selectedSiteId = 0; - _toast.ShowError("You are not permitted to debug instances on that site."); - return; - } + _instancesLoading = true; try { - _siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)) - .Where(i => i.State == InstanceState.Enabled) - .ToList(); - _siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList(); + _siteInstances = new(); + _siteAreas = new(); + _selectedInstanceId = 0; + if (_selectedSiteId == 0) return; + // Site scoping (CentralUI-002): re-check the claim server-side — a query + // string or stale localStorage value could name a site outside the grant. + if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId)) + { + _selectedSiteId = 0; + _toast.ShowError("You are not permitted to debug instances on that site."); + return; + } + try + { + _siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)) + .Where(i => i.State == InstanceState.Enabled) + .ToList(); + _siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList(); + } + catch (Exception ex) + { + _toast.ShowError($"Failed to load instances: {ex.Message}"); + } } - catch (Exception ex) + finally { - _toast.ShowError($"Failed to load instances: {ex.Message}"); + _instancesLoading = false; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor index d5ab097f..1dfb1a66 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor @@ -69,7 +69,7 @@ loaded once per site — the picker treats a new list as a site change. *@
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs index a2296c36..0c9608b9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs @@ -122,6 +122,16 @@ public partial class FlowDebugger : ComponentBase, IDisposable /// The selected site's areas; the picker nests them into its tree. See . private List _areas = []; + + /// + /// True while is fetching a site's instances and areas, and + /// the sole reason the picker is disabled for that window. Blazor renders the moment the load + /// hits its first await, and by then is already the NEW site + /// while the two lists are still the OLD ones — so without this the field is enabled over + /// stale data, and anything typed into it is wiped when the lists land (the picker reads a new + /// list object as "the site changed" and clears its text). + /// + private bool _instancesLoading; private List _scripts = []; private int _selectedMethodId; @@ -419,19 +429,33 @@ public partial class FlowDebugger : ComponentBase, IDisposable await PredictAsync(); } + /// + /// Loads the selected site's instances and areas into the picker's two lists, holding + /// for the whole round trip so the field cannot be typed + /// into while it is still showing the previous site's tree. The flag is cleared in a + /// finally: a repository fault must not leave the picker disabled forever. + /// private async Task LoadInstancesAsync() { - if (_selectedSiteId <= 0) + _instancesLoading = true; + try { - _instances = []; - _areas = []; - return; - } + if (_selectedSiteId <= 0) + { + _instances = []; + _areas = []; + return; + } - _instances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)) - .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList(); - // The picker nests these itself (InstanceTreeBuilder), so order does not matter here. - _areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList(); + // Neither list is sorted here: the picker nests and orders both itself + // (InstanceTreeBuilder). The OrderBy this replaces dated from the flat