Updating structure

This commit is contained in:
2026-09-26 00:48:37 -04:00
parent 6eceff8002
commit 36b3c4f2b6
116 changed files with 1947 additions and 140 deletions
@@ -0,0 +1,117 @@
---
sessionId: session-260925-234832-1pc0
---
# Requirements
### Overview & Goals
Rename all projects in the repository from single-word names (`Build`, `Deploy`, `Model`, `Web`) to qualified names starting with the `ZeroSpace.` prefix (`ZeroSpace.Build`, `ZeroSpace.Deploy`, `ZeroSpace.Model`, `ZeroSpace.Web`). Ensure all solution definitions, project files, folder paths, project references, namespaces, and script paths are updated consistently.
### Scope
- **In Scope:**
- Renaming project directories: `ZS/Build` → `ZS/ZeroSpace.Build`, `ZS/Deploy` → `ZS/ZeroSpace.Deploy`, `ZS/Model` → `ZS/ZeroSpace.Model`, `ZS/Web` → `ZS/ZeroSpace.Web`.
- Renaming `.csproj` files: `Build.csproj` → `ZeroSpace.Build.csproj`, `Deploy.csproj` → `ZeroSpace.Deploy.csproj`, `Model.csproj` → `ZeroSpace.Model.csproj`, `Web.csproj` → `ZeroSpace.Web.csproj`.
- Updating `ZS/ZeroSpace.sln` project declarations and configuration mappings.
- Updating `<ProjectReference>` in `ZeroSpace.Web.csproj`.
- Updating C# namespaces and `@using` directives to match the `ZeroSpace.*` convention.
- Updating hardcoded project/folder paths in `ZeroSpace.Build/Program.cs` and `ZeroSpace.Deploy/Program.cs`.
- **Out of Scope:**
- Modifying business logic or UI behavior in the Blazor web application.
- Renaming Markdown documentation files under `zs.docs`.
# Technical Design
### Current Implementation
The solution contains 4 projects in `ZS/`:
- `ZS/Model/Model.csproj`: Class library declaring `namespace Model;`.
- `ZS/Build/Build.csproj`: Console generator referencing `Model/Units.g.cs` and scanning for `ZS.sln`.
- `ZS/Deploy/Deploy.csproj`: Deployment tool targeting `../Web/Web.csproj`.
- `ZS/Web/Web.csproj`: Blazor WebAssembly app referencing `..\Model\Model.csproj` and using `Web`, `Web.Layout`, and `Model`.
- `ZS/ZeroSpace.sln`: Solution listing `Web`, `Build`, `Model`, and `Deploy`.
### Key Decisions
- **Rename Project Directories:** Project folder names will be updated to match the project names (`ZeroSpace.Build`, `ZeroSpace.Deploy`, `ZeroSpace.Model`, `ZeroSpace.Web`) to maintain standard .NET convention where directory name matches the assembly/project name.
- **Update Namespaces:** Align namespaces (`ZeroSpace.Model`, `ZeroSpace.Web`) with project naming for consistency across the solution.
- **Fix Solution Search in Build Tool:** Update `FindSolutionRoot` in `ZeroSpace.Build/Program.cs` to locate `ZeroSpace.sln` (or any `*.sln`).
### Proposed Changes
- **Project Structure & Naming:**
- `ZS/Build/` → `ZS/ZeroSpace.Build/`
- `ZS/Build/Build.csproj` → `ZS/ZeroSpace.Build/ZeroSpace.Build.csproj`
- `ZS/Deploy/` → `ZS/ZeroSpace.Deploy/`
- `ZS/Deploy/Deploy.csproj` → `ZS/ZeroSpace.Deploy/ZeroSpace.Deploy.csproj`
- `ZS/Model/` → `ZS/ZeroSpace.Model/`
- `ZS/Model/Model.csproj` → `ZS/ZeroSpace.Model/ZeroSpace.Model.csproj`
- `ZS/Web/` → `ZS/ZeroSpace.Web/`
- `ZS/Web/Web.csproj` → `ZS/ZeroSpace.Web/ZeroSpace.Web.csproj`
- **Solution File (`ZS/ZeroSpace.sln`):**
- Update project name strings and project file paths to `ZeroSpace.*`.
- **Project Reference (`ZS/ZeroSpace.Web/ZeroSpace.Web.csproj`):**
- Change `..\Model\Model.csproj` to `..\ZeroSpace.Model\ZeroSpace.Model.csproj`.
- **Source Files & Namespaces:**
- `ZS/ZeroSpace.Model/UnitData.cs`: `namespace ZeroSpace.Model;`
- `ZS/ZeroSpace.Model/UnitEfficiency.cs`: `namespace ZeroSpace.Model;`
- `ZS/ZeroSpace.Model/Units.g.cs`: `namespace ZeroSpace.Model;`
- `ZS/ZeroSpace.Build/Program.cs`: update model path to `ZeroSpace.Model`, generate `namespace ZeroSpace.Model;`, check `ZeroSpace.sln`.
- `ZS/ZeroSpace.Deploy/Program.cs`: update path lookups for `ZeroSpace.Web` and `ZeroSpace.Web.csproj`.
- `ZS/ZeroSpace.Web/Program.cs`: `using ZeroSpace.Web;`
- `ZS/ZeroSpace.Web/_Imports.razor`: `@using ZeroSpace.Web`, `@using ZeroSpace.Web.Layout`, `@using ZeroSpace.Model`
- `ZS/ZeroSpace.Web/Pages/Units.razor`: `@using ZeroSpace.Model`
### Architecture Diagram
```mermaid
graph TD
Sln[ZS/ZeroSpace.sln] --> Web[ZS/ZeroSpace.Web]
Sln --> Model[ZS/ZeroSpace.Model]
Sln --> Build[ZS/ZeroSpace.Build]
Sln --> Deploy[ZS/ZeroSpace.Deploy]
Web --> Model
Build -. generates Units.g.cs .-> Model
Deploy -. publishes .-> Web
```
# Testing
### Validation Approach
- Verify solution builds cleanly via CLI (`dotnet build ZS/ZeroSpace.sln`).
- Run code generation via `dotnet run --project ZS/ZeroSpace.Build/ZeroSpace.Build.csproj` to confirm `Units.g.cs` is generated with the proper namespace.
- Validate project references and dependencies resolve without warnings or missing assembly errors.
### Key Scenarios
- `dotnet build ZS/ZeroSpace.sln` succeeds with 0 errors and 0 warnings.
- `ZeroSpace.Web` compiles with reference to `ZeroSpace.Model`.
- `ZeroSpace.Build` locates `zs.docs` and writes generated code into `ZS/ZeroSpace.Model/Units.g.cs`.
### Edge Cases
- Relative path traversal from build outputs (`AppContext.BaseDirectory` in `ZeroSpace.Deploy`) resolving the renamed `ZeroSpace.Web` project directory.
- `FindSolutionRoot` helper locating `ZeroSpace.sln` accurately.
# Delivery Steps
### ✓ Step 1: Rename project directories and project files
Project folders and `.csproj` files follow the `ZeroSpace.*` naming convention.
- Rename directory `ZS/Model` to `ZS/ZeroSpace.Model` and project file `Model.csproj` to `ZeroSpace.Model.csproj`.
- Rename directory `ZS/Build` to `ZS/ZeroSpace.Build` and project file `Build.csproj` to `ZeroSpace.Build.csproj`.
- Rename directory `ZS/Deploy` to `ZS/ZeroSpace.Deploy` and project file `Deploy.csproj` to `ZeroSpace.Deploy.csproj`.
- Rename directory `ZS/Web` to `ZS/ZeroSpace.Web` and project file `Web.csproj` to `ZeroSpace.Web.csproj`.
### ✓ Step 2: Update solution file and project references
`ZeroSpace.sln` and project reference paths correctly reference the renamed projects and directories.
- Update `ZS/ZeroSpace.sln` project entries and relative paths for `ZeroSpace.Web`, `ZeroSpace.Build`, `ZeroSpace.Model`, and `ZeroSpace.Deploy`.
- Update project reference `<ProjectReference Include="..\ZeroSpace.Model\ZeroSpace.Model.csproj" />` in `ZS/ZeroSpace.Web/ZeroSpace.Web.csproj`.
### ✓ Step 3: Update namespaces and code path references
Source code files, generator scripts, and deployment scripts use the updated `ZeroSpace.*` namespaces and folder paths.
- Update C# namespaces in `ZS/ZeroSpace.Model/UnitData.cs`, `ZS/ZeroSpace.Model/UnitEfficiency.cs`, and `ZS/ZeroSpace.Model/Units.g.cs` to `ZeroSpace.Model`.
- Update code generator in `ZS/ZeroSpace.Build/Program.cs` to target `ZeroSpace.Model` folder, output `namespace ZeroSpace.Model;`, and verify solution root resolution for `ZeroSpace.sln`.
- Update deploy script paths in `ZS/ZeroSpace.Deploy/Program.cs` to locate `ZeroSpace.Web` and `ZeroSpace.Web.csproj`.
- Update `ZS/ZeroSpace.Web/Program.cs`, `ZS/ZeroSpace.Web/_Imports.razor`, and `ZS/ZeroSpace.Web/Pages/Units.razor` to reference `ZeroSpace.Web`, `ZeroSpace.Web.Layout`, and `ZeroSpace.Model`.
### ✓ Step 4: Validate solution build and code generation
The entire solution builds cleanly with zero errors/warnings and code generation runs successfully.
- Run `dotnet build ZS/ZeroSpace.sln` to ensure all projects compile and link without errors.
- Run `ZeroSpace.Build` console application to ensure unit code generation completes and updates `ZeroSpace.Model/Units.g.cs` correctly.
-13
View File
@@ -1,13 +0,0 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Web;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddTelerikBlazor();
await builder.Build().RunAsync();
-34
View File
@@ -1,34 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "Web\Web.csproj", "{16296B27-D17B-4BFF-B08F-60F20DF36066}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "Build\Build.csproj", "{C1C04020-AFD6-493B-ABDB-277977D3488E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Model", "Model\Model.csproj", "{E31A06B0-9B2B-4805-B343-5687A5992E9A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deploy", "Deploy\Deploy.csproj", "{DECE54F2-51BB-4D7D-BD20-BE7F1C298FA3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{16296B27-D17B-4BFF-B08F-60F20DF36066}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{16296B27-D17B-4BFF-B08F-60F20DF36066}.Debug|Any CPU.Build.0 = Debug|Any CPU
{16296B27-D17B-4BFF-B08F-60F20DF36066}.Release|Any CPU.ActiveCfg = Release|Any CPU
{16296B27-D17B-4BFF-B08F-60F20DF36066}.Release|Any CPU.Build.0 = Release|Any CPU
{C1C04020-AFD6-493B-ABDB-277977D3488E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C1C04020-AFD6-493B-ABDB-277977D3488E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C1C04020-AFD6-493B-ABDB-277977D3488E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C1C04020-AFD6-493B-ABDB-277977D3488E}.Release|Any CPU.Build.0 = Release|Any CPU
{E31A06B0-9B2B-4805-B343-5687A5992E9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E31A06B0-9B2B-4805-B343-5687A5992E9A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E31A06B0-9B2B-4805-B343-5687A5992E9A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E31A06B0-9B2B-4805-B343-5687A5992E9A}.Release|Any CPU.Build.0 = Release|Any CPU
{DECE54F2-51BB-4D7D-BD20-BE7F1C298FA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DECE54F2-51BB-4D7D-BD20-BE7F1C298FA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DECE54F2-51BB-4D7D-BD20-BE7F1C298FA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DECE54F2-51BB-4D7D-BD20-BE7F1C298FA3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+9
View File
@@ -0,0 +1,9 @@
**/.git
**/.vs
**/.idea
**/.junie
**/bin
**/obj
**/node_modules
*.tmp
*.log
@@ -0,0 +1,199 @@
---
sessionId: session-260925-235814-7y2w
---
# Requirements
### Overview & Goals
The goal is to modernize the ZeroSpace application by introducing **.NET Aspire** orchestration, adding a dedicated backend **GraphQL API service** powered by **Hot Chocolate** with **Nitro UI (Banana Cake Pop)**, integrating a **PostgreSQL database**, containerizing components with **Docker**, and managing deployments on **Kubernetes** using industry-standard tooling (Aspirate / K8s manifests).
### Scope
- **In Scope**:
- Creation of `.NET Aspire` AppHost (`ZeroSpace.AppHost`) and shared Service Defaults (`ZeroSpace.ServiceDefaults`).
- Creation of an ASP.NET Core GraphQL API service (`ZeroSpace.ApiService`) using **Hot Chocolate** with Entity Framework Core & PostgreSQL (`Npgsql`) support.
- Interactive **Nitro UI** (Banana Cake Pop) GraphQL IDE integration at the `/graphql` endpoint for rapid schema exploration and testing.
- GraphQL queries, mutations, filtering, sorting, and projections for `UnitData`.
- PostgreSQL container provisioning and database connection orchestration via Aspire.
- Updating `ZeroSpace.Web` (Blazor WASM) to consume GraphQL operations from `ZeroSpace.ApiService`.
- Docker containerization for both `ZeroSpace.ApiService` and `ZeroSpace.Web`.
- Kubernetes manifest management and Aspirate integration for cluster deployment.
- **Out of Scope**:
- Modifying the unit model generation logic in `ZeroSpace.Build`.
- Cloud-specific managed Kubernetes cluster creation scripts (e.g. Terraform/Bicep for AKS/EKS).
### User Stories
- **As a Developer**, I want to start the web app, GraphQL API, and PostgreSQL database with a single command using .NET Aspire, and use Nitro UI to interactively explore and query schemas so that local development and testing maximize productivity.
- **As an Operator**, I want to generate Kubernetes deployment manifests from the Aspire architecture so that all services and databases deploy consistently to production Kubernetes clusters.
- **As a User**, I want to browse and manage ZeroSpace unit data through the Blazor Web UI backed by efficient, precision GraphQL data fetching and persistent database storage.
### Functional Requirements
- **FR-1**: The Aspire AppHost must orchestrate the PostgreSQL container, GraphQL API service, and Web app with configured dependencies and environment variables.
- **FR-2**: The `ZeroSpace.ApiService` must expose a Hot Chocolate GraphQL server with schema filtering, sorting, and projection capabilities over PostgreSQL `UnitData`.
- **FR-3**: The Nitro UI GraphQL IDE must be hosted and accessible at `/graphql` to enable in-browser schema exploration, query execution, and debugging.
- **FR-4**: Database schema migrations and initial seed data from `ZeroSpace.Model.Units.All` must be executed automatically upon startup.
- **FR-5**: `ZeroSpace.Web` must execute GraphQL queries/mutations against `ZeroSpace.ApiService` using configured HTTP endpoints.
- **FR-6**: Container definitions (Dockerfiles) must exist for all custom applications.
- **FR-7**: Kubernetes manifests must specify Deployments, Services, PersistentVolumeClaims, Secrets, and Ingress routing for Web and GraphQL endpoints.
### Non-Functional Requirements
- **Observability**: OpenTelemetry tracing, metrics, and structured logging enabled across services via `ServiceDefaults` (including Hot Chocolate diagnostic tracing).
- **Resilience & Health**: Health check endpoints (`/health`, `/alive`) configured for Kubernetes readiness and liveness probes.
- **Security**: Database credentials managed through environment variables, Secrets, and Aspire connection strings without hardcoded passwords.
- **Query Efficiency**: GraphQL projections ensure only requested fields are fetched from PostgreSQL, minimizing network transfer and database payload overhead.
# Technical Design
### Current Implementation
- `ZeroSpace.Web` is a standalone Blazor WebAssembly application (`Microsoft.NET.Sdk.BlazorWebAssembly`) targeting .NET 10 with Telerik UI for Blazor.
- `ZeroSpace.Model` contains records like `UnitData.cs`, `UnitEfficiency.cs`, and generated static unit arrays in `Units.g.cs`.
- `ZeroSpace.Deploy` publishes static files to Azure Static Web Apps.
- `ZeroSpace.AppHost`, `ZeroSpace.ServiceDefaults`, and `ZeroSpace.ApiService` are integrated into the solution.
### Key Decisions
1. **Hot Chocolate GraphQL Server with Nitro UI (`ZeroSpace.ApiService`)**:
- *Decision*: Introduce Hot Chocolate GraphQL server (`HotChocolate.AspNetCore`, `HotChocolate.Data.EntityFramework`) and enable the embedded Nitro UI (Banana Cake Pop) GraphQL tool suite.
- *Rationale*: GraphQL enables client-driven data fetching, reducing over-fetching and network serialization overhead. Nitro UI provides a rich developer and operator environment for real-time schema introspection, query building, and mutation execution directly in the browser.
2. **.NET Aspire Orchestration & Aspirate for Kubernetes**:
- *Decision*: Use .NET Aspire AppHost for local orchestration and developer dashboard, and use Aspirate (Aspir8) alongside standard Kubernetes manifests.
- *Rationale*: Aspire provides official .NET cloud-native developer tooling, OpenTelemetry defaults, and service discovery, while Aspirate transforms the Aspire manifest into production-ready Kubernetes Helm/Kustomize/YAML artifacts.
3. **Database Architecture & Seeding**:
- *Decision*: Entity Framework Core with `Npgsql.EntityFrameworkCore.PostgreSQL` and PostgreSQL 17 container.
- *Rationale*: Native EF Core support with Aspire component `Aspire.Npgsql.EntityFrameworkCore.PostgreSQL` enables automatic connection string binding, resilient retries, and database health checks.
### Architecture Diagram
```mermaid
graph TD
subgraph Local / Dev [Local .NET Aspire AppHost]
AppHost[ZeroSpace.AppHost]
Dash[Aspire Dashboard]
Nitro[Nitro UI / Banana Cake Pop]
AppHost --> Dash
AppHost --> Nitro
end
subgraph Kubernetes Cluster [Kubernetes Cluster]
Ingress[Kubernetes Ingress / Gateway]
subgraph Web Pod [Web Deployment]
Web[ZeroSpace.Web / Nginx]
end
subgraph API Pod [API Deployment]
API[ZeroSpace.ApiService / Hot Chocolate]
end
subgraph DB Pod [PostgreSQL StatefulSet / Pod]
DB[(PostgreSQL 17)]
PVC[PersistentVolumeClaim]
DB --- PVC
end
end
Ingress --> Web
Ingress --> API
Web -->|GraphQL Queries / Mutations| API
Nitro -->|Interactive Queries / Schema Docs| API
API -->|EF Core / Npgsql| DB
```
### Components
- **`ZeroSpace.AppHost`**: Aspire orchestrator project that links PostgreSQL container, `ZeroSpace.ApiService`, and `ZeroSpace.Web`.
- **`ZeroSpace.ServiceDefaults`**: Shared project configuring OpenTelemetry, health checks, discovery, and resilience policies.
- **`ZeroSpace.ApiService`**: ASP.NET Core GraphQL API powered by Hot Chocolate, hosting Nitro UI at `/graphql`, and handling PostgreSQL persistence with EF Core.
- **`ZeroSpace.Web`**: Client-side Blazor WASM UI configured with `IUnitsApiClient` communicating with `ZeroSpace.ApiService` via GraphQL.
- **`k8s/`**: Kubernetes manifests (Deployments, Services, PVC, Ingress, Secrets) and Aspirate generation configuration.
### File Structure
```
ZeroSpace/
├── ZeroSpace.AppHost/
│ ├── Program.cs
│ ├── appsettings.json
│ └── ZeroSpace.AppHost.csproj
├── ZeroSpace.ServiceDefaults/
│ ├── Extensions.cs
│ └── ZeroSpace.ServiceDefaults.csproj
├── ZeroSpace.ApiService/
│ ├── Data/
│ │ ├── ZeroSpaceDbContext.cs
│ │ └── DbInitializer.cs
│ ├── GraphQL/
│ │ ├── Query.cs
│ │ ├── Mutation.cs
│ │ └── Types/
│ ├── Dockerfile
│ ├── Program.cs
│ └── ZeroSpace.ApiService.csproj
├── ZeroSpace.Web/
│ ├── Services/
│ │ ├── IUnitsApiClient.cs
│ │ └── UnitsApiClient.cs
│ ├── Dockerfile
│ ├── nginx.conf
│ └── ...
├── k8s/
│ ├── postgres-pvc.yaml
│ ├── postgres-deployment.yaml
│ ├── apiservice-deployment.yaml
│ ├── web-deployment.yaml
│ └── ingress.yaml
├── aspirate.json
└── ZeroSpace.sln
```
### Data Models / Contracts
```graphql
# GraphQL Schema Contract
type Query {
units(
where: UnitDataFilterInput
order: [UnitDataSortInput!]
): [UnitData!]!
unitById(id: String!): UnitData
}
type Mutation {
createUnit(input: CreateUnitInput!): UnitData!
updateUnit(id: String!, input: UpdateUnitInput!): UnitData!
deleteUnit(id: String!): Boolean!
}
```
### Risks & Mitigations
- **Risk**: GraphQL N+1 or deep query performance impacts.
- *Mitigation*: Leverage Hot Chocolate's `[UseProjection]`, `[UseFiltering]`, and `[UseSorting]` which translate GraphQL AST directly into optimized PostgreSQL SQL via EF Core `IQueryable`.
- **Risk**: Nitro UI access across different deployment environments.
- *Mitigation*: Configure Nitro UI / Banana Cake Pop middleware settings so developer tooling is available locally and behind authorized Ingress routes in staging/production as needed.
- **Risk**: Blazor WASM CORS issues when executing GraphQL POST requests.
- *Mitigation*: Configure permissive CORS policies in `ZeroSpace.ApiService` for GraphQL endpoints and route requests through Kubernetes Ingress.
# Testing
### Validation Approach
- Verify local orchestration via `dotnet run --project ZeroSpace.AppHost`.
- Access Nitro UI at `http://localhost:5142/graphql` (or through Aspire Dashboard endpoint) to test interactive queries, mutations, and schema documentation.
- Test GraphQL queries and mutations executed by `ZeroSpace.Web` against `ZeroSpace.ApiService`.
- Build and run Docker containers for `ZeroSpace.ApiService` and `ZeroSpace.Web`.
- Validate Kubernetes manifest syntax and Aspirate manifest compilation (`aspirate generate`).
### Key Scenarios
1. **Aspire Local Launch**: Starting `ZeroSpace.AppHost` boots PostgreSQL container, runs EF Core migrations, starts `ZeroSpace.ApiService` with Hot Chocolate GraphQL & Nitro UI, and launches `ZeroSpace.Web`.
2. **Nitro UI Interactive Execution**: Open `/graphql` in browser; Banana Cake Pop / Nitro UI loads; execute `{ units { id name hexite flux tier faction } }` and receive structured JSON response.
3. **Blazor UI Data Flow**: Navigating to `/units` in the Blazor UI sends a GraphQL query payload to `ZeroSpace.ApiService`, which queries PostgreSQL and returns unit records.
4. **Container Build**: `docker build` succeeds for both `ZeroSpace.ApiService` and `ZeroSpace.Web` Dockerfiles.
5. **Kubernetes Deployment**: Applying the generated Kubernetes manifests provisions the PVC, deploys PostgreSQL, starts API/Web pods, and exposes them via Service and Ingress.
### Edge Cases
- **PostgreSQL Connection Failures**: GraphQL queries gracefully return structured GraphQL errors and health checks report unhealthy status.
- **Empty Database**: Seeding logic populates initial units from `ZeroSpace.Model` if the database table is empty before serving GraphQL requests.
- **Complex GraphQL Filtering**: Verify filtering by tier and faction compiles to efficient SQL `WHERE` clauses in PostgreSQL without in-memory buffering.
# Execution Plan
### ✓ Step 1: Configure Hot Chocolate GraphQL and Nitro UI in ZeroSpace.ApiService
### ✓ Step 2: Update ZeroSpace.Web to consume GraphQL API operations
### ✓ Step 3: Update AppHost, Ingress, and Kubernetes configuration for GraphQL endpoint
### ✓ Step 4: Build and Verify Solution
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.Data;
public static class DbInitializer
{
public static async Task InitializeDatabaseAsync(IServiceProvider serviceProvider, ILogger logger, CancellationToken cancellationToken = default)
{
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ZeroSpaceDbContext>();
logger.LogInformation("Ensuring database schema exists...");
await db.Database.EnsureCreatedAsync(cancellationToken);
if (!await db.Units.AnyAsync(cancellationToken))
{
logger.LogInformation("Seeding initial units into PostgreSQL...");
var initialUnits = Units.All;
await db.Units.AddRangeAsync(initialUnits, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
logger.LogInformation("Seeded {Count} units into PostgreSQL database.", initialUnits.Count);
}
else
{
logger.LogInformation("Database already contains unit data. Skipping seed.");
}
}
}
@@ -0,0 +1,66 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.Data;
public class ZeroSpaceDbContext : DbContext
{
public ZeroSpaceDbContext(DbContextOptions<ZeroSpaceDbContext> options) : base(options)
{
}
public DbSet<UnitData> Units => Set<UnitData>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<UnitData>(entity =>
{
entity.ToTable("units");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id").HasMaxLength(100);
entity.Property(e => e.Name).HasColumnName("name").IsRequired().HasMaxLength(200);
entity.Property(e => e.Hexite).HasColumnName("hexite");
entity.Property(e => e.Flux).HasColumnName("flux");
entity.Property(e => e.Supply).HasColumnName("supply");
entity.Property(e => e.ProductionTime).HasColumnName("production_time");
entity.Property(e => e.Health).HasColumnName("health");
entity.Property(e => e.Energy).HasColumnName("energy");
entity.Property(e => e.Shields).HasColumnName("shields");
entity.Property(e => e.ArmorRating).HasColumnName("armor_rating");
entity.Property(e => e.MovementSpeed).HasColumnName("movement_speed");
entity.Property(e => e.DamagePerSecond).HasColumnName("damage_per_second");
entity.Property(e => e.AttackRange).HasColumnName("attack_range");
entity.Property(e => e.Tier).HasColumnName("tier");
entity.Property(e => e.Faction).HasColumnName("faction").IsRequired().HasMaxLength(100);
entity.Property(e => e.Hotkey).HasColumnName("hotkey").HasMaxLength(20);
entity.Property(e => e.BuildAtSameTime).HasColumnName("build_at_same_time");
entity.Property(e => e.Limit).HasColumnName("limit");
var valueComparer = new ValueComparer<IReadOnlyList<string>>(
(c1, c2) => (c1 == null && c2 == null) || (c1 != null && c2 != null && c1.SequenceEqual(c2)),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
c => c.ToList()
);
entity.Property(e => e.Attributes)
.HasColumnName("attributes")
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => (IReadOnlyList<string>)(JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>())
)
.Metadata.SetValueComparer(valueComparer);
entity.Ignore(e => e.DpsPerHexite);
entity.Ignore(e => e.DpsPerFlux);
entity.Ignore(e => e.DpsPerTotalCost);
entity.Ignore(e => e.HealthPerHexite);
entity.Ignore(e => e.HealthPerFlux);
entity.Ignore(e => e.HealthPerTotalCost);
});
}
}
+21
View File
@@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy project files for caching restore layer
COPY ["ZeroSpace.ApiService/ZeroSpace.ApiService.csproj", "ZeroSpace.ApiService/"]
COPY ["ZeroSpace.Model/ZeroSpace.Model.csproj", "ZeroSpace.Model/"]
COPY ["ZeroSpace.ServiceDefaults/ZeroSpace.ServiceDefaults.csproj", "ZeroSpace.ServiceDefaults/"]
RUN dotnet restore "ZeroSpace.ApiService/ZeroSpace.ApiService.csproj"
# Copy source code and build
COPY . .
WORKDIR "/src/ZeroSpace.ApiService"
RUN dotnet publish "ZeroSpace.ApiService.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
EXPOSE 8080
EXPOSE 8443
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "ZeroSpace.ApiService.dll"]
@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using ZeroSpace.ApiService.Data;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.Endpoints;
public static class UnitsEndpoints
{
public static IEndpointRouteBuilder MapUnitsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/units")
.WithTags("Units");
group.MapGet("/", async (ZeroSpaceDbContext db, string? faction, int? tier) =>
{
IQueryable<UnitData> query = db.Units.AsNoTracking();
if (!string.IsNullOrWhiteSpace(faction))
{
query = query.Where(u => u.Faction == faction);
}
if (tier.HasValue)
{
query = query.Where(u => u.Tier == tier.Value);
}
var units = await query.ToListAsync();
return Results.Ok(units);
})
.WithName("GetAllUnits")
.WithSummary("Get all units optionally filtered by faction and tier.");
group.MapGet("/{id}", async (string id, ZeroSpaceDbContext db) =>
{
var unit = await db.Units.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
return unit is not null ? Results.Ok(unit) : Results.NotFound();
})
.WithName("GetUnitById")
.WithSummary("Get a unit by id.");
group.MapPost("/", async (UnitData unit, ZeroSpaceDbContext db) =>
{
var exists = await db.Units.AnyAsync(u => u.Id == unit.Id);
if (exists)
{
return Results.Conflict($"Unit with id '{unit.Id}' already exists.");
}
db.Units.Add(unit);
await db.SaveChangesAsync();
return Results.Created($"/api/units/{unit.Id}", unit);
})
.WithName("CreateUnit")
.WithSummary("Create a new unit.");
group.MapPut("/{id}", async (string id, UnitData updatedUnit, ZeroSpaceDbContext db) =>
{
var unit = await db.Units.FindAsync(id);
if (unit is null)
{
return Results.NotFound();
}
db.Units.Remove(unit);
db.Units.Add(updatedUnit with { Id = id });
await db.SaveChangesAsync();
return Results.Ok(updatedUnit with { Id = id });
})
.WithName("UpdateUnit")
.WithSummary("Update an existing unit.");
group.MapDelete("/{id}", async (string id, ZeroSpaceDbContext db) =>
{
var unit = await db.Units.FindAsync(id);
if (unit is null)
{
return Results.NotFound();
}
db.Units.Remove(unit);
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithName("DeleteUnit")
.WithSummary("Delete a unit by id.");
return routes;
}
}
@@ -0,0 +1,136 @@
using HotChocolate;
using Microsoft.EntityFrameworkCore;
using ZeroSpace.ApiService.Data;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.GraphQL;
public record CreateUnitInput(
string Id,
string Name,
int Hexite,
int Flux,
int Supply,
int ProductionTime,
int Health,
int Energy,
int Shields,
int ArmorRating,
int MovementSpeed,
int DamagePerSecond,
int AttackRange,
IReadOnlyList<string>? Attributes,
int Tier,
string Faction,
string? Hotkey,
int BuildAtSameTime,
int? Limit
);
public record UpdateUnitInput(
string Name,
int Hexite,
int Flux,
int Supply,
int ProductionTime,
int Health,
int Energy,
int Shields,
int ArmorRating,
int MovementSpeed,
int DamagePerSecond,
int AttackRange,
IReadOnlyList<string>? Attributes,
int Tier,
string Faction,
string? Hotkey,
int BuildAtSameTime,
int? Limit
);
public class Mutation
{
public async Task<UnitData> CreateUnitAsync(CreateUnitInput input, ZeroSpaceDbContext db, CancellationToken cancellationToken)
{
var exists = await db.Units.AnyAsync(u => u.Id == input.Id, cancellationToken);
if (exists)
{
throw new GraphQLException(ErrorBuilder.New().SetMessage($"Unit with id '{input.Id}' already exists.").SetCode("UNIT_ALREADY_EXISTS").Build());
}
var unit = new UnitData(
input.Id,
input.Name,
input.Hexite,
input.Flux,
input.Supply,
input.ProductionTime,
input.Health,
input.Energy,
input.Shields,
input.ArmorRating,
input.MovementSpeed,
input.DamagePerSecond,
input.AttackRange,
input.Attributes ?? new List<string>(),
input.Tier,
input.Faction,
input.Hotkey,
input.BuildAtSameTime,
input.Limit
);
db.Units.Add(unit);
await db.SaveChangesAsync(cancellationToken);
return unit;
}
public async Task<UnitData> UpdateUnitAsync(string id, UpdateUnitInput input, ZeroSpaceDbContext db, CancellationToken cancellationToken)
{
var existing = await db.Units.FindAsync(new object[] { id }, cancellationToken);
if (existing is null)
{
throw new GraphQLException(ErrorBuilder.New().SetMessage($"Unit with id '{id}' not found.").SetCode("UNIT_NOT_FOUND").Build());
}
var updated = new UnitData(
id,
input.Name,
input.Hexite,
input.Flux,
input.Supply,
input.ProductionTime,
input.Health,
input.Energy,
input.Shields,
input.ArmorRating,
input.MovementSpeed,
input.DamagePerSecond,
input.AttackRange,
input.Attributes ?? new List<string>(),
input.Tier,
input.Faction,
input.Hotkey,
input.BuildAtSameTime,
input.Limit
);
db.Units.Remove(existing);
db.Units.Add(updated);
await db.SaveChangesAsync(cancellationToken);
return updated;
}
public async Task<bool> DeleteUnitAsync(string id, ZeroSpaceDbContext db, CancellationToken cancellationToken)
{
var unit = await db.Units.FindAsync(new object[] { id }, cancellationToken);
if (unit is null)
{
return false;
}
db.Units.Remove(unit);
await db.SaveChangesAsync(cancellationToken);
return true;
}
}
@@ -0,0 +1,19 @@
using HotChocolate;
using HotChocolate.Data;
using Microsoft.EntityFrameworkCore;
using ZeroSpace.ApiService.Data;
using ZeroSpace.Model;
namespace ZeroSpace.ApiService.GraphQL;
public class Query
{
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<UnitData> GetUnits(ZeroSpaceDbContext db) =>
db.Units.AsNoTracking();
public async Task<UnitData?> GetUnitByIdAsync(string id, ZeroSpaceDbContext db, CancellationToken cancellationToken) =>
await db.Units.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
+64
View File
@@ -0,0 +1,64 @@
using ZeroSpace.ApiService.Data;
using ZeroSpace.ApiService.Endpoints;
using ZeroSpace.ApiService.GraphQL;
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & Aspire components
builder.AddServiceDefaults();
// Add EF Core with PostgreSQL connection from Aspire ("zerospacedb")
builder.AddNpgsqlDbContext<ZeroSpaceDbContext>("zerospacedb");
// Add CORS for Web client
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
});
// Configure Hot Chocolate GraphQL Server with Nitro UI (Banana Cake Pop)
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddProjections()
.AddFiltering()
.AddSorting();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
var app = builder.Build();
app.UseCors();
app.MapDefaultEndpoints();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// Map GraphQL endpoint with Nitro UI (Banana Cake Pop) at /graphql
app.MapGraphQL();
// Optional REST endpoints for auxiliary compatibility
app.MapUnitsEndpoints();
// Initialize database schema and initial data
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await DbInitializer.InitializeDatabaseAsync(app.Services, app.Logger, cts.Token);
}
catch (Exception ex)
{
app.Logger.LogError(ex, "An error occurred while initializing the database.");
}
await app.RunAsync();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7142;http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.5.4" />
<PackageReference Include="HotChocolate.AspNetCore" Version="16.6.7" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="16.6.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZeroSpace.Model\ZeroSpace.Model.csproj" />
<ProjectReference Include="..\ZeroSpace.ServiceDefaults\ZeroSpace.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+15
View File
@@ -0,0 +1,15 @@
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithDataVolume()
.AddDatabase("zerospacedb");
var apiService = builder.AddProject<Projects.ZeroSpace_ApiService>("apiservice")
.WithReference(postgres)
.WaitFor(postgres);
var web = builder.AddProject<Projects.ZeroSpace_Web>("web")
.WithReference(apiService)
.WaitFor(apiService);
builder.Build().Run();
@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17228;http://localhost:15228",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21145",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15228",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19145",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
}
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Aspire.AppHost.Sdk/13.5.4">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<UserSecretsId>zerospace-apphost-secrets</UserSecretsId>
<NoWarn>$(NoWarn);ASPIRE010</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.5.4" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.5.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZeroSpace.ApiService\ZeroSpace.ApiService.csproj" />
<ProjectReference Include="..\ZeroSpace.Web\ZeroSpace.Web.csproj" />
<ProjectReference Include="..\ZeroSpace.ServiceDefaults\ZeroSpace.ServiceDefaults.csproj" IsAspireProjectResource="false" />
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"ResourceLifecycle": "Information"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"ResourceLifecycle": "Information"
}
}
}
@@ -5,7 +5,7 @@ var solutionRoot = FindSolutionRoot();
var docsRoot = args.Length > 0
? Path.GetFullPath(args[0])
: Path.GetFullPath(Path.Combine(solutionRoot, "..", "zs.docs"));
var modelRoot = Path.Combine(solutionRoot, "Model");
var modelRoot = Path.Combine(solutionRoot, "ZeroSpace.Model");
var outputPath = Path.Combine(modelRoot, "Units.g.cs");
if (!Directory.Exists(docsRoot))
@@ -21,7 +21,7 @@ if (!Directory.Exists(modelRoot))
var units = new List<UnitSource>();
var skipped = new List<string>();
foreach (var path in Directory.EnumerateFiles(docsRoot, "*.md").Order(StringComparer.OrdinalIgnoreCase))
foreach (var path in Directory.EnumerateFiles(docsRoot, "*.md", SearchOption.AllDirectories).Order(StringComparer.OrdinalIgnoreCase))
{
var frontMatter = ReadFrontMatter(path);
if (frontMatter is null)
@@ -71,19 +71,29 @@ if (skipped.Count > 0)
static string FindSolutionRoot()
{
var directory = new DirectoryInfo(Environment.CurrentDirectory);
var candidates = new[] { Environment.CurrentDirectory, AppContext.BaseDirectory };
foreach (var candidate in candidates)
{
var directory = new DirectoryInfo(candidate);
while (directory is not null)
{
if (directory.EnumerateFiles("ZS.sln").Any())
if (File.Exists(Path.Combine(directory.FullName, "ZeroSpace.sln")))
{
return directory.FullName;
}
directory = directory.Parent;
if (File.Exists(Path.Combine(directory.FullName, "ZeroSpace", "ZeroSpace.sln")))
{
return Path.Combine(directory.FullName, "ZeroSpace");
}
throw new DirectoryNotFoundException("Could not find ZS.sln in the current directory or its parents.");
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException("Could not find ZeroSpace.sln in the current directory or its parents.");
}
static Dictionary<string, FrontMatterValue>? ReadFrontMatter(string path)
@@ -312,7 +322,7 @@ static string GenerateUnits(IReadOnlyList<UnitSource> units)
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated />");
builder.AppendLine("namespace Model;");
builder.AppendLine("namespace ZeroSpace.Model;");
builder.AppendLine();
builder.AppendLine("public static class Units");
builder.AppendLine("{");
@@ -1,8 +1,8 @@
using System.Diagnostics;
var deployDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
var webDir = Path.GetFullPath(Path.Combine(deployDir, "..", "Web"));
var webProject = Path.Combine(webDir, "Web.csproj");
var webDir = Path.GetFullPath(Path.Combine(deployDir, "..", "ZeroSpace.Web"));
var webProject = Path.Combine(webDir, "ZeroSpace.Web.csproj");
var publishDir = Path.Combine(Path.GetTempPath(), "zs-deploy", Guid.NewGuid().ToString());
var deploymentToken = Environment.GetEnvironmentVariable("ZS_DeployToken") ?? throw new InvalidOperationException("ZS_DeployToken environment variable not set.");
var deployEnv = Environment.GetEnvironmentVariable("ZS_DeployEnv") ?? "preview";
@@ -1,4 +1,4 @@
namespace Model;
namespace ZeroSpace.Model;
public sealed record UnitData(
string Id,
@@ -1,4 +1,4 @@
namespace Model;
namespace ZeroSpace.Model;
public static class UnitEfficiency
{
@@ -1,5 +1,5 @@
// <auto-generated />
namespace Model;
namespace ZeroSpace.Model;
public static class Units
{
@@ -150,48 +150,6 @@ public static class Units
BuildAtSameTime: 1,
Limit: null);
public static readonly UnitData Lasher = new(
Id: "Lasher",
Name: "Lasher",
Hexite: 50,
Flux: 25,
Supply: 2,
ProductionTime: 26,
Health: 150,
Energy: 0,
Shields: 0,
ArmorRating: 0,
MovementSpeed: 550,
DamagePerSecond: 14,
AttackRange: 8,
Attributes: ["Light Armor", "Biological", "Bonus Damage vs Medium Armor"],
Tier: 1,
Faction: "Grell",
Hotkey: "R",
BuildAtSameTime: 1,
Limit: null);
public static readonly UnitData Skrell = new(
Id: "Skrell",
Name: "Skrell",
Hexite: 12,
Flux: 19,
Supply: 1,
ProductionTime: 35,
Health: 50,
Energy: 0,
Shields: 0,
ArmorRating: 0,
MovementSpeed: 900,
DamagePerSecond: 5,
AttackRange: 2,
Attributes: ["Air unit", "Light Armor", "Biological", "Bonus Damage vs Air"],
Tier: 1,
Faction: "Grell",
Hotkey: "T",
BuildAtSameTime: 4,
Limit: null);
public static readonly UnitData Stinger = new(
Id: "Stinger",
Name: "Stinger",
@@ -255,27 +213,6 @@ public static class Units
BuildAtSameTime: 1,
Limit: null);
public static readonly UnitData Thresher = new(
Id: "Thresher",
Name: "Thresher",
Hexite: 100,
Flux: 175,
Supply: 8,
ProductionTime: 65,
Health: 300,
Energy: 0,
Shields: 0,
ArmorRating: 0,
MovementSpeed: 450,
DamagePerSecond: 36,
AttackRange: 27,
Attributes: ["Biological", "Heavy Armor", "Bonus Damage vs Buildings"],
Tier: 2,
Faction: "Grell",
Hotkey: "S",
BuildAtSameTime: 1,
Limit: null);
public static readonly UnitData Behemoth = new(
Id: "Behemoth",
Name: "Behemoth",
@@ -348,12 +285,9 @@ public static class Units
CorsairFlamer,
Gatherer,
BroodGuard,
Lasher,
Skrell,
Stinger,
Harbinger,
Mandragora,
Thresher,
Behemoth,
Reaver,
Weaver,
@@ -0,0 +1,108 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
// Adds common .NET Aspire service defaults: OpenTelemetry, health checks, resilience, and service discovery.
public static class Extensions
{
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in other environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks("/health");
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
else
{
app.MapHealthChecks("/health");
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.10.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.19.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.19.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.11.1" />
</ItemGroup>
</Project>
+20
View File
@@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy project files for caching restore layer
COPY ["ZeroSpace.Web/ZeroSpace.Web.csproj", "ZeroSpace.Web/"]
COPY ["ZeroSpace.Model/ZeroSpace.Model.csproj", "ZeroSpace.Model/"]
RUN dotnet restore "ZeroSpace.Web/ZeroSpace.Web.csproj"
# Copy source code and build
COPY . .
WORKDIR "/src/ZeroSpace.Web"
RUN dotnet publish "ZeroSpace.Web.csproj" -c Release -o /app/publish
FROM nginx:alpine AS final
WORKDIR /usr/share/nginx/html
COPY --from=build /app/publish/wwwroot .
COPY ZeroSpace.Web/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
@@ -1,8 +1,9 @@
@page "/units"
@using Model
@using ZeroSpace.Model
@using ZeroSpace.Web.Services
@using Telerik.Blazor
@using Telerik.Blazor.Components
@inject IUnitsApiClient UnitsApiClient
<PageTitle>Units</PageTitle>
@@ -11,14 +12,29 @@
<div class="ms-3 flex-grow-1 border-bottom opacity-25"></div>
</div>
@if (gearNotes == null)
@if (isLoading)
{
<div class="d-flex justify-content-center py-5">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">Loading...</span>
<span class="visually-hidden">Loading units...</span>
</div>
</div>
}
else if (errorMessage != null)
{
<div class="alert alert-danger d-flex align-items-center justify-content-between" role="alert">
<div>
<strong>Error:</strong> @errorMessage
</div>
<button class="btn btn-outline-danger btn-sm" @onclick="LoadUnitsAsync">Retry</button>
</div>
}
else if (gearNotes == null || gearNotes.Count == 0)
{
<div class="alert alert-warning" role="alert">
No units found.
</div>
}
else
{
<div class="grid-container">
@@ -54,12 +70,31 @@ else
@code {
private List<UnitData>? gearNotes;
private bool isLoading = true;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
var index = Model.Units.All;
gearNotes = index
.ToList();
await LoadUnitsAsync();
}
private async Task LoadUnitsAsync()
{
isLoading = true;
errorMessage = null;
try
{
var units = await UnitsApiClient.GetUnitsAsync();
gearNotes = units.ToList();
}
catch (Exception ex)
{
errorMessage = $"Failed to load units: {ex.Message}";
}
finally
{
isLoading = false;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using ZeroSpace.Web;
using ZeroSpace.Web.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddHttpClient<IUnitsApiClient, UnitsApiClient>(client =>
{
var apiBaseUrl = builder.Configuration["services:apiservice:https:0"]
?? builder.Configuration["services:apiservice:http:0"]
?? builder.Configuration["ApiServiceUrl"]
?? builder.HostEnvironment.BaseAddress;
if (!apiBaseUrl.EndsWith('/'))
{
apiBaseUrl += "/";
}
client.BaseAddress = new Uri(apiBaseUrl);
});
builder.Services.AddTelerikBlazor();
await builder.Build().RunAsync();
@@ -0,0 +1,11 @@
using ZeroSpace.Model;
namespace ZeroSpace.Web.Services;
public interface IUnitsApiClient
{
Task<IReadOnlyList<UnitData>> GetUnitsAsync(string? faction = null, int? tier = null, CancellationToken cancellationToken = default);
Task<UnitData?> GetUnitByIdAsync(string id, CancellationToken cancellationToken = default);
Task<UnitData?> CreateUnitAsync(UnitData unit, CancellationToken cancellationToken = default);
Task<bool> DeleteUnitAsync(string id, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,278 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using ZeroSpace.Model;
namespace ZeroSpace.Web.Services;
public class UnitsApiClient : IUnitsApiClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<UnitsApiClient> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private const string AllUnitFields = @"
id
name
hexite
flux
supply
productionTime
health
energy
shields
armorRating
movementSpeed
damagePerSecond
attackRange
attributes
tier
faction
hotkey
buildAtSameTime
limit";
public UnitsApiClient(HttpClient httpClient, ILogger<UnitsApiClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<IReadOnlyList<UnitData>> GetUnitsAsync(string? faction = null, int? tier = null, CancellationToken cancellationToken = default)
{
try
{
Dictionary<string, object?>? whereFilter = null;
if (!string.IsNullOrWhiteSpace(faction) || tier.HasValue)
{
whereFilter = new Dictionary<string, object?>();
if (!string.IsNullOrWhiteSpace(faction))
{
whereFilter["faction"] = new Dictionary<string, object?> { ["eq"] = faction };
}
if (tier.HasValue)
{
whereFilter["tier"] = new Dictionary<string, object?> { ["eq"] = tier.Value };
}
}
var query = $@"
query GetUnits($where: UnitDataFilterInput) {{
units(where: $where) {{
{AllUnitFields}
}}
}}";
var requestBody = new
{
query,
variables = new
{
where = whereFilter
}
};
var response = await _httpClient.PostAsJsonAsync("graphql", requestBody, cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<GraphQLResponse<UnitsQueryResult>>(JsonOptions, cancellationToken);
if (result?.Data?.Units is not null && result.Data.Units.Count > 0)
{
return result.Data.Units;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load units via GraphQL API service. Falling back to static units list.");
}
// Fallback to static model data if GraphQL call fails
var fallback = Units.All.AsEnumerable();
if (!string.IsNullOrWhiteSpace(faction))
{
fallback = fallback.Where(u => u.Faction.Equals(faction, StringComparison.OrdinalIgnoreCase));
}
if (tier.HasValue)
{
fallback = fallback.Where(u => u.Tier == tier.Value);
}
return fallback.ToList();
}
public async Task<UnitData?> GetUnitByIdAsync(string id, CancellationToken cancellationToken = default)
{
try
{
var query = $@"
query GetUnitById($id: String!) {{
unitById(id: $id) {{
{AllUnitFields}
}}
}}";
var requestBody = new
{
query,
variables = new
{
id
}
};
var response = await _httpClient.PostAsJsonAsync("graphql", requestBody, cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<GraphQLResponse<UnitByIdQueryResult>>(JsonOptions, cancellationToken);
if (result?.Data?.UnitById is not null)
{
return result.Data.UnitById;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get unit '{Id}' via GraphQL API.", id);
}
return Units.All.FirstOrDefault(u => u.Id == id);
}
public async Task<UnitData?> CreateUnitAsync(UnitData unit, CancellationToken cancellationToken = default)
{
try
{
var mutation = $@"
mutation CreateUnit($input: CreateUnitInput!) {{
createUnit(input: $input) {{
{AllUnitFields}
}}
}}";
var input = new
{
id = unit.Id,
name = unit.Name,
hexite = unit.Hexite,
flux = unit.Flux,
supply = unit.Supply,
productionTime = unit.ProductionTime,
health = unit.Health,
energy = unit.Energy,
shields = unit.Shields,
armorRating = unit.ArmorRating,
movementSpeed = unit.MovementSpeed,
damagePerSecond = unit.DamagePerSecond,
attackRange = unit.AttackRange,
attributes = unit.Attributes,
tier = unit.Tier,
faction = unit.Faction,
hotkey = unit.Hotkey,
buildAtSameTime = unit.BuildAtSameTime,
limit = unit.Limit
};
var requestBody = new
{
query = mutation,
variables = new
{
input
}
};
var response = await _httpClient.PostAsJsonAsync("graphql", requestBody, cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<GraphQLResponse<CreateUnitMutationResult>>(JsonOptions, cancellationToken);
return result?.Data?.CreateUnit;
}
_logger.LogError("Failed to create unit via GraphQL. Status: {StatusCode}", response.StatusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating unit via GraphQL.");
}
return null;
}
public async Task<bool> DeleteUnitAsync(string id, CancellationToken cancellationToken = default)
{
try
{
var mutation = @"
mutation DeleteUnit($id: String!) {
deleteUnit(id: $id)
}";
var requestBody = new
{
query = mutation,
variables = new
{
id
}
};
var response = await _httpClient.PostAsJsonAsync("graphql", requestBody, cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<GraphQLResponse<DeleteUnitMutationResult>>(JsonOptions, cancellationToken);
return result?.Data?.DeleteUnit ?? false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting unit '{Id}' via GraphQL.", id);
}
return false;
}
private sealed class GraphQLResponse<T>
{
[JsonPropertyName("data")]
public T? Data { get; set; }
[JsonPropertyName("errors")]
public List<GraphQLErrorItem>? Errors { get; set; }
}
private sealed class GraphQLErrorItem
{
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
}
private sealed class UnitsQueryResult
{
[JsonPropertyName("units")]
public List<UnitData>? Units { get; set; }
}
private sealed class UnitByIdQueryResult
{
[JsonPropertyName("unitById")]
public UnitData? UnitById { get; set; }
}
private sealed class CreateUnitMutationResult
{
[JsonPropertyName("createUnit")]
public UnitData? CreateUnit { get; set; }
}
private sealed class DeleteUnitMutationResult
{
[JsonPropertyName("deleteUnit")]
public bool DeleteUnit { get; set; }
}
}
@@ -10,11 +10,12 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all"/>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" />
<PackageReference Include="Telerik.UI.for.Blazor" Version="14.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Model\Model.csproj" />
<ProjectReference Include="..\ZeroSpace.Model\ZeroSpace.Model.csproj" />
</ItemGroup>
</Project>
@@ -6,5 +6,6 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Web
@using Web.Layout
@using ZeroSpace.Web
@using ZeroSpace.Web.Layout
@using ZeroSpace.Model
+27
View File
@@ -0,0 +1,27 @@
events { }
http {
include mime.types;
types {
application/wasm wasm;
}
default_type application/octet-stream;
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(?:ico|css|js|gif|jpe?g|png|wasm|woff2?|eot|ttf|svg)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}
}
@@ -0,0 +1,3 @@
{
"ApiServiceUrl": "https://localhost:7142"
}
@@ -0,0 +1,3 @@
{
"ApiServiceUrl": "https://localhost:7142"
}

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -11,7 +11,7 @@
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="_content/Telerik.UI.for.Blazor/css/kendo-theme-default/all.css" rel="stylesheet"/>
<link href="Web.styles.css" rel="stylesheet" />
<link href="ZeroSpace.Web.styles.css" rel="stylesheet" />
<script type="importmap"></script>
</head>

Some files were not shown because too many files have changed in this diff Show More