Merge branch 'worktree-agent-a327a30cdb2e454ff' into feat/instance-picker

This commit is contained in:
Joseph Doherty
2026-09-08 10:01:47 -04:00
5 changed files with 212 additions and 35 deletions
@@ -85,7 +85,7 @@
<InstancePicker Areas="_siteAreas" Instances="_siteInstances"
ItemLabel="InstanceLabel"
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePicked"
Disabled="@_connected" DataTestPrefix="debug-instance" />
Disabled="@(_connected || _instancesLoading)" DataTestPrefix="debug-instance" />
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
@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);
}
}
/// <summary>
/// Loads the selected site's instances and areas into the picker's two lists, holding
/// <c>_instancesLoading</c> 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
/// <c>finally</c>: a scope refusal or a repository fault must not leave the picker
/// disabled forever.
/// </summary>
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;
}
}
@@ -69,7 +69,7 @@
loaded once per site — the picker treats a new list as a site change. *@
<InstancePicker Areas="_areas" Instances="_instances"
SelectedInstanceId="_selectedInstanceId" SelectedInstanceIdChanged="OnInstancePickedAsync"
Disabled="@(_running || _selectedSiteId == 0)"
Disabled="@(_running || _selectedSiteId == 0 || _instancesLoading)"
Placeholder="Search instances…" DataTestPrefix="flow-instance" />
</div>
<div class="col-md-3">
@@ -122,6 +122,16 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <summary>The selected site's areas; the picker nests them into its tree. See <see cref="_instances"/>.</summary>
private List<Area> _areas = [];
/// <summary>
/// True while <see cref="LoadInstancesAsync"/> 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 <see cref="_selectedSiteId"/> 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).
/// </summary>
private bool _instancesLoading;
private List<FlowScriptOption> _scripts = [];
private int _selectedMethodId;
@@ -419,19 +429,33 @@ public partial class FlowDebugger : ComponentBase, IDisposable
await PredictAsync();
}
/// <summary>
/// Loads the selected site's instances and areas into the picker's two lists, holding
/// <see cref="_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
/// <c>finally</c>: a repository fault must not leave the picker disabled forever.
/// </summary>
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 <select>.
_instances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).ToList();
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
}
finally
{
_instancesLoading = false;
}
}
/// <summary>