From e60b4b5867dc211a9fb0ba8f27181774fd36a7ee Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 May 2026 22:43:57 +0200 Subject: [PATCH] chore: add workspace crash diagnostics --- README.md | 1 + .../Pages/AdminWorkspaceContent.razor | 2 - .../Pages/AdminWorkspaceContent.razor.cs | 25 +++ .../Pages/CampaignsWorkspaceContent.razor | 2 - .../Pages/CampaignsWorkspaceContent.razor.cs | 25 +++ .../Pages/HomeControls/AppHeader.razor.cs | 59 +++--- .../HomeControls/CampaignLogPanel.razor.cs | 18 ++ .../HomeControls/CharacterPanel.razor.cs | 19 ++ .../RolemasterSkillRollModal.razor.cs | 41 ++-- .../HomeControls/SkillFormModal.razor.cs | 87 ++++---- .../Pages/PlayWorkspaceContent.razor | 2 - .../Pages/PlayWorkspaceContent.razor.cs | 25 +++ RpgRoller/Components/Pages/Workspace.razor | 8 +- RpgRoller/Components/Pages/Workspace.razor.cs | 79 ++++++- .../WorkspaceCampaignScopeCoordinator.cs | 26 ++- .../Pages/WorkspaceDiagnosticSummary.cs | 40 ++++ .../Pages/WorkspaceLiveStateController.cs | 15 +- .../Components/Pages/WorkspaceRouteView.razor | 42 ++++ .../Pages/WorkspaceSessionCoordinator.cs | 49 ++++- RpgRoller/Program.cs | 5 +- RpgRoller/wwwroot/js/rpgroller-api.js | 200 +++++++++++++++++- TASKS.md | 4 + 22 files changed, 662 insertions(+), 112 deletions(-) create mode 100644 RpgRoller/Components/Pages/AdminWorkspaceContent.razor.cs create mode 100644 RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor.cs create mode 100644 RpgRoller/Components/Pages/PlayWorkspaceContent.razor.cs create mode 100644 RpgRoller/Components/Pages/WorkspaceDiagnosticSummary.cs diff --git a/README.md b/README.md index 94ff240..056bfed 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ SQLite migration rule: - Static assets are linked through Blazor's `@Assets[...]` pipeline for fingerprinted cache-busting URLs. - Workspace reads are resolved through API requests in `WorkspaceQueryService`; browser interop stays focused on auth forms, session storage, SSE wiring, and small DOM helpers. - Interactive authenticated startup begins in `WorkspaceRouteView.razor` after first render because `RpgRollerApiClient` still depends on JS interop-backed `fetch`. +- Workspace startup diagnostics now log route initialization, route-content render phases, and browser-side workspace mutation snapshots to help isolate the remaining Firefox startup crash documented in `POSTMORTEM.md`. - Live workspace refresh compares separate roster, per-character sheet, and log versions so unrelated changes do not trigger full reloads. - Campaign log data is loaded in bounded slices: campaign summaries, one selected roster, one selected character sheet, and a 25-row incremental log window from `/api/campaigns/{campaignId}/log/page`. - Log rows return compact summary data first and lazy-load full detail from `/api/rolls/{rollId}` when expanded. diff --git a/RpgRoller/Components/Pages/AdminWorkspaceContent.razor b/RpgRoller/Components/Pages/AdminWorkspaceContent.razor index 96eda03..537e05d 100644 --- a/RpgRoller/Components/Pages/AdminWorkspaceContent.razor +++ b/RpgRoller/Components/Pages/AdminWorkspaceContent.razor @@ -64,7 +64,5 @@ @code { - [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; - private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading; } diff --git a/RpgRoller/Components/Pages/AdminWorkspaceContent.razor.cs b/RpgRoller/Components/Pages/AdminWorkspaceContent.razor.cs new file mode 100644 index 0000000..ec993ce --- /dev/null +++ b/RpgRoller/Components/Pages/AdminWorkspaceContent.razor.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public partial class AdminWorkspaceContent +{ + protected override void OnParametersSet() + { + Logger.LogInformation("AdminWorkspaceContent.OnParametersSet [{State}]", + WorkspaceDiagnosticSummary.DescribeAdminSurface(Workspace)); + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + Logger.LogInformation("AdminWorkspaceContent.OnAfterRenderAsync firstRender={FirstRender} [{State}]", + firstRender, WorkspaceDiagnosticSummary.DescribeAdminSurface(Workspace)); + return Task.CompletedTask; + } + + [Inject] private ILogger Logger { get; set; } = null!; + [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor b/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor index 675cb59..d1cd5a0 100644 --- a/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor +++ b/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor @@ -21,8 +21,6 @@ @code { - [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; - private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) { await Workspace.Campaigns.OnCampaignSelectionChangedAsync(args); diff --git a/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor.cs b/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor.cs new file mode 100644 index 0000000..de20975 --- /dev/null +++ b/RpgRoller/Components/Pages/CampaignsWorkspaceContent.razor.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public partial class CampaignsWorkspaceContent +{ + protected override void OnParametersSet() + { + Logger.LogInformation("CampaignsWorkspaceContent.OnParametersSet [{State}]", + WorkspaceDiagnosticSummary.DescribeCampaignsSurface(Workspace)); + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + Logger.LogInformation("CampaignsWorkspaceContent.OnAfterRenderAsync firstRender={FirstRender} [{State}]", + firstRender, WorkspaceDiagnosticSummary.DescribeCampaignsSurface(Workspace)); + return Task.CompletedTask; + } + + [Inject] private ILogger Logger { get; set; } = null!; + [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs b/RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs index 0cbb37d..b86bb93 100644 --- a/RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/AppHeader.razor.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; using RpgRoller.Contracts; namespace RpgRoller.Components.Pages.HomeControls; @@ -7,49 +8,55 @@ namespace RpgRoller.Components.Pages.HomeControls; [ExcludeFromCodeCoverage] public partial class AppHeader { + protected override void OnParametersSet() + { + Logger.LogInformation( + "AppHeader.OnParametersSet user={User} showCampaign={ShowCampaign} campaignName={CampaignName} showConnectionState={ShowConnectionState} connectionStateLabel={ConnectionStateLabel} menuItems={MenuItemCount} isMenuOpen={IsMenuOpen}", + User?.Username ?? "", ShowCampaign, CampaignName ?? "", ShowConnectionState, + ConnectionStateLabel, + MenuItems.Count, IsMenuOpen); + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + Logger.LogInformation( + "AppHeader.OnAfterRenderAsync firstRender={FirstRender} user={User} menuItems={MenuItemCount} isMenuOpen={IsMenuOpen}", + firstRender, User?.Username ?? "", MenuItems.Count, IsMenuOpen); + return Task.CompletedTask; + } + private Task SelectMenuItemAsync(AppHeaderMenuItem item) { return item.OnSelected?.Invoke() ?? Task.CompletedTask; } - [Parameter] - public string Title { get; set; } = "RpgRoller"; + [Inject] private ILogger Logger { get; set; } = null!; - [Parameter] - public UserSummary? User { get; set; } + [Parameter] public string Title { get; set; } = "RpgRoller"; - [Parameter] - public bool ShowCampaign { get; set; } + [Parameter] public UserSummary? User { get; set; } - [Parameter] - public string? CampaignName { get; set; } + [Parameter] public bool ShowCampaign { get; set; } - [Parameter] - public bool ShowConnectionState { get; set; } = true; + [Parameter] public string? CampaignName { get; set; } - [Parameter] - public string ConnectionStateLabel { get; set; } = "Offline fallback"; + [Parameter] public bool ShowConnectionState { get; set; } = true; - [Parameter] - public string ConnectionStateCssClass { get; set; } = "offline"; + [Parameter] public string ConnectionStateLabel { get; set; } = "Offline fallback"; - [Parameter] - public bool IsMenuOpen { get; set; } + [Parameter] public string ConnectionStateCssClass { get; set; } = "offline"; - [Parameter] - public string MenuButtonId { get; set; } = "screen-menu-button"; + [Parameter] public bool IsMenuOpen { get; set; } - [Parameter] - public string MenuId { get; set; } = "screen-menu"; + [Parameter] public string MenuButtonId { get; set; } = "screen-menu-button"; - [Parameter] - public IReadOnlyList MenuItems { get; set; } = []; + [Parameter] public string MenuId { get; set; } = "screen-menu"; - [Parameter] - public EventCallback ToggleMenuRequested { get; set; } + [Parameter] public IReadOnlyList MenuItems { get; set; } = []; - [Parameter] - public EventCallback LogoutRequested { get; set; } + [Parameter] public EventCallback ToggleMenuRequested { get; set; } + + [Parameter] public EventCallback LogoutRequested { get; set; } } public sealed class AppHeaderMenuItem diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs index 768fa1b..d406e6e 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; using Microsoft.JSInterop; using RpgRoller.Contracts; @@ -13,10 +14,18 @@ public partial class CampaignLogPanel protected override async Task OnAfterRenderAsync(bool firstRender) { var currentLastRollId = CampaignLog.LastOrDefault()?.RollId; + Logger.LogInformation( + "CampaignLogPanel.OnAfterRenderAsync start firstRender={FirstRender} loading={IsCampaignDataLoading} logCount={LogCount} selectedCharacter={SelectedCharacterId} expandedRollId={ExpandedRollId} lastRenderedLogCount={LastRenderedLogCount} lastRenderedLogRollId={LastRenderedLogRollId} currentLastRollId={CurrentLastRollId}", + firstRender, IsCampaignDataLoading, CampaignLog.Count, SelectedCharacterId, ExpandedRollId, + LastRenderedLogCount, + LastRenderedLogRollId, currentLastRollId); if (IsCampaignDataLoading || CampaignLog.Count == 0) { LastRenderedLogCount = CampaignLog.Count; LastRenderedLogRollId = currentLastRollId; + Logger.LogInformation( + "CampaignLogPanel.OnAfterRenderAsync earlyExit loading={IsCampaignDataLoading} logCount={LogCount}", + IsCampaignDataLoading, CampaignLog.Count); return; } @@ -24,19 +33,27 @@ public partial class CampaignLogPanel { try { + Logger.LogInformation( + "CampaignLogPanel.OnAfterRenderAsync scrollingToBottom logCount={LogCount} currentLastRollId={CurrentLastRollId}", + CampaignLog.Count, currentLastRollId); await JS.InvokeVoidAsync("rpgRollerApi.scrollElementToBottom", LogFeedRef); } catch (JSDisconnectedException) { + Logger.LogWarning("CampaignLogPanel.OnAfterRenderAsync scroll skipped due to JS disconnect"); } catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase)) { + Logger.LogWarning(ex, "CampaignLogPanel.OnAfterRenderAsync scroll skipped during static render"); } } LastRenderedLogCount = CampaignLog.Count; LastRenderedLogRollId = currentLastRollId; + Logger.LogInformation( + "CampaignLogPanel.OnAfterRenderAsync end firstRender={FirstRender} logCount={LogCount} lastRenderedLogRollId={LastRenderedLogRollId}", + firstRender, CampaignLog.Count, LastRenderedLogRollId); } private async Task SubmitCustomRollAsync() @@ -137,6 +154,7 @@ public partial class CampaignLogPanel [Inject] private IJSRuntime JS { get; set; } = null!; [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!; + [Inject] private ILogger Logger { get; set; } = null!; private ElementReference LogPanelRef { get; set; } private ElementReference LogFeedRef { get; set; } diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs index 12b07c6..74685e5 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; using RpgRoller.Contracts; namespace RpgRoller.Components.Pages.HomeControls; @@ -7,6 +8,23 @@ namespace RpgRoller.Components.Pages.HomeControls; [ExcludeFromCodeCoverage] public partial class CharacterPanel { + protected override void OnParametersSet() + { + Logger.LogInformation( + "CharacterPanel.OnParametersSet loading={IsCampaignDataLoading} selectedCampaign={SelectedCampaignId} selectedCharacter={SelectedCharacterId} campaignCharacters={CampaignCharacterCount} skills={SkillCount} skillGroups={SkillGroupCount} filter={SkillFilterText} createSkillModal={ShowCreateSkillModal} editSkillModal={ShowEditSkillModal} createGroupModal={ShowCreateSkillGroupModal} editGroupModal={ShowEditSkillGroupModal}", + IsCampaignDataLoading, SelectedCampaign?.Id, SelectedCharacterId, SelectedCampaign?.Characters.Length ?? 0, + SelectedCharacterSkills.Count, SelectedCharacterSkillGroups.Count, SkillFilterText, ShowCreateSkillModal, + ShowEditSkillModal, ShowCreateSkillGroupModal, ShowEditSkillGroupModal); + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + Logger.LogInformation( + "CharacterPanel.OnAfterRenderAsync firstRender={FirstRender} loading={IsCampaignDataLoading} selectedCampaign={SelectedCampaignId} selectedCharacter={SelectedCharacterId} filter={SkillFilterText}", + firstRender, IsCampaignDataLoading, SelectedCampaign?.Id, SelectedCharacterId, SkillFilterText); + return Task.CompletedTask; + } + private void OpenCreateSkillModal(Guid? skillGroupId = null) { var selectedGroup = skillGroupId.HasValue @@ -351,6 +369,7 @@ public partial class CharacterPanel private string SkillFilterText { get; set; } = string.Empty; [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!; + [Inject] private ILogger Logger { get; set; } = null!; [Parameter] public bool IsCampaignDataLoading { get; set; } diff --git a/RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs index 6224d8d..a0b9bcb 100644 --- a/RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/RolemasterSkillRollModal.razor.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging; namespace RpgRoller.Components.Pages.HomeControls; @@ -9,6 +10,9 @@ public partial class RolemasterSkillRollModal { protected override void OnParametersSet() { + Logger.LogInformation( + "RolemasterSkillRollModal.OnParametersSet visible={Visible} wasVisible={WasVisible} pendingFocus={PendingFocus} skillName={SkillName} isMutating={IsMutating} isSubmitting={IsSubmitting}", + Visible, WasVisible, PendingFocus, SkillName, IsMutating, IsSubmitting); CurrentModifierText = ModifierText; if (!Visible || WasVisible) { @@ -22,6 +26,9 @@ public partial class RolemasterSkillRollModal protected override async Task OnAfterRenderAsync(bool firstRender) { + Logger.LogInformation( + "RolemasterSkillRollModal.OnAfterRenderAsync firstRender={FirstRender} visible={Visible} pendingFocus={PendingFocus} skillName={SkillName}", + firstRender, Visible, PendingFocus, SkillName); if (!Visible || !PendingFocus) return; @@ -60,37 +67,27 @@ public partial class RolemasterSkillRollModal private bool WasVisible { get; set; } private string CurrentModifierText { get; set; } = string.Empty; private ElementReference ModifierInputElement { get; set; } + [Inject] private ILogger Logger { get; set; } = null!; - [Parameter] - public bool Visible { get; set; } + [Parameter] public bool Visible { get; set; } - [Parameter] - public string SkillName { get; set; } = string.Empty; + [Parameter] public string SkillName { get; set; } = string.Empty; - [Parameter] - public string Expression { get; set; } = string.Empty; + [Parameter] public string Expression { get; set; } = string.Empty; - [Parameter] - public string ModifierText { get; set; } = string.Empty; + [Parameter] public string ModifierText { get; set; } = string.Empty; - [Parameter] - public EventCallback ModifierTextChanged { get; set; } + [Parameter] public EventCallback ModifierTextChanged { get; set; } - [Parameter] - public string? ErrorMessage { get; set; } + [Parameter] public string? ErrorMessage { get; set; } - [Parameter] - public bool IsMutating { get; set; } + [Parameter] public bool IsMutating { get; set; } - [Parameter] - public bool IsSubmitting { get; set; } + [Parameter] public bool IsSubmitting { get; set; } - [Parameter] - public string ModifierInputId { get; set; } = "rolemaster-situational-modifier"; + [Parameter] public string ModifierInputId { get; set; } = "rolemaster-situational-modifier"; - [Parameter] - public EventCallback ConfirmRequested { get; set; } + [Parameter] public EventCallback ConfirmRequested { get; set; } - [Parameter] - public EventCallback CancelRequested { get; set; } + [Parameter] public EventCallback CancelRequested { get; set; } } \ No newline at end of file diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs index b394f09..52e9327 100644 --- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; using RpgRoller.Contracts; namespace RpgRoller.Components.Pages.HomeControls; @@ -9,6 +10,9 @@ public partial class SkillFormModal { protected override void OnParametersSet() { + Logger.LogInformation( + "SkillFormModal.OnParametersSet visible={Visible} formVersion={FormVersion} appliedFormVersion={AppliedFormVersion} rulesetId={RulesetId} selectedCharacterId={SelectedCharacterId} editingSkillId={EditingSkillId} autoFocusName={AutoFocusName}", + Visible, FormVersion, AppliedFormVersion, RulesetId, SelectedCharacterId, EditingSkillId, AutoFocusName); if (!Visible || FormVersion == AppliedFormVersion) return; @@ -28,6 +32,9 @@ public partial class SkillFormModal protected override async Task OnAfterRenderAsync(bool firstRender) { + Logger.LogInformation( + "SkillFormModal.OnAfterRenderAsync firstRender={FirstRender} visible={Visible} pendingNameFocus={PendingNameFocus} formVersion={FormVersion}", + firstRender, Visible, PendingNameFocus, FormVersion); if (!Visible || !PendingNameFocus) return; @@ -85,7 +92,10 @@ public partial class SkillFormModal { SkillSummary skill; if (EditingSkillId.HasValue) - skill = await ApiClient.RequestAsync("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry)); + skill = await ApiClient.RequestAsync("PUT", $"/api/skills/{EditingSkillId.Value}", + new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), + FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, + FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry)); else { if (!SelectedCharacterId.HasValue) @@ -94,7 +104,11 @@ public partial class SkillFormModal return; } - skill = await ApiClient.RequestAsync("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry)); + skill = await ApiClient.RequestAsync("POST", + $"/api/characters/{SelectedCharacterId.Value}/skills", + new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), + FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, + FormState.Model.FumbleRange, FormState.Model.RolemasterAutoRetry)); } await SkillSaved.InvokeAsync(skill.Id); @@ -147,12 +161,16 @@ public partial class SkillFormModal private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId); private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(RulesetId); - private bool IsRolemasterOpenEndedSelected => RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition); - private string ExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the dice expression used for this skill."; + private bool IsRolemasterOpenEndedSelected => + RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition); - [Inject] - private RpgRollerApiClient ApiClient { get; set; } = null!; + private string ExpressionHelpText => IsRolemasterRuleset + ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." + : "Enter the dice expression used for this skill."; + + [Inject] private RpgRollerApiClient ApiClient { get; set; } = null!; + [Inject] private ILogger Logger { get; set; } = null!; private FormState FormState { get; } = new(); private int AppliedFormVersion { get; set; } = -1; @@ -160,60 +178,41 @@ public partial class SkillFormModal private bool PendingNameFocus { get; set; } private ElementReference NameInputElement { get; set; } - [Parameter] - public bool Visible { get; set; } + [Parameter] public bool Visible { get; set; } - [Parameter] - public string RulesetId { get; set; } = string.Empty; + [Parameter] public string RulesetId { get; set; } = string.Empty; - [Parameter] - public string Title { get; set; } = "Skill"; + [Parameter] public string Title { get; set; } = "Skill"; - [Parameter] - public string SubmitLabel { get; set; } = "Save"; + [Parameter] public string SubmitLabel { get; set; } = "Save"; - [Parameter] - public string NameInputId { get; set; } = "skill-name"; + [Parameter] public string NameInputId { get; set; } = "skill-name"; - [Parameter] - public string ExpressionInputId { get; set; } = "skill-expression"; + [Parameter] public string ExpressionInputId { get; set; } = "skill-expression"; - [Parameter] - public string SkillGroupInputId { get; set; } = "skill-group"; + [Parameter] public string SkillGroupInputId { get; set; } = "skill-group"; - [Parameter] - public string WildDiceInputId { get; set; } = "skill-wild"; + [Parameter] public string WildDiceInputId { get; set; } = "skill-wild"; - [Parameter] - public string AllowFumbleInputId { get; set; } = "skill-fumble"; + [Parameter] public string AllowFumbleInputId { get; set; } = "skill-fumble"; - [Parameter] - public string FumbleRangeInputId { get; set; } = "skill-fumble-range"; + [Parameter] public string FumbleRangeInputId { get; set; } = "skill-fumble-range"; - [Parameter] - public SkillFormModel InitialModel { get; set; } = new(); + [Parameter] public SkillFormModel InitialModel { get; set; } = new(); - [Parameter] - public int FormVersion { get; set; } + [Parameter] public int FormVersion { get; set; } - [Parameter] - public Guid? SelectedCharacterId { get; set; } + [Parameter] public Guid? SelectedCharacterId { get; set; } - [Parameter] - public Guid? EditingSkillId { get; set; } + [Parameter] public Guid? EditingSkillId { get; set; } - [Parameter] - public IReadOnlyList AvailableSkillGroups { get; set; } = []; + [Parameter] public IReadOnlyList AvailableSkillGroups { get; set; } = []; - [Parameter] - public bool IsMutating { get; set; } + [Parameter] public bool IsMutating { get; set; } - [Parameter] - public bool AutoFocusName { get; set; } + [Parameter] public bool AutoFocusName { get; set; } - [Parameter] - public EventCallback SkillSaved { get; set; } + [Parameter] public EventCallback SkillSaved { get; set; } - [Parameter] - public EventCallback CancelRequested { get; set; } + [Parameter] public EventCallback CancelRequested { get; set; } } \ No newline at end of file diff --git a/RpgRoller/Components/Pages/PlayWorkspaceContent.razor b/RpgRoller/Components/Pages/PlayWorkspaceContent.razor index 80482a2..59c2ab5 100644 --- a/RpgRoller/Components/Pages/PlayWorkspaceContent.razor +++ b/RpgRoller/Components/Pages/PlayWorkspaceContent.razor @@ -71,7 +71,5 @@ CancelRequested="Workspace.Play.CancelRolemasterSkillRollAsync"/> @code { - [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; - private bool IsCampaignDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsCampaignDataLoading; } diff --git a/RpgRoller/Components/Pages/PlayWorkspaceContent.razor.cs b/RpgRoller/Components/Pages/PlayWorkspaceContent.razor.cs new file mode 100644 index 0000000..95bf5b7 --- /dev/null +++ b/RpgRoller/Components/Pages/PlayWorkspaceContent.razor.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public partial class PlayWorkspaceContent +{ + protected override void OnParametersSet() + { + Logger.LogInformation("PlayWorkspaceContent.OnParametersSet [{State}]", + WorkspaceDiagnosticSummary.DescribePlaySurface(Workspace)); + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + Logger.LogInformation("PlayWorkspaceContent.OnAfterRenderAsync firstRender={FirstRender} [{State}]", + firstRender, WorkspaceDiagnosticSummary.DescribePlaySurface(Workspace)); + return Task.CompletedTask; + } + + [Inject] private ILogger Logger { get; set; } = null!; + [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 0065a4c..ef4d428 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -1,5 +1,11 @@ @using RpgRoller.Components.Pages.HomeControls -
+

@State.LiveAnnouncement

@if (State.HasHealthIssue) diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index acbc12f..afdd107 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; using Microsoft.JSInterop; using RpgRoller.Components.Pages.HomeControls; using RpgRoller.Contracts; @@ -9,8 +10,16 @@ namespace RpgRoller.Components.Pages; [ExcludeFromCodeCoverage] public partial class Workspace : IAsyncDisposable { + protected override void OnInitialized() + { + Logger.LogInformation("Workspace.OnInitialized route={Route}", Route); + } + protected override void OnParametersSet() { + Logger.LogInformation( + "Workspace.OnParametersSet route={Route} previousRoute={PreviousRoute} hasSessionInitialized={HasSessionInitialized} state=[{State}]", + Route, PreviousRoute, HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(State)); State.IsScreenMenuOpen = false; if (PreviousRoute.HasValue && PreviousRoute.Value != Route && HasSessionInitialized) _ = InvokeAsync(HandleRouteChangedAsync); @@ -18,20 +27,34 @@ public partial class Workspace : IAsyncDisposable PreviousRoute = Route; } + protected override Task OnAfterRenderAsync(bool firstRender) + { + RenderCount += 1; + Logger.LogInformation( + "Workspace.OnAfterRenderAsync route={Route} renderCount={RenderCount} firstRender={FirstRender} hasSessionInitialized={HasSessionInitialized} state=[{State}]", + Route, RenderCount, firstRender, HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(State)); + return Task.CompletedTask; + } + [JSInvokable] public Task OnStateEventReceived(CampaignStateSnapshot state) { + Logger.LogInformation("Workspace.OnStateEventReceived route={Route} snapshot=[{Snapshot}]", + Route, WorkspaceDiagnosticSummary.DescribeSnapshot(state)); return Live.OnStateEventReceivedAsync(state); } [JSInvokable] public Task OnConnectionStateChanged(string state) { + Logger.LogInformation("Workspace.OnConnectionStateChanged route={Route} state={ConnectionState}", Route, state); return Live.OnConnectionStateChangedAsync(state); } public async ValueTask DisposeAsync() { + Logger.LogInformation("Workspace.DisposeAsync route={Route} state=[{State}]", + Route, WorkspaceDiagnosticSummary.DescribeState(State)); await StopStateEventsAsync(); DotNetRef?.Dispose(); } @@ -58,12 +81,15 @@ public partial class Workspace : IAsyncDisposable private async Task StartStateEventsCoreAsync(Guid campaignId) { + Logger.LogInformation("Workspace.StartStateEventsCoreAsync route={Route} campaignId={CampaignId}", Route, + campaignId); DotNetRef ??= DotNetObjectReference.Create(this); await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", campaignId.ToString(), DotNetRef); } private async Task StopStateEventsCoreAsync() { + Logger.LogInformation("Workspace.StopStateEventsCoreAsync route={Route}", Route); try { await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents"); @@ -83,6 +109,8 @@ public partial class Workspace : IAsyncDisposable private Task NavigateToRouteAsync(string route) { + Logger.LogInformation("Workspace.NavigateToRouteAsync fromRoute={Route} toRoute={TargetRoute} state=[{State}]", + Route, route, WorkspaceDiagnosticSummary.DescribeState(State)); State.IsScreenMenuOpen = false; Navigation.NavigateTo(route); return InvokeAsync(StateHasChanged); @@ -93,13 +121,27 @@ public partial class Workspace : IAsyncDisposable if (IsPlayRoute) return Task.CompletedTask; + Logger.LogWarning("Workspace.RedirectToPlayAsync fromRoute={Route} state=[{State}]", + Route, WorkspaceDiagnosticSummary.DescribeState(State)); Navigation.NavigateTo("/play"); return Task.CompletedTask; } private Task RequestRefreshAsync() { - return InvokeAsync(StateHasChanged); + return RequestRefreshAsync("Workspace"); + } + + private Task RequestRefreshAsync(string source) + { + Logger.LogInformation("Workspace.RequestRefreshAsync source={Source} route={Route} state=[{State}]", + source, Route, WorkspaceDiagnosticSummary.DescribeState(State)); + return InvokeAsync(() => + { + Logger.LogInformation("Workspace.StateHasChanged source={Source} route={Route} state=[{State}]", + source, Route, WorkspaceDiagnosticSummary.DescribeState(State)); + StateHasChanged(); + }); } private Task InitializeRouteAsync() @@ -110,11 +152,20 @@ public partial class Workspace : IAsyncDisposable private async Task InitializeRouteCoreAsync() { if (HasSessionInitialized) + { + Logger.LogInformation( + "Workspace.InitializeRouteCoreAsync skipped route={Route} alreadyInitialized state=[{State}]", + Route, WorkspaceDiagnosticSummary.DescribeState(State)); return; + } + Logger.LogInformation("Workspace.InitializeRouteCoreAsync start route={Route} stateBefore=[{State}]", + Route, WorkspaceDiagnosticSummary.DescribeState(State)); State.HasInteractiveRenderStarted = true; await Session.InitializeAsync(); HasSessionInitialized = true; + Logger.LogInformation("Workspace.InitializeRouteCoreAsync end route={Route} stateAfter=[{State}]", + Route, WorkspaceDiagnosticSummary.DescribeState(State)); await RequestRefreshAsync(); } @@ -123,16 +174,23 @@ public partial class Workspace : IAsyncDisposable if (!HasSessionInitialized) return; + Logger.LogInformation( + "Workspace.HandleRouteChangedAsync start route={Route} previousRoute={PreviousRoute} stateBefore=[{State}]", + Route, PreviousRoute, WorkspaceDiagnosticSummary.DescribeState(State)); if (IsAdminRoute) { await Live.SyncStateEventsAsync(); await EnsureAdminUsersLoadedAsync(); + Logger.LogInformation("Workspace.HandleRouteChangedAsync admin end route={Route} stateAfter=[{State}]", + Route, WorkspaceDiagnosticSummary.DescribeState(State)); await RequestRefreshAsync(); return; } await Scope.RefreshCampaignScopeAsync(); await Live.SyncStateEventsAsync(); + Logger.LogInformation("Workspace.HandleRouteChangedAsync end route={Route} stateAfter=[{State}]", + Route, WorkspaceDiagnosticSummary.DescribeState(State)); await RequestRefreshAsync(); } @@ -148,6 +206,8 @@ public partial class Workspace : IAsyncDisposable [Inject] private WorkspaceQueryService WorkspaceQuery { get; set; } = null!; [Inject] private NavigationManager Navigation { get; set; } = null!; + [Inject] private ILogger Logger { get; set; } = null!; + [Inject] private ILoggerFactory LoggerFactory { get; set; } = null!; [Parameter] public EventCallback LoggedOut { get; set; } [Parameter] public WorkspaceRoute Route { get; set; } = WorkspaceRoute.Play; @@ -170,16 +230,18 @@ public partial class Workspace : IAsyncDisposable () => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking, ClearAuthenticatedState, - StopStateEventsAsync, message => LoggedOut.InvokeAsync(message)); + StopStateEventsAsync, message => LoggedOut.InvokeAsync(message), + LoggerFactory.CreateLogger("Workspace.CampaignScope")); private WorkspaceLiveStateController Live => m_Live ??= new(State, Feedback, () => IsPlayRoute, () => IsAdminRoute, StartStateEventsCoreAsync, StopStateEventsCoreAsync, Scope.RefreshCampaignRosterAsync, Play.RefreshSelectedCharacterSheetAsync, - Play.RefreshCampaignLogAsync, () => InvokeAsync(StateHasChanged)); + Play.RefreshCampaignLogAsync, () => RequestRefreshAsync("WorkspaceLiveStateController"), + LoggerFactory.CreateLogger("Workspace.LiveState")); private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, () => IsPlayRoute, ApiClient, WorkspaceQuery, - CanEditCharacter, () => InvokeAsync(StateHasChanged)); + CanEditCharacter, () => RequestRefreshAsync("WorkspacePlayCoordinator")); private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient, Session.LoadKnownUsernamesAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, @@ -188,13 +250,15 @@ public partial class Workspace : IAsyncDisposable private WorkspaceAdminCoordinator Admin => m_Admin ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message)); - private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged)); + private WorkspaceFeedbackService Feedback => + m_Feedback ??= new(State, () => RequestRefreshAsync("WorkspaceFeedbackService")); private WorkspaceSessionCoordinator Session => m_Session ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, () => IsAdminRoute, RedirectToPlayAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync, Live.StopStateEventsAsync, EnsureAdminUsersLoadedAsync, - Play.ResetCampaignLogDetailState, message => LoggedOut.InvokeAsync(message)); + Play.ResetCampaignLogDetailState, message => LoggedOut.InvokeAsync(message), + LoggerFactory.CreateLogger("Workspace.Session")); private IReadOnlyList HeaderMenuItems { @@ -243,4 +307,5 @@ public partial class Workspace : IAsyncDisposable private WorkspaceSessionCoordinator? m_Session; private Task? InitializationTask { get; set; } private WorkspaceRoute? PreviousRoute { get; set; } -} + private int RenderCount { get; set; } +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs b/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs index 322230f..c0311cc 100644 --- a/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs +++ b/RpgRoller/Components/Pages/WorkspaceCampaignScopeCoordinator.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; using Microsoft.JSInterop; namespace RpgRoller.Components.Pages; @@ -17,10 +18,13 @@ public sealed class WorkspaceCampaignScopeCoordinator( Action resetCampaignStateTracking, Action clearAuthenticatedState, Func stopStateEventsAsync, - Func onLoggedOutAsync) + Func onLoggedOutAsync, + ILogger logger) { public async Task ReloadCampaignsAsync(Guid? preferredCampaignId) { + logger.LogInformation("WorkspaceCampaignScopeCoordinator.ReloadCampaignsAsync start preferredCampaignId={PreferredCampaignId}", + preferredCampaignId); var campaigns = await workspaceQuery.GetCampaignsAsync(); state.Campaigns = campaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); @@ -39,21 +43,29 @@ public sealed class WorkspaceCampaignScopeCoordinator( await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, state.SelectedCampaignId?.ToString()); + logger.LogInformation("WorkspaceCampaignScopeCoordinator.ReloadCampaignsAsync end selectedCampaignId={SelectedCampaignId} campaignCount={CampaignCount}", + state.SelectedCampaignId, state.Campaigns.Count); } public async Task ReloadCharacterCampaignOptionsAsync() { + logger.LogInformation("WorkspaceCampaignScopeCoordinator.ReloadCharacterCampaignOptionsAsync start"); var campaignOptions = await workspaceQuery.GetCharacterCampaignOptionsAsync(); state.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); + logger.LogInformation("WorkspaceCampaignScopeCoordinator.ReloadCharacterCampaignOptionsAsync end optionCount={OptionCount}", + state.CharacterCampaignOptions.Count); } public async Task RefreshCampaignRosterAsync() { + logger.LogInformation("WorkspaceCampaignScopeCoordinator.RefreshCampaignRosterAsync start selectedCampaignId={SelectedCampaignId}", + state.SelectedCampaignId); if (!state.SelectedCampaignId.HasValue) { state.SelectedCampaign = null; state.SelectedCharacterId = null; + logger.LogInformation("WorkspaceCampaignScopeCoordinator.RefreshCampaignRosterAsync no selected campaign"); return; } @@ -65,10 +77,15 @@ public sealed class WorkspaceCampaignScopeCoordinator( state.SelectedCharacterId = state.PlaySelectedCharacterId; await ensureSelectedCharacterActiveAsync(); + logger.LogInformation( + "WorkspaceCampaignScopeCoordinator.RefreshCampaignRosterAsync end selectedCampaign={SelectedCampaignId} selectedCharacterId={SelectedCharacterId} rosterCharacters={CharacterCount}", + state.SelectedCampaign?.Id, state.SelectedCharacterId, state.SelectedCampaign?.Characters.Length ?? 0); } public async Task RefreshCampaignScopeAsync() { + logger.LogInformation("WorkspaceCampaignScopeCoordinator.RefreshCampaignScopeAsync start isPlayRoute={IsPlayRoute} stateBefore=[{State}]", + isPlayRoute(), WorkspaceDiagnosticSummary.DescribeState(state)); if (!state.SelectedCampaignId.HasValue) { state.SelectedCampaign = null; @@ -80,6 +97,7 @@ public sealed class WorkspaceCampaignScopeCoordinator( state.CurrentCampaignState = null; state.CampaignLogCursor = null; resetCampaignLogDetailState(); + logger.LogInformation("WorkspaceCampaignScopeCoordinator.RefreshCampaignScopeAsync cleared empty scope"); return; } @@ -106,22 +124,28 @@ public sealed class WorkspaceCampaignScopeCoordinator( } catch (ApiRequestException ex) when (ex.StatusCode == 401) { + logger.LogWarning(ex, "WorkspaceCampaignScopeCoordinator.RefreshCampaignScopeAsync unauthorized"); clearAuthenticatedState(); await stopStateEventsAsync(); await onLoggedOutAsync("Session expired. Please log in again."); } catch (ApiRequestException ex) { + logger.LogError(ex, "WorkspaceCampaignScopeCoordinator.RefreshCampaignScopeAsync failed"); feedback.SetStatus(ex.Message, true); } finally { state.IsCampaignDataLoading = false; + logger.LogInformation("WorkspaceCampaignScopeCoordinator.RefreshCampaignScopeAsync end stateAfter=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); } } public async Task SetMobilePanelAsync(string panel) { + logger.LogInformation("WorkspaceCampaignScopeCoordinator.SetMobilePanelAsync old={OldPanel} new={NewPanel}", + state.MobilePanel, panel); state.MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character"; await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, state.MobilePanel); } diff --git a/RpgRoller/Components/Pages/WorkspaceDiagnosticSummary.cs b/RpgRoller/Components/Pages/WorkspaceDiagnosticSummary.cs new file mode 100644 index 0000000..38d2e5d --- /dev/null +++ b/RpgRoller/Components/Pages/WorkspaceDiagnosticSummary.cs @@ -0,0 +1,40 @@ +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +internal static class WorkspaceDiagnosticSummary +{ + public static string DescribeState(WorkspaceState state) + { + var user = state.User?.Username ?? ""; + var selectedCampaign = state.SelectedCampaignId?.ToString() ?? ""; + var selectedCharacter = state.SelectedCharacterId?.ToString() ?? ""; + return + $"user={user}, selectedCampaign={selectedCampaign}, selectedCharacter={selectedCharacter}, campaigns={state.Campaigns.Count}, adminUsers={state.AdminUsers.Count}, skills={state.SelectedCharacterSkills.Count}, skillGroups={state.SelectedCharacterSkillGroups.Count}, logEntries={state.CampaignLog.Count}, isCampaignLoading={state.IsCampaignDataLoading}, isAdminLoading={state.IsAdminDataLoading}, connection={state.ConnectionState}, mobilePanel={state.MobilePanel}, hasHealthIssue={state.HasHealthIssue}"; + } + + public static string DescribeSnapshot(CampaignStateSnapshot snapshot) + { + return + $"campaignId={snapshot.CampaignId}, totalVersion={snapshot.TotalVersion}, rosterVersion={snapshot.RosterVersion}, logVersion={snapshot.LogVersion}, characterVersions={snapshot.CharacterVersions.Count}"; + } + + public static string DescribePlaySurface(WorkspacePageContext workspace) + { + var playCampaign = workspace.State.PlaySelectedCampaign; + return + $"hasSessionInitialized={workspace.HasSessionInitialized}, selectedCampaign={workspace.State.SelectedCampaignId?.ToString() ?? ""}, playCampaignCharacters={playCampaign?.Characters.Length ?? 0}, playSelectedCharacter={workspace.State.PlaySelectedCharacterId?.ToString() ?? ""}, playSkills={workspace.State.PlaySelectedCharacterSkills.Count}, playSkillGroups={workspace.State.PlaySelectedCharacterSkillGroups.Count}, playLog={workspace.State.PlayVisibleCampaignLog.Count}, mobilePanel={workspace.State.MobilePanel}, isCampaignLoading={workspace.State.IsCampaignDataLoading}, showRolemasterModal={workspace.State.ShowRolemasterSkillRollModal}, showCreateCharacterModal={workspace.State.ShowCreateCharacterModal}, showEditCharacterModal={workspace.State.ShowEditCharacterModal}"; + } + + public static string DescribeCampaignsSurface(WorkspacePageContext workspace) + { + return + $"hasSessionInitialized={workspace.HasSessionInitialized}, selectedCampaign={workspace.State.SelectedCampaignId?.ToString() ?? ""}, selectedCampaignName={workspace.State.SelectedCampaignName ?? ""}, campaigns={workspace.State.Campaigns.Count}, rulesets={workspace.State.Rulesets.Count}, selectedRosterCharacters={workspace.State.SelectedCampaign?.Characters.Length ?? 0}, characterCampaignOptions={workspace.State.CharacterCampaignOptions.Count}, isCampaignLoading={workspace.State.IsCampaignDataLoading}, showCreateCharacterModal={workspace.State.ShowCreateCharacterModal}, showEditCharacterModal={workspace.State.ShowEditCharacterModal}"; + } + + public static string DescribeAdminSurface(WorkspacePageContext workspace) + { + return + $"hasSessionInitialized={workspace.HasSessionInitialized}, currentUser={(workspace.State.User?.Username ?? "")}, isCurrentUserAdmin={workspace.State.IsCurrentUserAdmin}, adminUsers={workspace.State.AdminUsers.Count}, hasLoadedAdminUsers={workspace.State.HasLoadedAdminUsers}, isAdminLoading={workspace.State.IsAdminDataLoading}, isMutating={workspace.State.IsMutating}"; + } +} \ No newline at end of file diff --git a/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs b/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs index 444404b..dbf8ce4 100644 --- a/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs +++ b/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; using RpgRoller.Contracts; namespace RpgRoller.Components.Pages; @@ -14,10 +15,13 @@ public sealed class WorkspaceLiveStateController( Func refreshCampaignRosterAsync, Func refreshSelectedCharacterSheetAsync, Func refreshCampaignLogAsync, - Func requestRefreshAsync) + Func requestRefreshAsync, + ILogger logger) { public async Task OnStateEventReceivedAsync(CampaignStateSnapshot state1) { + logger.LogInformation("WorkspaceLiveStateController.OnStateEventReceivedAsync start snapshot=[{Snapshot}] stateBefore=[{State}]", + WorkspaceDiagnosticSummary.DescribeSnapshot(state1), WorkspaceDiagnosticSummary.DescribeState(state)); if (state.StateRefreshInProgress) return; @@ -58,12 +62,15 @@ public sealed class WorkspaceLiveStateController( finally { state.StateRefreshInProgress = false; + logger.LogInformation("WorkspaceLiveStateController.OnStateEventReceivedAsync end stateAfter=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); await requestRefreshAsync(); } } public async Task OnConnectionStateChangedAsync(string state1) { + logger.LogInformation("WorkspaceLiveStateController.OnConnectionStateChangedAsync newState={ConnectionState}", state1); state.ConnectionState = state1 switch { "connected" => "connected", @@ -82,15 +89,20 @@ public sealed class WorkspaceLiveStateController( public async Task SyncStateEventsAsync() { + logger.LogInformation("WorkspaceLiveStateController.SyncStateEventsAsync start isPlayRoute={IsPlayRoute} isAdminRoute={IsAdminRoute} state=[{State}]", + isPlayRoute(), isAdminRoute(), WorkspaceDiagnosticSummary.DescribeState(state)); if (state.User is null || !state.SelectedCampaignId.HasValue || isAdminRoute() || !isPlayRoute()) { await StopStateEventsAsync(); state.ConnectionState = "offline"; + logger.LogInformation("WorkspaceLiveStateController.SyncStateEventsAsync disabled"); return; } await startStateEventsAsync(state.SelectedCampaignId.Value); state.ConnectionState = "reconnecting"; + logger.LogInformation("WorkspaceLiveStateController.SyncStateEventsAsync started campaignId={CampaignId}", + state.SelectedCampaignId.Value); } public async Task StopStateEventsAsync() @@ -98,6 +110,7 @@ public sealed class WorkspaceLiveStateController( if (!state.HasInteractiveRenderStarted) return; + logger.LogInformation("WorkspaceLiveStateController.StopStateEventsAsync"); await stopStateEventsCoreAsync(); } diff --git a/RpgRoller/Components/Pages/WorkspaceRouteView.razor b/RpgRoller/Components/Pages/WorkspaceRouteView.razor index 25a7d63..6eb31c0 100644 --- a/RpgRoller/Components/Pages/WorkspaceRouteView.razor +++ b/RpgRoller/Components/Pages/WorkspaceRouteView.razor @@ -1,18 +1,60 @@ @using Microsoft.AspNetCore.Components +@using Microsoft.JSInterop @ChildContent(Workspace) @code { protected override async Task OnAfterRenderAsync(bool firstRender) { + Logger.LogInformation( + "WorkspaceRouteView.OnAfterRenderAsync routeFlags=play:{IsPlayRoute} campaigns:{IsCampaignsRoute} admin:{IsAdminRoute} firstRender={FirstRender} hasSessionInitialized={HasSessionInitialized} state=[{State}]", + Workspace.IsPlayRoute, Workspace.IsCampaignsRoute, Workspace.IsAdminRoute, firstRender, + Workspace.HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(Workspace.State)); + await TryMarkWorkspacePhaseAsync(firstRender ? "after-first-render" : "after-render"); if (!firstRender) return; + await TryInstallWorkspaceDiagnosticsAsync(); await Workspace.InitializeRouteAsync(); + Logger.LogInformation("WorkspaceRouteView.OnAfterRenderAsync initialized state=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(Workspace.State)); + await TryMarkWorkspacePhaseAsync("after-initialize-route"); await InvokeAsync(StateHasChanged); } + [Inject] private ILogger Logger { get; set; } = null!; + [Inject] private IJSRuntime JS { get; set; } = null!; [Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!; [Parameter, EditorRequired] public RenderFragment ChildContent { get; set; } = null!; + + private async Task TryInstallWorkspaceDiagnosticsAsync() + { + try + { + await JS.InvokeVoidAsync("rpgRollerApi.installWorkspaceDiagnostics", WorkspaceRouteName); + } + catch (JSDisconnectedException) + { + } + catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase)) + { + } + } + + private async Task TryMarkWorkspacePhaseAsync(string phase) + { + try + { + await JS.InvokeVoidAsync("rpgRollerApi.markWorkspacePhase", $"{WorkspaceRouteName}:{phase}"); + } + catch (JSDisconnectedException) + { + } + catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase)) + { + } + } + + private string WorkspaceRouteName => Workspace.IsPlayRoute ? "play" : Workspace.IsCampaignsRoute ? "campaigns" : "admin"; } diff --git a/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs b/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs index 5a75bea..ec864e5 100644 --- a/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs +++ b/RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs @@ -1,4 +1,5 @@ using Microsoft.JSInterop; +using Microsoft.Extensions.Logging; using RpgRoller.Contracts; namespace RpgRoller.Components.Pages; @@ -18,10 +19,13 @@ public sealed class WorkspaceSessionCoordinator( Func stopStateEventsAsync, Func ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, - Func onLoggedOutAsync) + Func onLoggedOutAsync, + ILogger logger) { public async Task InitializeAsync() { + logger.LogInformation("WorkspaceSessionCoordinator.InitializeAsync start isAdminRoute={IsAdminRoute} state=[{State}]", + isAdminRoute(), WorkspaceDiagnosticSummary.DescribeState(state)); var storedPanel = await js.InvokeAsync("rpgRollerApi.getSessionValue", MobilePanelSessionKey); if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) state.MobilePanel = "log"; @@ -38,19 +42,29 @@ public sealed class WorkspaceSessionCoordinator( preferredCampaignId = parsedCampaignId; } + logger.LogInformation( + "WorkspaceSessionCoordinator.InitializeAsync sessionValues panel={Panel} rollVisibility={RollVisibility} preferredCampaignId={PreferredCampaignId}", + state.MobilePanel, state.RollVisibility, preferredCampaignId); + await CheckHealthAsync(); var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId); + logger.LogInformation("WorkspaceSessionCoordinator.InitializeAsync end reloaded={Reloaded} state=[{State}]", + reloaded, WorkspaceDiagnosticSummary.DescribeState(state)); if (!reloaded) await onLoggedOutAsync("Session expired. Please log in again."); } public async Task RetryAfterHealthIssueAsync() { + logger.LogWarning("WorkspaceSessionCoordinator.RetryAfterHealthIssueAsync start state=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); await CheckHealthAsync(); if (!state.HasHealthIssue && state.User is not null) { var reloaded = await ReloadAuthenticatedSessionAsync(state.SelectedCampaignId); + logger.LogInformation("WorkspaceSessionCoordinator.RetryAfterHealthIssueAsync reloaded={Reloaded} state=[{State}]", + reloaded, WorkspaceDiagnosticSummary.DescribeState(state)); if (!reloaded) await onLoggedOutAsync("Session expired. Please log in again."); } @@ -72,6 +86,8 @@ public sealed class WorkspaceSessionCoordinator( public async Task LogoutAsync() { + logger.LogWarning("WorkspaceSessionCoordinator.LogoutAsync start state=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); if (state.IsMutating) return; @@ -95,12 +111,16 @@ public sealed class WorkspaceSessionCoordinator( public async Task OnRollVisibilityChangedAsync(string visibility) { + logger.LogInformation("WorkspaceSessionCoordinator.OnRollVisibilityChangedAsync old={OldVisibility} new={NewVisibility}", + state.RollVisibility, visibility); state.RollVisibility = NormalizeRollVisibility(visibility); await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, state.RollVisibility); } public void ClearAuthenticatedState() { + logger.LogWarning("WorkspaceSessionCoordinator.ClearAuthenticatedState stateBefore=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); state.User = null; state.ActiveCharacterId = null; state.SelectedCampaignId = null; @@ -126,6 +146,8 @@ public sealed class WorkspaceSessionCoordinator( state.HasLoadedAdminUsers = false; state.IsAdminDataLoading = false; feedback.ClearToasts(); + logger.LogWarning("WorkspaceSessionCoordinator.ClearAuthenticatedState stateAfter=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); } private async Task CheckHealthAsync() @@ -140,18 +162,25 @@ public sealed class WorkspaceSessionCoordinator( try { state.Rulesets = (await workspaceQuery.GetRulesetsAsync()).ToList(); + logger.LogInformation("WorkspaceSessionCoordinator.LoadRulesetsAsync loadedRulesets={RulesetCount}", + state.Rulesets.Count); } catch (ApiRequestException ex) { + logger.LogError(ex, "WorkspaceSessionCoordinator.LoadRulesetsAsync failed"); feedback.SetStatus(ex.Message, true); } } private async Task ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId) { + logger.LogInformation( + "WorkspaceSessionCoordinator.ReloadAuthenticatedSessionAsync start preferredCampaignId={PreferredCampaignId} isAdminRoute={IsAdminRoute}", + preferredCampaignId, isAdminRoute()); var me = await TryGetMeAsync(); if (me is null) { + logger.LogWarning("WorkspaceSessionCoordinator.ReloadAuthenticatedSessionAsync unauthorized"); ClearAuthenticatedState(); await stopStateEventsAsync(); return false; @@ -159,22 +188,36 @@ public sealed class WorkspaceSessionCoordinator( state.User = me.User; state.ActiveCharacterId = me.ActiveCharacterId; + logger.LogInformation( + "WorkspaceSessionCoordinator.ReloadAuthenticatedSessionAsync me user={User} activeCharacterId={ActiveCharacterId} currentCampaignId={CurrentCampaignId}", + me.User.Username, me.ActiveCharacterId, me.CurrentCampaignId); if (!await EnsureRouteAccessAsync()) return true; if (isAdminRoute()) { + logger.LogInformation("WorkspaceSessionCoordinator.ReloadAuthenticatedSessionAsync adminRoute path"); await stopStateEventsAsync(); state.ConnectionState = "offline"; await ensureAdminUsersLoadedAsync(); + logger.LogInformation("WorkspaceSessionCoordinator.ReloadAuthenticatedSessionAsync adminRoute end state=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); return true; } await LoadRulesetsAsync(); await reloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); + logger.LogInformation("WorkspaceSessionCoordinator.ReloadAuthenticatedSessionAsync campaigns loaded state=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); await reloadCharacterCampaignOptionsAsync(); + logger.LogInformation("WorkspaceSessionCoordinator.ReloadAuthenticatedSessionAsync campaign options loaded count={Count}", + state.CharacterCampaignOptions.Count); await refreshCampaignScopeAsync(); + logger.LogInformation("WorkspaceSessionCoordinator.ReloadAuthenticatedSessionAsync campaign scope refreshed state=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); await syncStateEventsAsync(); + logger.LogInformation("WorkspaceSessionCoordinator.ReloadAuthenticatedSessionAsync end state=[{State}]", + WorkspaceDiagnosticSummary.DescribeState(state)); return true; } @@ -187,6 +230,7 @@ public sealed class WorkspaceSessionCoordinator( } catch (ApiRequestException ex) when (ex.StatusCode == 401) { + logger.LogWarning(ex, "WorkspaceSessionCoordinator.TryGetMeAsync unauthorized"); return null; } } @@ -200,6 +244,7 @@ public sealed class WorkspaceSessionCoordinator( state.AdminUsers = []; state.HasLoadedAdminUsers = false; + logger.LogWarning("WorkspaceSessionCoordinator.EnsureRouteAccessAsync redirecting non-admin away from admin route"); await redirectToPlayAsync(); return false; } @@ -212,4 +257,4 @@ public sealed class WorkspaceSessionCoordinator( private const string CampaignSessionKey = "campaign"; private const string MobilePanelSessionKey = "play-panel"; private const string RollVisibilitySessionKey = "roll-visibility"; -} \ No newline at end of file +} diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 445ae97..89ee928 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -6,7 +6,10 @@ using RpgRoller.Hosting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); -builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(options => +{ + options.DetailedErrors = builder.Environment.IsDevelopment(); +}); builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; diff --git a/RpgRoller/wwwroot/js/rpgroller-api.js b/RpgRoller/wwwroot/js/rpgroller-api.js index 56afe70..478c1ad 100644 --- a/RpgRoller/wwwroot/js/rpgroller-api.js +++ b/RpgRoller/wwwroot/js/rpgroller-api.js @@ -1,5 +1,6 @@ window.rpgRollerApi = (() => { const sessionPrefix = "rpgroller."; + const debugPrefix = "[rpgroller-api]"; const stateStream = { source: null, dotNetRef: null, @@ -8,6 +9,177 @@ window.rpgRollerApi = (() => { reconnectTimer: null, stopped: true }; + const workspaceDiagnostics = { + observer: null, + route: null, + globalHandlersInstalled: false, + mutationBatchCount: 0 + }; + + function debug(...args) { + console.info(debugPrefix, new Date().toISOString(), ...args); + } + + function warn(...args) { + console.warn(debugPrefix, new Date().toISOString(), ...args); + } + + function summarizeNode(node) { + if (!node) { + return ""; + } + + if (node.nodeType === Node.TEXT_NODE) { + const text = (node.textContent || "").trim().replace(/\s+/g, " "); + return text ? `#text(${text.slice(0, 40)})` : "#text"; + } + + if (!(node instanceof Element)) { + return `nodeType:${node.nodeType}`; + } + + const id = node.id ? `#${node.id}` : ""; + const classes = node.classList && node.classList.length > 0 + ? `.${Array.from(node.classList).join(".")}` + : ""; + return `${node.tagName.toLowerCase()}${id}${classes}`; + } + + function summarizeElementQuery(selector) { + const matches = Array.from(document.querySelectorAll(selector)); + return { + count: matches.length, + first: matches.length > 0 ? summarizeNode(matches[0]) : null, + firstParent: matches.length > 0 ? summarizeNode(matches[0].parentElement) : null + }; + } + + function getWorkspaceRoot() { + return document.querySelector("[data-workspace-root]"); + } + + function logWorkspaceSnapshot(label) { + const root = getWorkspaceRoot(); + const trackedSelectors = [ + ".workspace-shell", + ".workspace-header", + ".play-screen", + ".management-screen", + ".mobile-bottom-nav", + "#skill-filter-input", + "#roll-visibility", + "#custom-roll-expression", + ".modal-overlay", + ".management-list" + ]; + const tracked = Object.fromEntries(trackedSelectors.map((selector) => [selector, summarizeElementQuery(selector)])); + debug("workspace snapshot", { + label, + route: workspaceDiagnostics.route, + root: summarizeNode(root), + rootAttributes: root + ? { + route: root.getAttribute("data-workspace-route"), + sessionInitialized: root.getAttribute("data-workspace-session-initialized"), + campaignLoading: root.getAttribute("data-workspace-campaign-loading"), + adminLoading: root.getAttribute("data-workspace-admin-loading"), + user: root.getAttribute("data-workspace-user") + } + : null, + rootChildren: root ? Array.from(root.children).slice(0, 8).map(summarizeNode) : [], + activeElement: summarizeNode(document.activeElement), + tracked + }); + } + + function installGlobalDiagnostics() { + if (workspaceDiagnostics.globalHandlersInstalled) { + return; + } + + workspaceDiagnostics.globalHandlersInstalled = true; + window.addEventListener("error", (event) => { + warn("window error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + target: summarizeNode(event.target) + }); + logWorkspaceSnapshot("window-error"); + }); + window.addEventListener("unhandledrejection", (event) => { + warn("window unhandledrejection", { + reason: String(event.reason), + type: typeof event.reason + }); + logWorkspaceSnapshot("unhandledrejection"); + }); + } + + function summarizeMutation(mutation) { + return { + type: mutation.type, + target: summarizeNode(mutation.target), + attributeName: mutation.attributeName || null, + added: Array.from(mutation.addedNodes || []).slice(0, 5).map(summarizeNode), + removed: Array.from(mutation.removedNodes || []).slice(0, 5).map(summarizeNode) + }; + } + + function installWorkspaceDiagnostics(route) { + installGlobalDiagnostics(); + workspaceDiagnostics.route = route; + workspaceDiagnostics.mutationBatchCount = 0; + + if (workspaceDiagnostics.observer) { + workspaceDiagnostics.observer.disconnect(); + workspaceDiagnostics.observer = null; + } + + const root = getWorkspaceRoot(); + if (!root) { + warn("installWorkspaceDiagnostics skipped; no workspace root", { route }); + return; + } + + workspaceDiagnostics.observer = new MutationObserver((mutations) => { + workspaceDiagnostics.mutationBatchCount += 1; + debug("workspace mutations", { + route: workspaceDiagnostics.route, + mutationBatchCount: workspaceDiagnostics.mutationBatchCount, + mutations: mutations.slice(0, 20).map(summarizeMutation) + }); + logWorkspaceSnapshot(`mutation-batch-${workspaceDiagnostics.mutationBatchCount}`); + }); + workspaceDiagnostics.observer.observe(root, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: [ + "class", + "hidden", + "aria-expanded", + "data-workspace-route", + "data-workspace-session-initialized", + "data-workspace-campaign-loading", + "data-workspace-admin-loading", + "data-workspace-user" + ] + }); + + debug("installWorkspaceDiagnostics attached", { route, root: summarizeNode(root) }); + logWorkspaceSnapshot(`install:${route}`); + queueMicrotask(() => logWorkspaceSnapshot(`microtask:${route}`)); + requestAnimationFrame(() => logWorkspaceSnapshot(`raf1:${route}`)); + setTimeout(() => logWorkspaceSnapshot(`timeout25:${route}`), 25); + setTimeout(() => logWorkspaceSnapshot(`timeout100:${route}`), 100); + } + + function markWorkspacePhase(label) { + debug("workspace phase", label); + logWorkspaceSnapshot(`phase:${label}`); + } function toAppUrl(url) { if (!url || typeof url !== "string") { @@ -31,9 +203,11 @@ window.rpgRollerApi = (() => { function invokeDotNet(method, ...args) { if (!stateStream.dotNetRef) { + warn("invokeDotNet skipped; missing dotNetRef", method, args); return; } + debug("invokeDotNet", method, args); stateStream.dotNetRef.invokeMethodAsync(method, ...args).catch(() => { }); } @@ -57,25 +231,30 @@ window.rpgRollerApi = (() => { function connectStateStream() { if (stateStream.stopped || !stateStream.campaignId) { + debug("connectStateStream skipped", { stopped: stateStream.stopped, campaignId: stateStream.campaignId }); return; } clearReconnectTimer(); + debug("connectStateStream start", { campaignId: stateStream.campaignId }); invokeDotNet("OnConnectionStateChanged", "reconnecting"); const source = new EventSource(toAppUrl(`api/events/state?campaignId=${encodeURIComponent(stateStream.campaignId)}`)); stateStream.source = source; source.onopen = () => { + debug("state stream open", { campaignId: stateStream.campaignId }); stateStream.reconnectDelayMs = 1000; invokeDotNet("OnConnectionStateChanged", "connected"); }; source.addEventListener("state", (event) => { try { + debug("state stream payload", event.data); const payload = JSON.parse(event.data); invokeDotNet("OnStateEventReceived", payload); } catch { + warn("state stream payload parse failed", event.data); invokeDotNet("OnStateEventReceived", { campaignId: stateStream.campaignId, totalVersion: 0, @@ -87,6 +266,7 @@ window.rpgRollerApi = (() => { }); source.onerror = () => { + warn("state stream error", { campaignId: stateStream.campaignId, stopped: stateStream.stopped }); if (stateStream.source === source) { source.close(); stateStream.source = null; @@ -102,6 +282,7 @@ window.rpgRollerApi = (() => { } function stopStateEvents() { + debug("stopStateEvents", { campaignId: stateStream.campaignId }); stateStream.stopped = true; clearReconnectTimer(); @@ -115,16 +296,19 @@ window.rpgRollerApi = (() => { } window.addEventListener("offline", () => { + warn("window offline"); invokeDotNet("OnConnectionStateChanged", "offline"); }); window.addEventListener("online", () => { + debug("window online"); if (!stateStream.stopped) { connectStateStream(); } }); async function request(method, url, body) { + debug("request start", { method, url, hasBody: body !== null && body !== undefined }); const options = { method, credentials: "same-origin", @@ -142,6 +326,7 @@ window.rpgRollerApi = (() => { try { response = await fetch(toAppUrl(url), options); } catch (error) { + warn("request network error", { method, url, error: String(error) }); return { ok: false, status: 0, @@ -160,6 +345,7 @@ window.rpgRollerApi = (() => { } if (!response.ok) { + warn("request failed", { method, url, status: response.status, parsed }); return { ok: false, status: response.status, @@ -168,6 +354,7 @@ window.rpgRollerApi = (() => { }; } + debug("request success", { method, url, status: response.status }); return { ok: true, status: response.status, @@ -176,10 +363,12 @@ window.rpgRollerApi = (() => { } function getSessionValue(key) { + debug("getSessionValue", key); return sessionStorage.getItem(`${sessionPrefix}${key}`); } function setSessionValue(key, value) { + debug("setSessionValue", key, value); const qualifiedKey = `${sessionPrefix}${key}`; if (value === null || value === undefined || value === "") { sessionStorage.removeItem(qualifiedKey); @@ -190,6 +379,7 @@ window.rpgRollerApi = (() => { } function startStateEvents(campaignId, dotNetRef) { + debug("startStateEvents", { campaignId }); stopStateEvents(); stateStream.stopped = false; stateStream.dotNetRef = dotNetRef; @@ -200,26 +390,32 @@ window.rpgRollerApi = (() => { function scrollElementToBottom(element) { if (!element) { + warn("scrollElementToBottom skipped; missing element"); return; } + debug("scrollElementToBottom"); element.scrollTop = element.scrollHeight; } function clearInputValue(element) { if (!element) { + warn("clearInputValue skipped; missing element"); return; } + debug("clearInputValue"); element.value = ""; } function initializeAuthPage() { const root = document.querySelector("[data-auth-page]"); if (!root) { + debug("initializeAuthPage skipped; no auth root"); return; } + debug("initializeAuthPage start"); const statusElement = root.querySelector("[data-auth-status]"); const forms = root.querySelectorAll("[data-auth-form]"); forms.forEach((form) => { @@ -390,6 +586,8 @@ window.rpgRollerApi = (() => { startStateEvents, stopStateEvents, scrollElementToBottom, - clearInputValue + clearInputValue, + installWorkspaceDiagnostics, + markWorkspacePhase }; })(); diff --git a/TASKS.md b/TASKS.md index 7f391ec..8bf8da2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -23,6 +23,7 @@ The change is complete when a human can run the app, open `/`, observe the corre - [x] (2026-05-04 21:42Z) Split the large `Workspace` render tree into a shared shell plus route-owned play, campaign-management, and admin content components, and kept the Selenium route and DOM-wrap coverage green after the split. - [x] (2026-05-04 21:58Z) Removed shell-level `OnAfterRenderAsync` bootstrapping, moved the JS-dependent authenticated startup into a route-owned `WorkspaceRouteView`, removed shell-owned staged control renders, restored the missing development database fixture, and updated README to describe the completed route-first architecture. - [x] (2026-05-04) Updated host tests, Selenium smoke tests, and docs so the real-route model is the documented and verified Milestone 2 behavior. +- [x] (2026-05-04) Added expanded workspace startup diagnostics across Blazor lifecycle logging, route-content render logging, and browser-side DOM mutation snapshots to narrow the remaining Firefox batch-2 crash. ## Surprises & Discoveries @@ -47,6 +48,9 @@ The change is complete when a human can run the app, open `/`, observe the corre - Observation: the first Milestone 4 attempt was still incomplete because authenticated startup remained route-agnostic behind `Session.InitializeAsync()`. Evidence: `/admin` and `/play` could still hit the Firefox `insertBefore` circuit crash until admin and campaign-management routes stopped preloading play-only campaign scope, selected sheets, logs, and SSE startup during their first interactive batch. +- Observation: the remaining Firefox failure still happens during Blazor batch application, so server-side coordinator logs alone are not enough to localize it. + Evidence: after route-scoping startup, Firefox still reported `There was an error applying batch 2` with `TypeError: can't access property "insertBefore", n.parentNode is null`, which motivated adding route render lifecycle logs plus browser-side workspace mutation snapshots. + - Observation: the locally installed Snap Firefox build on this machine is viable for Selenium through `geckodriver`, but not for Playwright protocol control. Evidence: Playwright stalled during the `-juggler-pipe` handshake, while a `geckodriver` plus Selenium session against `/snap/firefox/current/usr/lib/firefox/firefox` completed the same Milestone 1 verification successfully.