feat(ui): Blazor WebAssembly 마이그레이션 및 API-First 로그인 구현

This commit is contained in:
2026-07-01 11:22:09 +09:00
parent bdb9262f4e
commit 4de9339163
21 changed files with 246 additions and 80 deletions
@@ -1,16 +1,16 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using QuantEngine.Web.Client.Services;
namespace QuantEngine.Web.Infrastructure
namespace QuantEngine.Web.Client.Infrastructure
{
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ProtectedLocalStorage _localStorage;
private readonly LocalStorageService _localStorage;
private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
private const string StorageKey = "quant_admin_session";
public CustomAuthenticationStateProvider(ProtectedLocalStorage localStorage)
public CustomAuthenticationStateProvider(LocalStorageService localStorage)
{
_localStorage = localStorage;
}
@@ -19,11 +19,9 @@ namespace QuantEngine.Web.Infrastructure
{
try
{
// ProtectedLocalStorage call will throw an exception during pre-rendering
var result = await _localStorage.GetAsync<string>(StorageKey);
if (result.Success && !string.IsNullOrEmpty(result.Value))
var username = await _localStorage.GetAsync<string>(StorageKey);
if (!string.IsNullOrEmpty(username))
{
var username = result.Value;
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, username),
@@ -36,7 +34,7 @@ namespace QuantEngine.Web.Infrastructure
}
catch
{
// Return anonymous state during pre-rendering or if storage reading fails
// Return anonymous if localStorage isn't ready
}
return new AuthenticationState(_anonymous);
@@ -1,11 +1,10 @@
@inherits LayoutComponentBase
@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment WebHostEnvironment
@inject HttpClient Http
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@using System.IO
@using System.Text.Json
@using System.Net.Http.Json
@using Microsoft.FluentUI.AspNetCore.Components
@using QuantEngine.Web.Infrastructure
@using QuantEngine.Web.Client.Infrastructure
<FluentStack Orientation="Orientation.Vertical" Class="h-100 w-100">
<!-- Header -->
@@ -77,25 +76,15 @@
private string appVersion = "Local Debug";
private string buildTime = "N/A";
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
try
{
var versionFilePath = Path.Combine(WebHostEnvironment.WebRootPath, "version.json");
if (File.Exists(versionFilePath))
var versionInfo = await Http.GetFromJsonAsync<VersionInfo>("version.json");
if (versionInfo != null)
{
var jsonContent = File.ReadAllText(versionFilePath);
using var doc = System.Text.Json.JsonDocument.Parse(jsonContent);
var root = doc.RootElement;
if (root.TryGetProperty("version", out var versionProp))
{
appVersion = versionProp.GetString() ?? "Local Debug";
}
if (root.TryGetProperty("built", out var builtProp))
{
buildTime = builtProp.GetString() ?? "N/A";
}
appVersion = versionInfo.Version ?? "Local Debug";
buildTime = versionInfo.Built ?? "N/A";
}
}
catch
@@ -110,5 +99,11 @@
await customProvider.MarkUserAsLoggedOutAsync();
NavigationManager.NavigateTo("login");
}
private class VersionInfo
{
public string? Version { get; set; }
public string? Built { get; set; }
}
}
@@ -1,5 +1,5 @@
@page "/collection"
@using QuantEngine.Web.Services
@using QuantEngine.Web.Client.Services
@inject ApiClient ApiClient
@inject ILogger<Collection> Logger
@@ -1,6 +1,6 @@
@page "/"
@using QuantEngine.Core.Infrastructure
@inject IWebHostEnvironment Environment
@inject HttpClient Http
<PageTitle>Quant Engine - Dashboard</PageTitle>
@@ -96,15 +96,26 @@
private string RawFeedLabel = "DISCONNECTED";
private string ReportPath = "n/a";
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
ReportPath = Path.GetFullPath(Path.Combine(Environment.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
var report = OperationalReportLoader.Load(ReportPath);
Sections.AddRange(report.Sections);
SectionCountLabel = report.SectionCount.ToString();
GeneratedAtLabel = report.GeneratedAt;
SourceLabel = report.SourceJson;
ReportStateLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
ReportChipLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
try
{
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
if (report != null)
{
Sections.Clear();
Sections.AddRange(report.Sections);
SectionCountLabel = report.SectionCount.ToString();
GeneratedAtLabel = report.GeneratedAt;
SourceLabel = report.SourceJson;
ReportStateLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
ReportChipLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
}
}
catch
{
ReportStateLabel = "DATA_MISSING";
ReportChipLabel = "DATA_MISSING";
}
}
}
@@ -2,7 +2,7 @@
@attribute [AllowAnonymous]
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject IConfiguration Configuration
@inject HttpClient Http
<PageTitle>로그인 - QuantEngine</PageTitle>
@@ -281,11 +281,9 @@
try
{
// Verify against configurations in appsettings.json
var expectedUser = Configuration["AdminSettings:Username"] ?? "admin";
var expectedPass = Configuration["AdminSettings:Password"] ?? "quant123!";
var response = await Http.PostAsJsonAsync("api/auth/login", new { Username, Password });
if (Username == expectedUser && Password == expectedPass)
if (response.IsSuccessStatusCode)
{
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
await customProvider.MarkUserAsAuthenticatedAsync(Username);
@@ -1,6 +1,6 @@
@page "/operations"
@using QuantEngine.Core.Infrastructure
@inject IWebHostEnvironment Environment
@inject HttpClient Http
<PageTitle>Quant Engine - Operations</PageTitle>
@@ -97,19 +97,29 @@
protected override async Task OnInitializedAsync()
{
ReportPath = Path.GetFullPath(Path.Combine(Environment.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
try
{
var report = await Http.GetFromJsonAsync<OperationalReportData>("api/operational-report");
if (report != null)
{
SchemaVersion = report.SchemaVersion;
SourceJson = report.SourceJson;
GeneratedAt = report.GeneratedAt;
Sections.Clear();
Sections.AddRange(report.Sections);
var report = OperationalReportLoader.Load(ReportPath);
SchemaVersion = report.SchemaVersion;
SourceJson = report.SourceJson;
GeneratedAt = report.GeneratedAt;
Sections.AddRange(report.Sections);
HighlightSections.Clear();
HighlightSections.AddRange(Sections.Take(4));
HighlightSections.Clear();
HighlightSections.AddRange(Sections.Take(4));
SectionCountLabel = report.SectionCount.ToString();
RenderedSectionCountLabel = Sections.Count.ToString();
HealthLabel = Sections.Count > 0 ? "PASS" : "DATA_MISSING";
SectionCountLabel = report.SectionCount.ToString();
RenderedSectionCountLabel = Sections.Count.ToString();
HealthLabel = Sections.Count > 0 ? "PASS" : "DATA_MISSING";
}
}
catch
{
HealthLabel = "DATA_MISSING";
}
}
}
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using QuantEngine.Web.Client.Services;
using QuantEngine.Web.Client.Infrastructure;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Register Fluent UI
builder.Services.AddFluentUIComponents();
// Register LocalStorage for cross-platform session persistence
builder.Services.AddScoped<LocalStorageService>();
// Authentication setup in WebAssembly client
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
// HttpClient register (API-First standard)
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
await builder.Build().RunAsync();
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\QuantEngine.Core\QuantEngine.Core.csproj" />
<ProjectReference Include="..\..\QuantEngine.Application\QuantEngine.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0-preview.2.25120.18" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.0-preview.2.25120.18" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="5.0.0-rc.4-26177.1" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="5.0.0-rc.4-26177.1" />
</ItemGroup>
</Project>
@@ -2,19 +2,16 @@ using System.Net.Http.Json;
using System.Text.Json.Serialization;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Web.Services;
namespace QuantEngine.Web.Client.Services;
public class ApiClient
{
private readonly HttpClient _http;
private readonly ILogger<ApiClient> _logger;
private string BaseUrl { get; set; }
public ApiClient(HttpClient http, ILogger<ApiClient> logger)
{
_http = http;
_logger = logger;
BaseUrl = "http://localhost:5001"; // Default for Blazor Server
}
// Collection API Methods
@@ -23,7 +20,7 @@ public class ApiClient
{
try
{
return await _http.GetFromJsonAsync<CollectionDashboardStateDto>($"{BaseUrl}/api/collection/state");
return await _http.GetFromJsonAsync<CollectionDashboardStateDto>("api/collection/state");
}
catch (Exception ex)
{
@@ -36,7 +33,7 @@ public class ApiClient
{
try
{
return await _http.GetFromJsonAsync<CollectionRunsResponse>($"{BaseUrl}/api/collection/runs?limit={limit}");
return await _http.GetFromJsonAsync<CollectionRunsResponse>($"api/collection/runs?limit={limit}");
}
catch (Exception ex)
{
@@ -49,7 +46,7 @@ public class ApiClient
{
try
{
return await _http.GetFromJsonAsync<CollectionRunSnapshotsResponse>($"{BaseUrl}/api/collection/runs/{runId}/snapshots");
return await _http.GetFromJsonAsync<CollectionRunSnapshotsResponse>($"api/collection/runs/{runId}/snapshots");
}
catch (Exception ex)
{
@@ -62,7 +59,7 @@ public class ApiClient
{
try
{
return await _http.GetFromJsonAsync<CollectionRunErrorsResponse>($"{BaseUrl}/api/collection/runs/{runId}/errors?limit={limit}");
return await _http.GetFromJsonAsync<CollectionRunErrorsResponse>($"api/collection/runs/{runId}/errors?limit={limit}");
}
catch (Exception ex)
{
@@ -75,7 +72,7 @@ public class ApiClient
{
try
{
var response = await _http.PostAsJsonAsync($"{BaseUrl}/api/collection/run", new { });
var response = await _http.PostAsJsonAsync("api/collection/run", new { });
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<CollectionRunStartResponse>();
@@ -0,0 +1,43 @@
using Microsoft.JSInterop;
using System.Text.Json;
namespace QuantEngine.Web.Client.Services
{
public class LocalStorageService
{
private readonly IJSRuntime _js;
public LocalStorageService(IJSRuntime js)
{
_js = js;
}
public async Task SetAsync<T>(string key, T value)
{
var json = JsonSerializer.Serialize(value);
await _js.InvokeVoidAsync("localStorage.setItem", key, json);
}
public async Task<T?> GetAsync<T>(string key)
{
try
{
var json = await _js.InvokeAsync<string?>("localStorage.getItem", key);
if (string.IsNullOrEmpty(json))
{
return default;
}
return JsonSerializer.Deserialize<T>(json);
}
catch
{
return default;
}
}
public async Task DeleteAsync(string key)
{
await _js.InvokeVoidAsync("localStorage.removeItem", key);
}
}
}
@@ -8,7 +8,10 @@
@using Microsoft.JSInterop
@using Microsoft.FluentUI.AspNetCore.Components
@using Microsoft.FluentUI.AspNetCore.Components.Icons
@using QuantEngine.Web
@using QuantEngine.Web.Components
@using QuantEngine.Web.Components.Layout
@using QuantEngine.Web.Services
@using QuantEngine.Web.Client
@using QuantEngine.Web.Client.Pages
@using QuantEngine.Web.Client.Layout
@using QuantEngine.Web.Client.Infrastructure
@using QuantEngine.Web.Client.Services
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization
@@ -13,12 +13,12 @@
<link rel="stylesheet" href="@Assets["QuantEngine.Web.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
<HeadOutlet @rendermode="InteractiveWebAssembly" />
</head>
<body>
<FluentDesignSystemProvider>
<Routes />
<Routes @rendermode="InteractiveWebAssembly" />
<ReconnectModal />
</FluentDesignSystemProvider>
@@ -1,7 +1,11 @@
@using QuantEngine.Web.Client
@using QuantEngine.Web.Client.Pages
@using QuantEngine.Web.Client.Layout
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Router AppAssembly="typeof(Dashboard).Assembly" NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
+40 -5
View File
@@ -1,7 +1,7 @@
using QuantEngine.Web.Components;
using QuantEngine.Web.Services;
using QuantEngine.Infrastructure.Data;
using Microsoft.AspNetCore.Components.Authorization;
using QuantEngine.Web.Infrastructure;
using QuantEngine.Infrastructure.Repositories;
using QuantEngine.Infrastructure.Services;
using QuantEngine.Core.Interfaces;
@@ -10,7 +10,8 @@ using System.Text.Json;
using static QuantEngine.Application.Services.DataCollectionService;
using Microsoft.FluentUI.AspNetCore.Components;
using Serilog;
using QuantEngine.Web.Infrastructure;
using QuantEngine.Web.Client.Infrastructure;
using QuantEngine.Web.Client.Services;
using QuantEngine.Web.Endpoints;
// Serilog Configuration with Telegram Sink
@@ -25,10 +26,11 @@ builder.Host.UseSerilog();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
.AddInteractiveWebAssemblyComponents();
// Authentication and Custom State Provider
// Authentication and Custom State Provider (Shared client components)
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<LocalStorageService>();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
builder.Services.AddAuthorizationCore();
@@ -100,6 +102,32 @@ app.MapStaticAssets();
// Collection API Endpoints (must be before MapRazorComponents)
app.MapCollectionEndpoints();
// Login API (API-First for Blazor WASM client authentication)
app.MapPost("/api/auth/login", (LoginRequest request, IConfiguration config) =>
{
var expectedUser = config["AdminSettings:Username"] ?? "admin";
var expectedPass = config["AdminSettings:Password"] ?? "quant123!";
if (request.Username == expectedUser && request.Password == expectedPass)
{
return Results.Ok(new { success = true, username = request.Username });
}
return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401);
});
// Operational Report serving API (WASM safe file loading substitute)
app.MapGet("/api/operational-report", async (IWebHostEnvironment env) =>
{
var path = Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
if (!File.Exists(path))
{
return Results.NotFound(new { gate = "FAIL", error = "operational_report_missing" });
}
var json = await File.ReadAllTextAsync(path);
using var doc = JsonDocument.Parse(json);
return Results.Ok(doc.RootElement);
});
app.MapGet("/api/history/{domain}", async (string domain, int? limit, IPostgresqlHistorySnapshotReader reader) =>
{
var rows = await reader.ReadAsync(domain, limit ?? 500);
@@ -144,7 +172,14 @@ app.MapPost("/api/history/{domain}", async (string domain, JsonElement payload,
});
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(QuantEngine.Web.Client._Imports).Assembly);
app.Run();
public class LoginRequest
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
}
@@ -4,12 +4,22 @@
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
<ProjectReference Include="Client\QuantEngine.Web.Client.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="5.0.0-rc.4-26177.1" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="5.0.0-rc.4-26177.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.0-preview.2.25120.18" />
</ItemGroup>
<ItemGroup>
<!-- Exclude client project files from server build to avoid duplicate compilations -->
<Compile Remove="Client\**" />
<Content Remove="Client\**" />
<EmbeddedResource Remove="Client\**" />
<None Remove="Client\**" />
</ItemGroup>
<PropertyGroup>
+17 -1
View File
@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
@@ -15,6 +15,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Core.Tests", "Q
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Tools", "QuantEngine.Tools\QuantEngine.Tools.csproj", "{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Web.Client", "QuantEngine.Web\Client\QuantEngine.Web.Client.csproj", "{C5F2F3BD-1258-40FC-803A-EE7EEC928107}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -97,8 +100,21 @@ Global
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x64.Build.0 = Release|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.ActiveCfg = Release|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.Build.0 = Release|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|x64.ActiveCfg = Debug|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|x64.Build.0 = Debug|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|x86.ActiveCfg = Debug|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Debug|x86.Build.0 = Debug|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|Any CPU.Build.0 = Release|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|x64.ActiveCfg = Release|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|x64.Build.0 = Release|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|x86.ActiveCfg = Release|Any CPU
{C5F2F3BD-1258-40FC-803A-EE7EEC928107}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal