Skip to content

Commit 35ec1d4

Browse files
Merge pull request #125 from bartoszclapinski/sprint3/phase3.7-signalr-client-#124
feat(signalr): implement client-side SignalR for real-time dashboard
2 parents f3e9699 + 0dfad94 commit 35ec1d4

File tree

5 files changed

+392
-0
lines changed

5 files changed

+392
-0
lines changed

src/DevMetricsPro.Web/Components/Pages/Home.razor

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,38 @@
66
@using DevMetricsPro.Web.Components.Shared
77
@using DevMetricsPro.Web.Components.Shared.Charts
88
@using DevMetricsPro.Web.Services
9+
@using Microsoft.AspNetCore.SignalR.Client
10+
@implements IAsyncDisposable
911
@inject AuthStateService AuthState
1012
@inject IChartDataService ChartDataService
1113
@inject ILeaderboardService LeaderboardService
14+
@inject SignalRService SignalR
1215
@inject HttpClient Http
1316
@inject NavigationManager Navigation
1417
@inject ILogger<Home> Logger
1518
@inject ISnackbar Snackbar
1619

1720
<PageTitle>Dashboard - DevMetrics Pro</PageTitle>
1821

22+
<!-- Sync Status Indicator -->
23+
@if (_isSyncing)
24+
{
25+
<MudAlert Severity="Severity.Info" Class="mb-4" Icon="@Icons.Material.Filled.Sync">
26+
<div style="display: flex; align-items: center; gap: 12px;">
27+
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
28+
<span>Syncing data from GitHub... Dashboard will update automatically.</span>
29+
</div>
30+
</MudAlert>
31+
}
32+
33+
<!-- Connection Status (only show when disconnected) -->
34+
@if (_isAuthenticated && !_isSignalRConnected && _signalRConnectionAttempted)
35+
{
36+
<MudAlert Severity="Severity.Warning" Class="mb-4" Icon="@Icons.Material.Filled.CloudOff">
37+
<span>Real-time updates unavailable. Data will still refresh on page reload.</span>
38+
</MudAlert>
39+
}
40+
1941
@if (!_isAuthenticated)
2042
{
2143
<div style="text-align: center; padding: 60px 20px;">
@@ -380,18 +402,31 @@ else
380402
private List<LeaderboardEntryDto>? _leaderboardEntries;
381403
private bool _isLoadingLeaderboard = false;
382404
private LeaderboardMetric _selectedMetric = LeaderboardMetric.Commits;
405+
406+
// SignalR state - Phase 3.7
407+
private bool _isSyncing = false;
408+
private bool _isSignalRConnected = false;
409+
private bool _signalRConnectionAttempted = false;
410+
private string? _currentUserId;
411+
383412
protected override async Task OnInitializedAsync()
384413
{
385414
_isAuthenticated = await AuthState.IsAuthenticatedAsync();
386415

387416
if (_isAuthenticated)
388417
{
418+
// Get user ID for SignalR
419+
_currentUserId = await AuthState.GetUserIdAsync();
420+
389421
await CheckGitHubConnectionStatus();
390422
await LoadRecentCommitsAsync();
391423
await LoadCommitActivityAsync(7);
392424
await LoadPRStatsAsync(30);
393425
await LoadHeatmapAsync(52);
394426
await LoadLeaderboardAsync(_selectedMetric);
427+
428+
// Initialize SignalR connection
429+
await InitializeSignalRAsync();
395430
}
396431

397432
// Check if we were redirected back from GitHub OAuth
@@ -404,6 +439,117 @@ else
404439
}
405440
}
406441

442+
private async Task InitializeSignalRAsync()
443+
{
444+
if (string.IsNullOrEmpty(_currentUserId))
445+
return;
446+
447+
try
448+
{
449+
// Register event handlers
450+
SignalR.OnSyncStarted += HandleSyncStarted;
451+
SignalR.OnSyncCompleted += HandleSyncCompleted;
452+
SignalR.OnMetricsUpdated += HandleMetricsUpdated;
453+
SignalR.OnConnectionStateChanged += HandleConnectionStateChanged;
454+
455+
// Start connection
456+
await SignalR.StartAsync(_currentUserId);
457+
_isSignalRConnected = true;
458+
_signalRConnectionAttempted = true;
459+
460+
Logger.LogInformation("SignalR connected for dashboard updates");
461+
}
462+
catch (Exception ex)
463+
{
464+
Logger.LogError(ex, "Failed to initialize SignalR connection");
465+
_isSignalRConnected = false;
466+
_signalRConnectionAttempted = true;
467+
// Don't show error - dashboard still works without real-time updates
468+
}
469+
}
470+
471+
private async Task HandleSyncStarted()
472+
{
473+
await InvokeAsync(() =>
474+
{
475+
_isSyncing = true;
476+
StateHasChanged();
477+
});
478+
}
479+
480+
private async Task HandleSyncCompleted(SyncResultDto result)
481+
{
482+
await InvokeAsync(async () =>
483+
{
484+
_isSyncing = false;
485+
486+
if (result.Success)
487+
{
488+
// Show success notification
489+
Snackbar.Add(
490+
$"Sync complete! {result.RepositoriesSynced} repos, {result.CommitsSynced} commits, {result.PullRequestsSynced} PRs",
491+
Severity.Success,
492+
config => config.Icon = Icons.Material.Filled.CheckCircle);
493+
494+
// Refresh all dashboard data
495+
await RefreshAllDataAsync();
496+
}
497+
else
498+
{
499+
Snackbar.Add(
500+
$"Sync failed: {result.ErrorMessage ?? "Unknown error"}",
501+
Severity.Error,
502+
config => config.Icon = Icons.Material.Filled.Error);
503+
}
504+
505+
StateHasChanged();
506+
});
507+
}
508+
509+
private async Task HandleMetricsUpdated()
510+
{
511+
await InvokeAsync(async () =>
512+
{
513+
Snackbar.Add("Metrics updated!", Severity.Info, config => config.Icon = Icons.Material.Filled.Refresh);
514+
await RefreshAllDataAsync();
515+
StateHasChanged();
516+
});
517+
}
518+
519+
private void HandleConnectionStateChanged(HubConnectionState state)
520+
{
521+
InvokeAsync(() =>
522+
{
523+
_isSignalRConnected = state == HubConnectionState.Connected;
524+
525+
if (state == HubConnectionState.Reconnecting)
526+
{
527+
Snackbar.Add("Connection lost. Reconnecting...", Severity.Warning);
528+
}
529+
else if (state == HubConnectionState.Connected && _signalRConnectionAttempted)
530+
{
531+
Snackbar.Add("Reconnected!", Severity.Success);
532+
}
533+
534+
StateHasChanged();
535+
});
536+
}
537+
538+
private async Task RefreshAllDataAsync()
539+
{
540+
// Reload all data in parallel for efficiency
541+
var tasks = new List<Task>
542+
{
543+
LoadRecentCommitsAsync(),
544+
LoadCommitActivityAsync(_selectedDays),
545+
LoadPRStatsAsync(_selectedPRDays),
546+
LoadHeatmapAsync(_selectedWeeks),
547+
LoadLeaderboardAsync(_selectedMetric)
548+
};
549+
550+
await Task.WhenAll(tasks);
551+
}
552+
407553
private async Task CheckGitHubConnectionStatus()
408554
{
409555
try
@@ -679,4 +825,27 @@ else
679825
public int LinesAdded { get; set; }
680826
public int LinesRemoved { get; set; }
681827
}
828+
829+
public async ValueTask DisposeAsync()
830+
{
831+
// Unregister event handlers
832+
SignalR.OnSyncStarted -= HandleSyncStarted;
833+
SignalR.OnSyncCompleted -= HandleSyncCompleted;
834+
SignalR.OnMetricsUpdated -= HandleMetricsUpdated;
835+
SignalR.OnConnectionStateChanged -= HandleConnectionStateChanged;
836+
837+
// Note: Don't dispose SignalR service here as it's managed by DI
838+
// Just leave the dashboard group
839+
if (!string.IsNullOrEmpty(_currentUserId))
840+
{
841+
try
842+
{
843+
await SignalR.LeaveDashboardAsync();
844+
}
845+
catch (Exception ex)
846+
{
847+
Logger.LogWarning(ex, "Error leaving dashboard group during dispose");
848+
}
849+
}
850+
}
682851
}

src/DevMetricsPro.Web/DevMetricsPro.Web.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.12" />
1515
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
1616
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="9.0.10" />
17+
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
1718
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
1819
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1920
<PrivateAssets>all</PrivateAssets>

src/DevMetricsPro.Web/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
// SignalR for real-time updates
171171
builder.Services.AddSignalR();
172172
builder.Services.AddScoped<IMetricsHubService, MetricsHubService>();
173+
builder.Services.AddScoped<SignalRService>();
173174

174175
// Configure application cookie
175176
builder.Services.ConfigureApplicationCookie(options =>

src/DevMetricsPro.Web/Services/AuthStateService.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,29 @@ public async Task<bool> IsAuthenticatedAsync()
9999
return null;
100100
}
101101
}
102+
103+
/// <summary>
104+
/// Get the user ID from the JWT token
105+
/// </summary>
106+
/// <returns>The user ID or null if not found</returns>
107+
public async Task<string?> GetUserIdAsync()
108+
{
109+
var token = await GetTokenAsync();
110+
if (string.IsNullOrEmpty(token)) return null;
111+
112+
try
113+
{
114+
var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(token);
115+
// Try standard claims first, then JWT-specific ones
116+
return jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value
117+
?? jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value
118+
?? jwtToken.Claims.FirstOrDefault(c => c.Type == "userId")?.Value;
119+
}
120+
catch
121+
{
122+
return null;
123+
}
124+
}
102125
}
103126

104127
public class UserInfo

0 commit comments

Comments
 (0)