Skip to content

Commit 7d42947

Browse files
committed
Add real-time GraphQL subscriptions for infrastructure task events (backend + Blazor UI)
1 parent e980c0f commit 7d42947

File tree

11 files changed

+257
-146
lines changed

11 files changed

+257
-146
lines changed

management-portal/deploy-container-apps.ps1

Lines changed: 3 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -215,24 +215,6 @@ finally {
215215
Pop-Location
216216
}
217217

218-
# Build and push DAB image
219-
Write-Host "📦 Building DAB container image..." -ForegroundColor Yellow
220-
Push-Location "dab"
221-
try {
222-
docker build -t $dabImage .
223-
if ($LASTEXITCODE -ne 0) {
224-
throw "DAB image build failed"
225-
}
226-
227-
Write-Host "📤 Pushing DAB image to registry..." -ForegroundColor Yellow
228-
docker push $dabImage
229-
if ($LASTEXITCODE -ne 0) {
230-
throw "DAB image push failed"
231-
}
232-
}
233-
finally {
234-
Pop-Location
235-
}
236218

237219
# Phase 3: Deploy Container Apps
238220
Write-Host "🏗️ Phase 3: Deploying Container Apps..." -ForegroundColor Yellow
@@ -256,34 +238,9 @@ $acrPassword = az acr credential show `
256238
--query "passwords[0].value" `
257239
--output tsv
258240

259-
# Create DAB Container App
260-
Write-Host "🚀 Creating DAB Container App..." -ForegroundColor Yellow
261-
az containerapp create `
262-
--name "ca-stamps-dab" `
263-
--resource-group $ResourceGroupName `
264-
--environment $containerAppsEnvironmentName `
265-
--image $dabImage `
266-
--target-port 80 `
267-
--ingress external `
268-
--registry-server $acrLoginServer `
269-
--registry-username $containerRegistryName `
270-
--registry-password $acrPassword `
271-
--secrets "cosmos-connection-string=$cosmosConnectionString" "appinsights-connection-string=$appInsightsConnectionString" `
272-
--env-vars "COSMOS_CONNECTION_STRING=secretref:cosmos-connection-string" "ASPNETCORE_ENVIRONMENT=Production" "APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connection-string" `
273-
--cpu 0.25 `
274-
--memory 0.5Gi `
275-
--min-replicas 1 `
276-
--max-replicas 3 `
277-
--output none
278241

279-
# Get DAB URL
280-
$dabUrl = az containerapp show `
281-
--name "ca-stamps-dab" `
282-
--resource-group $ResourceGroupName `
283-
--query "properties.configuration.ingress.fqdn" `
284-
--output tsv
285242

286-
# Create Portal Container App
243+
# Create Portal Container App (HotChocolate GraphQL only)
287244
Write-Host "🚀 Creating Portal Container App..." -ForegroundColor Yellow
288245
az containerapp create `
289246
--name "ca-stamps-portal" `
@@ -295,8 +252,8 @@ az containerapp create `
295252
--registry-server $acrLoginServer `
296253
--registry-username $containerRegistryName `
297254
--registry-password $acrPassword `
298-
--secrets "dab-graphql-url=https://$dabUrl/graphql" "appinsights-connection-string=$appInsightsConnectionString" "azure-ad-client-id=e691193e-4e25-4a72-9185-1ce411aa2fd8" "azure-ad-tenant-id=16b3c013-d300-468d-ac64-7eda0820b6d3" `
299-
--env-vars "DAB_GRAPHQL_URL=secretref:dab-graphql-url" "ASPNETCORE_ENVIRONMENT=Production" "APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connection-string" "ASPNETCORE_URLS=http://+:8080" "AzureAd__ClientId=secretref:azure-ad-client-id" "AzureAd__TenantId=secretref:azure-ad-tenant-id" "AzureAd__Instance=https://login.microsoftonline.com/" "AzureAd__CallbackPath=/signin-oidc" "AzureAd__SignedOutCallbackPath=/signout-callback-oidc" "RUNNING_IN_PRODUCTION=true" `
255+
--secrets "appinsights-connection-string=$appInsightsConnectionString" "azure-ad-client-id=e691193e-4e25-4a72-9185-1ce411aa2fd8" "azure-ad-tenant-id=16b3c013-d300-468d-ac64-7eda0820b6d3" `
256+
--env-vars "ASPNETCORE_ENVIRONMENT=Production" "APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connection-string" "ASPNETCORE_URLS=http://+:8080" "AzureAd__ClientId=secretref:azure-ad-client-id" "AzureAd__TenantId=secretref:azure-ad-tenant-id" "AzureAd__Instance=https://login.microsoftonline.com/" "AzureAd__CallbackPath=/signin-oidc" "AzureAd__SignedOutCallbackPath=/signout-callback-oidc" "RUNNING_IN_PRODUCTION=true" `
300257
--cpu 0.5 `
301258
--memory 1Gi `
302259
--min-replicas 1 `
@@ -319,7 +276,6 @@ Write-Host " Container Registry: $containerRegistryName" -ForegroundColor White
319276
Write-Host ""
320277
Write-Host "🌐 Application URLs:" -ForegroundColor Cyan
321278
Write-Host " Portal: https://$portalUrl" -ForegroundColor White
322-
Write-Host " Data API Builder: https://$dabUrl" -ForegroundColor White
323279
Write-Host ""
324280
Write-Host "📊 Monitoring:" -ForegroundColor Cyan
325281
Write-Host " Application Insights: $appInsightsName" -ForegroundColor White
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using HotChocolate;
2+
using HotChocolate.Types;
3+
using Stamps.ManagementPortal.Models;
4+
using Stamps.ManagementPortal.Services;
5+
using System.Collections.Generic;
6+
using System.Threading.Tasks;
7+
8+
namespace Stamps.ManagementPortal.GraphQL;
9+
10+
public class Query
11+
{
12+
private readonly ICosmosDiscoveryService _cosmosDiscoveryService;
13+
14+
public Query(ICosmosDiscoveryService cosmosDiscoveryService)
15+
{
16+
_cosmosDiscoveryService = cosmosDiscoveryService;
17+
}
18+
19+
[UsePaging]
20+
[UseFiltering]
21+
[UseSorting]
22+
public async Task<IEnumerable<Tenant>> GetTenantsAsync() => await _cosmosDiscoveryService.DiscoverTenantsAsync();
23+
24+
[UsePaging]
25+
[UseFiltering]
26+
[UseSorting]
27+
public async Task<IEnumerable<Cell>> GetCellsAsync() => await _cosmosDiscoveryService.DiscoverCellsAsync();
28+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using HotChocolate;
2+
using HotChocolate.Subscriptions;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
6+
namespace Stamps.ManagementPortal.GraphQL
7+
{
8+
public class TaskEvent
9+
{
10+
public string Id { get; set; }
11+
public string Status { get; set; }
12+
public string Message { get; set; }
13+
public DateTime Timestamp { get; set; }
14+
}
15+
16+
public class Subscription
17+
{
18+
[Subscribe]
19+
[Topic("TASK_EVENTS")]
20+
public TaskEvent OnTaskEvent([EventMessage] TaskEvent taskEvent) => taskEvent;
21+
}
22+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace Stamps.ManagementPortal.Models;
2+
3+
public class TaskEventModel
4+
{
5+
public string Id { get; set; }
6+
public string Status { get; set; }
7+
public string Message { get; set; }
8+
public DateTime Timestamp { get; set; }
9+
}

management-portal/src/Portal/Pages/Infrastructure.razor

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,28 @@
22
@using Stamps.ManagementPortal.Models
33
@using Stamps.ManagementPortal.Services
44
@using Microsoft.JSInterop
5+
@using Stamps.ManagementPortal.Models
6+
@using System.Net.WebSockets
7+
@using System.Text.Json
8+
@using System.Threading
9+
@using System.Threading.Tasks
10+
@using System.Collections.Concurrent
11+
@inject NavigationManager Navigation
512
@inject Stamps.ManagementPortal.Services.IDataService Data
613
@inject Stamps.ManagementPortal.Services.IAzureInfrastructureService AzureInfrastructureService
714
@inject ILogger<Infrastructure> Logger
815
@inject IJSRuntime JSRuntime
916

1017
<div class="infrastructure-container">
18+
<div class="realtime-events mb-3">
19+
<h6>🔔 Real-Time Task Events</h6>
20+
<ul class="list-unstyled">
21+
@foreach (var evt in taskEvents)
22+
{
23+
<li><span class="badge bg-info">@evt.Status</span> @evt.Message <small class="text-muted">@evt.Timestamp.ToLocalTime()</small></li>
24+
}
25+
</ul>
26+
</div>
1127
<div class="page-header">
1228
<h1>🏗️ Infrastructure Discovery</h1>
1329
<p class="lead">Real-time view of your Azure Stamps infrastructure across all regions</p>
@@ -444,6 +460,78 @@
444460
</style>
445461

446462
@code {
463+
private List<TaskEventModel> taskEvents = new();
464+
private ClientWebSocket? ws;
465+
private CancellationTokenSource? wsCts;
466+
467+
protected override async Task OnInitializedAsync()
468+
{
469+
await base.OnInitializedAsync();
470+
_ = ConnectToTaskEvents();
471+
}
472+
473+
private async Task ConnectToTaskEvents()
474+
{
475+
try
476+
{
477+
wsCts = new CancellationTokenSource();
478+
ws = new ClientWebSocket();
479+
var wsUri = new Uri(Navigation.ToAbsoluteUri("/graphql").ToString().Replace("http","ws"));
480+
await ws.ConnectAsync(wsUri, wsCts.Token);
481+
482+
// Send GraphQL subscription start message (Apollo protocol)
483+
var payload = new
484+
{
485+
id = "1",
486+
type = "start",
487+
payload = new
488+
{
489+
query = "subscription { taskEvents { id status message timestamp } }"
490+
}
491+
};
492+
var json = JsonSerializer.Serialize(payload);
493+
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
494+
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, wsCts.Token);
495+
496+
// Listen for messages
497+
var buffer = new byte[4096];
498+
while (ws.State == WebSocketState.Open)
499+
{
500+
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), wsCts.Token);
501+
if (result.MessageType == WebSocketMessageType.Close) break;
502+
var msg = System.Text.Encoding.UTF8.GetString(buffer, 0, result.Count);
503+
if (msg.Contains("taskEvents"))
504+
{
505+
var doc = JsonDocument.Parse(msg);
506+
var evt = doc.RootElement
507+
.GetProperty("payload").GetProperty("data").GetProperty("taskEvents");
508+
var model = new TaskEventModel
509+
{
510+
Id = evt.GetProperty("id").GetString() ?? string.Empty,
511+
Status = evt.GetProperty("status").GetString() ?? string.Empty,
512+
Message = evt.GetProperty("message").GetString() ?? string.Empty,
513+
Timestamp = evt.GetProperty("timestamp").GetDateTime()
514+
};
515+
taskEvents.Insert(0, model);
516+
if (taskEvents.Count > 20) taskEvents.RemoveAt(taskEvents.Count - 1);
517+
StateHasChanged();
518+
}
519+
}
520+
}
521+
catch (Exception ex)
522+
{
523+
Logger.LogError(ex, "WebSocket subscription error");
524+
}
525+
}
526+
527+
public async ValueTask DisposeAsync()
528+
{
529+
if (ws != null)
530+
{
531+
wsCts?.Cancel();
532+
ws.Dispose();
533+
}
534+
}
447535
private bool loading = false;
448536
private DateTime lastUpdated = DateTime.Now;
449537

management-portal/src/Portal/Portal.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,7 @@
2424
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
2525
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
2626
<PackageReference Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.2.0" />
27+
<PackageReference Include="HotChocolate.AspNetCore" Version="13.0.6" />
28+
<PackageReference Include="HotChocolate.Data" Version="13.0.6" />
2729
</ItemGroup>
2830
</Project>

management-portal/src/Portal/Program.cs

Lines changed: 27 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
});
2323
}
2424

25-
// Add authentication for production
25+
// Add authentication and authorization only in Production
2626
if (builder.Environment.IsProduction())
2727
{
2828
// Configure authentication to use HTTPS URLs
@@ -39,36 +39,37 @@
3939
}
4040
};
4141
});
42-
42+
4343
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
4444
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
45-
45+
4646
builder.Services.AddAuthorization(options =>
4747
{
4848
// Require authentication by default
4949
options.FallbackPolicy = options.DefaultPolicy;
50-
50+
5151
// Add role-based authorization
5252
options.AddPolicy("PlatformAdmin", policy =>
5353
policy.RequireRole("platform.admin"));
54-
54+
5555
options.AddPolicy("Authenticated", policy =>
5656
policy.RequireAuthenticatedUser());
5757
});
58-
58+
5959
builder.Services.AddControllersWithViews(options =>
6060
{
6161
var policy = new AuthorizationPolicyBuilder()
6262
.RequireAuthenticatedUser()
6363
.Build();
6464
options.Filters.Add(new AuthorizeFilter(policy));
6565
}).AddDapr();
66-
66+
6767
builder.Services.AddRazorPages()
6868
.AddMicrosoftIdentityUI();
6969
}
7070
else
7171
{
72+
// Development: No authentication/authorization
7273
builder.Services.AddRazorPages();
7374
builder.Services.AddControllers().AddDapr();
7475
}
@@ -88,41 +89,19 @@
8889
builder.Services.AddApplicationInsightsTelemetry();
8990
}
9091

91-
// Configure GraphQL client
92-
builder.Services.AddHttpClient("GraphQL", (sp, client) =>
93-
{
94-
var cfg = sp.GetRequiredService<IConfiguration>();
95-
var baseUrl = cfg["DAB_GRAPHQL_URL"] ?? "";
96-
if (!string.IsNullOrWhiteSpace(baseUrl))
97-
{
98-
client.BaseAddress = new Uri(baseUrl);
99-
}
100-
});
92+
// Configure HotChocolate GraphQL server
93+
builder.Services.AddGraphQLServer()
94+
.AddQueryType<Stamps.ManagementPortal.GraphQL.Query>()
95+
.AddSubscriptionType<Stamps.ManagementPortal.GraphQL.Subscription>();
10196

102-
// Configure data service with Dapr capabilities
103-
var dabGraphQLUrl = builder.Configuration["DAB_GRAPHQL_URL"];
104-
var useGraphQL = !string.IsNullOrWhiteSpace(dabGraphQLUrl);
105-
var useDapr = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DAPR_HTTP_PORT"));
97+
// Add HotChocolate in-memory subscription support
98+
builder.Services.AddInMemorySubscriptions();
10699

107-
Console.WriteLine($"Service Configuration - UseGraphQL: {useGraphQL}, UseDapr: {useDapr}, DAB_URL: '{dabGraphQLUrl}'");
108-
109-
if (useGraphQL && useDapr)
110-
{
111-
// Use Dapr-enabled data service for enhanced debugging and resilience
112-
builder.Services.AddScoped<Stamps.ManagementPortal.Services.GraphQLDataService>();
113-
builder.Services.AddScoped<Stamps.ManagementPortal.Services.IDataService, Stamps.ManagementPortal.Services.DaprDataService>();
114-
}
115-
else if (useGraphQL)
116-
{
117-
// Use direct GraphQL service
118-
builder.Services.AddScoped<Stamps.ManagementPortal.Services.IDataService, Stamps.ManagementPortal.Services.GraphQLDataService>();
119-
}
120-
else
121-
{
122-
// Use in-memory service for development
123-
Console.WriteLine("Using InMemoryDataService for development");
124-
builder.Services.AddScoped<Stamps.ManagementPortal.Services.IDataService, Stamps.ManagementPortal.Services.InMemoryDataService>();
125-
}
100+
// Register TaskEventPublisher
101+
builder.Services.AddSingleton<Stamps.ManagementPortal.Services.ITaskEventPublisher, Stamps.ManagementPortal.Services.TaskEventPublisher>();
102+
// Use in-memory service for development
103+
Console.WriteLine("Using InMemoryDataService for development");
104+
builder.Services.AddScoped<Stamps.ManagementPortal.Services.IDataService, Stamps.ManagementPortal.Services.InMemoryDataService>();
126105

127106
// Configure Azure Infrastructure Service
128107
builder.Services.AddScoped<Stamps.ManagementPortal.Services.IAzureInfrastructureService, Stamps.ManagementPortal.Services.AzureInfrastructureService>();
@@ -135,6 +114,9 @@
135114

136115
var app = builder.Build();
137116

117+
// Map HotChocolate GraphQL endpoint
118+
app.MapGraphQL("/graphql");
119+
138120
// Configure the HTTP request pipeline
139121
if (!app.Environment.IsDevelopment())
140122
{
@@ -148,12 +130,16 @@
148130
app.UseForwardedHeaders();
149131
}
150132

133+
151134
app.UseHttpsRedirection();
152135
app.UseStaticFiles();
153136

154137
app.UseRouting();
155138

156-
// Add authentication middleware for production
139+
// Enable WebSockets for GraphQL subscriptions
140+
app.UseWebSockets();
141+
142+
// Add authentication middleware for production only
157143
if (app.Environment.IsProduction())
158144
{
159145
app.UseAuthentication();

0 commit comments

Comments
 (0)