chore: add workspace crash diagnostics
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -64,7 +64,5 @@
|
||||
</main>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
||||
|
||||
private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading;
|
||||
}
|
||||
|
||||
25
RpgRoller/Components/Pages/AdminWorkspaceContent.razor.cs
Normal file
25
RpgRoller/Components/Pages/AdminWorkspaceContent.razor.cs
Normal file
@@ -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<AdminWorkspaceContent> Logger { get; set; } = null!;
|
||||
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
||||
}
|
||||
@@ -21,8 +21,6 @@
|
||||
<CharacterManagementModals Workspace="Workspace"/>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
||||
|
||||
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
await Workspace.Campaigns.OnCampaignSelectionChangedAsync(args);
|
||||
|
||||
@@ -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<CampaignsWorkspaceContent> Logger { get; set; } = null!;
|
||||
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
||||
}
|
||||
@@ -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 ?? "<null>", ShowCampaign, CampaignName ?? "<null>", 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 ?? "<null>", 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<AppHeader> 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<AppHeaderMenuItem> MenuItems { get; set; } = [];
|
||||
[Parameter] public string MenuId { get; set; } = "screen-menu";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback ToggleMenuRequested { get; set; }
|
||||
[Parameter] public IReadOnlyList<AppHeaderMenuItem> 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
|
||||
|
||||
@@ -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<CampaignLogPanel> Logger { get; set; } = null!;
|
||||
|
||||
private ElementReference LogPanelRef { get; set; }
|
||||
private ElementReference LogFeedRef { get; set; }
|
||||
|
||||
@@ -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<CharacterPanel> Logger { get; set; } = null!;
|
||||
|
||||
[Parameter] public bool IsCampaignDataLoading { get; set; }
|
||||
|
||||
|
||||
@@ -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<RolemasterSkillRollModal> 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<string> ModifierTextChanged { get; set; }
|
||||
[Parameter] public EventCallback<string> 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<string> ConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfirmRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
[Parameter] public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
@@ -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<SkillSummary>("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<SkillSummary>("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<SkillSummary>("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<SkillSummary>("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<SkillFormModal> Logger { get; set; } = null!;
|
||||
|
||||
private FormState<SkillFormModel> 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<CharacterSheetSkillGroup> AvailableSkillGroups { get; set; } = [];
|
||||
[Parameter] public IReadOnlyList<CharacterSheetSkillGroup> 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<Guid> SkillSaved { get; set; }
|
||||
[Parameter] public EventCallback<Guid> SkillSaved { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
[Parameter] public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
25
RpgRoller/Components/Pages/PlayWorkspaceContent.razor.cs
Normal file
25
RpgRoller/Components/Pages/PlayWorkspaceContent.razor.cs
Normal file
@@ -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<PlayWorkspaceContent> Logger { get; set; } = null!;
|
||||
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
<div class="@AppCssClass">
|
||||
<div class="@AppCssClass"
|
||||
data-workspace-root="true"
|
||||
data-workspace-route="@Route"
|
||||
data-workspace-session-initialized="@HasSessionInitialized"
|
||||
data-workspace-campaign-loading="@State.IsCampaignDataLoading"
|
||||
data-workspace-admin-loading="@State.IsAdminDataLoading"
|
||||
data-workspace-user="@(State.User?.Username ?? "loading")">
|
||||
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
||||
|
||||
@if (State.HasHealthIssue)
|
||||
|
||||
@@ -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<Workspace> Logger { get; set; } = null!;
|
||||
[Inject] private ILoggerFactory LoggerFactory { get; set; } = null!;
|
||||
|
||||
[Parameter] public EventCallback<string?> 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<AppHeaderMenuItem> 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; }
|
||||
}
|
||||
@@ -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<Task> stopStateEventsAsync,
|
||||
Func<string?, Task> onLoggedOutAsync)
|
||||
Func<string?, Task> 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);
|
||||
}
|
||||
|
||||
40
RpgRoller/Components/Pages/WorkspaceDiagnosticSummary.cs
Normal file
40
RpgRoller/Components/Pages/WorkspaceDiagnosticSummary.cs
Normal file
@@ -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 ?? "<null>";
|
||||
var selectedCampaign = state.SelectedCampaignId?.ToString() ?? "<null>";
|
||||
var selectedCharacter = state.SelectedCharacterId?.ToString() ?? "<null>";
|
||||
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() ?? "<null>"}, playCampaignCharacters={playCampaign?.Characters.Length ?? 0}, playSelectedCharacter={workspace.State.PlaySelectedCharacterId?.ToString() ?? "<null>"}, 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() ?? "<null>"}, selectedCampaignName={workspace.State.SelectedCampaignName ?? "<null>"}, 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 ?? "<null>")}, isCurrentUserAdmin={workspace.State.IsCurrentUserAdmin}, adminUsers={workspace.State.AdminUsers.Count}, hasLoadedAdminUsers={workspace.State.HasLoadedAdminUsers}, isAdminLoading={workspace.State.IsAdminDataLoading}, isMutating={workspace.State.IsMutating}";
|
||||
}
|
||||
}
|
||||
@@ -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<Task> refreshCampaignRosterAsync,
|
||||
Func<Task> refreshSelectedCharacterSheetAsync,
|
||||
Func<Guid?, Task> refreshCampaignLogAsync,
|
||||
Func<Task> requestRefreshAsync)
|
||||
Func<Task> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WorkspaceRouteView> Logger { get; set; } = null!;
|
||||
[Inject] private IJSRuntime JS { get; set; } = null!;
|
||||
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
||||
|
||||
[Parameter, EditorRequired] public RenderFragment<WorkspacePageContext> 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";
|
||||
}
|
||||
|
||||
@@ -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<Task> stopStateEventsAsync,
|
||||
Func<Task> ensureAdminUsersLoadedAsync,
|
||||
Action resetCampaignLogDetailState,
|
||||
Func<string?, Task> onLoggedOutAsync)
|
||||
Func<string?, Task> 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<string?>("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<bool> 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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 "<null>";
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
})();
|
||||
|
||||
4
TASKS.md
4
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user