From 1fde98ca792b806c80c61ea37e18ac0945130c3d Mon Sep 17 00:00:00 2001 From: local Date: Fri, 27 Mar 2026 22:58:19 +0000 Subject: [PATCH] feat(01-02): implement health check round-trip with CORS and tutorial comments - Add shared HealthResponse DTO in ChatAgent.Shared - Add HealthController API endpoint with CORS policy for localhost:5200 - Add ChatApiClient typed HttpClient wrapper in WASM client - Update Home.razor to display health check result on load - Simplify MainLayout to minimal centered layout - Add global imports for Services and Shared.Models - Replace app.css with clean Phase 1 light theme styles - Remove unused OpenAPI package from API project - All files include tutorial-style inline comments (CODE-01) --- src/ChatAgent.Api/ChatAgent.Api.csproj | 4 - .../Controllers/HealthController.cs | 47 ++++ src/ChatAgent.Api/Program.cs | 82 +++++-- src/ChatAgent.Client/ChatAgent.Client.csproj | 1 + src/ChatAgent.Client/Layout/MainLayout.razor | 39 +-- src/ChatAgent.Client/Pages/Home.razor | 93 ++++++- src/ChatAgent.Client/Program.cs | 58 ++++- .../Services/ChatApiClient.cs | 53 ++++ src/ChatAgent.Client/_Imports.razor | 29 ++- src/ChatAgent.Client/wwwroot/css/app.css | 231 +++++++++--------- src/ChatAgent.Shared/Models/HealthResponse.cs | 32 +++ 11 files changed, 484 insertions(+), 185 deletions(-) create mode 100644 src/ChatAgent.Api/Controllers/HealthController.cs create mode 100644 src/ChatAgent.Client/Services/ChatApiClient.cs create mode 100644 src/ChatAgent.Shared/Models/HealthResponse.cs diff --git a/src/ChatAgent.Api/ChatAgent.Api.csproj b/src/ChatAgent.Api/ChatAgent.Api.csproj index 86a2035..b7f0468 100644 --- a/src/ChatAgent.Api/ChatAgent.Api.csproj +++ b/src/ChatAgent.Api/ChatAgent.Api.csproj @@ -6,10 +6,6 @@ enable - - - - diff --git a/src/ChatAgent.Api/Controllers/HealthController.cs b/src/ChatAgent.Api/Controllers/HealthController.cs new file mode 100644 index 0000000..2321339 --- /dev/null +++ b/src/ChatAgent.Api/Controllers/HealthController.cs @@ -0,0 +1,47 @@ +// HealthController.cs -- API endpoint that proves the WASM-to-API connection works. +// +// This is the first controller in the project. It exists to verify that: +// 1. The API server starts and responds to HTTP requests +// 2. CORS allows the Blazor WASM client to call across origins +// 3. The shared HealthResponse DTO serializes/deserializes correctly +// +// In ASP.NET Core, a "controller" is a class that handles HTTP requests. +// The framework routes requests to controller methods based on attributes. + +using ChatAgent.Shared.Models; +using Microsoft.AspNetCore.Mvc; + +namespace ChatAgent.Api.Controllers +{ + // [ApiController] enables several conveniences for API development: + // - Automatic HTTP 400 responses for invalid model state + // - Automatic inference of [FromBody] for complex types + // - Problem details responses for error status codes + // It signals "this class handles API requests, not HTML pages." + [ApiController] + + // [Route("api/[controller]")] maps this class to the URL path /api/health. + // The [controller] token is replaced with the class name minus "Controller", + // so HealthController becomes "health" -> /api/health. + [Route("api/[controller]")] + public class HealthController : ControllerBase + { + // [HttpGet] maps this method to HTTP GET requests at the controller's route. + // When a client sends GET /api/health, ASP.NET Core calls this method. + // It returns an IActionResult, which gives us control over the HTTP status code. + [HttpGet] + public IActionResult Get() + { + // Return HTTP 200 OK with a HealthResponse body. + // The framework serializes the object to JSON automatically + // because this is an [ApiController]. + // DateTime.UtcNow provides a live timestamp so the client can verify + // the response is fresh (not cached). + return Ok(new HealthResponse + { + Status = "healthy", + Timestamp = DateTime.UtcNow + }); + } + } +} diff --git a/src/ChatAgent.Api/Program.cs b/src/ChatAgent.Api/Program.cs index 6189b36..6a4e491 100644 --- a/src/ChatAgent.Api/Program.cs +++ b/src/ChatAgent.Api/Program.cs @@ -1,23 +1,59 @@ -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. - -builder.Services.AddControllers(); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); - -app.UseAuthorization(); - -app.MapControllers(); - -app.Run(); +// Program.cs -- ASP.NET Core Web API entry point for ChatAgent. +// +// This is the backend server. In Phase 1, it only serves a health check endpoint. +// In later phases, it will proxy OpenAI API calls (keeping the API key server-side) +// and manage JSON file storage for conversation persistence. +// +// ASP.NET Core uses a "builder pattern": first configure services (DI container), +// then build the app, configure middleware pipeline, and run. + +var builder = WebApplication.CreateBuilder(args); + +// --- Service Registration (Dependency Injection container) --- + +// AddControllers() registers MVC controller services so ASP.NET Core discovers +// classes decorated with [ApiController]. We use Controllers (not Minimal API) +// for explicit structure -- each controller is a separate file with clear routing (D-05). +builder.Services.AddControllers(); + +// AddCors() registers Cross-Origin Resource Sharing services. +// CORS is REQUIRED because the Blazor WASM client runs on a different origin +// (https://localhost:5200) than this API (https://localhost:7100). +// Browsers block cross-origin HTTP requests by default as a security measure. +// Without this policy, the client's fetch() calls to the API would be rejected. +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowBlazorClient", policy => + { + policy + // Only allow requests from the Blazor WASM client origin + .WithOrigins("https://localhost:5200") + // Allow any HTTP header (Content-Type, Accept, etc.) + .AllowAnyHeader() + // Allow any HTTP method (GET, POST, PUT, DELETE, etc.) + .AllowAnyMethod(); + }); +}); + +var app = builder.Build(); + +// --- Middleware Pipeline --- +// Middleware order matters in ASP.NET Core -- each middleware runs in the order +// it is registered. CORS must be applied before routing and authorization +// so that preflight (OPTIONS) requests are handled correctly. + +// Apply the CORS policy globally -- every response includes the correct +// Access-Control-Allow-Origin header for the Blazor client's origin. +app.UseCors("AllowBlazorClient"); + +// UseAuthorization() enables the authorization middleware. Even though we have +// no auth in Phase 1, it is included because ASP.NET Core expects it in the +// pipeline when controllers are used. It is a no-op without [Authorize] attributes. +app.UseAuthorization(); + +// MapControllers() scans the assembly for all classes with [ApiController] +// and maps their routes. This is what connects HealthController's +// [Route("api/[controller]")] to the URL path /api/health. +app.MapControllers(); + +app.Run(); diff --git a/src/ChatAgent.Client/ChatAgent.Client.csproj b/src/ChatAgent.Client/ChatAgent.Client.csproj index 6e3a9b4..5ba2fba 100644 --- a/src/ChatAgent.Client/ChatAgent.Client.csproj +++ b/src/ChatAgent.Client/ChatAgent.Client.csproj @@ -9,6 +9,7 @@ + diff --git a/src/ChatAgent.Client/Layout/MainLayout.razor b/src/ChatAgent.Client/Layout/MainLayout.razor index e465845..f946585 100644 --- a/src/ChatAgent.Client/Layout/MainLayout.razor +++ b/src/ChatAgent.Client/Layout/MainLayout.razor @@ -1,16 +1,23 @@ -@inherits LayoutComponentBase -
- - -
-
- About -
- -
- @Body -
-
-
+@* MainLayout.razor -- The root layout component for the application. + + In Blazor, layout components wrap page content. Every routed page (@page) + is rendered inside the layout's @Body placeholder. This is similar to + _Layout.cshtml in MVC or master pages in Web Forms. + + Phase 1 uses a minimal layout -- just centered content with padding. + Later phases will add a sidebar for conversation management. +*@ + +@* @inherits LayoutComponentBase makes this a layout component. + LayoutComponentBase provides the Body property, which is a RenderFragment + containing the routed page's content. Without this base class, @Body + would not be available. *@ +@inherits LayoutComponentBase + +
+ @* @Body is where the routed page content renders. + When the user navigates to "/", the Home.razor component's markup + appears here. When they navigate to another @page, that component + renders here instead. The layout stays the same -- only @Body changes. *@ + @Body +
diff --git a/src/ChatAgent.Client/Pages/Home.razor b/src/ChatAgent.Client/Pages/Home.razor index df05023..f3d3630 100644 --- a/src/ChatAgent.Client/Pages/Home.razor +++ b/src/ChatAgent.Client/Pages/Home.razor @@ -1,7 +1,86 @@ -@page "/" - -Home - -

Hello, world!

- -Welcome to your new app. +@* Home.razor -- The landing page for ChatAgent. + + @page "/" maps this component to the root URL. When a user navigates to "/", + the Blazor router renders this component inside MainLayout's @Body placeholder. + + This page demonstrates the health check round-trip: + 1. On load, it calls the API's /api/health endpoint via ChatApiClient + 2. Displays the server's health status and timestamp + 3. Proves CORS is working (WASM on :5200 calling API on :7100) +*@ + +@* @page directive maps this component to a URL route. + "/" means this is the default/home page. *@ +@page "/" + +@* Import the service and model namespaces for this component. + These could also be in _Imports.razor for global access. *@ +@using ChatAgent.Client.Services +@using ChatAgent.Shared.Models + +@* @inject requests a service from the Dependency Injection (DI) container. + ChatApiClient was registered in Program.cs via AddHttpClient. + Blazor creates one instance per component and injects it here. *@ +@inject ChatApiClient ApiClient + +Chat Agent + +

Chat Agent

+ +@* Conditional rendering: Blazor re-renders the component after OnInitializedAsync completes. + We show different content based on the state of our data fields. *@ + +@if (_healthResponse is null && _error is null) +{ + @* Loading state: OnInitializedAsync has not completed yet *@ +

Checking API connection...

+} +else if (_healthResponse is not null) +{ + @* Success state: API responded with a HealthResponse *@ +
+

API Status: @_healthResponse.Status

+

Server Time: @_healthResponse.Timestamp.ToString("yyyy-MM-dd HH:mm:ss UTC")

+
+} +else if (_error is not null) +{ + @* Error state: the API call failed (network error, CORS blocked, server down, etc.) *@ +
+

Connection Error: @_error

+
+} + +@code { + // Private fields to hold the health check result or error message. + // Blazor components use private fields for state that drives the UI. + private HealthResponse? _healthResponse; + private string? _error; + + /// + /// OnInitializedAsync is a Blazor lifecycle method called once when the component + /// is first rendered. It runs after the component receives its initial parameters. + /// + /// Because it returns Task, Blazor awaits it and automatically calls + /// StateHasChanged() when it completes, triggering a re-render. + /// This means we do NOT need to call StateHasChanged() manually here. + /// + /// The component renders twice: once immediately (showing "Checking..."), + /// and again after this method completes (showing the result or error). + /// + protected override async Task OnInitializedAsync() + { + try + { + // Call the API health endpoint via our typed HttpClient wrapper. + // This makes an HTTP GET to https://localhost:7100/api/health. + _healthResponse = await ApiClient.GetHealthAsync(); + } + catch (Exception ex) + { + // Catch any exception (network error, CORS block, timeout, etc.) + // and store the message for display in the error UI. + _error = $"Failed to reach API: {ex.Message}"; + } + } +} diff --git a/src/ChatAgent.Client/Program.cs b/src/ChatAgent.Client/Program.cs index d93447e..2d60c4e 100644 --- a/src/ChatAgent.Client/Program.cs +++ b/src/ChatAgent.Client/Program.cs @@ -1,11 +1,47 @@ -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using ChatAgent.Client; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); -builder.RootComponents.Add("#app"); -builder.RootComponents.Add("head::after"); - -builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); - -await builder.Build().RunAsync(); +// Program.cs -- Blazor WebAssembly application entry point for ChatAgent. +// +// This is where the WASM client is configured and launched. Unlike a server-side +// ASP.NET Core app, a Blazor WASM app runs entirely in the browser. The +// WebAssemblyHostBuilder configures: +// - Root components (what gets rendered into the HTML page) +// - Services (dependency injection container, similar to server-side DI) +// - Configuration (reads from wwwroot/appsettings.json) +// +// This file will grow as we add more services in later phases (e.g., chat state management). + +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using ChatAgent.Client; +using ChatAgent.Client.Services; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +// Add("#app") tells Blazor to render the App component inside the +//
element in wwwroot/index.html. This is the "mount point" +// for the entire Blazor component tree. +builder.RootComponents.Add("#app"); + +// HeadOutlet allows Razor components to modify elements (e.g., ) +// using the <PageTitle> and <HeadContent> components. "head::after" means +// content is appended after existing head elements. +builder.RootComponents.Add<HeadOutlet>("head::after"); + +// Read the API base URL from configuration. +// In Blazor WASM, configuration comes from wwwroot/appsettings.json. +// IMPORTANT: wwwroot/ files are PUBLIC -- they are downloaded to the browser. +// Never put secrets (API keys, passwords) in appsettings.json for a WASM app. +// The API key lives server-side in the ChatAgent.Api project. +var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? "https://localhost:7100"; + +// AddHttpClient<ChatApiClient> registers a typed HttpClient using IHttpClientFactory. +// IHttpClientFactory manages the underlying HttpMessageHandler lifetime to prevent +// socket exhaustion (a common problem with raw HttpClient in long-running apps). +// The lambda configures the client with the API base URL so ChatApiClient +// does not need to know the URL -- it is injected with a pre-configured HttpClient. +// In Blazor WASM, HttpClient uses the browser's Fetch API under the hood. +builder.Services.AddHttpClient<ChatApiClient>(client => +{ + client.BaseAddress = new Uri(apiBaseUrl); +}); + +await builder.Build().RunAsync(); diff --git a/src/ChatAgent.Client/Services/ChatApiClient.cs b/src/ChatAgent.Client/Services/ChatApiClient.cs new file mode 100644 index 0000000..b7e177e --- /dev/null +++ b/src/ChatAgent.Client/Services/ChatApiClient.cs @@ -0,0 +1,53 @@ +// ChatApiClient.cs -- Typed HttpClient wrapper for communicating with the ChatAgent API. +// +// WHY A TYPED CLIENT? Instead of injecting HttpClient directly into components, +// we wrap it in a dedicated service class. This follows the "typed client" pattern (D-04): +// - Components depend on ChatApiClient, not HttpClient (loose coupling) +// - API URL paths are centralized here, not scattered across components +// - Easy to mock for testing -- swap ChatApiClient, not HttpClient +// - IHttpClientFactory manages the underlying HttpClient lifetime +// +// In Blazor WASM, HttpClient is backed by the browser's Fetch API. +// The base URL is configured in Program.cs via AddHttpClient<ChatApiClient>. + +using System.Net.Http.Json; +using ChatAgent.Shared.Models; + +namespace ChatAgent.Client.Services +{ + /// <summary> + /// Typed HttpClient for the ChatAgent API. Each public method maps to one API endpoint. + /// Constructor injection of HttpClient is provided by IHttpClientFactory, + /// which was configured in Program.cs with the API base URL. + /// </summary> + public class ChatApiClient + { + // The HttpClient instance is injected by the DI container (IHttpClientFactory). + // It is pre-configured with BaseAddress pointing to the API server. + private readonly HttpClient _httpClient; + + /// <summary> + /// Constructor receives an HttpClient from IHttpClientFactory DI registration. + /// The client is already configured with the API base URL. + /// </summary> + public ChatApiClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + /// <summary> + /// Calls GET /api/health on the API server and deserializes the JSON response + /// into a HealthResponse object. Returns null if deserialization fails. + /// + /// GetFromJsonAsync is an extension method from System.Net.Http.Json that + /// combines the HTTP GET call and JSON deserialization into a single step. + /// It uses System.Text.Json internally (the built-in .NET JSON serializer). + /// </summary> + public async Task<HealthResponse?> GetHealthAsync() + { + // "api/health" is a relative URL -- it is appended to the BaseAddress + // configured in Program.cs (e.g., https://localhost:7100/api/health). + return await _httpClient.GetFromJsonAsync<HealthResponse>("api/health"); + } + } +} diff --git a/src/ChatAgent.Client/_Imports.razor b/src/ChatAgent.Client/_Imports.razor index f256289..28adc3a 100644 --- a/src/ChatAgent.Client/_Imports.razor +++ b/src/ChatAgent.Client/_Imports.razor @@ -1,10 +1,19 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.AspNetCore.Components.WebAssembly.Http -@using Microsoft.JSInterop -@using ChatAgent.Client -@using ChatAgent.Client.Layout +@* _Imports.razor -- Global using directives for all .razor files in this project. + + Any @using directive placed here is automatically available in every .razor file + in the ChatAgent.Client project. This avoids repeating common imports in each component. + It works like a "global usings" file but specifically for Razor components. +*@ + +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using ChatAgent.Client +@using ChatAgent.Client.Layout +@using ChatAgent.Client.Services +@using ChatAgent.Shared.Models diff --git a/src/ChatAgent.Client/wwwroot/css/app.css b/src/ChatAgent.Client/wwwroot/css/app.css index d919e04..60635aa 100644 --- a/src/ChatAgent.Client/wwwroot/css/app.css +++ b/src/ChatAgent.Client/wwwroot/css/app.css @@ -1,114 +1,117 @@ -html, body { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -h1:focus { - outline: none; -} - -a, .btn-link { - color: #0071c1; -} - -.btn-primary { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; -} - -.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { - box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; -} - -.content { - padding-top: 1.1rem; -} - -.valid.modified:not([type=checkbox]) { - outline: 1px solid #26b050; -} - -.invalid { - outline: 1px solid red; -} - -.validation-message { - color: red; -} - -#blazor-error-ui { - color-scheme: light only; - background: lightyellow; - bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); - box-sizing: border-box; - display: none; - left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; - position: fixed; - width: 100%; - z-index: 1000; -} - - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } - -.blazor-error-boundary { - background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; - padding: 1rem 1rem 1rem 3.7rem; - color: white; -} - - .blazor-error-boundary::after { - content: "An error has occurred." - } - -.loading-progress { - position: relative; - display: block; - width: 8rem; - height: 8rem; - margin: 20vh auto 1rem auto; -} - - .loading-progress circle { - fill: none; - stroke: #e0e0e0; - stroke-width: 0.6rem; - transform-origin: 50% 50%; - transform: rotate(-90deg); - } - - .loading-progress circle:last-child { - stroke: #1b6ec2; - stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; - transition: stroke-dasharray 0.05s ease-in-out; - } - -.loading-progress-text { - position: absolute; - text-align: center; - font-weight: bold; - inset: calc(20vh + 3.25rem) 0 auto 0.2rem; -} - - .loading-progress-text:after { - content: var(--blazor-load-percentage-text, "Loading"); - } - -code { - color: #c02d76; -} - -.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { - color: var(--bs-secondary-color); - text-align: end; -} - -.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { - text-align: start; -} \ No newline at end of file +/* app.css -- Application styles for ChatAgent (Phase 1). + * + * Phase 1 uses plain HTML/CSS (D-10) with a light theme (D-11). + * MudBlazor will be introduced in Phase 5 for UI polish. + * These styles provide a clean, minimal appearance for the health check page. + */ + +html, body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + margin: 0; + padding: 0; + background-color: #ffffff; + color: #333333; +} + +main { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +h1 { + color: #1a1a1a; + margin-bottom: 1.5rem; +} + +.health-status { + padding: 1rem; + border: 1px solid #e0e0e0; + border-radius: 8px; + background-color: #f9f9f9; +} + +.health-status p { + margin: 0.5rem 0; +} + +.error-message { + color: #d32f2f; + padding: 1rem; + border: 1px solid #d32f2f; + border-radius: 8px; + background-color: #fce4ec; +} + +.loading { + color: #666666; + font-style: italic; +} + +/* Blazor error UI -- shown when an unhandled exception occurs. + * This is built into the Blazor template's index.html and should be kept. */ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + +.blazor-error-boundary::after { + content: "An error has occurred." +} + +/* Loading progress indicator -- shown while the WASM runtime downloads. + * This SVG-based progress circle is defined in index.html. */ +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + +.loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); +} + +.loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; +} + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + +.loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); +} diff --git a/src/ChatAgent.Shared/Models/HealthResponse.cs b/src/ChatAgent.Shared/Models/HealthResponse.cs new file mode 100644 index 0000000..42d9312 --- /dev/null +++ b/src/ChatAgent.Shared/Models/HealthResponse.cs @@ -0,0 +1,32 @@ +// HealthResponse.cs -- Shared Data Transfer Object (DTO) for the health check endpoint. +// +// WHY SHARED? This class lives in the ChatAgent.Shared project, which is referenced by +// both the API (ChatAgent.Api) and the client (ChatAgent.Client). By sharing the type: +// - The API serializes a HealthResponse to JSON when responding to GET /api/health +// - The Client deserializes that same JSON back into a HealthResponse object +// - Both sides agree on the shape of the data -- no mismatched property names or types +// +// This is the "shared contract" pattern: one type definition, two consumers. + +namespace ChatAgent.Shared.Models +{ + /// <summary> + /// Data Transfer Object (DTO) returned by the API health check endpoint. + /// Contains the server's health status and current timestamp to prove + /// the response is live (not cached or stale). + /// </summary> + public class HealthResponse + { + /// <summary> + /// Server health status string (e.g., "healthy"). + /// Initialized to empty string to avoid null warnings with nullable enabled. + /// </summary> + public string Status { get; set; } = string.Empty; + + /// <summary> + /// Server-side UTC timestamp at the moment the health check ran. + /// The client displays this to confirm the response is fresh. + /// </summary> + public DateTime Timestamp { get; set; } + } +}