diff --git a/.agents/rules/api-conventions.md b/.agents/rules/api-conventions.md new file mode 100644 index 0000000000..11eedf5d7d --- /dev/null +++ b/.agents/rules/api-conventions.md @@ -0,0 +1,65 @@ +--- +paths: + - "src/Modules/**/Features/**/*" + - "src/Modules/**/*Endpoint*.cs" +--- + +# API Conventions + +Rules for API endpoints in FSH. + +## Endpoint Requirements + +Every endpoint MUST have: + +```csharp +endpoints.MapPost("/", handler) + .WithName(nameof(CommandOrQuery)) // Required: Unique name + .WithSummary("Description") // Required: OpenAPI description + .RequirePermission(Permission) // Required: Or .AllowAnonymous() +``` + +## HTTP Method Mapping + +| Operation | Method | Return | +|-----------|--------|--------| +| Create | `MapPost` | `TypedResults.Created(...)` | +| Read single | `MapGet` | `TypedResults.Ok(...)` | +| Read list | `MapGet` | `TypedResults.Ok(...)` | +| Update | `MapPut` | `TypedResults.Ok(...)` or `NoContent()` | +| Delete | `MapDelete` | `TypedResults.NoContent()` | + +## Route Patterns + +``` +/api/v1/{module}/{entities} # Collection +/api/v1/{module}/{entities}/{id} # Single item +/api/v1/{module}/{entities}/{id}/sub # Sub-resource +``` + +## Response Types + +Always use `TypedResults`: +- `TypedResults.Ok(data)` +- `TypedResults.Created($"/path/{id}", data)` +- `TypedResults.NoContent()` +- `TypedResults.NotFound()` +- `TypedResults.BadRequest(errors)` + +Never return raw objects or use `Results.Ok()`. + +## Permission Format + +```csharp +.RequirePermission({Module}Permissions.{Entity}.{Action}) +``` + +Actions: `View`, `Create`, `Update`, `Delete` + +## Query Parameters + +Use `[AsParameters]` for complex queries: + +```csharp +endpoints.MapGet("/", async ([AsParameters] GetProductsQuery query, ...) => ...) +``` diff --git a/.agents/rules/architecture.md b/.agents/rules/architecture.md new file mode 100644 index 0000000000..2a6f497ffd --- /dev/null +++ b/.agents/rules/architecture.md @@ -0,0 +1,247 @@ +--- +paths: + - "src/**" +--- + +# Architecture Rules + +FSH is a **Modular Monolith** — NOT microservices, NOT a traditional layered architecture. + +## Core Principles + +### 1. Modular Monolith + +``` +Single deployment unit + ↓ +Multiple bounded contexts (modules) + ↓ +Each module is self-contained + ↓ +Communication via Contracts (interfaces/DTOs) +``` + +**Modules:** +- Identity (users, roles, permissions) +- Multitenancy (tenants, subscriptions) +- Auditing (audit trails) +- Your business modules (e.g., Catalog, Orders) + +**Rules:** +- Modules CANNOT reference other module internals +- Modules CAN reference other module Contracts +- Modules share BuildingBlocks (framework code) + +### 2. CQRS (Mediator Library) + +**Commands** (write operations): +```csharp +public record CreateUserCommand(string Email) : ICommand; + +public class CreateUserHandler : ICommandHandler +{ + public async ValueTask Handle(CreateUserCommand cmd, CancellationToken ct) + { + // Write to database + return user.Id; + } +} +``` + +**Queries** (read operations): +```csharp +public record GetUserQuery(Guid Id) : IQuery; + +public class GetUserHandler : IQueryHandler +{ + public async ValueTask Handle(GetUserQuery query, CancellationToken ct) + { + // Read from database + return userDto; + } +} +``` + +⚠️ **NOT MediatR:** FSH uses `Mediator` library (different interfaces!) + +### 3. Domain-Driven Design + +**Entities** inherit `BaseEntity`: +```csharp +public class Product : BaseEntity, IAuditable +{ + public string Name { get; private set; } = default!; + public Money Price { get; private set; } = default!; + + public static Product Create(string name, Money price) + { + // Factory method, enforce invariants + return new Product { Name = name, Price = price }; + } +} +``` + +**Value Objects** (immutable): +```csharp +public record Money(decimal Amount, string Currency); +``` + +**Aggregates:** +- Root entity controls access to child entities +- Enforce business rules +- Transaction boundary + +### 4. Multi-Tenancy + +**Finbuckle.MultiTenant:** +- Shared database, tenant isolation via TenantId +- Automatic query filtering +- Tenant resolution from HTTP header or claim + +```csharp +// Tenant-aware entity +public class Order : BaseEntity, IMustHaveTenant +{ + public Guid TenantId { get; set; } // Auto-filtered +} +``` + +**Tenant Resolution Order:** +1. HTTP header: `X-Tenant` +2. JWT claim: `tenant` +3. Host/route strategy (optional) + +### 5. Vertical Slice Architecture + +Each feature = complete slice (command/handler/validator/endpoint in one folder). + +``` +Features/v1/CreateProduct/ +├── CreateProductCommand.cs +├── CreateProductHandler.cs +├── CreateProductValidator.cs +└── CreateProductEndpoint.cs +``` + +**Benefits:** +- High cohesion (related code together) +- Low coupling (features don't depend on each other) +- Easy to find/modify + +### 6. BuildingBlocks (Shared Kernel) + +11 packages providing cross-cutting concerns: + +| Package | Purpose | +|---------|---------| +| Core | Base entities, interfaces, exceptions | +| Persistence | EF Core, repositories, specifications | +| Caching | Redis/memory caching | +| Mailing | Email templates, MailKit integration | +| Jobs | Hangfire background jobs | +| Storage | File storage (AWS S3, local) | +| Web | API conventions, filters, middleware | +| Eventing | Domain events, message bus | + +| Shared | DTOs, constants | +| Eventing.Abstractions | Event contracts | + +**Protected:** BuildingBlocks should NOT be modified without approval. See `.claude/rules/buildingblocks-protection.md`. + +### 7. Dependency Flow + +``` +API Layer (Minimal APIs) + ↓ +Application Layer (Commands/Queries/Handlers) + ↓ +Domain Layer (Entities/Value Objects) + ↓ +Infrastructure Layer (Persistence/External Services) +``` + +**Rules:** +- Domain CANNOT depend on infrastructure +- Application CANNOT depend on infrastructure directly +- Infrastructure implements domain interfaces + +### 8. Persistence Strategy + +**DbContext per Module:** +- IdentityDbContext +- MultitenancyDbContext +- AuditingDbContext +- Your module DbContexts + +**Repository Pattern:** +```csharp +public interface IRepository where T : BaseEntity +{ + Task GetByIdAsync(Guid id, CancellationToken ct); + Task> ListAsync(Specification spec, CancellationToken ct); + Task AddAsync(T entity, CancellationToken ct); + Task UpdateAsync(T entity, CancellationToken ct); + Task DeleteAsync(T entity, CancellationToken ct); +} +``` + +**Specification Pattern** (queries): +```csharp +public class ActiveProductsSpec : Specification +{ + public ActiveProductsSpec() + { + Query.Where(p => !p.IsDeleted && p.IsActive); + } +} +``` + +## Architectural Tests + +`Architecture.Tests` project enforces rules: + +```csharp +[Fact] +public void Modules_Should_Not_Reference_Other_Modules() +{ + // Fails if Module A references Module B directly +} + +[Fact] +public void Domain_Should_Not_Depend_On_Infrastructure() +{ + // Fails if domain entities reference EF Core +} +``` + +## Technology Stack + +- **.NET 10** (latest LTS) +- **EF Core 10** (PostgreSQL provider) +- **Mediator** (CQRS) +- **FluentValidation** (input validation) +- **Mapster** (object mapping) +- **Hangfire** (background jobs) +- **Finbuckle.MultiTenant** (multi-tenancy) +- **MailKit** (email) +- **Scalar** (OpenAPI docs) +- **Serilog** (logging) +- **OpenTelemetry** (observability) +- **Aspire** (orchestration) + +## Key Takeaways + +1. **Modular Monolith** ≠ Microservices. Modules share process, database, infrastructure. +2. **CQRS** separates reads/writes. Use `ICommand`/`IQuery`, not `IRequest`. +3. **DDD** enforces business rules in domain. Entities control their state. +4. **Multi-Tenancy** is built-in. Every entity is either tenant-aware or shared. +5. **Vertical Slices** keep features independent. No shared "services" layer. +6. **BuildingBlocks** provide infrastructure. Don't reinvent, reuse. +7. **Tests enforce architecture**. Violate rules → build fails. + +--- + +For implementation details, see: +- `ARCHITECTURE_ANALYSIS.md` (deep dive) +- `.claude/rules/modules.md` (module patterns) +- `.claude/rules/persistence.md` (data access patterns) diff --git a/.agents/rules/buildingblocks-protection.md b/.agents/rules/buildingblocks-protection.md new file mode 100644 index 0000000000..e50ed4bf5f --- /dev/null +++ b/.agents/rules/buildingblocks-protection.md @@ -0,0 +1,36 @@ +--- +paths: + - "src/BuildingBlocks/**/*" +--- + +# ⚠️ BuildingBlocks Protection + +**STOP. You are modifying BuildingBlocks.** + +Changes to BuildingBlocks affect ALL modules across the entire framework. These are core abstractions that many projects depend on. + +## Before Proceeding + +1. **Confirm explicit approval** - Has the user specifically approved this change? +2. **Consider alternatives** - Can this be done in the module instead? +3. **Assess impact** - What modules will this affect? + +## If Approved + +- Make minimal, focused changes +- Ensure backward compatibility +- Update all affected modules +- Run full test suite: `dotnet test src/FSH.Starter.slnx` +- Document the change + +## Alternatives to Consider + +| Instead of... | Consider... | +|---------------|-------------| +| Modifying Core | Extension method in module | +| Changing Persistence | Custom repository in module | +| Updating Web | Module-specific middleware | + +## If Not Approved + +Do not proceed. Suggest alternatives that don't require BuildingBlocks modifications. diff --git a/.agents/rules/modules.md b/.agents/rules/modules.md new file mode 100644 index 0000000000..e0e3230a95 --- /dev/null +++ b/.agents/rules/modules.md @@ -0,0 +1,375 @@ +--- +paths: + - "src/Modules/**" +--- + +# Module Rules + +Modules are **bounded contexts** in the modular monolith. Each module is self-contained. + +## Module Structure + +``` +Modules/{ModuleName}/ +├── {ModuleName}.Contracts/ # Public interface (DTOs, events) +│ ├── {Entity}Dto.cs +│ ├── I{Module}Service.cs +│ └── {Module}Events.cs +├── {ModuleName}/ # Implementation (internal) +│ ├── Features/ # CQRS features +│ │ └── v1/{Feature}/ +│ │ ├── {Action}Command.cs +│ │ ├── {Action}Handler.cs +│ │ ├── {Action}Validator.cs +│ │ └── {Action}Endpoint.cs +│ ├── Entities/ # Domain models +│ ├── Persistence/ # DbContext, configurations +│ ├── Permissions/ # Permission constants +│ └── Extensions.cs # DI registration +``` + +## Module Independence + +### ✅ Allowed + +```csharp +// Reference Contracts project +using FSH.Modules.Identity.Contracts; + +public record UserDto(Guid Id, string Email); // Public DTO +``` + +```csharp +// Use BuildingBlocks +using FSH.BuildingBlocks.Core; +using FSH.BuildingBlocks.Persistence; +``` + +### ❌ Forbidden + +```csharp +// Direct reference to another module's internals +using FSH.Modules.Identity; // ❌ NO! Use .Contracts instead + +using FSH.Modules.Identity.Entities; // ❌ Domain models are internal +``` + +## Communication Between Modules + +### Option 1: Contracts (Preferred) + +**Identity.Contracts:** +```csharp +public interface IUserService +{ + Task GetUserByIdAsync(Guid userId); +} +``` + +**Identity implementation:** +```csharp +internal class UserService : IUserService +{ + public async Task GetUserByIdAsync(Guid userId) + { + // Query database + return userDto; + } +} +``` + +**Other module uses it:** +```csharp +public class OrderHandler(IUserService userService) +{ + public async ValueTask Handle(...) + { + var user = await userService.GetUserByIdAsync(userId); + } +} +``` + +### Option 2: Domain Events + +**Identity module raises event:** +```csharp +public record UserCreatedEvent(Guid UserId, string Email) : DomainEvent; + +// In handler +await eventBus.PublishAsync(new UserCreatedEvent(user.Id, user.Email)); +``` + +**Other module subscribes:** +```csharp +public class UserCreatedEventHandler : IEventHandler +{ + public async Task Handle(UserCreatedEvent evt, CancellationToken ct) + { + // React to user creation (e.g., send welcome email) + } +} +``` + +## Creating a New Module + +### 1. Create Projects + +```bash +# Contracts (public interface) +dotnet new classlib -n FSH.Modules.Catalog.Contracts -o src/Modules/Catalog/Modules.Catalog.Contracts + +# Implementation (internal) +dotnet new classlib -n FSH.Modules.Catalog -o src/Modules/Catalog/Modules.Catalog +``` + +### 2. Add to Solution + +```bash +dotnet sln src/FSH.Starter.slnx add \ + src/Modules/Catalog/Modules.Catalog.Contracts/Modules.Catalog.Contracts.csproj \ + src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj +``` + +### 3. Reference BuildingBlocks + +```xml + + + + + + +``` + +### 4. Create Entities + +```csharp +namespace FSH.Modules.Catalog.Entities; + +public class Product : BaseEntity, IAuditable, IMustHaveTenant +{ + public string Name { get; private set; } = default!; + public string Description { get; private set; } = default!; + public Money Price { get; private set; } = default!; + public Guid TenantId { get; set; } + + public static Product Create(string name, string description, Money price) + { + return new Product + { + Name = name, + Description = description, + Price = price + }; + } + + public void Update(string name, string description, Money price) + { + Name = name; + Description = description; + Price = price; + } +} +``` + +### 5. Create DbContext + +```csharp +namespace FSH.Modules.Catalog.Persistence; + +public class CatalogDbContext(DbContextOptions options) + : BaseDbContext(options) +{ + public DbSet Products => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("catalog"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); + base.OnModelCreating(modelBuilder); + } +} +``` + +### 6. Create Entity Configuration + +```csharp +namespace FSH.Modules.Catalog.Persistence.Configurations; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("products", "catalog"); + + builder.Property(p => p.Name) + .IsRequired() + .HasMaxLength(200); + + builder.OwnsOne(p => p.Price, price => + { + price.Property(m => m.Amount).HasColumnName("price_amount"); + price.Property(m => m.Currency).HasColumnName("price_currency"); + }); + } +} +``` + +### 7. Register Module (Extensions.cs) + +```csharp +namespace FSH.Modules.Catalog; + +public static class Extensions +{ + public static IServiceCollection AddCatalogModule(this IServiceCollection services) + { + // Register DbContext + services.AddDbContext(); + + // Register repositories + services.AddScoped, Repository>(); + + // Register services (if any) + // services.AddScoped(); + + return services; + } + + public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/v1/catalog") + .WithTags("Catalog"); + + // Map feature endpoints here + // group.MapCreateProductEndpoint(); + + return endpoints; + } +} +``` + +### 8. Wire Up in Program.cs + +```csharp +// In FSH.Starter.Api/Program.cs +builder.Services.AddCatalogModule(); + +// ... + +app.MapCatalogEndpoints(); +``` + +## Module Boundaries + +### Namespace Convention + +- **Public:** `FSH.Modules.{Module}.Contracts` +- **Internal:** `FSH.Modules.{Module}.*` + +### Assembly Internals + +Mark module types as `internal` unless explicitly needed externally: + +```csharp +internal class ProductService { } // ✅ Internal by default +public record ProductDto { } // ✅ Public DTO in Contracts +``` + +### Dependency Direction + +``` +Other Modules → Module.Contracts + ↑ + Module (implements Contracts) + ↑ + BuildingBlocks +``` + +**Never:** +- Module A → Module B (direct reference) +- Module → Playground (implementation referencing host) + +## Testing Modules + +**Architecture Test:** +```csharp +[Fact] +public void Catalog_Module_Should_Not_Reference_Identity_Module() +{ + var catalog = Types.InAssembly(typeof(CatalogDbContext).Assembly); + var identity = Types.InAssembly(typeof(IdentityDbContext).Assembly); + + catalog.Should().NotHaveDependencyOn(identity.Assemblies); +} +``` + +**Unit Test:** +```csharp +public class ProductTests +{ + [Fact] + public void Create_Should_Set_Properties() + { + var product = Product.Create("Test", "Description", new Money(100, "USD")); + + product.Name.Should().Be("Test"); + product.Price.Amount.Should().Be(100); + } +} +``` + +## Common Patterns + +### Permissions + +```csharp +namespace FSH.Modules.Catalog.Permissions; + +public static class CatalogPermissions +{ + public static class Products + { + public const string View = "catalog.products.view"; + public const string Create = "catalog.products.create"; + public const string Update = "catalog.products.update"; + public const string Delete = "catalog.products.delete"; + } +} +``` + +### DTOs (in Contracts) + +```csharp +namespace FSH.Modules.Catalog.Contracts; + +public record ProductDto( + Guid Id, + string Name, + string Description, + decimal Price, + string Currency, + DateTime CreatedAt); +``` + +### Events (in Contracts) + +```csharp +namespace FSH.Modules.Catalog.Contracts; + +public record ProductCreatedEvent(Guid ProductId, string Name) : DomainEvent; +public record ProductUpdatedEvent(Guid ProductId) : DomainEvent; +public record ProductDeletedEvent(Guid ProductId) : DomainEvent; +``` + +## Key Rules + +1. **Contracts are public**, internals are `internal` +2. **Modules communicate via Contracts or events**, never direct references +3. **Each module has its own DbContext** +4. **Features are vertical slices** within modules +5. **BuildingBlocks are shared**, modules are independent + +--- + +For scaffolding help: Use `/add-module` skill or `module-creator` agent. diff --git a/.agents/rules/persistence.md b/.agents/rules/persistence.md new file mode 100644 index 0000000000..5d6912d3cd --- /dev/null +++ b/.agents/rules/persistence.md @@ -0,0 +1,431 @@ +--- +paths: + - "src/**/Persistence/**" + - "src/**/Entities/**" +--- + +# Persistence Rules + +EF Core patterns and repository usage in FSH. + +## DbContext Pattern + +### One DbContext Per Module + +```csharp +namespace FSH.Modules.Catalog.Persistence; + +public class CatalogDbContext(DbContextOptions options) + : BaseDbContext(options) +{ + public DbSet Products => Set(); + public DbSet Categories => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("catalog"); // ✅ Module-specific schema + modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); + base.OnModelCreating(modelBuilder); + } +} +``` + +### BaseDbContext Features + +Inherited from `BuildingBlocks.Persistence`: +- Automatic tenant filtering +- Audit trail (Created/Modified timestamps) +- Soft delete support +- Domain event publishing + +## Entity Configuration + +### Use Fluent API (NOT Data Annotations) + +```csharp +namespace FSH.Modules.Catalog.Persistence.Configurations; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("products", "catalog"); + + // Primary key + builder.HasKey(p => p.Id); + + // Properties + builder.Property(p => p.Name) + .IsRequired() + .HasMaxLength(200); + + builder.Property(p => p.Description) + .HasMaxLength(2000); + + // Value object (owned type) + builder.OwnsOne(p => p.Price, price => + { + price.Property(m => m.Amount) + .HasColumnName("price_amount") + .HasPrecision(18, 2); + + price.Property(m => m.Currency) + .HasColumnName("price_currency") + .HasMaxLength(3); + }); + + // Relationships + builder.HasOne(p => p.Category) + .WithMany() + .HasForeignKey(p => p.CategoryId); + + // Indexes + builder.HasIndex(p => p.Name); + builder.HasIndex(p => p.TenantId); // ✅ For multi-tenancy + } +} +``` + +## Repository Pattern + +### Generic Repository (Provided by BuildingBlocks) + +```csharp +public interface IRepository where T : BaseEntity +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> ListAsync(CancellationToken ct = default); + Task> ListAsync(Specification spec, CancellationToken ct = default); + Task AddAsync(T entity, CancellationToken ct = default); + Task UpdateAsync(T entity, CancellationToken ct = default); + Task DeleteAsync(T entity, CancellationToken ct = default); + Task CountAsync(Specification spec, CancellationToken ct = default); + Task AnyAsync(Specification spec, CancellationToken ct = default); +} +``` + +### Usage in Handlers + +```csharp +public class CreateProductHandler(IRepository productRepo) + : ICommandHandler +{ + public async ValueTask Handle(CreateProductCommand cmd, CancellationToken ct) + { + var product = Product.Create(cmd.Name, cmd.Description, cmd.Price); + + await productRepo.AddAsync(product, ct); + + return product.Id; + } +} +``` + +## Specification Pattern + +### Creating Specifications + +```csharp +namespace FSH.Modules.Catalog.Specifications; + +public class ProductsByNameSpec : Specification +{ + public ProductsByNameSpec(string searchTerm) + { + Query + .Where(p => p.Name.Contains(searchTerm)) + .OrderBy(p => p.Name); + } +} + +public class ActiveProductsSpec : Specification +{ + public ActiveProductsSpec() + { + Query + .Where(p => !p.IsDeleted && p.IsActive) + .Include(p => p.Category) + .OrderByDescending(p => p.CreatedAt); + } +} +``` + +### Using Specifications + +```csharp +public class GetProductsHandler(IRepository repo) + : IQueryHandler> +{ + public async ValueTask> Handle(GetProductsQuery query, CancellationToken ct) + { + var spec = new ActiveProductsSpec(); + var products = await repo.ListAsync(spec, ct); + + return products.Select(p => p.ToDto()).ToList(); + } +} +``` + +### Pagination Specification + +```csharp +public class ProductsPaginatedSpec : Specification +{ + public ProductsPaginatedSpec(int pageNumber, int pageSize) + { + Query + .Where(p => !p.IsDeleted) + .OrderBy(p => p.Name) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize); + } +} +``` + +## Entity Base Classes + +### BaseEntity + +```csharp +public abstract class BaseEntity +{ + public Guid Id { get; set; } + public DateTime CreatedAt { get; set; } + public Guid? CreatedBy { get; set; } + public DateTime? ModifiedAt { get; set; } + public Guid? ModifiedBy { get; set; } +} +``` + +### IAuditable + +```csharp +public interface IAuditable +{ + DateTime CreatedAt { get; set; } + Guid? CreatedBy { get; set; } + DateTime? ModifiedAt { get; set; } + Guid? ModifiedBy { get; set; } +} +``` + +### IMustHaveTenant + +```csharp +public interface IMustHaveTenant +{ + Guid TenantId { get; set; } // ✅ Automatically filtered by Finbuckle +} +``` + +### ISoftDelete + +```csharp +public interface ISoftDelete +{ + bool IsDeleted { get; set; } + DateTime? DeletedAt { get; set; } + Guid? DeletedBy { get; set; } +} +``` + +## Multi-Tenancy + +### Tenant-Aware Entities + +```csharp +public class Order : BaseEntity, IAuditable, IMustHaveTenant +{ + public Guid TenantId { get; set; } // ✅ Required for tenant isolation + public string OrderNumber { get; private set; } = default!; + public decimal Total { get; private set; } + + // ... +} +``` + +### Global Query Filter (Automatic) + +BaseDbContext automatically applies: +```csharp +modelBuilder.Entity() + .HasQueryFilter(e => e.TenantId == currentTenantId); +``` + +**Result:** All queries automatically filter by current tenant. No need to add `.Where(x => x.TenantId == ...)` everywhere. + +### Shared Entities (No Tenant) + +```csharp +public class Country : BaseEntity // ❌ No IMustHaveTenant +{ + public string Name { get; private set; } = default!; + public string Code { get; private set; } = default!; +} +``` + +## Migrations + +### Creating Migrations + +```bash +# From solution root +dotnet ef migrations add InitialCatalog \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --context CatalogDbContext \ + --output-dir Migrations/Catalog +``` + +### Applying Migrations + +```bash +# Automatic on startup (FSH.Starter.Api) +# Or manually: +dotnet ef database update \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --context CatalogDbContext +``` + +### Migration Project Pattern + +FSH uses a separate migrations project (`Migrations.PostgreSQL`) to: +- Keep migrations out of module code +- Support multiple database providers +- Simplify deployment + +## Transactions + +### Implicit Transactions + +Commands automatically run in a transaction: +```csharp +public async ValueTask Handle(CreateOrderCommand cmd, CancellationToken ct) +{ + var order = Order.Create(...); + await orderRepo.AddAsync(order, ct); + + var payment = Payment.Create(...); + await paymentRepo.AddAsync(payment, ct); + + // ✅ Both saved in one transaction automatically + return order.Id; +} +``` + +### Explicit Transactions + +```csharp +await using var transaction = await dbContext.Database.BeginTransactionAsync(ct); + +try +{ + await orderRepo.AddAsync(order, ct); + await paymentRepo.AddAsync(payment, ct); + + await transaction.CommitAsync(ct); +} +catch +{ + await transaction.RollbackAsync(ct); + throw; +} +``` + +## Performance Patterns + +### Projection (DTO Mapping) + +```csharp +// ❌ Bad: Load full entity, map in memory +var products = await repo.ListAsync(spec, ct); +return products.Select(p => new ProductDto(...)).ToList(); + +// ✅ Good: Project in database +var query = dbContext.Products + .Where(p => !p.IsDeleted) + .Select(p => new ProductDto(p.Id, p.Name, p.Price.Amount)); +return await query.ToListAsync(ct); +``` + +### AsNoTracking for Read-Only + +```csharp +public class ProductsReadOnlySpec : Specification +{ + public ProductsReadOnlySpec() + { + Query + .AsNoTracking() // ✅ Faster for queries + .Where(p => !p.IsDeleted); + } +} +``` + +### Batch Operations + +```csharp +// ✅ Good: Batch delete +await dbContext.Products + .Where(p => p.CategoryId == categoryId) + .ExecuteDeleteAsync(ct); + +// ✅ Good: Batch update +await dbContext.Products + .Where(p => p.CategoryId == categoryId) + .ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false), ct); +``` + +## Common Pitfalls + +### ❌ Tracking Issues + +```csharp +// ❌ Don't detach entities manually +dbContext.Entry(product).State = EntityState.Detached; + +// ✅ Use repository pattern +await repo.UpdateAsync(product, ct); +``` + +### ❌ N+1 Queries + +```csharp +// ❌ Bad: N+1 +var orders = await repo.ListAsync(ct); +foreach (var order in orders) +{ + var customer = await customerRepo.GetByIdAsync(order.CustomerId, ct); // N queries! +} + +// ✅ Good: Eager loading +var spec = new OrdersWithCustomersSpec(); // Includes .Include(o => o.Customer) +var orders = await repo.ListAsync(spec, ct); +``` + +### ❌ Lazy Loading + +```csharp +// ❌ Lazy loading is DISABLED in FSH +var order = await repo.GetByIdAsync(orderId, ct); +var customer = order.Customer; // ❌ NULL! Not loaded + +// ✅ Explicit loading via specification +var spec = new OrderByIdWithCustomerSpec(orderId); +var order = await repo.FirstOrDefaultAsync(spec, ct); +var customer = order.Customer; // ✅ Loaded +``` + +## Key Rules + +1. **One DbContext per module**, separate schemas +2. **Fluent API for configuration**, not data annotations +3. **Repository pattern for writes**, direct DbContext for complex reads +4. **Specifications for reusable queries** +5. **Tenant isolation is automatic** (via IMustHaveTenant) +6. **Migrations in separate project** (Migrations.PostgreSQL) +7. **AsNoTracking for read-only queries** +8. **Project to DTOs in database** (avoid loading full entities) + +--- + +For migration help: Use `migration-helper` agent or see EF Core docs. diff --git a/.agents/rules/testing-rules.md b/.agents/rules/testing-rules.md new file mode 100644 index 0000000000..c7018746e7 --- /dev/null +++ b/.agents/rules/testing-rules.md @@ -0,0 +1,77 @@ +--- +paths: + - "src/Tests/**/*" +--- + +# Testing Rules + +Rules for tests in FSH. + +## Test Organization + +``` +src/Tests/ +├── Architecture.Tests/ # Layering enforcement (mandatory) +├── {Module}.Tests/ # Module-specific tests +└── Generic.Tests/ # Shared utilities +``` + +## Naming Conventions + +| Type | Pattern | +|------|---------| +| Test class | `{ClassUnderTest}Tests` | +| Test method | `{Method}_{Scenario}_{ExpectedResult}` | +| Test file | Same as class name | + +## Test Structure + +Always use Arrange-Act-Assert: + +```csharp +[Fact] +public async Task Handle_ValidCommand_ReturnsId() +{ + // Arrange + var command = new CreateProductCommand("Test", 10m); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Id.Should().NotBeEmpty(); +} +``` + +## Required Tests + +### For Handlers +- Happy path with valid input +- Edge cases (empty, null, boundary values) +- Repository interactions verified + +### For Validators +- Each validation rule has a test +- Valid input passes +- Invalid input fails with correct property + +### For Entities +- Factory method creates valid entity +- Invalid input throws appropriate exception +- Domain events raised correctly + +## Libraries + +- **xUnit** - Test framework +- **FluentAssertions** - `.Should()` assertions +- **Moq** - `Mock` for dependencies + +## Architecture Tests + +Architecture tests in `Architecture.Tests/` are mandatory and enforce: +- Module boundary isolation +- No cross-module internal dependencies +- Handlers/validators are sealed +- Contracts don't depend on implementations + +These run on every build and PR. diff --git a/.agents/skills/add-entity/SKILL.md b/.agents/skills/add-entity/SKILL.md new file mode 100644 index 0000000000..b0d88bf124 --- /dev/null +++ b/.agents/skills/add-entity/SKILL.md @@ -0,0 +1,164 @@ +--- +name: add-entity +description: Create a domain entity with multi-tenancy, auditing, soft-delete, and domain events. Use when adding new database entities to a module. +argument-hint: [ModuleName] [EntityName] +--- + +# Add Entity + +Create a domain entity following FSH patterns with full multi-tenancy support. + +## Entity Template + +```csharp +public sealed class {Entity} : AggregateRoot, IHasTenant, IAuditableEntity, ISoftDeletable +{ + // Domain properties + public string Name { get; private set; } = null!; + public decimal Price { get; private set; } + public string? Description { get; private set; } + + // IHasTenant - automatic tenant isolation + public string TenantId { get; private set; } = null!; + + // IAuditableEntity - automatic audit trails + public DateTimeOffset CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset? LastModifiedAt { get; set; } + public string? LastModifiedBy { get; set; } + + // ISoftDeletable - automatic soft deletes + public DateTimeOffset? DeletedAt { get; set; } + public string? DeletedBy { get; set; } + + // Private constructor for EF Core + private {Entity}() { } + + // Factory method - the only way to create + public static {Entity} Create(string name, decimal price, string tenantId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price); + + var entity = new {Entity} + { + Id = Guid.NewGuid(), + Name = name, + Price = price, + TenantId = tenantId + }; + + entity.AddDomainEvent(new {Entity}CreatedEvent(entity.Id)); + return entity; + } + + // Domain methods for state changes + public void UpdateDetails(string name, decimal price, string? description) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price); + + Name = name; + Price = price; + Description = description; + + AddDomainEvent(new {Entity}UpdatedEvent(Id)); + } +} +``` + +## Domain Events + +```csharp +public sealed record {Entity}CreatedEvent(Guid {Entity}Id) : IDomainEvent; +public sealed record {Entity}UpdatedEvent(Guid {Entity}Id) : IDomainEvent; +public sealed record {Entity}DeletedEvent(Guid {Entity}Id) : IDomainEvent; +``` + +## EF Core Configuration + +```csharp +public sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}> +{ + public void Configure(EntityTypeBuilder<{Entity}> builder) + { + builder.ToTable("{entities}"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name) + .IsRequired() + .HasMaxLength(200); + + builder.Property(x => x.Price) + .HasPrecision(18, 2); + + builder.Property(x => x.TenantId) + .IsRequired() + .HasMaxLength(64); + + builder.HasIndex(x => x.TenantId); + + // Global query filter for soft-delete + builder.HasQueryFilter(x => x.DeletedAt == null); + } +} +``` + +## Register in DbContext + +```csharp +public sealed class {Module}DbContext : DbContext +{ + public DbSet<{Entity}> {Entities} => Set<{Entity}>(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("{module}"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof({Module}DbContext).Assembly); + } +} +``` + +## Add Migration + +```bash +dotnet ef migrations add Add{Entity} \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Playground/FSH.Starter.Api + +dotnet ef database update \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Playground/FSH.Starter.Api +``` + +## Interfaces Reference + +| Interface | Purpose | Auto-Handled | +|-----------|---------|--------------| +| `IHasTenant` | Tenant isolation | Query filtering | +| `IAuditableEntity` | Created/Modified tracking | SaveChanges interceptor | +| `ISoftDeletable` | Soft delete support | Delete interceptor | +| `AggregateRoot` | Domain events support | Event dispatcher | + +## Key Rules + +1. **Private constructor** - EF Core needs it, but users use factory methods +2. **Factory methods** - All creation goes through `Create()` static method +3. **Domain methods** - State changes through methods, not property setters +4. **Domain events** - Raise events for significant state changes +5. **Validation in methods** - Validate in factory/domain methods, not entity +6. **No public setters** - Properties are `private set` + +## Checklist + +- [ ] Implements `AggregateRoot` +- [ ] Implements `IHasTenant` for tenant isolation +- [ ] Implements `IAuditableEntity` for audit trails +- [ ] Implements `ISoftDeletable` for soft deletes +- [ ] Has private constructor +- [ ] Has static factory method +- [ ] Domain events raised for state changes +- [ ] EF configuration created +- [ ] Added to DbContext +- [ ] Migration created diff --git a/.agents/skills/add-feature/SKILL.md b/.agents/skills/add-feature/SKILL.md new file mode 100644 index 0000000000..671cde2164 --- /dev/null +++ b/.agents/skills/add-feature/SKILL.md @@ -0,0 +1,117 @@ +--- +name: add-feature +description: Create a new API endpoint with Command, Handler, Validator, and Endpoint following FSH vertical slice architecture. Use when adding any new feature, API endpoint, or business operation. +argument-hint: [ModuleName] [FeatureName] +--- + +# Add Feature + +Create a complete vertical slice feature with all required files. + +## File Structure + +``` +src/Modules/{Module}/Features/v1/{FeatureName}/ +├── {Action}{Entity}Command.cs # or Get{Entity}Query.cs +├── {Action}{Entity}Handler.cs +├── {Action}{Entity}Validator.cs # Commands only +└── {Action}{Entity}Endpoint.cs +``` + +## Step 1: Create Command or Query + +**For state changes (POST/PUT/DELETE):** +```csharp +public sealed record Create{Entity}Command( + string Name, + decimal Price) : ICommand; +``` + +**For reads (GET):** +```csharp +public sealed record Get{Entity}Query(Guid Id) : IQuery<{Entity}Dto>; +``` + +## Step 2: Create Handler + +```csharp +public sealed class Create{Entity}Handler( + IRepository<{Entity}> repository, + ICurrentUser currentUser) : ICommandHandler +{ + public async ValueTask Handle( + Create{Entity}Command command, + CancellationToken ct) + { + var entity = {Entity}.Create(command.Name, command.Price, currentUser.TenantId); + await repository.AddAsync(entity, ct); + return new Create{Entity}Response(entity.Id); + } +} +``` + +## Step 3: Create Validator (Commands Only) + +```csharp +public sealed class Create{Entity}Validator : AbstractValidator +{ + public Create{Entity}Validator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.Price).GreaterThan(0); + } +} +``` + +## Step 4: Create Endpoint + +```csharp +public static class Create{Entity}Endpoint +{ + public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) => + endpoints.MapPost("/", async ( + Create{Entity}Command command, + IMediator mediator, + CancellationToken ct) => TypedResults.Created( + $"/{entities}/{(await mediator.Send(command, ct)).Id}")) + .WithName(nameof(Create{Entity}Command)) + .WithSummary("Create a new {entity}") + .RequirePermission({Module}Permissions.{Entities}.Create); +} +``` + +## Step 5: Add DTOs to Contracts + +In `src/Modules/{Module}/Modules.{Module}.Contracts/`: + +```csharp +public sealed record Create{Entity}Response(Guid Id); +public sealed record {Entity}Dto(Guid Id, string Name, decimal Price); +``` + +## Step 6: Wire Endpoint in Module + +In `{Module}Module.cs` MapEndpoints method: + +```csharp +var entities = endpoints.MapGroup("/{entities}").WithTags("{Entities}"); +entities.Map{Action}{Entity}Endpoint(); +``` + +## Step 7: Verify + +```bash +dotnet build src/FSH.Starter.slnx # Must be 0 warnings +dotnet test src/FSH.Starter.slnx +``` + +## Checklist + +- [ ] Command/Query uses `ICommand` or `IQuery` (NOT MediatR's IRequest) +- [ ] Handler uses `ICommandHandler` or `IQueryHandler` +- [ ] Handler returns `ValueTask` (NOT `Task`) +- [ ] Validator exists for commands +- [ ] Endpoint has `.RequirePermission()` or `.AllowAnonymous()` +- [ ] Endpoint has `.WithName()` and `.WithSummary()` +- [ ] DTOs in Contracts project, not internal +- [ ] Build passes with 0 warnings diff --git a/.agents/skills/add-module/SKILL.md b/.agents/skills/add-module/SKILL.md new file mode 100644 index 0000000000..21174aa830 --- /dev/null +++ b/.agents/skills/add-module/SKILL.md @@ -0,0 +1,176 @@ +--- +name: add-module +description: Create a new module (bounded context) with proper project structure, permissions, DbContext, and registration. Use when adding a new business domain that needs its own entities and endpoints. +argument-hint: [ModuleName] +--- + +# Add Module + +Create a new bounded context with full project structure. + +## When to Create a New Module + +- Has its own domain entities +- Could be deployed independently +- Represents a distinct business domain + +If it's just a feature in an existing domain, use `add-feature` instead. + +## Project Structure + +``` +src/Modules/{Name}/ +├── Modules.{Name}/ +│ ├── Modules.{Name}.csproj +│ ├── {Name}Module.cs +│ ├── {Name}PermissionConstants.cs +│ ├── {Name}DbContext.cs +│ ├── Domain/ +│ │ └── {Entity}.cs +│ └── Features/v1/ +│ └── {Feature}/ +└── Modules.{Name}.Contracts/ + ├── Modules.{Name}.Contracts.csproj + └── DTOs/ +``` + +## Step 1: Create Projects + +### Main Module Project +`src/Modules/{Name}/Modules.{Name}/Modules.{Name}.csproj`: +```xml + + + net10.0 + + + + + + + + +``` + +### Contracts Project +`src/Modules/{Name}/Modules.{Name}.Contracts/Modules.{Name}.Contracts.csproj`: +```xml + + + net10.0 + + +``` + +## Step 2: Implement IModule + +```csharp +public sealed class {Name}Module : IModule +{ + public void ConfigureServices(IHostApplicationBuilder builder) + { + // Register DbContext + builder.Services.AddDbContext<{Name}DbContext>((sp, options) => + { + var dbOptions = sp.GetRequiredService>().Value; + options.UseNpgsql(dbOptions.ConnectionString); + }); + + // Register repositories + builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); + builder.Services.AddScoped(typeof(IReadRepository<>), typeof(Repository<>)); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/v1/{name}"); + // Map feature endpoints here + } +} +``` + +## Step 3: Add Permission Constants + +```csharp +public static class {Name}PermissionConstants +{ + public static class {Entities} + { + public const string View = "{Entities}.View"; + public const string Create = "{Entities}.Create"; + public const string Update = "{Entities}.Update"; + public const string Delete = "{Entities}.Delete"; + } +} +``` + +## Step 4: Create DbContext + +```csharp +public sealed class {Name}DbContext : DbContext +{ + public {Name}DbContext(DbContextOptions<{Name}DbContext> options) : base(options) { } + + public DbSet<{Entity}> {Entities} => Set<{Entity}>(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("{name}"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof({Name}DbContext).Assembly); + } +} +``` + +## Step 5: Register in Program.cs + +```csharp +// Add to moduleAssemblies array +var moduleAssemblies = new Assembly[] +{ + typeof(IdentityModule).Assembly, + typeof(MultitenancyModule).Assembly, + typeof(AuditingModule).Assembly, + typeof({Name}Module).Assembly, // Add here +}; + +// Add Mediator assemblies +builder.Services.AddMediator(o => +{ + o.Assemblies = [ + // ... existing + typeof({Name}Module).Assembly, + ]; +}); +``` + +## Step 6: Add to Solution + +```bash +dotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}/Modules.{Name}.csproj +dotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}.Contracts/Modules.{Name}.Contracts.csproj +``` + +## Step 7: Reference from API + +In `src/Playground/FSH.Starter.Api/FSH.Starter.Api.csproj`: +```xml + +``` + +## Step 8: Verify + +```bash +dotnet build src/FSH.Starter.slnx # Must be 0 warnings +dotnet test src/FSH.Starter.slnx +``` + +## Checklist + +- [ ] Both projects created (main + contracts) +- [ ] IModule implemented with ConfigureServices and MapEndpoints +- [ ] Permission constants defined +- [ ] DbContext created with proper schema +- [ ] Registered in Program.cs moduleAssemblies +- [ ] Added to solution file +- [ ] Referenced from FSH.Starter.Api +- [ ] Build passes with 0 warnings diff --git a/.agents/skills/mediator-reference/SKILL.md b/.agents/skills/mediator-reference/SKILL.md new file mode 100644 index 0000000000..295fccc3dd --- /dev/null +++ b/.agents/skills/mediator-reference/SKILL.md @@ -0,0 +1,129 @@ +--- +name: mediator-reference +description: Mediator library patterns and interfaces for FSH. This project uses the Mediator source generator, NOT MediatR. Reference when implementing commands, queries, and handlers. +user-invocable: false +--- + +# Mediator Reference + +⚠️ **FSH uses the `Mediator` source generator library, NOT `MediatR`.** + +These are different libraries with different interfaces. Using MediatR interfaces will cause build errors. + +## Interface Comparison + +| Purpose | ✅ Mediator (Use This) | ❌ MediatR (Don't Use) | +|---------|------------------------|------------------------| +| Command | `ICommand` | `IRequest` | +| Query | `IQuery` | `IRequest` | +| Command Handler | `ICommandHandler` | `IRequestHandler` | +| Query Handler | `IQueryHandler` | `IRequestHandler` | +| Notification | `INotification` | `INotification` | +| Notification Handler | `INotificationHandler` | `INotificationHandler` | + +## Command Pattern + +```csharp +// ✅ Correct - Mediator +public sealed record CreateUserCommand(string Email, string Name) : ICommand; + +public sealed class CreateUserHandler : ICommandHandler +{ + public async ValueTask Handle(CreateUserCommand command, CancellationToken ct) + { + // Implementation + } +} + +// ❌ Wrong - MediatR +public sealed record CreateUserCommand(string Email, string Name) : IRequest; + +public sealed class CreateUserHandler : IRequestHandler +{ + public async Task Handle(CreateUserCommand request, CancellationToken ct) + { + // This won't work! + } +} +``` + +## Query Pattern + +```csharp +// ✅ Correct - Mediator +public sealed record GetUserQuery(Guid Id) : IQuery; + +public sealed class GetUserHandler : IQueryHandler +{ + public async ValueTask Handle(GetUserQuery query, CancellationToken ct) + { + // Implementation + } +} +``` + +## Key Differences + +| Aspect | Mediator | MediatR | +|--------|----------|---------| +| Return type | `ValueTask` | `Task` | +| Parameter name | `command` / `query` | `request` | +| Registration | Source generated | Runtime reflection | +| Performance | Faster (compile-time) | Slower (runtime) | + +## Sending Commands/Queries + +```csharp +// In endpoint +public static async Task Handle( + CreateUserCommand command, + IMediator mediator, // Same interface name as MediatR + CancellationToken ct) +{ + var result = await mediator.Send(command, ct); + return TypedResults.Created($"/users/{result}"); +} +``` + +## Registration + +```csharp +// In Program.cs +builder.Services.AddMediator(options => +{ + options.Assemblies = + [ + typeof(IdentityModule).Assembly, + typeof(MultitenancyModule).Assembly, + // Add your module assemblies here + ]; +}); +``` + +## Common Errors + +### Error: `IRequest` not found +**Cause:** Using MediatR interface +**Fix:** Change to `ICommand` or `IQuery` + +### Error: `IRequestHandler` not found +**Cause:** Using MediatR interface +**Fix:** Change to `ICommandHandler` or `IQueryHandler` + +### Error: Handler not found at runtime +**Cause:** Assembly not registered in AddMediator +**Fix:** Add assembly to `options.Assemblies` array + +### Error: `Task` vs `ValueTask` +**Cause:** Using MediatR return type +**Fix:** Change handler return type to `ValueTask` + +## Namespaces + +```csharp +// ✅ Correct +using Mediator; + +// ❌ Wrong +using MediatR; +``` diff --git a/.agents/skills/query-patterns/SKILL.md b/.agents/skills/query-patterns/SKILL.md new file mode 100644 index 0000000000..f2ce63cacb --- /dev/null +++ b/.agents/skills/query-patterns/SKILL.md @@ -0,0 +1,176 @@ +--- +name: query-patterns +description: Query patterns including pagination, search, filtering, and specifications for FSH. Use when implementing GET endpoints that return lists or need filtering. +--- + +# Query Patterns + +Reference for implementing queries with pagination, search, and filtering. + +## Basic Paginated Query + +```csharp +// Query +public sealed record Get{Entities}Query( + string? Search, + int PageNumber = 1, + int PageSize = 10) : IQuery>; + +// Handler +public sealed class Get{Entities}Handler( + IReadRepository<{Entity}> repository) : IQueryHandler> +{ + public async ValueTask> Handle( + Get{Entities}Query query, + CancellationToken ct) + { + var spec = new {Entity}SearchSpec(query.Search, query.PageNumber, query.PageSize); + return await repository.PaginatedListAsync(spec, ct); + } +} +``` + +## Specification Pattern + +```csharp +public sealed class {Entity}SearchSpec : EntitiesByPaginationFilterSpec<{Entity}, {Entity}Dto> +{ + public {Entity}SearchSpec(string? search, int pageNumber, int pageSize) + : base(new PaginationFilter(pageNumber, pageSize)) + { + Query + .OrderByDescending(x => x.CreatedAt) + .Where(x => string.IsNullOrEmpty(search) || + x.Name.Contains(search) || + x.Description!.Contains(search)); + } +} +``` + +## Get Single Entity + +```csharp +// Query +public sealed record Get{Entity}Query(Guid Id) : IQuery<{Entity}Dto>; + +// Handler +public sealed class Get{Entity}Handler( + IReadRepository<{Entity}> repository) : IQueryHandler +{ + public async ValueTask<{Entity}Dto> Handle(Get{Entity}Query query, CancellationToken ct) + { + var spec = new {Entity}ByIdSpec(query.Id); + var entity = await repository.FirstOrDefaultAsync(spec, ct); + + return entity ?? throw new NotFoundException($"{Entity} {query.Id} not found"); + } +} + +// Specification +public sealed class {Entity}ByIdSpec : Specification<{Entity}, {Entity}Dto>, ISingleResultSpecification<{Entity}> +{ + public {Entity}ByIdSpec(Guid id) + { + Query.Where(x => x.Id == id); + } +} +``` + +## Advanced Filtering + +```csharp +public sealed record Get{Entities}Query( + string? Search, + Guid? CategoryId, + decimal? MinPrice, + decimal? MaxPrice, + DateTimeOffset? CreatedAfter, + bool? IsActive, + string? SortBy, + bool SortDescending = false, + int PageNumber = 1, + int PageSize = 10) : IQuery>; + +public sealed class {Entity}FilterSpec : EntitiesByPaginationFilterSpec<{Entity}, {Entity}Dto> +{ + public {Entity}FilterSpec(Get{Entities}Query query) + : base(new PaginationFilter(query.PageNumber, query.PageSize)) + { + Query + .Where(x => string.IsNullOrEmpty(query.Search) || x.Name.Contains(query.Search)) + .Where(x => !query.CategoryId.HasValue || x.CategoryId == query.CategoryId) + .Where(x => !query.MinPrice.HasValue || x.Price >= query.MinPrice) + .Where(x => !query.MaxPrice.HasValue || x.Price <= query.MaxPrice) + .Where(x => !query.IsActive.HasValue || x.IsActive == query.IsActive); + + ApplySorting(query.SortBy, query.SortDescending); + } + + private void ApplySorting(string? sortBy, bool descending) + { + switch (sortBy?.ToLowerInvariant()) + { + case "name": + if (descending) Query.OrderByDescending(x => x.Name); + else Query.OrderBy(x => x.Name); + break; + case "price": + if (descending) Query.OrderByDescending(x => x.Price); + else Query.OrderBy(x => x.Price); + break; + default: + Query.OrderByDescending(x => x.CreatedAt); + break; + } + } +} +``` + +## Endpoint Patterns + +### List Endpoint +```csharp +public static RouteHandlerBuilder MapGet{Entities}Endpoint(this IEndpointRouteBuilder endpoints) => + endpoints.MapGet("/", async ( + [AsParameters] Get{Entities}Query query, + IMediator mediator, + CancellationToken ct) => TypedResults.Ok(await mediator.Send(query, ct))) + .WithName(nameof(Get{Entities}Query)) + .WithSummary("Get paginated list of {entities}") + .RequirePermission({Module}Permissions.{Entities}.View); +``` + +### Single Entity Endpoint +```csharp +public static RouteHandlerBuilder MapGet{Entity}Endpoint(this IEndpointRouteBuilder endpoints) => + endpoints.MapGet("/{id:guid}", async ( + Guid id, + IMediator mediator, + CancellationToken ct) => TypedResults.Ok(await mediator.Send(new Get{Entity}Query(id), ct))) + .WithName(nameof(Get{Entity}Query)) + .WithSummary("Get {entity} by ID") + .RequirePermission({Module}Permissions.{Entities}.View); +``` + +## Response Types + +```csharp +// In Contracts project +public sealed record {Entity}Dto( + Guid Id, + string Name, + decimal Price, + string? Description, + DateTimeOffset CreatedAt); + +// PagedList is from BuildingBlocks +// Returns: Items, PageNumber, PageSize, TotalCount, TotalPages +``` + +## Key Points + +1. **Use specifications** - Don't write raw LINQ in handlers +2. **Tenant filtering is automatic** - Framework handles `IHasTenant` +3. **Soft delete filtering is automatic** - DeletedAt != null filtered out +4. **Use `[AsParameters]`** - For query parameters in endpoints +5. **Project to DTOs** - Never return entities directly diff --git a/.agents/skills/testing-guide/SKILL.md b/.agents/skills/testing-guide/SKILL.md new file mode 100644 index 0000000000..67cc6bae52 --- /dev/null +++ b/.agents/skills/testing-guide/SKILL.md @@ -0,0 +1,223 @@ +--- +name: testing-guide +description: Write unit tests, integration tests, and architecture tests for FSH features. Use when adding tests or understanding the testing strategy. +--- + +# Testing Guide + +FSH uses a layered testing strategy with architecture tests as guardrails. + +## Test Project Structure + +``` +src/Tests/ +├── Architecture.Tests/ # Enforces layering rules +├── Generic.Tests/ # Shared test utilities +├── Identity.Tests/ # Identity module tests +├── Multitenancy.Tests/ # Multitenancy module tests +└── Auditing.Tests/ # Auditing module tests +``` + +## Architecture Tests + +Architecture tests enforce module boundaries and layering. They run on every build. + +```csharp +public class ArchitectureTests +{ + [Fact] + public void Modules_ShouldNot_DependOnOtherModules() + { + var result = Types.InAssembly(typeof(IdentityModule).Assembly) + .ShouldNot() + .HaveDependencyOn("Modules.Multitenancy") + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Contracts_ShouldNot_DependOnImplementation() + { + var result = Types.InAssembly(typeof(UserDto).Assembly) + .ShouldNot() + .HaveDependencyOn("Modules.Identity") + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Handlers_ShouldBe_Sealed() + { + var result = Types.InAssembly(typeof(IdentityModule).Assembly) + .That() + .ImplementInterface(typeof(ICommandHandler<,>)) + .Or() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .BeSealed() + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } +} +``` + +## Unit Test Patterns + +### Handler Tests + +```csharp +public class Create{Entity}HandlerTests +{ + private readonly Mock> _repositoryMock; + private readonly Mock _currentUserMock; + private readonly Create{Entity}Handler _handler; + + public Create{Entity}HandlerTests() + { + _repositoryMock = new Mock>(); + _currentUserMock = new Mock(); + _currentUserMock.Setup(x => x.TenantId).Returns("test-tenant"); + + _handler = new Create{Entity}Handler( + _repositoryMock.Object, + _currentUserMock.Object); + } + + [Fact] + public async Task Handle_ValidCommand_Returns{Entity}Id() + { + // Arrange + var command = new Create{Entity}Command("Test", 99.99m); + _repositoryMock + .Setup(x => x.AddAsync(It.IsAny<{Entity}>(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Id.Should().NotBeEmpty(); + _repositoryMock.Verify(x => x.AddAsync( + It.Is<{Entity}>(e => e.Name == "Test" && e.Price == 99.99m), + It.IsAny()), Times.Once); + } +} +``` + +### Validator Tests + +```csharp +public class Create{Entity}ValidatorTests +{ + private readonly Create{Entity}Validator _validator = new(); + + [Fact] + public void Validate_EmptyName_Fails() + { + var command = new Create{Entity}Command("", 99.99m); + var result = _validator.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Name"); + } + + [Fact] + public void Validate_NegativePrice_Fails() + { + var command = new Create{Entity}Command("Test", -1m); + var result = _validator.Validate(command); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "Price"); + } + + [Theory] + [InlineData("Valid Name", 10)] + [InlineData("Another", 0.01)] + public void Validate_ValidCommand_Passes(string name, decimal price) + { + var command = new Create{Entity}Command(name, price); + var result = _validator.Validate(command); + + result.IsValid.Should().BeTrue(); + } +} +``` + +### Entity Tests + +```csharp +public class {Entity}Tests +{ + [Fact] + public void Create_ValidInput_Creates{Entity}WithEvent() + { + var entity = {Entity}.Create("Test", 99.99m, "tenant-1"); + + entity.Id.Should().NotBeEmpty(); + entity.Name.Should().Be("Test"); + entity.Price.Should().Be(99.99m); + entity.TenantId.Should().Be("tenant-1"); + entity.DomainEvents.Should().ContainSingle(e => e is {Entity}CreatedEvent); + } + + [Fact] + public void Create_EmptyName_ThrowsArgumentException() + { + var act = () => {Entity}.Create("", 99.99m, "tenant-1"); + + act.Should().Throw(); + } + + [Fact] + public void UpdateDetails_ValidInput_UpdatesAndRaisesEvent() + { + var entity = {Entity}.Create("Original", 50m, "tenant-1"); + entity.ClearDomainEvents(); + + entity.UpdateDetails("Updated", 75m, "New description"); + + entity.Name.Should().Be("Updated"); + entity.Price.Should().Be(75m); + entity.Description.Should().Be("New description"); + entity.DomainEvents.Should().ContainSingle(e => e is {Entity}UpdatedEvent); + } +} +``` + +## Running Tests + +```bash +# Run all tests +dotnet test src/FSH.Starter.slnx + +# Run specific test project +dotnet test src/Tests/Architecture.Tests + +# Run with coverage +dotnet test src/FSH.Starter.slnx --collect:"XPlat Code Coverage" + +# Run specific test +dotnet test --filter "FullyQualifiedName~Create{Entity}HandlerTests" +``` + +## Test Conventions + +| Convention | Example | +|------------|---------| +| Test class name | `{ClassUnderTest}Tests` | +| Test method name | `{Method}_{Scenario}_{ExpectedResult}` | +| Structure | Always Arrange-Act-Assert | +| Assertions | Multiple asserts OK if same concept | + +## Key Rules + +1. **Architecture tests are mandatory** - They enforce module boundaries +2. **Validators need tests** - Cover edge cases +3. **Handlers need tests** - Mock dependencies +4. **Entities need tests** - Test factory methods and domain logic +5. **Use FluentAssertions** - `.Should()` syntax +6. **Use Moq for mocking** - `Mock` pattern diff --git a/.agents/workflows/architecture-guard.md b/.agents/workflows/architecture-guard.md new file mode 100644 index 0000000000..091fc00159 --- /dev/null +++ b/.agents/workflows/architecture-guard.md @@ -0,0 +1,118 @@ +--- +description: Verify changes don't violate architecture rules. Run architecture tests, check module boundaries, verify BuildingBlocks aren't modified. Use before commits or PRs. +--- + +You are an architecture guardian for FullStackHero .NET Starter Kit. Your job is to verify architectural integrity. You are READ-ONLY — never modify files. + +## Verification Steps + +### 1. Check for BuildingBlocks Modifications + +```bash +git diff --name-only | grep -E "^src/BuildingBlocks/" +``` + +If any files listed: **STOP** - BuildingBlocks changes require explicit approval. + +### 2. Run Architecture Tests + +```bash +dotnet test src/Tests/Architecture.Tests --no-build +``` + +All tests must pass. + +### 3. Verify Build Has 0 Warnings + +```bash +dotnet build src/FSH.Starter.slnx 2>&1 | grep -E "warning|error" +``` + +Must show no warnings or errors. + +### 4. Check Module Boundaries + +Verify no cross-module internal dependencies: + +```bash +# Check if any module references another module's internal types +grep -r "using Modules\." src/Modules/ --include="*.cs" | grep -v "\.Contracts" +``` + +Should only show references to `.Contracts` namespaces. + +### 5. Verify Mediator Usage + +```bash +# Check for MediatR usage (should be empty) +grep -r "MediatR\|IRequest<\|IRequestHandler<" src/Modules/ --include="*.cs" +``` + +Must be empty - all should use Mediator interfaces. + +### 6. Check Validator Coverage + +For each command, verify a validator exists: + +```bash +# List commands +find src/Modules -name "*Command.cs" -type f + +# List validators +find src/Modules -name "*Validator.cs" -type f +``` + +Every command needs a corresponding validator. + +### 7. Check Endpoint Authorization + +```bash +# Find endpoints without authorization +grep -r "\.Map\(Get\|Post\|Put\|Delete\)" src/Modules/ --include="*.cs" -A 5 | \ +grep -v "RequirePermission\|AllowAnonymous" +``` + +Every endpoint must have explicit authorization. + +## Output Format + +``` +## Architecture Verification Report + +### BuildingBlocks +✅ No modifications | ⚠️ MODIFIED - Requires approval + +### Architecture Tests +✅ All passed | ❌ {count} failed + +### Build Warnings +✅ 0 warnings | ❌ {count} warnings + +### Module Boundaries +✅ Clean | ❌ Cross-module dependencies found + +### Mediator Usage +✅ Correct | ❌ MediatR interfaces detected + +### Validators +✅ All commands have validators | ❌ Missing: {list} + +### Authorization +✅ All endpoints authorized | ❌ Missing: {list} + +--- +**Overall:** ✅ PASS | ❌ FAIL - Fix issues before commit +``` + +## Quick Commands + +```bash +# Full verification +dotnet build src/FSH.Starter.slnx && dotnet test src/FSH.Starter.slnx + +# Architecture tests only +dotnet test src/Tests/Architecture.Tests + +# Check for common issues +git diff --name-only | xargs grep -l "IRequest<\|MediatR" +``` diff --git a/.agents/workflows/code-reviewer.md b/.agents/workflows/code-reviewer.md new file mode 100644 index 0000000000..e0e3cd522c --- /dev/null +++ b/.agents/workflows/code-reviewer.md @@ -0,0 +1,93 @@ +--- +description: Review code changes against FSH patterns and conventions. Run after any code modifications to catch violations before commit. +--- + +You are a code reviewer for the FullStackHero .NET Starter Kit. Your job is to review code changes and ensure they follow FSH patterns, outputting a structured report. + +## Review Process + +1. Run `git diff` to see recent changes +2. Identify which files were modified +3. Check each change against the rules below +4. Report violations with specific file:line references + +## Critical Rules to Check + +### Architecture +- [ ] Features are in `Modules/{Module}/Features/v1/{Name}/` structure +- [ ] DTOs are in Contracts project, not internal +- [ ] No cross-module dependencies (modules only use Contracts) +- [ ] BuildingBlocks not modified without explicit approval + +### Mediator (NOT MediatR!) +- [ ] Commands use `ICommand` not `IRequest` +- [ ] Queries use `IQuery` not `IRequest` +- [ ] Handlers use `ICommandHandler` or `IQueryHandler` +- [ ] Handler methods return `ValueTask` not `Task` +- [ ] Using `Mediator` namespace, not `MediatR` + +### Validation +- [ ] Every command has a matching `AbstractValidator` +- [ ] Validators use FluentValidation rules + +### Endpoints +- [ ] Has `.RequirePermission()` or `.AllowAnonymous()` +- [ ] Has `.WithName()` matching the command/query name +- [ ] Has `.WithSummary()` with description +- [ ] Returns TypedResults, not raw objects + +### Entities +- [ ] Implements required interfaces (IHasTenant, IAuditableEntity, ISoftDeletable) +- [ ] Has private constructor for EF Core +- [ ] Uses factory method for creation +- [ ] Properties have `private set` +- [ ] Domain events raised for state changes + +### Naming +- [ ] Commands: `{Action}{Entity}Command` +- [ ] Queries: `Get{Entity}Query` or `Get{Entities}Query` +- [ ] Handlers: `{CommandOrQuery}Handler` +- [ ] Validators: `{Command}Validator` +- [ ] DTOs: `{Entity}Dto`, `{Entity}Response` + +## Commands to Run + +```bash +# Review staged/uncommitted changes +git diff HEAD + +# Check for MediatR usage (must be empty) +grep -r "MediatR\|IRequest<\|IRequestHandler<" src/Modules/ --include="*.cs" + +# Check build +dotnet build src/FSH.Starter.slnx 2>&1 | grep -E "warning|error" +``` + +## Output Format + +``` +## Code Review Summary + +### ✅ Passed +- [List what's correct] + +### ❌ Violations Found +1. **{Rule}** - {file}:{line} + - Issue: {description} + - Fix: {how to fix} + +### ⚠️ Warnings +- [Optional suggestions] + +### Build Verification +Run: `dotnet build src/FSH.Starter.slnx` +Expected: 0 warnings +``` + +## After Review + +Always suggest running: +```bash +dotnet build src/FSH.Starter.slnx # Verify 0 warnings +dotnet test src/FSH.Starter.slnx # Run tests +``` diff --git a/.agents/workflows/feature-scaffolder.md b/.agents/workflows/feature-scaffolder.md new file mode 100644 index 0000000000..dcc50a2908 --- /dev/null +++ b/.agents/workflows/feature-scaffolder.md @@ -0,0 +1,107 @@ +--- +description: Generate complete feature slices (Command/Handler/Validator/Endpoint) from requirements. Use when creating new API endpoints or features. +--- + +You are a feature scaffolder for FullStackHero .NET Starter Kit. Your job is to generate complete vertical slice features. + +## Required Information + +Before generating, confirm: +1. **Module name** - Which module? (e.g., Identity, Catalog) +2. **Feature name** - What action? (e.g., CreateProduct, GetUser) +3. **Entity name** - What entity? (e.g., Product, User) +4. **Operation type** - Command (state change) or Query (read)? +5. **Properties** - What fields does the command/query need? + +## Generation Process + +### Step 1: Create Feature Folder + +``` +src/Modules/{Module}/Features/v1/{FeatureName}/ +``` + +### Step 2: Generate Files + +For **Commands** (POST/PUT/DELETE), create 4 files: +1. `{Action}{Entity}Command.cs` +2. `{Action}{Entity}Handler.cs` +3. `{Action}{Entity}Validator.cs` +4. `{Action}{Entity}Endpoint.cs` + +For **Queries** (GET), create 3 files: +1. `Get{Entity}Query.cs` or `Get{Entities}Query.cs` +2. `Get{Entity}Handler.cs` +3. `Get{Entity}Endpoint.cs` + +### Step 3: Add DTOs to Contracts + +Create response/DTO types in: +``` +src/Modules/{Module}/Modules.{Module}.Contracts/ +``` + +### Step 4: Wire Endpoint + +Show where to add endpoint mapping in the module's `MapEndpoints` method. + +## Template: Command + +```csharp +// {Action}{Entity}Command.cs +public sealed record {Action}{Entity}Command( + {Properties}) : ICommand<{Action}{Entity}Response>; + +// {Action}{Entity}Handler.cs +public sealed class {Action}{Entity}Handler( + IRepository<{Entity}> repository, + ICurrentUser currentUser) : ICommandHandler<{Action}{Entity}Command, {Action}{Entity}Response> +{ + public async ValueTask<{Action}{Entity}Response> Handle( + {Action}{Entity}Command command, + CancellationToken ct) + { + // Implementation + } +} + +// {Action}{Entity}Validator.cs +public sealed class {Action}{Entity}Validator : AbstractValidator<{Action}{Entity}Command> +{ + public {Action}{Entity}Validator() + { + // Validation rules + } +} + +// {Action}{Entity}Endpoint.cs +public static class {Action}{Entity}Endpoint +{ + public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) => + endpoints.Map{HttpMethod}("/", async ( + {Action}{Entity}Command command, + IMediator mediator, + CancellationToken ct) => TypedResults.{Result}(await mediator.Send(command, ct))) + .WithName(nameof({Action}{Entity}Command)) + .WithSummary("{Summary}") + .RequirePermission({Module}Permissions.{Entities}.{Action}); +} +``` + +## Checklist Before Completion + +- [ ] All files use `Mediator` interfaces (NOT MediatR) +- [ ] Handler returns `ValueTask` +- [ ] Validator exists for commands +- [ ] Endpoint has `.RequirePermission()` and `.WithName()` and `.WithSummary()` +- [ ] DTOs in Contracts project +- [ ] Shown where to wire endpoint in module + +## Verification + +After generation, run: +```bash +dotnet build src/FSH.Starter.slnx +``` + +Must show 0 warnings. diff --git a/.agents/workflows/migration-helper.md b/.agents/workflows/migration-helper.md new file mode 100644 index 0000000000..f2614f3eec --- /dev/null +++ b/.agents/workflows/migration-helper.md @@ -0,0 +1,126 @@ +--- +description: Handle EF Core migrations safely. Create, apply, and manage database migrations for the FSH multi-tenant setup. Use when adding entities or changing database schema. +--- + +You are a migration helper for FullStackHero .NET Starter Kit. Your job is to safely manage EF Core migrations. + +## Project Paths + +- **Migrations project:** `src/Playground/FSH.Starter.Migrations.PostgreSQL` +- **Startup project:** `src/Playground/FSH.Starter.Api` +- **DbContexts:** Each module has its own DbContext + +## Common Operations + +### Add Migration + +```bash +dotnet ef migrations add {MigrationName} \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Playground/FSH.Starter.Api \ + --context {DbContextName} +``` + +**Context names:** +- `IdentityDbContext` - Identity module +- `MultitenancyDbContext` - Multitenancy module +- `AuditingDbContext` - Auditing module +- `{Module}DbContext` - Custom modules + +### Apply Migrations + +```bash +dotnet ef database update \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Playground/FSH.Starter.Api \ + --context {DbContextName} +``` + +### List Migrations + +```bash +dotnet ef migrations list \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Playground/FSH.Starter.Api \ + --context {DbContextName} +``` + +### Remove Last Migration + +```bash +dotnet ef migrations remove \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Playground/FSH.Starter.Api \ + --context {DbContextName} +``` + +### Generate SQL Script + +```bash +dotnet ef migrations script \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Playground/FSH.Starter.Api \ + --context {DbContextName} \ + --output migrations.sql +``` + +## Multi-Tenant Considerations + +FSH uses per-tenant databases. Migrations apply to: +1. **Host database** - Tenant registry, shared data +2. **Tenant databases** - Tenant-specific data + +The framework handles tenant database migrations automatically on startup via `UseHeroMultiTenantDatabases()`. + +## Migration Naming Conventions + +Use descriptive names: +- `Add{Entity}` - Adding new entity +- `Add{Property}To{Entity}` - Adding column +- `Remove{Property}From{Entity}` - Removing column +- `Create{Index}Index` - Adding index +- `Rename{Old}To{New}` - Renaming + +## Pre-Migration Checklist + +- [ ] Entity configuration exists (`IEntityTypeConfiguration`) +- [ ] Entity added to DbContext (`DbSet`) +- [ ] Build succeeds with 0 warnings +- [ ] Backup database if production + +## Post-Migration Checklist + +- [ ] Review generated migration file +- [ ] Check Up() and Down() methods are correct +- [ ] Test migration on development database +- [ ] Verify rollback works (Down method) + +## Troubleshooting + +### "No DbContext was found" +Specify context explicitly with `--context {Name}DbContext` + +### "Build failed" +Run `dotnet build src/FSH.Starter.slnx` first + +### "Pending migrations" +Apply pending migrations or remove them if not needed + +### "Migration already applied" +Check `__EFMigrationsHistory` table in database + +## Example: Adding a New Entity + +1. Create entity in `Domain/` folder +2. Create configuration (`IEntityTypeConfiguration`) +3. Add `DbSet` to DbContext +4. Build: `dotnet build src/FSH.Starter.slnx` +5. Add migration: + ```bash + dotnet ef migrations add Add{Entity} \ + --project src/Playground/FSH.Starter.Migrations.PostgreSQL \ + --startup-project src/Playground/FSH.Starter.Api \ + --context {Module}DbContext + ``` +6. Review migration file +7. Apply: `dotnet ef database update ...` diff --git a/.agents/workflows/module-creator.md b/.agents/workflows/module-creator.md new file mode 100644 index 0000000000..6689d4e65c --- /dev/null +++ b/.agents/workflows/module-creator.md @@ -0,0 +1,150 @@ +--- +description: Create new modules (bounded contexts) with complete project structure, DbContext, permissions, and registration. Use when adding a new business domain. +--- + +You are a module creator for FullStackHero .NET Starter Kit. Your job is to scaffold complete new modules. + +## When to Create a New Module + +Ask these questions: +- Does it have its own domain entities? → Yes = new module +- Could it be deployed independently? → Yes = new module +- Is it just a feature in an existing domain? → No = use existing module + +## Required Information + +Before generating, confirm: +1. **Module name** - PascalCase (e.g., Catalog, Inventory, Billing) +2. **Initial entities** - What domain entities? +3. **Permissions** - What operations need permissions? + +## Generation Process + +### Step 1: Create Project Structure + +``` +src/Modules/{Name}/ +├── Modules.{Name}/ +│ ├── Modules.{Name}.csproj +│ ├── {Name}Module.cs +│ ├── {Name}PermissionConstants.cs +│ ├── {Name}DbContext.cs +│ ├── Domain/ +│ └── Features/v1/ +└── Modules.{Name}.Contracts/ + ├── Modules.{Name}.Contracts.csproj + └── DTOs/ +``` + +### Step 2: Generate Core Files + +**Modules.{Name}.csproj:** +```xml + + + net10.0 + + + + + + + + +``` + +**{Name}Module.cs:** +```csharp +public sealed class {Name}Module : IModule +{ + public void ConfigureServices(IHostApplicationBuilder builder) + { + // DbContext, repositories, services + builder.Services.AddDbContext<{Name}DbContext>((sp, options) => + { + var dbOptions = sp.GetRequiredService>().Value; + options.UseNpgsql(dbOptions.ConnectionString); + }); + builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); + builder.Services.AddScoped(typeof(IReadRepository<>), typeof(Repository<>)); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/v1/{name}"); + // Map feature endpoints + } +} +``` + +**{Name}PermissionConstants.cs:** +```csharp +public static class {Name}PermissionConstants +{ + public static class {Entities} + { + public const string View = "{Entities}.View"; + public const string Create = "{Entities}.Create"; + public const string Update = "{Entities}.Update"; + public const string Delete = "{Entities}.Delete"; + } +} +``` + +**{Name}DbContext.cs:** +```csharp +public sealed class {Name}DbContext : DbContext +{ + public {Name}DbContext(DbContextOptions<{Name}DbContext> options) : base(options) { } + + public DbSet<{Entity}> {Entities} => Set<{Entity}>(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("{name}"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof({Name}DbContext).Assembly); + } +} +``` + +### Step 3: Create Contracts Project + +**Modules.{Name}.Contracts.csproj:** +```xml + + + net10.0 + + +``` + +### Step 4: Register Module + +Show changes needed in: +1. `src/Playground/FSH.Starter.Api/Program.cs` - Add to moduleAssemblies +2. `src/Playground/FSH.Starter.Api/FSH.Starter.Api.csproj` - Add ProjectReference +3. Solution file - Add both projects + +### Step 5: Add to Solution + +```bash +dotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}/Modules.{Name}.csproj +dotnet sln src/FSH.Starter.slnx add src/Modules/{Name}/Modules.{Name}.Contracts/Modules.{Name}.Contracts.csproj +``` + +## Checklist + +- [ ] Both projects created (main + contracts) +- [ ] IModule implemented +- [ ] Permission constants defined +- [ ] DbContext created with schema +- [ ] Registered in Program.cs +- [ ] Added to solution +- [ ] Referenced from FSH.Starter.Api +- [ ] Build passes with 0 warnings + +## Verification + +```bash +dotnet build src/FSH.Starter.slnx # Must be 0 warnings +``` diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000000..ba55c4179f --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "nswag.consolecore": { + "version": "14.6.3", + "commands": [ + "nswag" + ], + "rollForward": false + }, + "dotnet-ef": { + "version": "10.0.2", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + }, + "fullstackhero.cli": { + "version": "10.0.0-rc.1", + "commands": [ + "fsh" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..5e690f421b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "name": "FullStackHero .NET Starter Kit", + "image": "mcr.microsoft.com/dotnet/sdk:10.0", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, + "postCreateCommand": "dotnet workload install aspire && dotnet restore src/FSH.Starter.slnx", + "forwardPorts": [5030, 7030, 15888], + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csdevkit", + "ms-dotnettools.csharp" + ] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..9df01736b5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/bin +**/obj +**/.vs +**/node_modules +.git +*.md +src/Tests/ diff --git a/.github/workflows/blazor.yml b/.github/workflows/blazor.yml deleted file mode 100644 index 1939d2b8db..0000000000 --- a/.github/workflows/blazor.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Build / Publish Blazor WebAssembly Project - -on: - workflow_dispatch: - - push: - branches: - - main - paths: - - "src/apps/blazor/**" - - "src/Directory.Packages.props" - - "src/Dockerfile.Blazor" - - pull_request: - branches: - - main - paths: - - "src/apps/blazor/**" - - "src/Directory.Packages.props" - - "src/Dockerfile.Blazor" - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: setup dotnet - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.x - - name: restore dependencies - run: dotnet restore ./src/apps/blazor/client/Client.csproj - - name: build - run: dotnet build ./src/apps/blazor/client/Client.csproj --no-restore - - name: test - run: dotnet test ./src/apps/blazor/client/Client.csproj --no-build --verbosity normal - - publish: - needs: build - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@v4 - - name: docker login - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: build and publish to github container registry - working-directory: ./src/ - run: | - docker build -t ghcr.io/${{ github.repository_owner }}/blazor:latest -f Dockerfile.Blazor . - docker push ghcr.io/${{ github.repository_owner }}/blazor:latest diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml deleted file mode 100644 index 7a88fcb9b4..0000000000 --- a/.github/workflows/changelog.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Release Drafter - -on: - workflow_dispatch: - push: - branches: - - main - -permissions: - contents: read - -jobs: - update_release_draft: - permissions: - # write permission is required to create a github release - contents: write - # write permission is required for autolabeler - # otherwise, read permission is required at least - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@v6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..ed26e4df2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,302 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + - develop + tags: + - 'v*' + paths: + - 'src/**' + pull_request: + branches: + - main + - develop + paths: + - 'src/**' + workflow_dispatch: + inputs: + version: + description: 'Package version (e.g., 10.0.0-rc.1)' + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + packages: write + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore dependencies + run: dotnet restore src/FSH.Starter.slnx + + - name: Build + run: dotnet build src/FSH.Starter.slnx -c Release --no-restore -warnaserror + + - name: Check for vulnerable packages + run: dotnet list src/FSH.Starter.slnx package --vulnerable --include-transitive 2>&1 | tee vulnerability-report.txt + continue-on-error: true + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: build-output + path: | + src/**/bin/Release + src/**/obj/Release + retention-days: 1 + + test: + name: Test - ${{ matrix.test-project.name }} + runs-on: ubuntu-latest + needs: build + + strategy: + fail-fast: false + matrix: + test-project: + - name: Architecture.Tests + path: src/Tests/Architecture.Tests + - name: Auditing.Tests + path: src/Tests/Auditing.Tests + - name: Generic.Tests + path: src/Tests/Generic.Tests + - name: Identity.Tests + path: src/Tests/Identity.Tests + - name: Multitenancy.Tests + path: src/Tests/Multitenancy.Tests + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + name: build-output + path: src + + - name: Run ${{ matrix.test-project.name }} + run: dotnet test ${{ matrix.test-project.path }} -c Release --no-build --verbosity normal --logger "trx;LogFileName=${{ matrix.test-project.name }}.trx" + + - name: Upload test results + uses: actions/upload-artifact@v7 + if: always() + with: + name: test-results-${{ matrix.test-project.name }} + path: '**/*.trx' + retention-days: 7 + + integration-test: + name: Integration Tests + runs-on: ubuntu-latest + needs: build + # Testcontainers requires Docker — ubuntu-latest has it pre-installed. + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + # Integration tests use WebApplicationFactory + Testcontainers. + # They must build from source (can't use pre-built artifacts). + - name: Run Integration Tests + run: dotnet test src/Tests/Integration.Tests -c Release --verbosity normal --logger "trx;LogFileName=Integration.Tests.trx" + + - name: Upload test results + uses: actions/upload-artifact@v7 + if: always() + with: + name: test-results-Integration.Tests + path: '**/*.trx' + retention-days: 7 + + publish-dev-containers: + name: Publish Dev Containers + runs-on: ubuntu-latest + needs: [test, integration-test] + if: github.ref == 'refs/heads/develop' && github.event_name == 'push' + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish API container image + run: | + dotnet publish src/Playground/FSH.Starter.Api/FSH.Starter.Api.csproj \ + -c Release -r linux-x64 \ + -p:PublishProfile=DefaultContainer \ + -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-api \ + -p:ContainerImageTags='"dev-${{ github.sha }};dev-latest"' + + + publish-release: + name: Publish Release (NuGet + Containers) + runs-on: ubuntu-latest + needs: [test, integration-test] + if: | + (github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch') || + startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Determine version + id: version + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.version }}" ]; then + VERSION="${{ github.event.inputs.version }}" + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF#refs/tags/v}" + else + echo "No version specified and not a tag push" + exit 1 + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Publishing version: $VERSION" + + - name: Restore and Build with version + run: | + dotnet restore src/FSH.Starter.slnx + dotnet build src/FSH.Starter.slnx -c Release --no-restore -p:Version=${{ steps.version.outputs.version }} + + - name: Pack BuildingBlocks + run: | + dotnet pack src/BuildingBlocks/Core/Core.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Shared/Shared.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Persistence/Persistence.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Caching/Caching.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Mailing/Mailing.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Jobs/Jobs.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Storage/Storage.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Eventing/Eventing.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/BuildingBlocks/Web/Web.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + + - name: Pack Modules + run: | + dotnet pack src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Identity/Modules.Identity/Modules.Identity.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Webhooks/Modules.Webhooks.Contracts/Modules.Webhooks.Contracts.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + dotnet pack src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + + - name: Pack CLI Tool + run: dotnet pack src/Tools/CLI/FSH.CLI.csproj -c Release --no-build -o ./nupkgs -p:PackageVersion=${{ steps.version.outputs.version }} + + - name: Push to NuGet.org + run: dotnet nuget push "./nupkgs/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push API container + run: | + dotnet publish src/Playground/FSH.Starter.Api/FSH.Starter.Api.csproj \ + -c Release -r linux-x64 \ + -p:PublishProfile=DefaultContainer \ + -p:ContainerRepository=ghcr.io/${{ github.repository_owner }}/fsh-api \ + -p:ContainerImageTags='"${{ steps.version.outputs.version }};latest"' + diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..be7949966c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,27 @@ +name: CodeQL Analysis +on: + pull_request: + branches: [main, develop] + paths: + - 'src/**' + schedule: + - cron: '0 6 * * 1' + +permissions: + security-events: write + contents: read + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: github/codeql-action/init@v3 + with: + languages: csharp + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' + - run: dotnet build src/FSH.Starter.slnx -c Release + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml deleted file mode 100644 index 4ee62a6ff5..0000000000 --- a/.github/workflows/nuget.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Publish Package to NuGet.org -on: - push: - branches: - - main - paths: - - "FSH.StarterKit.nuspec" -jobs: - publish: - name: publish nuget - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - name: checkout code - - uses: nuget/setup-nuget@v2 - name: setup nuget - with: - nuget-version: "latest" - nuget-api-key: ${{ secrets.NUGET_API_KEY }} - - name: generate package - run: nuget pack FSH.StarterKit.nuspec -NoDefaultExcludes - - name: publish package - run: nuget push *.nupkg -Source 'https://api.nuget.org/v3/index.json' -SkipDuplicate diff --git a/.github/workflows/webapi.yml b/.github/workflows/webapi.yml deleted file mode 100644 index a84e28f03a..0000000000 --- a/.github/workflows/webapi.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Build / Publish .NET WebAPI Project - -on: - workflow_dispatch: - - push: - branches: - - main - paths: - - "src/api/**" - - "src/Directory.Packages.props" - - pull_request: - branches: - - main - paths: - - "src/api/**" - - "src/Directory.Packages.props" - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: setup dotnet - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.x - - name: restore dependencies - run: dotnet restore ./src/api/server/Server.csproj - - name: build - run: dotnet build ./src/api/server/Server.csproj --no-restore - - name: test - run: dotnet test ./src/api/server/Server.csproj --no-build --verbosity normal - - publish: - needs: build - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@v4 - - name: setup dotnet - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.x - - name: docker login - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: publish to github container registry - working-directory: ./src/api/server/ - run: | - dotnet publish -c Release -p:ContainerRepository=ghcr.io/${{ github.repository_owner}}/webapi -p:RuntimeIdentifier=linux-x64 - docker push ghcr.io/${{ github.repository_owner}}/webapi --all-tags diff --git a/.gitignore b/.gitignore index 9995d856ac..61c8bfd0da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,12 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +## Get latest from `dotnet new gitignore` + +*.terraform +terraform.tfstate +# dotenv files +.env # User-specific files *.rsuser @@ -31,16 +36,12 @@ bld/ [Oo]bj/ [Ll]og/ [Ll]ogs/ -[Ii]mages/ -[Dd]atabases/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ -.vscode/ - # Visual Studio 2017 auto generated files Generated\ Files/ @@ -61,7 +62,7 @@ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ -# .NET Core +# .NET project.lock.json project.fragment.lock.json artifacts/ @@ -97,6 +98,7 @@ StyleCopReport.xml *.tmp_proj *_wpftmp.csproj *.log +*.tlog *.vspscc *.vssscc .builds @@ -206,6 +208,9 @@ PublishScripts/ **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ +# except the Next.js workspace packages (monorepo, not NuGet restore artifacts). +!clients/**/packages/ +!clients/**/packages/** # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files @@ -300,6 +305,17 @@ node_modules/ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts @@ -356,6 +372,9 @@ ASALocalRun/ # Local History for Visual Studio .localhistory/ +# Visual Studio History (VSHistory) files +.vshistory/ + # BeatPulse healthcheck temp database healthchecksdb @@ -368,6 +387,28 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + ## ## Visual studio for Mac ## @@ -390,7 +431,7 @@ test-results/ *.dmg *.app -# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore # General .DS_Store .AppleDouble @@ -419,7 +460,7 @@ Network Trash Folder Temporary Items .apdisk -# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore # Windows thumbnail cache files Thumbs.db ehthumbs.db @@ -444,17 +485,28 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# JetBrains Rider -.idea/ -*.sln.iml - -## -## Visual Studio Code -## -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -**/Internal/Generated +# Vim temporary swap files +*.swp +/.bmad + +team/ +docs/node_modules/ +docs/dist/ +docs/.astro/ +spec-os/ +/PLAN.md +**/nul +**/wwwroot/uploads/* + +/.claude/settings.local.json +tmpclaude** + +# Auto Claude data directory +.auto-claude/ + +# Clients (Next.js / pnpm workspace) +clients/**/node_modules/ +clients/**/.next/ +clients/**/.turbo/ +clients/**/dist/ +clients/**/.env.local diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..a0868ce17d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "shadcn": { + "type": "http", + "url": "https://mcp.shadcn.com" + } + } +} \ No newline at end of file diff --git a/.template.config/template.json b/.template.config/template.json index a5415e836e..8f14c48c95 100644 --- a/.template.config/template.json +++ b/.template.config/template.json @@ -3,7 +3,8 @@ "author": "Mukesh Murugan", "classifications": [ "WebAPI", - "Clean Architecture", + "Modular Monolith", + "Vertical Slice", "Boilerplate", "ASP.NET Core", "Starter Kit", @@ -12,14 +13,44 @@ ], "tags": { "language": "C#", - "type": "project" + "type": "solution" }, "identity": "FullStackHero.NET.StarterKit", "name": "FullStackHero .NET Starter Kit", - "description": "The best way to start a full-stack .NET 9 Web App.", + "description": "A production-ready modular .NET 10 framework — Vertical Slice Architecture, CQRS, Multitenancy, Identity, Aspire.", "shortName": "fsh", "sourceName": "FSH.Starter", "preferNameDirectory": true, + "symbols": { + "db": { + "type": "parameter", + "datatype": "choice", + "description": "Database provider for migrations and persistence.", + "defaultValue": "postgresql", + "choices": [ + { + "choice": "postgresql", + "description": "PostgreSQL (default)" + }, + { + "choice": "sqlserver", + "description": "SQL Server" + } + ] + }, + "aspire": { + "type": "parameter", + "datatype": "bool", + "description": "Include the .NET Aspire AppHost project for orchestration.", + "defaultValue": "true" + }, + "skipRestore": { + "type": "parameter", + "datatype": "bool", + "description": "Skip dotnet restore after project creation.", + "defaultValue": "false" + } + }, "sources": [ { "source": "./", @@ -30,16 +61,42 @@ ".vscode/**", ".vs/**", ".github/**", + ".agents/**", + ".claude/**", + ".devcontainer/**", + ".git/**", "templates/**/*", + "clients/**", + "demo/**", + "docs/**", + "nupkgs/**", + "scripts/**", + "src/Tools/**", "**/*.filelist", "**/*.user", "**/images", "**/*.lock.json", - "*.nuspec" + "*.nuspec", + "**/bin/**", + "**/obj/**", + ".mcp.json", + ".gitignore", + "CLAUDE.md", + "GEMINI.md", + "README.md", + "LICENSE" ], "rename": { "README-template.md": "README.md" - } + }, + "modifiers": [ + { + "condition": "(!aspire)", + "exclude": [ + "src/Playground/FSH.Starter.AppHost/**" + ] + } + ] } ], "primaryOutputs": [ @@ -49,7 +106,8 @@ ], "postActions": [ { - "description": "restore webapi project dependencies", + "condition": "(!skipRestore)", + "description": "Restore NuGet packages", "manualInstructions": [ { "text": "Run 'dotnet restore'" @@ -59,4 +117,4 @@ "continueOnError": false } ] -} \ No newline at end of file +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..5927ac3d3c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch API", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/Playground/FSH.Starter.Api/bin/Debug/net10.0/FSH.Starter.Api.dll", + "args": [], + "cwd": "${workspaceFolder}/src/Playground/FSH.Starter.Api", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..dfdf130c95 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "omnisharp.dotnetPath": "/home/jarvis/.dotnet", + "omnisharp.sdkPath": "/home/jarvis/.dotnet/sdk/10.0.102", + "omnisharp.useModernNet": true, + "editor.formatOnSave": true, + "files.exclude": { + "**/bin": true, + "**/obj": true + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..2814caf09f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,76 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/FSH.Starter.slnx", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "test", + "command": "dotnet", + "type": "process", + "args": [ + "test", + "${workspaceFolder}/src/FSH.Starter.slnx" + ], + "problemMatcher": "$msCompile", + "group": "test" + }, + { + "label": "run (Aspire)", + "command": "dotnet", + "type": "process", + "args": [ + "run", + "--project", + "${workspaceFolder}/src/Playground/FSH.Starter.AppHost" + ], + "problemMatcher": "$msCompile", + "group": "none" + }, + { + "label": "run (API only)", + "command": "dotnet", + "type": "process", + "args": [ + "run", + "--project", + "${workspaceFolder}/src/Playground/FSH.Starter.Api" + ], + "problemMatcher": "$msCompile", + "group": "none" + }, + { + "label": "clean", + "command": "dotnet", + "type": "process", + "args": [ + "clean", + "${workspaceFolder}/src/FSH.Starter.slnx" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "restore", + "command": "dotnet", + "type": "process", + "args": [ + "restore", + "${workspaceFolder}/src/FSH.Starter.slnx" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..915ffea7a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,192 @@ +# FullStackHero .NET Starter Kit + +> A production-ready modular .NET framework for building enterprise applications. + +## Architecture + +**Modular Monolith + Vertical Slice Architecture (VSA)** + +- **BuildingBlocks** (`src/BuildingBlocks/`) — shared framework libraries (Core, Persistence, Web, Caching, Eventing, etc.) +- **Modules** (`src/Modules/`) — bounded contexts (Identity, Multitenancy, Auditing) +- **Playground** (`src/Playground/`) — sample host applications (API, AppHost) +- **Tests** (`src/Tests/`) — per-module test projects + architecture tests + +### Module Boundaries + +Modules communicate through **Contracts** projects only. A module MUST NOT reference another module's runtime project. + +``` +Modules.Identity/ ← runtime (internal) +Modules.Identity.Contracts/ ← public API (commands, queries, events, DTOs, service interfaces) +``` + +### Feature Folder Layout + +Each feature is a vertical slice inside `Features/v{version}/{Area}/{FeatureName}/`: + +``` +Features/v1/Users/RegisterUser/ +├── RegisterUserEndpoint.cs # Minimal API endpoint +├── RegisterUserCommandHandler.cs # CQRS handler +└── RegisterUserCommandValidator.cs # FluentValidation +``` + +Additional module folders: `Domain/`, `Data/`, `Services/`, `Events/`, `Authorization/`. + +## Tech Stack + +| Concern | Technology | +|---------|-----------| +| Framework | .NET 10 / C# latest | +| Solution format | `.slnx` (XML-based) | +| Package management | Central (`Directory.Packages.props`) | +| CQRS / Mediator | Mediator 3.0.1 (source generator) | +| Validation | FluentValidation 12.x | +| ORM | Entity Framework Core 10.x | +| Database | PostgreSQL (Npgsql) | +| Auth | JWT Bearer + ASP.NET Identity | +| Multitenancy | Finbuckle.MultiTenant 10.x (claim/header/query strategies) | +| Caching | Redis (StackExchange) | +| Jobs | Hangfire | +| Resilience | Microsoft.Extensions.Http.Resilience (Polly v8) | +| Feature Flags | Microsoft.FeatureManagement with tenant overrides | +| Idempotency | Idempotency-Key header with cache-based replay | +| Webhooks | Tenant-scoped subscriptions with HMAC signing | +| Real-time | Server-Sent Events (SSE) | +| Logging | Serilog + OpenTelemetry (OTLP) | +| Object mapping | Mapster | +| API docs | OpenAPI + Scalar | +| API versioning | Asp.Versioning | +| Hosting | .NET Aspire (AppHost) | +| Testing | xUnit, Shouldly, NSubstitute, AutoFixture, NetArchTest | + +## Build & Run + +```bash +# Build +dotnet build src/FSH.Starter.slnx + +# Run API (from repo root) +dotnet run --project src/Playground/FSH.Starter.Api + +# Run with Aspire +dotnet run --project src/Playground/FSH.Starter.AppHost + +# Run tests +dotnet test src/FSH.Starter.slnx +``` + +## Key Conventions + +### Endpoints + +Static extension methods on `IEndpointRouteBuilder`. Return `RouteHandlerBuilder`. + +```csharp +public static class RegisterUserEndpoint +{ + internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/register", (RegisterUserCommand command, + IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(command, cancellationToken)) + .WithName("RegisterUser") + .WithSummary("Register user") + .RequirePermission(IdentityPermissionConstants.Users.Create); + } +} +``` + +### CQRS + +- **Commands/Queries** → defined in `Modules.{Name}.Contracts` (implement `ICommand` / `IQuery`) +- **Handlers** → defined in `Modules.{Name}/Features/` (implement `ICommandHandler` / `IQueryHandler`) +- Handlers return `ValueTask` and use `.ConfigureAwait(false)` + +### Validation + +FluentValidation validators are auto-registered by `ModuleLoader`. Name them `{Command}Validator`. + +### Domain Events + +- Inherit from `DomainEvent` (abstract record with `EventId`, `OccurredOnUtc`, `CorrelationId`, `TenantId`) +- Entities implement `IHasDomainEvents` with `_domainEvents` list +- Integration events implement `IIntegrationEvent`, handlers implement `IIntegrationEventHandler` + +### Domain Entities + +- `BaseEntity` — `Id`, `CreatedAt`, `UpdatedAt`, `TenantId` +- `AggregateRoot` — extends `BaseEntity` with domain events +- `IHasTenant`, `IAuditableEntity`, `ISoftDeletable` — marker interfaces + +### Module Registration + +Each module implements `IModule` with `[FshModule(Order = n)]` attribute: + +```csharp +[FshModule(Order = 1)] +public class IdentityModule : IModule +{ + public void ConfigureServices(IHostApplicationBuilder builder) { ... } + public void MapEndpoints(IEndpointRouteBuilder endpoints) { ... } +} +``` + +Endpoints are grouped under versioned API paths: `api/v{version:apiVersion}/{module}`. + +### Exceptions + +Use framework exception types: `CustomException` (with `HttpStatusCode`), `NotFoundException`, `ForbiddenException`, `UnauthorizedException`. Global handler converts to `ProblemDetails` (RFC 9457). + +### Permissions + +Constants in `Shared/Identity/IdentityPermissionConstants.cs`. Applied via `.RequirePermission()` on endpoints. + +### Specifications + +Use `Specification` base class from `Persistence/Specifications/` for query composition. Default `AsNoTracking = true`. + +## Coding Style + +- **Namespace style**: File-scoped (`namespace X;`) +- **Indentation**: 4 spaces +- **Var usage**: Prefer explicit types; `var` only when type is apparent from RHS +- **Null checks**: `is null` / `is not null` (not `== null`) +- **Pattern matching**: Preferred over `is`/`as` casts +- **Switch expressions**: Preferred +- **Async**: `ValueTask` for handlers, `.ConfigureAwait(false)` on all awaits +- **Guard clauses**: `ArgumentNullException.ThrowIfNull(param)` at method entry +- **Properties**: Prefer auto-properties, `default!` for required non-nullable strings +- **Records**: Use for DTOs, events, and value objects + +## Testing Conventions + +- **Naming**: `MethodName_Should_ExpectedBehavior_When_Condition` +- **Pattern**: Arrange-Act-Assert with `#region` grouping (Happy Path, Exception, Edge Cases) +- **Assertions**: Shouldly (`result.ShouldBe(...)`, `result.ShouldNotBeNull()`) +- **Mocking**: NSubstitute (`Substitute.For()`) +- **Test data**: AutoFixture (`_fixture.Create()`) +- **Architecture tests**: NetArchTest enforces module boundary rules + +## Protected Directories + +**DO NOT modify BuildingBlocks** without explicit approval. These are shared framework libraries consumed by all modules. Changes here have wide blast radius. + +## Adding a New Feature + +1. Add command/query + response in `Modules.{Name}.Contracts/v1/{Area}/{Feature}/` +2. Add handler in `Modules.{Name}/Features/v1/{Area}/{Feature}/` +3. Add validator in the same feature folder +4. Add endpoint in the same feature folder +5. Wire endpoint in the module's `MapEndpoints()` method +6. Add tests in `Tests/{Name}.Tests/` + +## Adding a New Module + +1. Create `Modules.{Name}/` and `Modules.{Name}.Contracts/` projects under `src/Modules/{Name}/` +2. Implement `IModule` with `[FshModule(Order = n)]` +3. Add DbContext extending from framework base +4. Register in `Program.cs` module assemblies array +5. Add migration project if needed +6. Add test project in `src/Tests/` +7. Add architecture test rules diff --git a/FSH.StarterKit.nuspec b/FSH.StarterKit.nuspec deleted file mode 100644 index a96e4b69f1..0000000000 --- a/FSH.StarterKit.nuspec +++ /dev/null @@ -1,24 +0,0 @@ - - - - FullStackHero.NET.StarterKit - FullStackHero .NET Starter Kit - 2.0.4-rc - Mukesh Murugan - The best way to start a full-stack Multi-tenant .NET 9 Web App. - en-US - ./content/LICENSE - 2024 - ./content/README.md - https://fullstackhero.net/dotnet-starter-kit/general/getting-started/ - - - - - cleanarchitecture clean architecture WebAPI mukesh codewithmukesh fullstackhero solution csharp - ./content/icon.png - - - - - \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000000..a4b73947a6 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,139 @@ +# FSH .NET Starter Kit — Gemini AI Assistant Guide + +> Modular Monolith · CQRS · DDD · Multi-Tenant · .NET 10 + +## Quick Start + +```bash +dotnet build src/FSH.Starter.slnx # Build (0 warnings required) +dotnet test src/FSH.Starter.slnx # Run tests +dotnet run --project src/Playground/FSH.Starter.AppHost # Run with Aspire +``` + +## Project Layout + +``` +src/ +├── BuildingBlocks/ # Framework (11 packages) — ⚠️ Protected +├── Modules/ # Business features — Add code here +│ ├── Identity/ # Auth, users, roles, permissions +│ ├── Multitenancy/ # Tenant management (Finbuckle) +│ └── Auditing/ # Audit logging +├── Playground/ # Reference application +└── Tests/ # Architecture + unit tests +``` + +## The Pattern + +Every feature = vertical slice: + +``` +Modules/{Module}/Features/v1/{Feature}/ +├── {Action}{Entity}Command.cs # ICommand +├── {Action}{Entity}Handler.cs # ICommandHandler +├── {Action}{Entity}Validator.cs # AbstractValidator +└── {Action}{Entity}Endpoint.cs # MapPost/Get/Put/Delete +``` + +## Critical Rules + +| ⚠️ Rule | Why | +|---------|-----| +| Use **Mediator** not MediatR | Different library, different interfaces | +| `ICommand` / `IQuery` | NOT `IRequest` | +| `ValueTask` return type | NOT `Task` | +| Every command needs validator | FluentValidation, no exceptions | +| `.RequirePermission()` on endpoints | Explicit authorization | +| Zero build warnings | CI blocks merges | + +## Available Skills + +Invoke with `/skill-name` in your prompt. + +| Skill | Purpose | +|-------|---------| +| `/add-feature` | Create complete CQRS feature (command/handler/validator/endpoint) | +| `/add-entity` | Add domain entity with base class inheritance | +| `/add-module` | Scaffold new bounded context module | +| `/query-patterns` | Implement paginated/filtered queries | +| `/testing-guide` | Write architecture + unit tests | +| `/mediator-reference` | Mediator vs MediatR interface reference | + +## Available Workflows + +Delegate complex tasks to specialized workflows. + +| Workflow | Expertise | +|----------|-----------| +| `/code-reviewer` | Review changes against FSH patterns + architecture rules | +| `/feature-scaffolder` | Generate complete feature slices from requirements | +| `/module-creator` | Create new modules with contracts, persistence, DI setup | +| `/architecture-guard` | Verify layering, dependencies, module boundaries | +| `/migration-helper` | Generate and apply EF Core migrations | + +## Example: Create Feature + +```csharp +// Command +public sealed record CreateProductCommand(string Name, decimal Price) + : ICommand; + +// Handler +public sealed class CreateProductHandler(IRepository repo) + : ICommandHandler +{ + public async ValueTask Handle(CreateProductCommand cmd, CancellationToken ct) + { + var product = Product.Create(cmd.Name, cmd.Price); + await repo.AddAsync(product, ct); + return product.Id; + } +} + +// Validator +public sealed class CreateProductValidator : AbstractValidator +{ + public CreateProductValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(200); + RuleFor(x => x.Price).GreaterThan(0); + } +} + +// Endpoint +public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints) => + endpoints.MapPost("/", async (CreateProductCommand cmd, IMediator mediator, CancellationToken ct) => + TypedResults.Created($"/api/v1/products/{await mediator.Send(cmd, ct)}")) + .WithName(nameof(CreateProductCommand)) + .WithSummary("Create a new product") + .RequirePermission(CatalogPermissions.Products.Create); +``` + +## Architecture + +- **Pattern:** Modular Monolith (not microservices) +- **CQRS:** Mediator library (commands/queries) +- **DDD:** Rich domain models, aggregates, value objects +- **Multi-Tenancy:** Finbuckle.MultiTenant (shared DB, tenant isolation) +- **Modules:** 3 core (Identity, Multitenancy, Auditing) + your features +- **BuildingBlocks:** 11 packages (Core, Persistence, Caching, Jobs, Web, etc.) + +Details: See `.agents/rules/architecture.md` + +## Before Committing + +```bash +dotnet build src/FSH.Starter.slnx # Must pass with 0 warnings +dotnet test src/FSH.Starter.slnx # All tests must pass +``` + +## Documentation + +- **Architecture:** See `ARCHITECTURE_ANALYSIS.md` (19KB deep-dive) +- **Rules:** See `.agents/rules/*.md` (API conventions, testing, modules) +- **Skills:** See `.agents/skills/*/SKILL.md` (step-by-step guides) +- **Workflows:** See `.agents/workflows/*.md` (specialized assistants) + +--- + +**Philosophy:** This is a production-ready starter kit. Every pattern is battle-tested. Follow the conventions, and you'll ship faster. diff --git a/LICENSE b/LICENSE index fc25cd4f55..7e5256d336 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 fullstackhero +Copyright (c) 2021-2026 fullstackhero Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README-template.md b/README-template.md index e69de29bb2..998d79a167 100644 --- a/README-template.md +++ b/README-template.md @@ -0,0 +1,63 @@ +# FSH.Starter + +Built with [FullStackHero .NET Starter Kit](https://github.com/fullstackhero/dotnet-starter-kit) — a production-ready modular .NET 10 framework. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [Docker](https://www.docker.com/) (for PostgreSQL, Redis, and Aspire) +- .NET Aspire workload: `dotnet workload install aspire` + +## Quick Start + +```bash +# Start everything with Aspire (recommended) +dotnet run --project src/Playground/FSH.Starter.AppHost + +# Or run the API standalone (requires external Postgres + Redis) +dotnet run --project src/Playground/FSH.Starter.Api +``` + +The Aspire dashboard opens at `https://localhost:15888`. The API serves at `https://localhost:7030` with Scalar docs at `/scalar`. + +## Project Structure + +``` +src/ + BuildingBlocks/ # Shared framework libraries (do not modify unless necessary) + Modules/ # Bounded contexts (Identity, Multitenancy, Auditing, Webhooks) + Playground/ + FSH.Starter.Api/ # API host + FSH.Starter.AppHost/ # .NET Aspire orchestrator +FSH.Starter.Migrations.PostgreSQL/ # EF Core migrations + Tests/ # Unit, integration, and architecture tests +``` + +## Adding Your First Feature + +1. Define command/query in `src/Modules/{Module}.Contracts/v1/{Area}/{Feature}/` +2. Add handler in `src/Modules/{Module}/Features/v1/{Area}/{Feature}/` +3. Add FluentValidation validator in the same folder +4. Add endpoint in the same folder +5. Wire the endpoint in the module's `MapEndpoints()` method + +## Removing Unwanted Modules + +To remove a module (e.g., Webhooks): + +1. Delete `src/Modules/Webhooks/` folders +2. Remove its references from `src/Playground/FSH.Starter.Api/FSH.Starter.Api.csproj` +3. Remove its assembly from `Program.cs` (both `AddMediator` and `moduleAssemblies`) +4. Remove its migration folder from `src/Playground/FSH.Starter.Migrations.PostgreSQL/` +5. Remove from `src/FSH.Starter.slnx` + +## Running Tests + +```bash +dotnet test src/FSH.Starter.slnx +``` + +## Learn More + +- [FullStackHero Documentation](https://fullstackhero.net) +- [GitHub Repository](https://github.com/fullstackhero/dotnet-starter-kit) diff --git a/README.md b/README.md index 7682ba1331..a5853ee75c 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,119 @@ -# FullStackHero .NET 9 Starter Kit 🚀 +# FullStackHero .NET 10 Starter Kit -> With ASP.NET Core Web API & Blazor Client +[![NuGet](https://img.shields.io/nuget/v/FullStackHero.CLI?label=fsh%20cli)](https://www.nuget.org/packages/FullStackHero.CLI) +[![NuGet](https://img.shields.io/nuget/v/FullStackHero.Framework.Web?label=framework)](https://www.nuget.org/packages/FullStackHero.Framework.Web) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -FullStackHero .NET Starter Kit is a starting point for your next `.NET 9 Clean Architecture` Solution that incorporates the most essential packages and features your projects will ever need including out-of-the-box Multi-Tenancy support. This project can save well over 200+ hours of development time for your team. +An opinionated, production-first starter for building multi-tenant SaaS and enterprise APIs on .NET 10. You get ready-to-ship Identity, Multitenancy, Auditing, Webhooks, caching, mailing, jobs, storage, health, OpenAPI, and OpenTelemetry — wired through Minimal APIs, Mediator, and EF Core. -![FullStackHero .NET Starter Kit](./assets/fullstackhero-dotnet-starter-kit.png) +## Quick Start -# Important +You get the complete source code — BuildingBlocks, Modules, and Playground — with full project references. No black-box NuGet packages; you own and can modify everything. -This project is currently work in progress. The NuGet package is not yet available for v2. For now, you can fork this repository to try it out. [Follow @iammukeshm on X](https://x.com/iammukeshm) for project related updates. +### Option 1: FSH CLI (recommended) -# Quick Start Guide - -As the project is still in beta, the NuGet packages are not yet available. You can try out the project by pulling the code directly from this repository. - -Prerequisites: - -- .NET 9 SDK installed. -- Visual Studio IDE. -- Docker Desktop. -- PostgreSQL instance running on your machine or docker container. - -Please follow the below instructions. - -1. Fork this repository to your local. -2. Open up the `./src/FSH.Starter.sln`. -3. This would up the FSH Starter solution which has 3 main components. - 1. Aspire Dashboard (set as the default project) - 2. Web API - 3. Blazor -4. Now we will have to set the connection string for the API. Navigate to `./src/api/server/appsettings.Development.json` and change the `ConnectionString` under `DatabaseOptions`. Save it. -5. Once that is done, run the application via Visual Studio, with Aspire as the default project. This will open up Aspire Dashboard at `https://localhost:7200/`. -6. API will be running at `https://localhost:7000/swagger/index.html`. -7. Blazor will be running at `https://localhost:7100/`. - -# 🔎 The Project - -# ✨ Technologies - -- .NET 9 -- Entity Framework Core 9 -- Blazor -- MediatR -- PostgreSQL -- Redis -- FluentValidation - -# 👨‍🚀 Architecture - -# 📬 Service Endpoints - -| Endpoint | Method | Description | -| -------- | ------ | ---------------- | -| `/token` | POST | Generates Token. | +```bash +dotnet tool install -g FullStackHero.CLI +fsh new MyApp +cd MyApp +dotnet run --project src/Playground/MyApp.AppHost +``` -# 🧪 Running Locally +The interactive wizard lets you pick your database provider and whether to include Aspire. Run `fsh doctor` to verify your environment first. -# 🐳 Docker Support +### Option 2: dotnet new template -# ☁️ Deploying to AWS +```bash +dotnet new install FullStackHero.NET.StarterKit +dotnet new fsh -n MyApp +cd MyApp +dotnet run --project src/Playground/MyApp.AppHost +``` -# 🤝 Contributing +### Option 3: Clone the repository -# 🍕 Community +```bash +git clone https://github.com/fullstackhero/dotnet-starter-kit.git MyApp +cd MyApp +dotnet restore src/FSH.Starter.slnx +dotnet run --project src/Playground/FSH.Starter.AppHost +``` -Thanks to the community who contribute to this repository! [Submit your PR and join the elite list!](CONTRIBUTING.md) +### Option 4: GitHub Codespaces -[![FullStackHero .NET Starter Kit Contributors](https://contrib.rocks/image?repo=fullstackhero/dotnet-starter-kit "FullStackHero .NET Starter Kit Contributors")](https://github.com/fullstackhero/dotnet-starter-kit/graphs/contributors) +Click **"Use this template"** on GitHub, or open in Codespaces for a zero-install experience with .NET 10, Docker, and Aspire pre-configured. -# 📝 Notes +> Prerequisites: [.NET 10 SDK](https://dotnet.microsoft.com/download), [Docker](https://www.docker.com/) (for Postgres/Redis via Aspire) -## Add Migrations +## FSH CLI Commands -Navigate to `./api/server` and run the following EF CLI commands. +| Command | Description | +|---------|------------| +| `fsh new [name]` | Create a new project with interactive wizard | +| `fsh doctor` | Check your environment (SDK, Docker, Aspire, ports) | +| `fsh info` | Show CLI/template versions and available updates | +| `fsh update` | Update CLI tool and template to latest | ```bash -dotnet ef migrations add "Add Identity Schema" --project .././migrations/postgresql/ --context IdentityDbContext -o Identity -dotnet ef migrations add "Add Tenant Schema" --project .././migrations/postgresql/ --context TenantDbContext -o Tenant -dotnet ef migrations add "Add Todo Schema" --project .././migrations/postgresql/ --context TodoDbContext -o Todo -dotnet ef migrations add "Add Catalog Schema" --project .././migrations/postgresql/ --context CatalogDbContext -o Catalog -``` - -## What's Pending? +# Non-interactive with options +fsh new MyApp --db sqlserver --no-aspire --no-git -- Few Identity Endpoints -- Blazor Client -- File Storage Service -- NuGet Generation Pipeline -- Source Code Generation -- Searching / Sorting - -# ⚖️ LICENSE +# Dry run (preview without creating) +fsh new MyApp --dry-run +``` -MIT © [fullstackhero](LICENSE) +## Why teams pick this +- Modular vertical slices: drop `Modules.Identity`, `Modules.Multitenancy`, `Modules.Auditing`, `Modules.Webhooks` into any API and let the module loader wire endpoints. +- Battle-tested building blocks: persistence + specifications, distributed caching, mailing, jobs via Hangfire, storage abstractions, and web host primitives (auth, rate limiting, versioning, CORS, exception handling). +- Cloud-ready out of the box: Aspire AppHost spins up Postgres + Redis + the Playground API with OTLP tracing enabled. +- Multi-tenant from day one: Finbuckle-powered tenancy across Identity and your module DbContexts; helpers to migrate and seed tenant databases on startup. +- Observability baked in: OpenTelemetry traces/metrics/logs, structured logging, health checks, and security/exception auditing. + +## Stack highlights +- .NET 10, C# latest, Minimal APIs, Mediator for commands/queries, FluentValidation. +- EF Core 10 with domain events + specifications; Postgres by default, SQL Server ready. +- ASP.NET Identity with JWT issuance/refresh, roles/permissions, rate-limited auth endpoints. +- Hangfire for background jobs; Redis-backed distributed cache; pluggable storage. +- API versioning, rate limiting, CORS, security headers, OpenAPI (Swagger) + Scalar docs. + +## Repository map +- `src/BuildingBlocks` — Core abstractions (DDD primitives, exceptions), Persistence, Caching, Mailing, Jobs, Storage, Web host wiring. +- `src/Modules` — `Identity`, `Multitenancy`, `Auditing`, `Webhooks` runtime + contracts projects. +- `src/Playground` — Reference host (`FSH.Starter.Api`), Aspire app host (`FSH.Starter.AppHost`), Postgres migrations. +- `src/Tools/CLI` — The `fsh` CLI tool source code. +- `src/Tests` — Architecture tests that enforce layering and module boundaries. +- `deploy` — Docker, Dokploy, and Terraform deployment scaffolding. + +## Run it now (Aspire) +Prereqs: .NET 10 SDK, Aspire workload, Docker running (for Postgres/Redis). + +1. Restore: `dotnet restore src/FSH.Starter.slnx` +2. Start everything: `dotnet run --project src/Playground/FSH.Starter.AppHost` + - Aspire brings up Postgres + Redis containers, wires env vars, launches the Playground API, and enables OTLP export on https://localhost:4317. +3. Hit the API: `https://localhost:5285` (Swagger/Scalar and module endpoints under `/api/v1/...`). + +### Run the API only +- Set env vars or appsettings for `DatabaseOptions__Provider`, `DatabaseOptions__ConnectionString`, `DatabaseOptions__MigrationsAssembly`, `CachingOptions__Redis`, and JWT options. +- Run: `dotnet run --project src/Playground/FSH.Starter.Api` +- The host applies migrations/seeding via `UseHeroMultiTenantDatabases()` and maps module endpoints via `UseHeroPlatform`. + +## Bring the framework into your API +- Reference the building block and module projects you need. +- In `Program.cs`: + - Register Mediator with assemblies containing your commands/queries and module handlers. + - Call `builder.AddHeroPlatform(...)` to enable auth, OpenAPI, caching, mailing, jobs, health, OTel, rate limiting. + - Call `builder.AddModules(moduleAssemblies)` and `app.UseHeroPlatform(p => p.MapModules = true);`. +- Configure connection strings, Redis, JWT, CORS, and OTel endpoints via configuration. Example wiring lives in `src/Playground/FSH.Starter.Api/Program.cs`. + +## Included modules +- **Identity** — ASP.NET Identity + JWT issuance/refresh, user/role/permission management, profile image storage, login/refresh auditing, health checks. +- **Multitenancy** — Tenant provisioning, migrations, status/upgrade APIs, tenant-aware EF Core contexts, health checks. +- **Auditing** — Security/exception/activity auditing with queryable endpoints; plugs into global exception handling and Identity events. +- **Webhooks** — Tenant-scoped webhook subscriptions with HMAC-signed delivery, retry policies, and delivery logs. + +## Development notes +- Target framework: `net10.0`; nullable enabled; analyzers on. +- Tests: `dotnet test src/FSH.Starter.slnx` (includes architecture guardrails). +- Want the deeper story? Start with `docs/framework/architecture.md` and the developer cookbook in `docs/framework/developer-cookbook.md`. + +Built and maintained by Mukesh Murugan for teams that want to ship faster without sacrificing architecture discipline. diff --git a/assets/fullstackhero-dotnet-starter-kit.png b/assets/fullstackhero-dotnet-starter-kit.png deleted file mode 100644 index d5ac1f26ff..0000000000 Binary files a/assets/fullstackhero-dotnet-starter-kit.png and /dev/null differ diff --git a/clients/admin/.gitignore b/clients/admin/.gitignore new file mode 100644 index 0000000000..5ef6a52078 --- /dev/null +++ b/clients/admin/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/clients/admin/AGENTS.md b/clients/admin/AGENTS.md new file mode 100644 index 0000000000..8bd0e39085 --- /dev/null +++ b/clients/admin/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/clients/admin/CLAUDE.md b/clients/admin/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/clients/admin/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/clients/admin/README.md b/clients/admin/README.md new file mode 100644 index 0000000000..e215bc4ccf --- /dev/null +++ b/clients/admin/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/clients/admin/eslint.config.mjs b/clients/admin/eslint.config.mjs new file mode 100644 index 0000000000..05e726d1b4 --- /dev/null +++ b/clients/admin/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/clients/admin/next.config.ts b/clients/admin/next.config.ts new file mode 100644 index 0000000000..cb651cdc00 --- /dev/null +++ b/clients/admin/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/clients/admin/package.json b/clients/admin/package.json new file mode 100644 index 0000000000..226dc2949f --- /dev/null +++ b/clients/admin/package.json @@ -0,0 +1,47 @@ +{ + "name": "@fsh/admin", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "type-check": "tsc --noEmit", + "clean": "rm -rf .next .turbo node_modules" + }, + "dependencies": { + "next": "16.2.1", + "react": "19.2.4", + "react-dom": "19.2.4", + "@fsh/ui": "workspace:*", + "@fsh/api-client": "workspace:*", + "@fsh/auth": "workspace:*", + "@tanstack/react-query": "^5.75.0", + "@tanstack/react-table": "^8.21.0", + "react-hook-form": "^7.56.0", + "@hookform/resolvers": "^5.1.0", + "zod": "^3.25.0", + "next-themes": "^0.4.6", + "next-auth": "5.0.0-beta.25", + "lucide-react": "^0.511.0", + "axios": "^1.9.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^3.3.0" + }, + "packageManager": "pnpm@10.11.0", + "engines": { + "node": ">=20.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.1", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/clients/admin/packages/api-client/package.json b/clients/admin/packages/api-client/package.json new file mode 100644 index 0000000000..a8e7d42d96 --- /dev/null +++ b/clients/admin/packages/api-client/package.json @@ -0,0 +1,30 @@ +{ + "name": "@fsh/api-client", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types/*": { + "types": "./src/types/*.ts", + "default": "./src/types/*.ts" + }, + "./endpoints/*": { + "types": "./src/endpoints/*.ts", + "default": "./src/endpoints/*.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "clean": "rm -rf node_modules .turbo" + }, + "dependencies": { + "axios": "^1.9.0" + }, + "devDependencies": { + "typescript": "^5.8.0" + } +} diff --git a/clients/admin/packages/api-client/src/client.ts b/clients/admin/packages/api-client/src/client.ts new file mode 100644 index 0000000000..5286faac1d --- /dev/null +++ b/clients/admin/packages/api-client/src/client.ts @@ -0,0 +1,36 @@ +import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from "axios"; + +export interface ApiClientConfig { + baseUrl: string; + getAccessToken?: () => Promise; + getTenantId?: () => string | null; +} + +export function createApiClient(config: ApiClientConfig): AxiosInstance { + const client = axios.create({ + baseURL: config.baseUrl, + headers: { + "Content-Type": "application/json", + }, + }); + + client.interceptors.request.use(async (req: InternalAxiosRequestConfig) => { + if (config.getAccessToken) { + const token = await config.getAccessToken(); + if (token) { + req.headers.Authorization = `Bearer ${token}`; + } + } + + if (config.getTenantId) { + const tenantId = config.getTenantId(); + if (tenantId) { + req.headers["X-Tenant"] = tenantId; + } + } + + return req; + }); + + return client; +} diff --git a/clients/admin/packages/api-client/src/endpoints/auth.ts b/clients/admin/packages/api-client/src/endpoints/auth.ts new file mode 100644 index 0000000000..87830f041c --- /dev/null +++ b/clients/admin/packages/api-client/src/endpoints/auth.ts @@ -0,0 +1,12 @@ +import type { AxiosInstance } from "axios"; +import type { TokenRequest, TokenResponse, RefreshTokenRequest } from "../types/auth"; + +export function createAuthEndpoints(client: AxiosInstance) { + return { + getToken: (data: TokenRequest) => + client.post("/api/v1/identity/tokens", data), + + refreshToken: (data: RefreshTokenRequest) => + client.post("/api/v1/identity/tokens/refresh", data), + }; +} diff --git a/clients/admin/packages/api-client/src/endpoints/roles.ts b/clients/admin/packages/api-client/src/endpoints/roles.ts new file mode 100644 index 0000000000..01f485821d --- /dev/null +++ b/clients/admin/packages/api-client/src/endpoints/roles.ts @@ -0,0 +1,24 @@ +import type { AxiosInstance } from "axios"; +import type { Role, RoleWithPermissions, CreateRoleRequest, UpdateRolePermissionsRequest } from "../types/role"; + +export function createRoleEndpoints(client: AxiosInstance) { + return { + list: () => + client.get("/api/v1/identity/roles"), + + get: (id: string) => + client.get(`/api/v1/identity/roles/${id}`), + + create: (data: CreateRoleRequest) => + client.post("/api/v1/identity/roles", data), + + delete: (id: string) => + client.delete(`/api/v1/identity/roles/${id}`), + + getPermissions: (id: string) => + client.get(`/api/v1/identity/roles/${id}/permissions`), + + updatePermissions: (data: UpdateRolePermissionsRequest) => + client.put(`/api/v1/identity/roles/${data.roleId}/permissions`, data), + }; +} diff --git a/clients/admin/packages/api-client/src/endpoints/tenants.ts b/clients/admin/packages/api-client/src/endpoints/tenants.ts new file mode 100644 index 0000000000..ada8e9cc52 --- /dev/null +++ b/clients/admin/packages/api-client/src/endpoints/tenants.ts @@ -0,0 +1,25 @@ +import type { AxiosInstance } from "axios"; +import type { PagedResponse, PaginationParams } from "../types/common"; +import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from "../types/tenant"; + +export function createTenantEndpoints(client: AxiosInstance) { + return { + list: (params?: PaginationParams) => + client.get>("/api/v1/multitenancy/tenants", { params }), + + get: (id: string) => + client.get(`/api/v1/multitenancy/tenants/${id}`), + + create: (data: CreateTenantRequest) => + client.post("/api/v1/multitenancy/tenants", data), + + update: (id: string, data: UpdateTenantRequest) => + client.put(`/api/v1/multitenancy/tenants/${id}`, data), + + activate: (id: string) => + client.post(`/api/v1/multitenancy/tenants/${id}/activate`), + + deactivate: (id: string) => + client.post(`/api/v1/multitenancy/tenants/${id}/deactivate`), + }; +} diff --git a/clients/admin/packages/api-client/src/endpoints/users.ts b/clients/admin/packages/api-client/src/endpoints/users.ts new file mode 100644 index 0000000000..d7396d5fd3 --- /dev/null +++ b/clients/admin/packages/api-client/src/endpoints/users.ts @@ -0,0 +1,29 @@ +import type { AxiosInstance } from "axios"; +import type { PagedResponse, PaginationParams } from "../types/common"; +import type { User, CreateUserRequest, UpdateUserRequest } from "../types/user"; +import type { UserRole } from "../types/role"; + +export function createUserEndpoints(client: AxiosInstance) { + return { + list: (params?: PaginationParams) => + client.get>("/api/v1/identity/users", { params }), + + get: (id: string) => + client.get(`/api/v1/identity/users/${id}`), + + create: (data: CreateUserRequest) => + client.post("/api/v1/identity/register", data), + + update: (id: string, data: UpdateUserRequest) => + client.put(`/api/v1/identity/users/${id}`, data), + + toggleStatus: (id: string) => + client.post(`/api/v1/identity/users/${id}/toggle-status`), + + getRoles: (id: string) => + client.get(`/api/v1/identity/users/${id}/roles`), + + assignRoles: (id: string, roles: { roleId: string; enabled: boolean }[]) => + client.post(`/api/v1/identity/users/${id}/roles`, roles), + }; +} diff --git a/clients/admin/packages/api-client/src/index.ts b/clients/admin/packages/api-client/src/index.ts new file mode 100644 index 0000000000..6424868024 --- /dev/null +++ b/clients/admin/packages/api-client/src/index.ts @@ -0,0 +1,11 @@ +export { createApiClient, type ApiClientConfig } from "./client"; +export { createAuthEndpoints } from "./endpoints/auth"; +export { createTenantEndpoints } from "./endpoints/tenants"; +export { createUserEndpoints } from "./endpoints/users"; +export { createRoleEndpoints } from "./endpoints/roles"; + +export type * from "./types/common"; +export type * from "./types/auth"; +export type * from "./types/tenant"; +export type * from "./types/user"; +export type * from "./types/role"; diff --git a/clients/admin/packages/api-client/src/types/auth.ts b/clients/admin/packages/api-client/src/types/auth.ts new file mode 100644 index 0000000000..1f965c4398 --- /dev/null +++ b/clients/admin/packages/api-client/src/types/auth.ts @@ -0,0 +1,15 @@ +export interface TokenRequest { + email: string; + password: string; +} + +export interface TokenResponse { + token: string; + refreshToken: string; + refreshTokenExpiryTime: string; +} + +export interface RefreshTokenRequest { + token: string; + refreshToken: string; +} diff --git a/clients/admin/packages/api-client/src/types/common.ts b/clients/admin/packages/api-client/src/types/common.ts new file mode 100644 index 0000000000..d9d76477ee --- /dev/null +++ b/clients/admin/packages/api-client/src/types/common.ts @@ -0,0 +1,24 @@ +export interface PagedResponse { + items: T[]; + totalCount: number; + pageNumber: number; + pageSize: number; + totalPages: number; + hasPreviousPage: boolean; + hasNextPage: boolean; +} + +export interface PaginationParams { + pageNumber?: number; + pageSize?: number; + search?: string; + sortBy?: string; + sortOrder?: "asc" | "desc"; +} + +export interface ApiError { + title: string; + status: number; + detail?: string; + errors?: Record; +} diff --git a/clients/admin/packages/api-client/src/types/role.ts b/clients/admin/packages/api-client/src/types/role.ts new file mode 100644 index 0000000000..a44a5847e8 --- /dev/null +++ b/clients/admin/packages/api-client/src/types/role.ts @@ -0,0 +1,30 @@ +export interface Role { + id: string; + name: string; + description?: string; +} + +export interface Permission { + permission: string; + description?: string; +} + +export interface RoleWithPermissions extends Role { + permissions: Permission[]; +} + +export interface CreateRoleRequest { + name: string; + description?: string; +} + +export interface UpdateRolePermissionsRequest { + roleId: string; + permissions: string[]; +} + +export interface UserRole { + roleId: string; + roleName: string; + enabled: boolean; +} diff --git a/clients/admin/packages/api-client/src/types/tenant.ts b/clients/admin/packages/api-client/src/types/tenant.ts new file mode 100644 index 0000000000..b6b06f9559 --- /dev/null +++ b/clients/admin/packages/api-client/src/types/tenant.ts @@ -0,0 +1,25 @@ +export interface Tenant { + id: string; + identifier: string; + name: string; + connectionString?: string; + adminEmail: string; + isActive: boolean; + validUpTo?: string; + issuer?: string; +} + +export interface CreateTenantRequest { + identifier: string; + name: string; + connectionString?: string; + adminEmail: string; + validUpTo?: string; +} + +export interface UpdateTenantRequest { + name?: string; + connectionString?: string; + adminEmail?: string; + validUpTo?: string; +} diff --git a/clients/admin/packages/api-client/src/types/user.ts b/clients/admin/packages/api-client/src/types/user.ts new file mode 100644 index 0000000000..66438a76e6 --- /dev/null +++ b/clients/admin/packages/api-client/src/types/user.ts @@ -0,0 +1,28 @@ +export interface User { + id: string; + firstName: string; + lastName: string; + email: string; + userName: string; + phoneNumber?: string; + isActive: boolean; + emailConfirmed: boolean; + imageUrl?: string; +} + +export interface CreateUserRequest { + firstName: string; + lastName: string; + email: string; + userName: string; + password: string; + confirmPassword: string; + phoneNumber?: string; +} + +export interface UpdateUserRequest { + firstName?: string; + lastName?: string; + phoneNumber?: string; + imageUrl?: string; +} diff --git a/clients/admin/packages/api-client/tsconfig.json b/clients/admin/packages/api-client/tsconfig.json new file mode 100644 index 0000000000..c973386333 --- /dev/null +++ b/clients/admin/packages/api-client/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/clients/admin/packages/auth/package.json b/clients/admin/packages/auth/package.json new file mode 100644 index 0000000000..5369e0f04b --- /dev/null +++ b/clients/admin/packages/auth/package.json @@ -0,0 +1,36 @@ +{ + "name": "@fsh/auth", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./proxy": { + "types": "./src/proxy.ts", + "default": "./src/proxy.ts" + }, + "./provider": { + "types": "./src/provider.tsx", + "default": "./src/provider.tsx" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "clean": "rm -rf node_modules .turbo" + }, + "dependencies": { + "next-auth": "5.0.0-beta.25", + "@fsh/api-client": "workspace:*" + }, + "peerDependencies": { + "next": ">=15.0.0", + "react": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "typescript": "^5.8.0" + } +} diff --git a/clients/admin/packages/auth/src/auth.config.ts b/clients/admin/packages/auth/src/auth.config.ts new file mode 100644 index 0000000000..eee39efdd9 --- /dev/null +++ b/clients/admin/packages/auth/src/auth.config.ts @@ -0,0 +1,108 @@ +import type { NextAuthConfig } from "next-auth"; +import type { NextRequest } from "next/server"; +import Credentials from "next-auth/providers/credentials"; +import { createApiClient, createAuthEndpoints } from "@fsh/api-client"; + +export const authConfig: NextAuthConfig = { + providers: [ + Credentials({ + name: "credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + tenantId: { label: "Tenant", type: "text" }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) return null; + + try { + const apiClient = createApiClient({ + baseUrl: process.env.FSH_API_URL ?? "http://localhost:5030", + }); + + if (credentials.tenantId) { + apiClient.defaults.headers.common["X-Tenant"] = + credentials.tenantId as string; + } + + const authApi = createAuthEndpoints(apiClient); + + const response = await authApi.getToken({ + email: credentials.email as string, + password: credentials.password as string, + }); + + if (!response.data?.token) return null; + + const token = response.data.token; + const payload = JSON.parse(atob(token.split(".")[1])); + + return { + id: payload.uid ?? payload.sub, + email: payload.email, + firstName: payload.given_name ?? "", + lastName: payload.family_name ?? "", + tenantId: + payload.tenant ?? (credentials.tenantId as string) ?? "", + permissions: payload.permission ?? [], + isSuperAdmin: payload.is_superadmin === "true", + accessToken: token, + refreshToken: response.data.refreshToken, + }; + } catch { + return null; + } + }, + }), + ], + callbacks: { + authorized({ + auth, + request, + }: { + auth: { user?: { email?: string | null } } | null; + request: NextRequest; + }) { + const isLoggedIn = !!auth?.user; + const { pathname } = request.nextUrl; + const isOnLogin = pathname.startsWith("/login"); + + if (isOnLogin) { + // Redirect logged-in users away from login page + if (isLoggedIn) return Response.redirect(new URL("/dashboard", request.nextUrl)); + return true; + } + + // Protect all other routes + return isLoggedIn; + }, + async jwt({ token, user }) { + if (user) { + token.user = { + id: user.id!, + email: user.email!, + firstName: user.firstName, + lastName: user.lastName, + tenantId: user.tenantId, + permissions: user.permissions, + isSuperAdmin: user.isSuperAdmin, + }; + token.accessToken = user.accessToken; + token.refreshToken = user.refreshToken; + } + return token; + }, + async session({ session, token }) { + session.user = token.user as typeof session.user; + session.accessToken = token.accessToken as string; + session.refreshToken = token.refreshToken as string; + return session; + }, + }, + pages: { + signIn: "/login", + }, + session: { + strategy: "jwt", + }, +}; diff --git a/clients/admin/packages/auth/src/auth.ts b/clients/admin/packages/auth/src/auth.ts new file mode 100644 index 0000000000..a68454b3b1 --- /dev/null +++ b/clients/admin/packages/auth/src/auth.ts @@ -0,0 +1,4 @@ +import NextAuth from "next-auth"; +import { authConfig } from "./auth.config"; + +export const { handlers, auth, signIn, signOut } = NextAuth(authConfig); diff --git a/clients/admin/packages/auth/src/index.ts b/clients/admin/packages/auth/src/index.ts new file mode 100644 index 0000000000..154d3635d3 --- /dev/null +++ b/clients/admin/packages/auth/src/index.ts @@ -0,0 +1,4 @@ +export { auth, signIn, signOut, handlers } from "./auth"; +export { authConfig } from "./auth.config"; +export { AuthProvider } from "./provider"; +export type { FshUser } from "./types"; diff --git a/clients/admin/packages/auth/src/provider.tsx b/clients/admin/packages/auth/src/provider.tsx new file mode 100644 index 0000000000..660ea4ab25 --- /dev/null +++ b/clients/admin/packages/auth/src/provider.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; +import type { ReactNode } from "react"; + +export function AuthProvider({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/clients/admin/packages/auth/src/proxy.ts b/clients/admin/packages/auth/src/proxy.ts new file mode 100644 index 0000000000..44c5b180a3 --- /dev/null +++ b/clients/admin/packages/auth/src/proxy.ts @@ -0,0 +1,5 @@ +export { auth as proxy } from "./auth"; + +export const config = { + matcher: ["/((?!login|api/auth|_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/clients/admin/packages/auth/src/types.ts b/clients/admin/packages/auth/src/types.ts new file mode 100644 index 0000000000..c2e72bf1f4 --- /dev/null +++ b/clients/admin/packages/auth/src/types.ts @@ -0,0 +1,32 @@ +import type { DefaultSession } from "next-auth"; + +export interface FshUser { + id: string; + email: string; + firstName: string; + lastName: string; + tenantId: string; + imageUrl?: string; + permissions: string[]; + isSuperAdmin: boolean; +} + +declare module "next-auth" { + interface Session extends DefaultSession { + user: FshUser; + accessToken: string; + refreshToken: string; + } + + interface User extends FshUser { + accessToken: string; + refreshToken: string; + } + + interface JWT { + user: FshUser; + accessToken: string; + refreshToken: string; + accessTokenExpires: number; + } +} diff --git a/clients/admin/packages/auth/tsconfig.json b/clients/admin/packages/auth/tsconfig.json new file mode 100644 index 0000000000..45bc4f12cd --- /dev/null +++ b/clients/admin/packages/auth/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/clients/admin/packages/ui/components.json b/clients/admin/packages/ui/components.json new file mode 100644 index 0000000000..e0c1eb3e27 --- /dev/null +++ b/clients/admin/packages/ui/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/globals.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "src/components", + "utils": "src/lib/utils", + "ui": "src/components", + "lib": "src/lib", + "hooks": "src/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/clients/admin/packages/ui/package.json b/clients/admin/packages/ui/package.json new file mode 100644 index 0000000000..26b4de135f --- /dev/null +++ b/clients/admin/packages/ui/package.json @@ -0,0 +1,42 @@ +{ + "name": "@fsh/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./components/*": { + "types": "./src/components/*.tsx", + "default": "./src/components/*.tsx" + }, + "./lib/*": { + "types": "./src/lib/*.ts", + "default": "./src/lib/*.ts" + }, + "./globals.css": "./src/globals.css" + }, + "scripts": { + "type-check": "tsc --noEmit", + "clean": "rm -rf node_modules .turbo" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.511.0", + "radix-ui": "^1.4.3", + "tailwind-merge": "^3.3.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.8.0" + } +} diff --git a/clients/admin/packages/ui/src/components/button.tsx b/clients/admin/packages/ui/src/components/button.tsx new file mode 100644 index 0000000000..c9e1c7dc05 --- /dev/null +++ b/clients/admin/packages/ui/src/components/button.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@fsh/ui/lib/utils" + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/clients/admin/packages/ui/src/components/checkbox.tsx b/clients/admin/packages/ui/src/components/checkbox.tsx new file mode 100644 index 0000000000..9220e1a357 --- /dev/null +++ b/clients/admin/packages/ui/src/components/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import { CheckIcon } from "lucide-react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@fsh/ui/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/clients/admin/packages/ui/src/components/input.tsx b/clients/admin/packages/ui/src/components/input.tsx new file mode 100644 index 0000000000..2851e3a77f --- /dev/null +++ b/clients/admin/packages/ui/src/components/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@fsh/ui/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/clients/admin/packages/ui/src/components/label.tsx b/clients/admin/packages/ui/src/components/label.tsx new file mode 100644 index 0000000000..d96d3432e0 --- /dev/null +++ b/clients/admin/packages/ui/src/components/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import { Label as LabelPrimitive } from "radix-ui" + +import { cn } from "@fsh/ui/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/clients/admin/packages/ui/src/globals.css b/clients/admin/packages/ui/src/globals.css new file mode 100644 index 0000000000..0db47e885b --- /dev/null +++ b/clients/admin/packages/ui/src/globals.css @@ -0,0 +1,132 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + button, + [role="button"], + a, + select, + summary, + [type="button"], + [type="submit"], + [type="reset"] { + cursor: pointer; + } +} diff --git a/clients/admin/packages/ui/src/index.ts b/clients/admin/packages/ui/src/index.ts new file mode 100644 index 0000000000..3b8868856a --- /dev/null +++ b/clients/admin/packages/ui/src/index.ts @@ -0,0 +1 @@ +export { cn } from "./lib/utils"; diff --git a/clients/admin/packages/ui/src/lib/utils.ts b/clients/admin/packages/ui/src/lib/utils.ts new file mode 100644 index 0000000000..365058cebd --- /dev/null +++ b/clients/admin/packages/ui/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/clients/admin/packages/ui/tsconfig.json b/clients/admin/packages/ui/tsconfig.json new file mode 100644 index 0000000000..45bc4f12cd --- /dev/null +++ b/clients/admin/packages/ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/clients/admin/pnpm-lock.yaml b/clients/admin/pnpm-lock.yaml new file mode 100644 index 0000000000..77cac01508 --- /dev/null +++ b/clients/admin/pnpm-lock.yaml @@ -0,0 +1,6068 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fsh/api-client': + specifier: workspace:* + version: link:packages/api-client + '@fsh/auth': + specifier: workspace:* + version: link:packages/auth + '@fsh/ui': + specifier: workspace:* + version: link:packages/ui + '@hookform/resolvers': + specifier: ^5.1.0 + version: 5.2.2(react-hook-form@7.72.1(react@19.2.4)) + '@tanstack/react-query': + specifier: ^5.75.0 + version: 5.96.2(react@19.2.4) + '@tanstack/react-table': + specifier: ^8.21.0 + version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + axios: + specifier: ^1.9.0 + version: 1.14.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.511.0 + version: 0.511.0(react@19.2.4) + next: + specifier: 16.2.1 + version: 16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-auth: + specifier: 5.0.0-beta.25 + version: 5.0.0-beta.25(next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + react-hook-form: + specifier: ^7.56.0 + version: 7.72.1(react@19.2.4) + tailwind-merge: + specifier: ^3.3.0 + version: 3.5.0 + zod: + specifier: ^3.25.0 + version: 3.25.76 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.2.2 + '@types/node': + specifier: ^20 + version: 20.19.39 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + eslint: + specifier: ^9 + version: 9.39.4(jiti@2.6.1) + eslint-config-next: + specifier: 16.2.1 + version: 16.2.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.2.2 + typescript: + specifier: ^5 + version: 5.9.3 + + packages/api-client: + dependencies: + axios: + specifier: ^1.9.0 + version: 1.14.0 + devDependencies: + typescript: + specifier: ^5.8.0 + version: 5.9.3 + + packages/auth: + dependencies: + '@fsh/api-client': + specifier: workspace:* + version: link:../api-client + next: + specifier: '>=15.0.0' + version: 16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-auth: + specifier: 5.0.0-beta.25 + version: 5.0.0-beta.25(next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.0.0 + version: 19.2.4 + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + + packages/ui: + dependencies: + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.511.0 + version: 0.511.0(react@19.2.4) + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + tailwind-merge: + specifier: ^3.3.0 + version: 3.5.0 + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + tailwindcss: + specifier: ^4.1.0 + version: 4.2.2 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@auth/core@0.37.2': + resolution: {integrity: sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@16.2.1': + resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} + + '@next/eslint-plugin-next@16.2.1': + resolution: {integrity: sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==} + + '@next/swc-darwin-arm64@16.2.1': + resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.1': + resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.1': + resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.2.1': + resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.2.1': + resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.2.1': + resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.2.1': + resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.1': + resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.2': + resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + + '@tanstack/query-core@5.96.2': + resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==} + + '@tanstack/react-query@5.96.2': + resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.2: + resolution: {integrity: sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==} + engines: {node: '>=4'} + + axios@1.14.0: + resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.14: + resolution: {integrity: sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001785: + resolution: {integrity: sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.331: + resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.1: + resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@16.2.1: + resolution: {integrity: sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.511.0: + resolution: {integrity: sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next-auth@5.0.0-beta.25: + resolution: {integrity: sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0-0 + nodemailer: ^6.6.5 + react: ^18.2.0 || ^19.0.0-0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + next@16.2.1: + resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + preact-render-to-string@5.2.3: + resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} + peerDependencies: + preact: '>=10' + + preact@10.11.3: + resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-hook-form@7.72.1: + resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@auth/core@0.37.2': + dependencies: + '@panva/hkdf': 1.2.1 + '@types/cookie': 0.6.0 + cookie: 0.7.1 + jose: 5.10.0 + oauth4webapi: 3.8.5 + preact: 10.11.3 + preact-render-to-string: 5.2.3(preact@10.11.3) + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.11': {} + + '@hookform/resolvers@5.2.2(react-hook-form@7.72.1(react@19.2.4))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.72.1(react@19.2.4) + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@16.2.1': {} + + '@next/eslint-plugin-next@16.2.1': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.2.1': + optional: true + + '@next/swc-darwin-x64@16.2.1': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.1': + optional: true + + '@next/swc-linux-arm64-musl@16.2.1': + optional: true + + '@next/swc-linux-x64-gnu@16.2.1': + optional: true + + '@next/swc-linux-x64-musl@16.2.1': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.1': + optional: true + + '@next/swc-win32-x64-msvc@16.2.1': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@panva/hkdf@1.2.1': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@rtsao/scc@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/postcss@4.2.2': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + postcss: 8.5.8 + tailwindcss: 4.2.2 + + '@tanstack/query-core@5.96.2': {} + + '@tanstack/react-query@5.96.2(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.96.2 + react: 19.2.4 + + '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tanstack/table-core@8.21.3': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.39': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.0': {} + + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.2: {} + + axios@1.14.0: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.14: {} + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.14 + caniuse-lite: 1.0.30001785 + electron-to-chromium: 1.5.331 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001785: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@0.7.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.331: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + safe-array-concat: 1.1.3 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.2.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.2.1 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + globals: 16.4.0 + typescript-eslint: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 2.0.0-next.6 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.13.7 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.2 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4(jiti@2.6.1) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 9.39.4(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.1 + eslint: 9.39.4(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + follow-redirects@1.15.11: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.6.1: {} + + jose@5.10.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.511.0(react@19.2.4): + dependencies: + react: 19.2.4 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next-auth@5.0.0-beta.25(next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + dependencies: + '@auth/core': 0.37.2 + next: 16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 16.2.1 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.14 + caniuse-lite: 1.0.30001785 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.1 + '@next/swc-darwin-x64': 16.2.1 + '@next/swc-linux-arm64-gnu': 16.2.1 + '@next/swc-linux-arm64-musl': 16.2.1 + '@next/swc-linux-x64-gnu': 16.2.1 + '@next/swc-linux-x64-musl': 16.2.1 + '@next/swc-win32-arm64-msvc': 16.2.1 + '@next/swc-win32-x64-msvc': 16.2.1 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.37: {} + + oauth4webapi@3.8.5: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact-render-to-string@5.2.3(preact@10.11.3): + dependencies: + preact: 10.11.3 + pretty-format: 3.8.0 + + preact@10.11.3: {} + + prelude-ls@1.2.1: {} + + pretty-format@3.8.0: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-hook-form@7.72.1(react@19.2.4): + dependencies: + react: 19.2.4 + + react-is@16.13.1: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react@19.2.4: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + stable-hash@0.0.5: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + optionalDependencies: + '@babel/core': 7.29.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/clients/admin/pnpm-workspace.yaml b/clients/admin/pnpm-workspace.yaml new file mode 100644 index 0000000000..dee51e928d --- /dev/null +++ b/clients/admin/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/clients/admin/postcss.config.mjs b/clients/admin/postcss.config.mjs new file mode 100644 index 0000000000..61e36849cf --- /dev/null +++ b/clients/admin/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/clients/admin/public/file.svg b/clients/admin/public/file.svg new file mode 100644 index 0000000000..004145cddf --- /dev/null +++ b/clients/admin/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clients/admin/public/globe.svg b/clients/admin/public/globe.svg new file mode 100644 index 0000000000..567f17b0d7 --- /dev/null +++ b/clients/admin/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clients/admin/public/next.svg b/clients/admin/public/next.svg new file mode 100644 index 0000000000..5174b28c56 --- /dev/null +++ b/clients/admin/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clients/admin/public/vercel.svg b/clients/admin/public/vercel.svg new file mode 100644 index 0000000000..7705396033 --- /dev/null +++ b/clients/admin/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clients/admin/public/window.svg b/clients/admin/public/window.svg new file mode 100644 index 0000000000..b2b2a44f6e --- /dev/null +++ b/clients/admin/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clients/admin/src/app/(auth)/layout.tsx b/clients/admin/src/app/(auth)/layout.tsx new file mode 100644 index 0000000000..108fb553ee --- /dev/null +++ b/clients/admin/src/app/(auth)/layout.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+ {/* Soft background wash */} +
+
+
+
+ + {/* Two-card container */} +
+ {/* ── Left card ── */} +
+ {/* Radial rings */} +
+
+
+
+
+ + {/* Ambient glow */} +
+ + {/* Content */} +
+

+ Enterprise-grade multi-tenant platform + built for teams that ship fast. +

+ +

+ Manage +
+ your platform +

+ +
+
+ + + + + +
+ + fullstackhero + +
+
+
+ + {/* ── Right card (overlaps left) ── */} +
+ {children} +
+
+
+ ); +} diff --git a/clients/admin/src/app/(auth)/login/page.tsx b/clients/admin/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000000..2549ec6c92 --- /dev/null +++ b/clients/admin/src/app/(auth)/login/page.tsx @@ -0,0 +1,5 @@ +import { LoginForm } from "@/components/auth/login-form"; + +export default function LoginPage() { + return ; +} diff --git a/clients/admin/src/app/(dashboard)/dashboard/page.tsx b/clients/admin/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000000..6d6e4c356e --- /dev/null +++ b/clients/admin/src/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Building2, Users, Shield, Activity } from "lucide-react"; +import { useTenantsQuery } from "@/hooks/use-tenants"; +import { useUsersQuery } from "@/hooks/use-users"; +import { useRolesQuery } from "@/hooks/use-roles"; + +export default function DashboardPage() { + const { data: tenants } = useTenantsQuery({ pageNumber: 1, pageSize: 1 }); + const { data: users } = useUsersQuery({ pageNumber: 1, pageSize: 1 }); + const { data: roles } = useRolesQuery(); + + const activeTenants = tenants?.totalCount ?? 0; + const totalUsers = users?.totalCount ?? 0; + const totalRoles = roles?.length ?? 0; + + return ( +
+
+

Dashboard

+

+ Overview of your tenant management system. +

+
+ +
+ + + + +
+ +
+
+

Recent Tenants

+ {tenants?.items?.length ? ( +
+ {tenants.items.slice(0, 5).map((tenant) => ( +
+
+

{tenant.name}

+

{tenant.identifier}

+
+ + {tenant.isActive ? "Active" : "Inactive"} + +
+ ))} +
+ ) : ( +

No tenants yet.

+ )} +
+ +
+

Quick Actions

+
+ + + + +
+
+
+
+ ); +} + +function StatCard({ + title, + value, + description, + icon: Icon, +}: { + title: string; + value: string | number; + description: string; + icon: React.ComponentType<{ className?: string }>; +}) { + return ( +
+
+

{title}

+ +
+

{value}

+

{description}

+
+ ); +} + +function QuickAction({ href, label }: { href: string; label: string }) { + return ( + + {label} + + ); +} diff --git a/clients/admin/src/app/(dashboard)/layout.tsx b/clients/admin/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000000..b277a6f3fb --- /dev/null +++ b/clients/admin/src/app/(dashboard)/layout.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; +import { AppSidebar } from "@/components/layout/app-sidebar"; +import { Header } from "@/components/layout/header"; +import { SidebarProvider } from "@/components/ui/sidebar"; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + return ( + + +
+
+
{children}
+
+
+ ); +} diff --git a/clients/admin/src/app/(dashboard)/roles/[id]/page.tsx b/clients/admin/src/app/(dashboard)/roles/[id]/page.tsx new file mode 100644 index 0000000000..3009619cd1 --- /dev/null +++ b/clients/admin/src/app/(dashboard)/roles/[id]/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { use } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { useRolePermissions, useUpdateRolePermissions } from "@/hooks/use-roles"; +import { useState, useEffect } from "react"; + +export default function RoleDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const { data: roleWithPermissions, isLoading } = useRolePermissions(id); + const updatePermissions = useUpdateRolePermissions(); + const [selected, setSelected] = useState>(new Set()); + + useEffect(() => { + if (roleWithPermissions?.permissions) { + setSelected(new Set(roleWithPermissions.permissions.map((p) => p.permission))); + } + }, [roleWithPermissions]); + + function handleToggle(permission: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(permission)) { + next.delete(permission); + } else { + next.add(permission); + } + return next; + }); + } + + function handleSave() { + updatePermissions.mutate({ + roleId: id, + permissions: Array.from(selected), + }); + } + + if (isLoading) return
Loading...
; + if (!roleWithPermissions) return
Role not found.
; + + const permissionGroups = groupPermissions(roleWithPermissions.permissions); + + return ( +
+
+ + + +
+

+ {roleWithPermissions.name} +

+

+ {roleWithPermissions.description ?? "Manage permissions for this role."} +

+
+
+ +
+ {Object.entries(permissionGroups).map(([group, permissions]) => ( +
+

+ {group} +

+
+ {permissions.map((p) => ( + + ))} +
+
+ ))} + + +
+
+ ); +} + +function groupPermissions(permissions: { permission: string; description?: string }[]) { + const groups: Record = {}; + for (const p of permissions) { + const parts = p.permission.split("."); + const group = parts.length > 1 ? parts[0] : "General"; + if (!groups[group]) groups[group] = []; + groups[group].push(p); + } + return groups; +} diff --git a/clients/admin/src/app/(dashboard)/roles/page.tsx b/clients/admin/src/app/(dashboard)/roles/page.tsx new file mode 100644 index 0000000000..543cec24ac --- /dev/null +++ b/clients/admin/src/app/(dashboard)/roles/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; +import { useRolesQuery, useDeleteRole } from "@/hooks/use-roles"; +import { Shield, Trash2 } from "lucide-react"; + +export default function RolesPage() { + const { data: roles, isLoading } = useRolesQuery(); + const deleteRole = useDeleteRole(); + + return ( +
+
+

Roles

+

+ Manage roles and permissions. +

+
+ + {isLoading ? ( +

Loading roles...

+ ) : !roles?.length ? ( +

No roles found.

+ ) : ( +
+ {roles.map((role) => ( +
+ + +
+

{role.name}

+ {role.description && ( +

{role.description}

+ )} +
+ + +
+ ))} +
+ )} +
+ ); +} diff --git a/clients/admin/src/app/(dashboard)/settings/page.tsx b/clients/admin/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000000..d760cc322b --- /dev/null +++ b/clients/admin/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,13 @@ +export default function SettingsPage() { + return ( +
+
+

Settings

+

+ Application settings and configuration. +

+
+

Settings coming soon...

+
+ ); +} diff --git a/clients/admin/src/app/(dashboard)/tenants/[id]/page.tsx b/clients/admin/src/app/(dashboard)/tenants/[id]/page.tsx new file mode 100644 index 0000000000..10f4641aa0 --- /dev/null +++ b/clients/admin/src/app/(dashboard)/tenants/[id]/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { use } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { useTenantQuery, useUpdateTenant } from "@/hooks/use-tenants"; +import { TenantForm } from "@/components/tenants/tenant-form"; +import type { CreateTenantFormValues } from "@/lib/schemas/tenant"; + +export default function TenantDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const router = useRouter(); + const { data: tenant, isLoading } = useTenantQuery(id); + const updateTenant = useUpdateTenant(id); + + function handleSubmit(data: CreateTenantFormValues) { + updateTenant.mutate( + { + name: data.name, + adminEmail: data.adminEmail, + connectionString: data.connectionString || undefined, + validUpTo: data.validUpTo || undefined, + }, + { + onSuccess: () => router.push("/tenants"), + } + ); + } + + if (isLoading) { + return
Loading tenant...
; + } + + if (!tenant) { + return
Tenant not found.
; + } + + return ( +
+
+ + + +
+

+ Edit: {tenant.name} +

+

+ {tenant.identifier} +

+
+
+ + +
+ ); +} diff --git a/clients/admin/src/app/(dashboard)/tenants/[id]/roles/page.tsx b/clients/admin/src/app/(dashboard)/tenants/[id]/roles/page.tsx new file mode 100644 index 0000000000..6f2b56b8a0 --- /dev/null +++ b/clients/admin/src/app/(dashboard)/tenants/[id]/roles/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { use } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +export default function TenantRolesPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + + return ( +
+
+ + + +
+

Tenant Roles

+

+ Manage roles for this tenant. +

+
+
+

Role management for tenant {id} coming soon...

+
+ ); +} diff --git a/clients/admin/src/app/(dashboard)/tenants/[id]/users/page.tsx b/clients/admin/src/app/(dashboard)/tenants/[id]/users/page.tsx new file mode 100644 index 0000000000..3b5b45d16d --- /dev/null +++ b/clients/admin/src/app/(dashboard)/tenants/[id]/users/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { use } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +export default function TenantUsersPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + + return ( +
+
+ + + +
+

Tenant Users

+

+ Manage users for this tenant. +

+
+
+

User management for tenant {id} coming soon...

+
+ ); +} diff --git a/clients/admin/src/app/(dashboard)/tenants/new/page.tsx b/clients/admin/src/app/(dashboard)/tenants/new/page.tsx new file mode 100644 index 0000000000..c9d3acdba7 --- /dev/null +++ b/clients/admin/src/app/(dashboard)/tenants/new/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCreateTenant } from "@/hooks/use-tenants"; +import { TenantForm } from "@/components/tenants/tenant-form"; +import type { CreateTenantFormValues } from "@/lib/schemas/tenant"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +export default function NewTenantPage() { + const router = useRouter(); + const createTenant = useCreateTenant(); + + function handleSubmit(data: CreateTenantFormValues) { + createTenant.mutate( + { + identifier: data.identifier, + name: data.name, + adminEmail: data.adminEmail, + connectionString: data.connectionString || undefined, + validUpTo: data.validUpTo || undefined, + }, + { + onSuccess: () => router.push("/tenants"), + } + ); + } + + return ( +
+
+ + + +
+

Create Tenant

+

+ Provision a new tenant in the system. +

+
+
+ + +
+ ); +} diff --git a/clients/admin/src/app/(dashboard)/tenants/page.tsx b/clients/admin/src/app/(dashboard)/tenants/page.tsx new file mode 100644 index 0000000000..85fa32403c --- /dev/null +++ b/clients/admin/src/app/(dashboard)/tenants/page.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Plus } from "lucide-react"; +import type { PaginationState, SortingState } from "@tanstack/react-table"; +import { useTenantsQuery } from "@/hooks/use-tenants"; +import { tenantColumns } from "@/components/tenants/tenant-columns"; +import { DataTable } from "@/components/ui/data-table"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; + +export default function TenantsPage() { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [sorting, setSorting] = useState([]); + + const { data, isLoading } = useTenantsQuery({ + pageNumber: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + search: debouncedSearch || undefined, + sortBy: sorting[0]?.id, + sortOrder: sorting[0]?.desc ? "desc" : "asc", + }); + + return ( +
+
+
+

Tenants

+

+ Manage all tenants in the system. +

+
+ + New Tenant + +
+ + +
+ ); +} diff --git a/clients/admin/src/app/(dashboard)/users/[id]/page.tsx b/clients/admin/src/app/(dashboard)/users/[id]/page.tsx new file mode 100644 index 0000000000..e7d2150bdc --- /dev/null +++ b/clients/admin/src/app/(dashboard)/users/[id]/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { use } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { useUserQuery, useUserRoles, useAssignUserRoles } from "@/hooks/use-users"; +import { useRolesQuery } from "@/hooks/use-roles"; + +export default function UserDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const { data: user, isLoading } = useUserQuery(id); + const { data: userRoles } = useUserRoles(id); + const { data: allRoles } = useRolesQuery(); + const assignRoles = useAssignUserRoles(id); + + function handleToggleRole(roleId: string, currentlyEnabled: boolean) { + if (!userRoles) return; + const updated = userRoles.map((r) => + r.roleId === roleId ? { roleId: r.roleId, enabled: !currentlyEnabled } : { roleId: r.roleId, enabled: r.enabled } + ); + assignRoles.mutate(updated); + } + + if (isLoading) return
Loading...
; + if (!user) return
User not found.
; + + return ( +
+
+ + + +
+

+ {user.firstName} {user.lastName} +

+

{user.email}

+
+
+ +
+ + + + + +
+ + {allRoles && userRoles && ( +
+

Roles

+
+ {allRoles.map((role) => { + const userRole = userRoles.find((r) => r.roleId === role.id); + const enabled = userRole?.enabled ?? false; + return ( + + ); + })} +
+
+ )} +
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/clients/admin/src/app/(dashboard)/users/page.tsx b/clients/admin/src/app/(dashboard)/users/page.tsx new file mode 100644 index 0000000000..c28708e947 --- /dev/null +++ b/clients/admin/src/app/(dashboard)/users/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import type { PaginationState, SortingState } from "@tanstack/react-table"; +import { useUsersQuery } from "@/hooks/use-users"; +import { userColumns } from "@/components/users/user-columns"; +import { DataTable } from "@/components/ui/data-table"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; + +export default function UsersPage() { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [sorting, setSorting] = useState([]); + + const { data, isLoading } = useUsersQuery({ + pageNumber: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + search: debouncedSearch || undefined, + sortBy: sorting[0]?.id, + sortOrder: sorting[0]?.desc ? "desc" : "asc", + }); + + return ( +
+
+

Users

+

+ Manage users across tenants. +

+
+ + +
+ ); +} diff --git a/clients/admin/src/app/api/auth/[...nextauth]/route.ts b/clients/admin/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000000..a381953c78 --- /dev/null +++ b/clients/admin/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@fsh/auth"; + +export const { GET, POST } = handlers; diff --git a/clients/admin/src/app/favicon.ico b/clients/admin/src/app/favicon.ico new file mode 100644 index 0000000000..718d6fea48 Binary files /dev/null and b/clients/admin/src/app/favicon.ico differ diff --git a/clients/admin/src/app/globals.css b/clients/admin/src/app/globals.css new file mode 100644 index 0000000000..01899851c8 --- /dev/null +++ b/clients/admin/src/app/globals.css @@ -0,0 +1,14 @@ +@import "tailwindcss"; +@import "@fsh/ui/globals.css"; + +@theme inline { + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); +} + +@keyframes drift { + 0%, 100% { transform: translate(0, 0) scale(1); } + 25% { transform: translate(30px, -40px) scale(1.05); } + 50% { transform: translate(-20px, 20px) scale(0.95); } + 75% { transform: translate(15px, 35px) scale(1.02); } +} diff --git a/clients/admin/src/app/layout.tsx b/clients/admin/src/app/layout.tsx new file mode 100644 index 0000000000..9e458757e1 --- /dev/null +++ b/clients/admin/src/app/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import { Inter, JetBrains_Mono } from "next/font/google"; +import { ThemeProvider } from "next-themes"; +import { AuthProvider } from "@fsh/auth/provider"; +import { QueryProvider } from "@/components/providers/query-provider"; +import "./globals.css"; + +const inter = Inter({ + variable: "--font-sans", + subsets: ["latin"], +}); + +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Admin | FullStackHero", + description: "Manage tenants, users, roles, and permissions.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + {children} + + + + + ); +} diff --git a/clients/admin/src/app/page.tsx b/clients/admin/src/app/page.tsx new file mode 100644 index 0000000000..a74cb27f16 --- /dev/null +++ b/clients/admin/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/dashboard"); +} diff --git a/clients/admin/src/components/auth/login-form.tsx b/clients/admin/src/components/auth/login-form.tsx new file mode 100644 index 0000000000..7fbf3758ef --- /dev/null +++ b/clients/admin/src/components/auth/login-form.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { signIn } from "next-auth/react"; +import { Eye, EyeOff, ArrowRight } from "lucide-react"; + +export function LoginForm() { + const router = useRouter(); + const [email, setEmail] = useState("admin@root.com"); + const [password, setPassword] = useState("123Pa$$word!"); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (result?.error) { + setError("Invalid email or password."); + } else { + router.push("/dashboard"); + router.refresh(); + } + } catch { + setError("Something went wrong. Please try again."); + } finally { + setLoading(false); + } + } + + return ( +
+ {/* Logo */} +
+
+ + + + + +
+ + fullstackhero + +
+ + {/* Form */} +
+

+ Sign in +

+

+ Enter your credentials to continue +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + placeholder="admin@fullstackhero.net" + autoComplete="email" + required + className="h-10 w-full rounded-lg border border-zinc-200 bg-white px-3 text-[14px] text-zinc-900 outline-none transition-all placeholder:text-zinc-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 dark:border-zinc-700 dark:bg-zinc-800/50 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-emerald-500" + /> +
+ +
+
+ + +
+
+ setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + required + className="h-10 w-full rounded-lg border border-zinc-200 bg-white px-3 pr-10 text-[14px] text-zinc-900 outline-none transition-all placeholder:text-zinc-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 dark:border-zinc-700 dark:bg-zinc-800/50 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-emerald-500" + /> + +
+
+ + +
+ + {/* Divider */} +
+
+ or +
+
+ + {/* Google */} + +
+ + {/* Footer */} +
+ + © {new Date().getFullYear()} fullstackhero + + +
+
+ ); +} diff --git a/clients/admin/src/components/layout/app-sidebar.tsx b/clients/admin/src/components/layout/app-sidebar.tsx new file mode 100644 index 0000000000..afe8a4d758 --- /dev/null +++ b/clients/admin/src/components/layout/app-sidebar.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + LayoutDashboard, + Building2, + Users, + Shield, + Settings, + Hexagon, +} from "lucide-react"; +import { + Sidebar, + SidebarHeader, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarMenuItem, + SidebarMenuButton, + useSidebar, +} from "@/components/ui/sidebar"; + +const navItems = [ + { + label: "Overview", + items: [ + { title: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, + ], + }, + { + label: "Management", + items: [ + { title: "Tenants", href: "/tenants", icon: Building2 }, + { title: "Users", href: "/users", icon: Users }, + { title: "Roles", href: "/roles", icon: Shield }, + ], + }, + { + label: "System", + items: [ + { title: "Settings", href: "/settings", icon: Settings }, + ], + }, +]; + +export function AppSidebar() { + const pathname = usePathname(); + const { open } = useSidebar(); + + return ( + + + + + {open && ( + + FullStackHero + + )} + + + + + {navItems.map((group) => ( + + {group.label} + {group.items.map((item) => ( + + + + + {open && {item.title}} + + + + ))} + + ))} + + + + {open && ( +

+ fullstackhero.net +

+ )} +
+
+ ); +} diff --git a/clients/admin/src/components/layout/breadcrumbs.tsx b/clients/admin/src/components/layout/breadcrumbs.tsx new file mode 100644 index 0000000000..f72415cdf8 --- /dev/null +++ b/clients/admin/src/components/layout/breadcrumbs.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; + +export function Breadcrumbs() { + const pathname = usePathname(); + const segments = pathname.split("/").filter(Boolean); + + if (segments.length === 0) return null; + + return ( + + ); +} diff --git a/clients/admin/src/components/layout/header.tsx b/clients/admin/src/components/layout/header.tsx new file mode 100644 index 0000000000..3ddb1fae9e --- /dev/null +++ b/clients/admin/src/components/layout/header.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { ThemeToggle } from "./theme-toggle"; +import { UserNav } from "./user-nav"; +import { Breadcrumbs } from "./breadcrumbs"; + +export function Header() { + return ( +
+ + +
+ + +
+
+ ); +} diff --git a/clients/admin/src/components/layout/theme-toggle.tsx b/clients/admin/src/components/layout/theme-toggle.tsx new file mode 100644 index 0000000000..f60c6c2574 --- /dev/null +++ b/clients/admin/src/components/layout/theme-toggle.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( + + ); +} diff --git a/clients/admin/src/components/layout/user-nav.tsx b/clients/admin/src/components/layout/user-nav.tsx new file mode 100644 index 0000000000..1c7bc94d94 --- /dev/null +++ b/clients/admin/src/components/layout/user-nav.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { LogOut, User } from "lucide-react"; + +export function UserNav() { + return ( +
+ + +
+ ); +} diff --git a/clients/admin/src/components/providers/query-provider.tsx b/clients/admin/src/components/providers/query-provider.tsx new file mode 100644 index 0000000000..b54f051f9e --- /dev/null +++ b/clients/admin/src/components/providers/query-provider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; + +export function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }, + }, + }) + ); + + return ( + {children} + ); +} diff --git a/clients/admin/src/components/tenants/tenant-actions.tsx b/clients/admin/src/components/tenants/tenant-actions.tsx new file mode 100644 index 0000000000..185cf8aaa5 --- /dev/null +++ b/clients/admin/src/components/tenants/tenant-actions.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import type { Tenant } from "@fsh/api-client"; +import { useActivateTenant, useDeactivateTenant } from "@/hooks/use-tenants"; +import { MoreHorizontal, Pencil, Power, Users, Shield } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; + +export function TenantActions({ tenant }: { tenant: Tenant }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const activate = useActivateTenant(); + const deactivate = useDeactivateTenant(); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + return ( +
+ + + {open && ( +
+ setOpen(false)} + > + Edit + + setOpen(false)} + > + Users + + setOpen(false)} + > + Roles + +
+ +
+ )} +
+ ); +} diff --git a/clients/admin/src/components/tenants/tenant-columns.tsx b/clients/admin/src/components/tenants/tenant-columns.tsx new file mode 100644 index 0000000000..49e36d0226 --- /dev/null +++ b/clients/admin/src/components/tenants/tenant-columns.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import type { Tenant } from "@fsh/api-client"; +import { TenantStatusBadge } from "./tenant-status-badge"; +import { TenantActions } from "./tenant-actions"; + +export const tenantColumns: ColumnDef[] = [ + { + accessorKey: "identifier", + header: "Identifier", + enableSorting: true, + cell: ({ row }) => ( + {row.original.identifier} + ), + }, + { + accessorKey: "name", + header: "Name", + enableSorting: true, + cell: ({ row }) => ( + {row.original.name} + ), + }, + { + accessorKey: "adminEmail", + header: "Admin Email", + enableSorting: false, + }, + { + accessorKey: "isActive", + header: "Status", + enableSorting: true, + cell: ({ row }) => , + }, + { + accessorKey: "validUpTo", + header: "Valid Until", + enableSorting: true, + cell: ({ row }) => + row.original.validUpTo + ? new Date(row.original.validUpTo).toLocaleDateString() + : "—", + }, + { + id: "actions", + header: "", + cell: ({ row }) => , + }, +]; diff --git a/clients/admin/src/components/tenants/tenant-form.tsx b/clients/admin/src/components/tenants/tenant-form.tsx new file mode 100644 index 0000000000..7cec252d23 --- /dev/null +++ b/clients/admin/src/components/tenants/tenant-form.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createTenantSchema, type CreateTenantFormValues } from "@/lib/schemas/tenant"; + +interface TenantFormProps { + defaultValues?: Partial; + onSubmit: (data: CreateTenantFormValues) => void; + isLoading?: boolean; + isEdit?: boolean; +} + +export function TenantForm({ defaultValues, onSubmit, isLoading, isEdit }: TenantFormProps) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createTenantSchema), + defaultValues: { + identifier: "", + name: "", + adminEmail: "", + connectionString: "", + validUpTo: "", + ...defaultValues, + }, + }); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +function FormField({ + label, + error, + children, +}: { + label: string; + error?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {error &&

{error}

} +
+ ); +} diff --git a/clients/admin/src/components/tenants/tenant-status-badge.tsx b/clients/admin/src/components/tenants/tenant-status-badge.tsx new file mode 100644 index 0000000000..037e99607d --- /dev/null +++ b/clients/admin/src/components/tenants/tenant-status-badge.tsx @@ -0,0 +1,16 @@ +import { cn } from "@fsh/ui"; + +export function TenantStatusBadge({ isActive }: { isActive: boolean }) { + return ( + + {isActive ? "Active" : "Inactive"} + + ); +} diff --git a/clients/admin/src/components/ui/data-table.tsx b/clients/admin/src/components/ui/data-table.tsx new file mode 100644 index 0000000000..b4e2a7753d --- /dev/null +++ b/clients/admin/src/components/ui/data-table.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { + flexRender, + getCoreRowModel, + useReactTable, + type ColumnDef, + type OnChangeFn, + type PaginationState, + type SortingState, +} from "@tanstack/react-table"; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUpDown, ArrowUp, ArrowDown, Search } from "lucide-react"; +import { cn } from "@fsh/ui"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + pageCount: number; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + sorting?: SortingState; + onSortingChange?: OnChangeFn; + searchValue?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + isLoading?: boolean; + toolbar?: React.ReactNode; +} + +export function DataTable({ + columns, + data, + pageCount, + pagination, + onPaginationChange, + sorting, + onSortingChange, + searchValue, + onSearchChange, + searchPlaceholder = "Search...", + isLoading, + toolbar, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + pageCount, + state: { pagination, sorting }, + onPaginationChange, + onSortingChange, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + manualSorting: true, + }); + + return ( +
+
+ {onSearchChange && ( +
+ + onSearchChange(e.target.value)} + placeholder={searchPlaceholder} + className="flex h-9 w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ )} + {toolbar &&
{toolbar}
} +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {isLoading ? ( + + + + ) : table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
+
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanSort() && ( + <> + {header.column.getIsSorted() === "asc" && } + {header.column.getIsSorted() === "desc" && } + {!header.column.getIsSorted() && } + + )} +
+
+ Loading... +
+ No results found. +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ +
+

+ Page {pagination.pageIndex + 1} of {Math.max(pageCount, 1)} +

+
+ table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + + + table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + + + table.nextPage()} + disabled={!table.getCanNextPage()} + > + + + table.setPageIndex(pageCount - 1)} + disabled={!table.getCanNextPage()} + > + + +
+
+
+ ); +} + +function PaginationButton({ + children, + onClick, + disabled, +}: { + children: React.ReactNode; + onClick: () => void; + disabled: boolean; +}) { + return ( + + ); +} diff --git a/clients/admin/src/components/ui/sidebar.tsx b/clients/admin/src/components/ui/sidebar.tsx new file mode 100644 index 0000000000..5aac06c1e2 --- /dev/null +++ b/clients/admin/src/components/ui/sidebar.tsx @@ -0,0 +1,194 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@fsh/ui"; +import { + PanelLeft, +} from "lucide-react"; + +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_COLLAPSED = "3rem"; + +interface SidebarContextType { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; +} + +const SidebarContext = React.createContext({ + open: true, + setOpen: () => {}, + toggle: () => {}, +}); + +export function useSidebar() { + return React.useContext(SidebarContext); +} + +export function SidebarProvider({ children }: { children: React.ReactNode }) { + const [open, setOpen] = React.useState(true); + const toggle = React.useCallback(() => setOpen((prev) => !prev), []); + + return ( + +
+ {children} +
+
+ ); +} + +export function Sidebar({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + const { open } = useSidebar(); + + return ( + + ); +} + +export function SidebarHeader({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function SidebarContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function SidebarFooter({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
{children}
+ ); +} + +export function SidebarGroup({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} + +export function SidebarGroupLabel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + const { open } = useSidebar(); + if (!open) return null; + + return ( +

+ {children} +

+ ); +} + +export function SidebarMenuItem({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} + +export function SidebarMenuButton({ + children, + isActive, + className, + ...props +}: React.ButtonHTMLAttributes & { + isActive?: boolean; +}) { + const { open } = useSidebar(); + + return ( + + ); +} + +export function SidebarTrigger({ className }: { className?: string }) { + const { toggle } = useSidebar(); + + return ( + + ); +} diff --git a/clients/admin/src/components/users/user-columns.tsx b/clients/admin/src/components/users/user-columns.tsx new file mode 100644 index 0000000000..435ebdd9a6 --- /dev/null +++ b/clients/admin/src/components/users/user-columns.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import type { User } from "@fsh/api-client"; +import { cn } from "@fsh/ui"; + +export const userColumns: ColumnDef[] = [ + { + accessorKey: "userName", + header: "Username", + enableSorting: true, + cell: ({ row }) => ( + {row.original.userName} + ), + }, + { + id: "name", + header: "Name", + enableSorting: false, + cell: ({ row }) => + `${row.original.firstName} ${row.original.lastName}`, + }, + { + accessorKey: "email", + header: "Email", + enableSorting: true, + }, + { + accessorKey: "isActive", + header: "Status", + enableSorting: true, + cell: ({ row }) => ( + + {row.original.isActive ? "Active" : "Inactive"} + + ), + }, + { + accessorKey: "emailConfirmed", + header: "Verified", + enableSorting: false, + cell: ({ row }) => ( + + {row.original.emailConfirmed ? "✓" : "—"} + + ), + }, +]; diff --git a/clients/admin/src/hooks/use-debounced-value.ts b/clients/admin/src/hooks/use-debounced-value.ts new file mode 100644 index 0000000000..94807c0dae --- /dev/null +++ b/clients/admin/src/hooks/use-debounced-value.ts @@ -0,0 +1,12 @@ +import { useState, useEffect } from "react"; + +export function useDebouncedValue(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} diff --git a/clients/admin/src/hooks/use-roles.ts b/clients/admin/src/hooks/use-roles.ts new file mode 100644 index 0000000000..0ab3fef784 --- /dev/null +++ b/clients/admin/src/hooks/use-roles.ts @@ -0,0 +1,54 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { CreateRoleRequest, UpdateRolePermissionsRequest } from "@fsh/api-client"; +import { api } from "@/lib/api"; + +export function useRolesQuery() { + return useQuery({ + queryKey: ["roles"], + queryFn: () => api.roles.list().then((r) => r.data), + }); +} + +export function useRoleQuery(id: string) { + return useQuery({ + queryKey: ["roles", id], + queryFn: () => api.roles.get(id).then((r) => r.data), + enabled: !!id, + }); +} + +export function useRolePermissions(id: string) { + return useQuery({ + queryKey: ["roles", id, "permissions"], + queryFn: () => api.roles.getPermissions(id).then((r) => r.data), + enabled: !!id, + }); +} + +export function useCreateRole() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateRoleRequest) => api.roles.create(data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["roles"] }), + }); +} + +export function useDeleteRole() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.roles.delete(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["roles"] }), + }); +} + +export function useUpdateRolePermissions() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UpdateRolePermissionsRequest) => + api.roles.updatePermissions(data), + onSuccess: (_, variables) => + queryClient.invalidateQueries({ + queryKey: ["roles", variables.roleId], + }), + }); +} diff --git a/clients/admin/src/hooks/use-tenants.ts b/clients/admin/src/hooks/use-tenants.ts new file mode 100644 index 0000000000..c082148285 --- /dev/null +++ b/clients/admin/src/hooks/use-tenants.ts @@ -0,0 +1,58 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { PaginationParams, Tenant, CreateTenantRequest, UpdateTenantRequest } from "@fsh/api-client"; +import { api } from "@/lib/api"; + +export function useTenantsQuery(params: PaginationParams) { + return useQuery({ + queryKey: ["tenants", params], + queryFn: () => api.tenants.list(params).then((r) => r.data), + }); +} + +export function useTenantQuery(id: string) { + return useQuery({ + queryKey: ["tenants", id], + queryFn: () => api.tenants.get(id).then((r) => r.data), + enabled: !!id, + }); +} + +export function useCreateTenant() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateTenantRequest) => api.tenants.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + }, + }); +} + +export function useUpdateTenant(id: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UpdateTenantRequest) => api.tenants.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + }, + }); +} + +export function useActivateTenant() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.tenants.activate(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + }, + }); +} + +export function useDeactivateTenant() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.tenants.deactivate(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + }, + }); +} diff --git a/clients/admin/src/hooks/use-users.ts b/clients/admin/src/hooks/use-users.ts new file mode 100644 index 0000000000..6447964d3a --- /dev/null +++ b/clients/admin/src/hooks/use-users.ts @@ -0,0 +1,60 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { PaginationParams, CreateUserRequest, UpdateUserRequest } from "@fsh/api-client"; +import { api } from "@/lib/api"; + +export function useUsersQuery(params: PaginationParams) { + return useQuery({ + queryKey: ["users", params], + queryFn: () => api.users.list(params).then((r) => r.data), + }); +} + +export function useUserQuery(id: string) { + return useQuery({ + queryKey: ["users", id], + queryFn: () => api.users.get(id).then((r) => r.data), + enabled: !!id, + }); +} + +export function useCreateUser() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateUserRequest) => api.users.create(data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }), + }); +} + +export function useUpdateUser(id: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UpdateUserRequest) => api.users.update(id, data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }), + }); +} + +export function useToggleUserStatus() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.users.toggleStatus(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }), + }); +} + +export function useUserRoles(userId: string) { + return useQuery({ + queryKey: ["users", userId, "roles"], + queryFn: () => api.users.getRoles(userId).then((r) => r.data), + enabled: !!userId, + }); +} + +export function useAssignUserRoles(userId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (roles: { roleId: string; enabled: boolean }[]) => + api.users.assignRoles(userId, roles), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["users", userId, "roles"] }), + }); +} diff --git a/clients/admin/src/lib/api.ts b/clients/admin/src/lib/api.ts new file mode 100644 index 0000000000..999595d1dc --- /dev/null +++ b/clients/admin/src/lib/api.ts @@ -0,0 +1,23 @@ +import { createApiClient, createTenantEndpoints, createUserEndpoints, createRoleEndpoints, createAuthEndpoints } from "@fsh/api-client"; +import { getSession } from "next-auth/react"; + +const apiClient = createApiClient({ + baseUrl: process.env.NEXT_PUBLIC_FSH_API_URL ?? "http://localhost:5000", + getAccessToken: async () => { + const session = await getSession(); + return session?.accessToken ?? null; + }, + getTenantId: () => { + if (typeof window === "undefined") return null; + return localStorage.getItem("fsh-active-tenant") ?? null; + }, +}); + +export const api = { + auth: createAuthEndpoints(apiClient), + tenants: createTenantEndpoints(apiClient), + users: createUserEndpoints(apiClient), + roles: createRoleEndpoints(apiClient), +}; + +export { apiClient }; diff --git a/clients/admin/src/lib/schemas/tenant.ts b/clients/admin/src/lib/schemas/tenant.ts new file mode 100644 index 0000000000..3c62980c16 --- /dev/null +++ b/clients/admin/src/lib/schemas/tenant.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +export const createTenantSchema = z.object({ + identifier: z + .string() + .min(2, "Identifier must be at least 2 characters") + .max(50, "Identifier must be at most 50 characters") + .regex(/^[a-z0-9-]+$/, "Only lowercase letters, numbers, and hyphens allowed"), + name: z + .string() + .min(2, "Name must be at least 2 characters") + .max(100, "Name must be at most 100 characters"), + adminEmail: z.string().email("Invalid email address"), + connectionString: z.string().optional(), + validUpTo: z.string().optional(), +}); + +export type CreateTenantFormValues = z.infer; + +export const updateTenantSchema = z.object({ + name: z + .string() + .min(2, "Name must be at least 2 characters") + .max(100, "Name must be at most 100 characters"), + adminEmail: z.string().email("Invalid email address"), + connectionString: z.string().optional(), + validUpTo: z.string().optional(), +}); + +export type UpdateTenantFormValues = z.infer; diff --git a/clients/admin/src/proxy.ts b/clients/admin/src/proxy.ts new file mode 100644 index 0000000000..fb8bbb0630 --- /dev/null +++ b/clients/admin/src/proxy.ts @@ -0,0 +1,5 @@ +export { proxy } from "@fsh/auth/proxy"; + +export const config = { + matcher: ["/((?!login|api/auth|_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/clients/admin/tsconfig.json b/clients/admin/tsconfig.json new file mode 100644 index 0000000000..cf9c65d3e0 --- /dev/null +++ b/clients/admin/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/compose/.env b/compose/.env deleted file mode 100644 index 1226475d10..0000000000 --- a/compose/.env +++ /dev/null @@ -1,11 +0,0 @@ -#BASE_PATH=/mnt/c/docker-services/fsh-dotnet-starter-kit -BASE_PATH=. -############################################################################################################################################################################ -# API Services -############################################################################################################################################################################ -FSH_DOTNETSTARTERKIT_WEBAPI_IMAGE=ghcr.io/fullstackhero/webapi:latest - -############################################################################################################################################################################ -# Websites -############################################################################################################################################################################ -FSH_DOTNETSTARTERKIT_BLAZOR_IMAGE=ghcr.io/fullstackhero/blazor:latest diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml deleted file mode 100644 index 8d75bcb074..0000000000 --- a/compose/docker-compose.yml +++ /dev/null @@ -1,195 +0,0 @@ -version: "4" #on wsl linux replace 3.8 -name: fullstackhero #on wsl linux replace with export COMPOSE_PROJECT_NAME=fullstackhero before docker-compose up command - -services: - webapi: - image: ${FSH_DOTNETSTARTERKIT_WEBAPI_IMAGE} - pull_policy: always - container_name: webapi - networks: - - fullstackhero - environment: - ASPNETCORE_ENVIRONMENT: docker - ASPNETCORE_URLS: https://+:7000;http://+:5000 - ASPNETCORE_HTTPS_PORT: 7000 - ASPNETCORE_Kestrel__Certificates__Default__Password: password! - ASPNETCORE_Kestrel__Certificates__Default__Path: /https/cert.pfx - DatabaseOptions__ConnectionString: Server=postgres;Port=5433;Database=fullstackhero;User Id=pgadmin;Password=pgadmin - DatabaseOptions__Provider: postgresql - JwtOptions__Key: QsJbczCNysv/5SGh+U7sxedX8C07TPQPBdsnSDKZ/aE= - HangfireOptions__Username: admin - HangfireOptions__Password: Secure1234!Me - MailOptions__From: mukesh@fullstackhero.net - MailOptions__Host: smtp.ethereal.email - MailOptions__Port: 587 - MailOptions__UserName: sherman.oconnell47@ethereal.email - MailOptions__Password: KbuTCFv4J6Fy7256vh - MailOptions__DisplayName: Mukesh Murugan - CorsOptions__AllowedOrigins__0: http://localhost:5010 - CorsOptions__AllowedOrigins__1: http://localhost:7100 - CorsOptions__AllowedOrigins__2: https://localhost:7020 - OpenTelemetryOptions__Endpoint: http://otel-collector:4317 - RateLimitOptions__EnableRateLimiting: "false" - OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 - OTEL_SERVICE_NAME: FSH.Starter.WebApi.Host - volumes: - - ~/.aspnet/https:/https:ro #on wsl linux - #- /mnt/c/Users/eduar/.aspnet/https:/https:ro - ports: - - 7000:7000 - - 5000:5000 - depends_on: - postgres: - condition: service_healthy - restart: on-failure - - blazor: - image: ${FSH_DOTNETSTARTERKIT_BLAZOR_IMAGE} - pull_policy: always - container_name: blazor - environment: - Frontend_FSHStarterBlazorClient_Settings__AppSettingsTemplate: /usr/share/nginx/html/appsettings.json.TEMPLATE - Frontend_FSHStarterBlazorClient_Settings__AppSettingsJson: /usr/share/nginx/html/appsettings.json - FSHStarterBlazorClient_ApiBaseUrl: https://localhost:7000 - ApiBaseUrl: https://localhost:7000 - networks: - - fullstackhero - entrypoint: [ - "/bin/sh", - "-c", - "envsubst < - $${Frontend_FSHStarterBlazorClient_Settings__AppSettingsTemplate} > - $${Frontend_FSHStarterBlazorClient_Settings__AppSettingsJson} && find - /usr/share/nginx/html -type f | xargs chmod +r && exec nginx -g - 'daemon off;'", - ] - volumes: - - ~/.aspnet/https:/https:ro - ports: - - 7100:80 - depends_on: - postgres: - condition: service_healthy - restart: on-failure - - postgres: - container_name: postgres - image: postgres:15-alpine - networks: - - fullstackhero - environment: - POSTGRES_USER: pgadmin - POSTGRES_PASSWORD: pgadmin - PGPORT: 5433 - ports: - - 5433:5433 - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U pgadmin"] - interval: 10s - timeout: 5s - retries: 5 - - prometheus: - image: prom/prometheus:latest - container_name: prometheus - restart: unless-stopped - networks: - - fullstackhero - volumes: - - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - - prometheus-data:/prometheus - ports: - - 9090:9090 - - grafana: - container_name: grafana - image: grafana/grafana:latest - user: "472" - environment: - GF_INSTALL_PLUGINS: "grafana-clock-panel,grafana-simple-json-datasource" - ports: - - 3000:3000 - volumes: - - grafana-data:/var/lib/grafana - - ./grafana/config/:/etc/grafana/ - - ./grafana/dashboards/:/var/lib/grafana/dashboards - depends_on: - - prometheus - restart: unless-stopped - networks: - - fullstackhero - - otel-collector: - image: otel/opentelemetry-collector-contrib:latest - container_name: otel-collector - command: --config /etc/otel/config.yaml - environment: - JAEGER_ENDPOINT: "jaeger:4317" - LOKI_ENDPOINT: "http://loki:3100/loki/api/v1/push" - volumes: - - $BASE_PATH/otel-collector/otel-config.yaml:/etc/otel/config.yaml - - $BASE_PATH/otel-collector/log:/log/otel - depends_on: - - jaeger - - loki - - prometheus - ports: - - 8888:8888 # Prometheus metrics exposed by the collector - - 8889:8889 # Prometheus metrics exporter (scrape endpoint) - - 13133:13133 # health_check extension - - "55679:55679" # ZPages extension - - 4317:4317 # OTLP gRPC receiver - - 4318:4318 # OTLP Http receiver (Protobuf) - networks: - - fullstackhero - - jaeger: - container_name: jaeger - image: jaegertracing/all-in-one:latest - command: --query.ui-config /etc/jaeger/jaeger-ui.json - environment: - - METRICS_STORAGE_TYPE=prometheus - - PROMETHEUS_SERVER_URL=http://prometheus:9090 - - COLLECTOR_OTLP_ENABLED=true - volumes: - - $BASE_PATH/jaeger/jaeger-ui.json:/etc/jaeger/jaeger-ui.json - depends_on: - - prometheus - ports: - - "16686:16686" - networks: - - fullstackhero - - loki: - container_name: loki - image: grafana/loki:3.1.0 - command: -config.file=/mnt/config/loki-config.yml - volumes: - - $BASE_PATH/loki/loki.yml:/mnt/config/loki-config.yml - ports: - - "3100:3100" - networks: - - fullstackhero - - node_exporter: - image: quay.io/prometheus/node-exporter:v1.5.0 - container_name: node_exporter - command: "--path.rootfs=/host" - pid: host - restart: unless-stopped - volumes: - - /proc:/host/proc:ro - - /sys:/host/sys:ro - - /:/rootfs:ro - networks: - - fullstackhero - -volumes: - postgres-data: - grafana-data: - prometheus-data: - -networks: - fullstackhero: diff --git a/compose/grafana/config/grafana.ini b/compose/grafana/config/grafana.ini deleted file mode 100644 index 4277397334..0000000000 --- a/compose/grafana/config/grafana.ini +++ /dev/null @@ -1,16 +0,0 @@ -[auth.anonymous] -enabled = true - -# Organization name that should be used for unauthenticated users -org_name = Main Org. - -# Role for unauthenticated users, other valid values are `Editor` and `Admin` -org_role = Admin - -# Hide the Grafana version text from the footer and help tooltip for unauthenticated users (default: false) -hide_version = true - -[dashboards] -default_home_dashboard_path = /var/lib/grafana/dashboards/aspnet-core.json - -min_refresh_interval = 1s \ No newline at end of file diff --git a/compose/grafana/config/provisioning/dashboards/default.yml b/compose/grafana/config/provisioning/dashboards/default.yml deleted file mode 100644 index d2f0a7ca80..0000000000 --- a/compose/grafana/config/provisioning/dashboards/default.yml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: 1 - -providers: -- name: 'Prometheus' - orgId: 1 - folder: '' - type: file - disableDeletion: false - editable: true - options: - path: /var/lib/grafana/dashboards \ No newline at end of file diff --git a/compose/grafana/config/provisioning/datasources/default.yml b/compose/grafana/config/provisioning/datasources/default.yml deleted file mode 100644 index 428d40e2ed..0000000000 --- a/compose/grafana/config/provisioning/datasources/default.yml +++ /dev/null @@ -1,69 +0,0 @@ -# config file version -apiVersion: 1 - -# list of datasources that should be deleted from the database -deleteDatasources: - - name: Prometheus - orgId: 1 - -# list of datasources to insert/update depending -# whats available in the database -datasources: -- name: Prometheus - type: prometheus - access: proxy - # Access mode - proxy (server in the UI) or direct (browser in the UI). - url: http://host.docker.internal:9090 - uid: prom - -- name: Loki - uid: loki - type: loki - access: proxy - url: http://loki:3100 - # allow users to edit datasources from the UI. - editable: true - jsonData: - derivedFields: - - datasourceUid: jaeger - matcherRegex: (?:"traceid"):"(\w+)" - name: TraceID - url: $${__value.raw} - -- name: Jaeger - type: jaeger - uid: jaeger - access: proxy - url: http://jaeger:16686 - readOnly: false - isDefault: false - # allow users to edit datasources from the UI. - editable: true - jsonData: - tracesToLogsV2: - # Field with an internal link pointing to a logs data source in Grafana. - # datasourceUid value must match the uid value of the logs data source. - datasourceUid: 'loki' - spanStartTimeShift: '1h' - spanEndTimeShift: '-1h' - tags: [{ key: 'service.names', value: 'service_name' }] - filterByTraceID: false - filterBySpanID: false - customQuery: true - query: '{$${__tags}} |="$${__trace.traceId}"' - tracesToMetrics: - datasourceUid: 'prom' - spanStartTimeShift: '1h' - spanEndTimeShift: '-1h' - tags: [{ key: 'service.name', value: 'service' }, { key: 'job' }] - queries: - - name: 'Sample query' - query: 'sum(rate(traces_spanmetrics_latency_bucket{$$__tags}[5m]))' - nodeGraph: - enabled: true - traceQuery: - timeShiftEnabled: true - spanStartTimeShift: '1h' - spanEndTimeShift: '-1h' - spanBar: - type: 'None' \ No newline at end of file diff --git a/compose/grafana/dashboards/aspnet-core-endpoint.json b/compose/grafana/dashboards/aspnet-core-endpoint.json deleted file mode 100644 index 05b5496712..0000000000 --- a/compose/grafana/dashboards/aspnet-core-endpoint.json +++ /dev/null @@ -1,933 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "description": "ASP.NET Core endpoint metrics from OpenTelemetry", - "editable": true, - "fiscalYearStartMonth": 0, - "gnetId": 19925, - "graphTooltip": 0, - "id": 10, - "links": [ - { - "asDropdown": false, - "icon": "dashboard", - "includeVars": false, - "keepTime": true, - "tags": [], - "targetBlank": false, - "title": " ASP.NET Core", - "tooltip": "", - "type": "link", - "url": "/d/KdDACDp4z/asp-net-core-metrics" - } - ], - "liveNow": false, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "axisSoftMin": 0, - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "match": "null+nan", - "result": { - "index": 0, - "text": "0 ms" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "__systemRef": "hideSeriesFrom", - "matcher": { - "id": "byNames", - "options": { - "mode": "exclude", - "names": [ - "p50" - ], - "prefix": "All except:", - "readOnly": true - } - }, - "properties": [ - { - "id": "custom.hideFrom", - "value": { - "legend": false, - "tooltip": false, - "viz": false - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 40, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "legendFormat": "p50", - "range": true, - "refId": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.75, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p75", - "range": true, - "refId": "p75" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p90", - "range": true, - "refId": "p90" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p95", - "range": true, - "refId": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.98, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p98", - "range": true, - "refId": "p98" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p99", - "range": true, - "refId": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.999, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", - "hide": false, - "legendFormat": "p99.9", - "range": true, - "refId": "p99.9" - } - ], - "title": "Requests Duration - $method $route", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic", - "seriesBy": "max" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "match": "null+nan", - "result": { - "index": 0, - "text": "0%" - } - }, - "type": "special" - } - ], - "max": 1, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "All" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "4XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "5XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 46, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\", http_response_status_code=~\"4..|5..\"}[5m]) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m]))", - "legendFormat": "All", - "range": true, - "refId": "All" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\", http_response_status_code=~\"4..\"}[5m]) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m]))", - "hide": false, - "legendFormat": "4XX", - "range": true, - "refId": "4XX" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\", http_response_status_code=~\"5..\"}[5m]) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m]))", - "hide": false, - "legendFormat": "5XX", - "range": true, - "refId": "5XX" - } - ], - "title": "Errors Rate - $method $route", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Requests" - }, - "properties": [ - { - "id": "custom.width", - "value": 300 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "gauge" - } - }, - { - "id": "color", - "value": { - "mode": "continuous-YlRd" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Route" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "title": "", - "url": "/d/NagEsjE4z/asp-net-core-endpoint-details?var-route=${__data.fields.Route}&var-method=${__data.fields.Method}&${__url_time_range}" - } - ] - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 9 - }, - "hideTimeOverride": false, - "id": 44, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Value" - } - ] - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "sum by (error_type) (\r\n max_over_time(http_server_request_duration_seconds_count{http_route=\"$route\", http_request_method=\"$method\", error_type!=\"\"}[$__rate_interval])\r\n)", - "format": "table", - "instant": true, - "interval": "", - "legendFormat": "{{route}}", - "range": false, - "refId": "A" - } - ], - "title": "Unhandled Exceptions", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "Time": true, - "method": false - }, - "indexByName": { - "Time": 0, - "Value": 2, - "error_type": 1 - }, - "renameByName": { - "Value": "Requests", - "error_type": "Exception", - "http_request_method": "Method", - "http_route": "Route" - } - } - } - ], - "type": "table" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "blue", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 12, - "x": 12, - "y": 9 - }, - "id": 42, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (http_response_status_code) (\r\n max_over_time(http_server_request_duration_seconds_count{http_route=\"$route\", http_request_method=\"$method\"}[$__rate_interval])\r\n )", - "legendFormat": "Status {{http_response_status_code}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests HTTP Status Code", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "green", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 12, - "y": 13 - }, - "id": 48, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (url_scheme) (\r\n max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[$__rate_interval])\r\n )", - "legendFormat": "{{scheme}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests Secured", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "purple", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 18, - "y": 13 - }, - "id": 50, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (method_route) (\r\n label_replace(max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[$__rate_interval]), \"method_route\", \"http/$1\", \"network_protocol_version\", \"(.*)\")\r\n )", - "legendFormat": "{{protocol}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests HTTP Protocol", - "type": "stat" - } - ], - "refresh": "", - "revision": 1, - "schemaVersion": 39, - "tags": [ - "dotnet", - "prometheus", - "aspnetcore" - ], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "fullstackhero.api", - "value": "fullstackhero.api" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_active_requests,job)", - "hide": 0, - "includeAll": false, - "label": "Job", - "multi": false, - "name": "job", - "options": [], - "query": { - "query": "label_values(http_server_active_requests,job)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "host.docker.internal:5000", - "value": "host.docker.internal:5000" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_active_requests{job=~\"$job\"},instance)", - "hide": 0, - "includeAll": false, - "label": "Instance", - "multi": false, - "name": "instance", - "options": [], - "query": { - "query": "label_values(http_server_active_requests{job=~\"$job\"},instance)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "api/roles/", - "value": "api/roles/" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_request_duration_seconds_count,http_route)", - "description": "Route", - "hide": 0, - "includeAll": false, - "label": "Route", - "multi": false, - "name": "route", - "options": [], - "query": { - "query": "label_values(http_server_request_duration_seconds_count,http_route)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "GET", - "value": "GET" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_request_duration_seconds_count{http_route=~\"$route\"},http_request_method)", - "hide": 0, - "includeAll": false, - "label": "Method", - "multi": false, - "name": "method", - "options": [], - "query": { - "query": "label_values(http_server_request_duration_seconds_count{http_route=~\"$route\"},http_request_method)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - } - ] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "1s", - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ] - }, - "timezone": "", - "title": "ASP.NET Core Endpoint", - "uid": "NagEsjE4j", - "version": 2, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/grafana/dashboards/aspnet-core.json b/compose/grafana/dashboards/aspnet-core.json deleted file mode 100644 index a0d2aa1740..0000000000 --- a/compose/grafana/dashboards/aspnet-core.json +++ /dev/null @@ -1,1332 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "description": "ASP.NET Core metrics from OpenTelemetry", - "editable": true, - "fiscalYearStartMonth": 0, - "gnetId": 19924, - "graphTooltip": 0, - "id": 9, - "links": [], - "liveNow": false, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "axisSoftMin": 0, - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "match": "null+nan", - "result": { - "index": 1, - "text": "0 ms" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "__systemRef": "hideSeriesFrom", - "matcher": { - "id": "byNames", - "options": { - "mode": "exclude", - "names": [ - "p50" - ], - "prefix": "All except:", - "readOnly": true - } - }, - "properties": [ - { - "id": "custom.hideFrom", - "value": { - "legend": false, - "tooltip": false, - "viz": false - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 40, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "legendFormat": "p50", - "range": true, - "refId": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.75, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p75", - "range": true, - "refId": "p75" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p90", - "range": true, - "refId": "p90" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p95", - "range": true, - "refId": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.98, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p98", - "range": true, - "refId": "p98" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99", - "range": true, - "refId": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.999, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99.9", - "range": true, - "refId": "p99.9" - } - ], - "title": "Requests Duration", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic", - "seriesBy": "max" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "match": "null+nan", - "result": { - "index": 1, - "text": "0%" - } - }, - "type": "special" - } - ], - "max": 1, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "All" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "4XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "5XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 47, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_response_status_code=~\"4..|5..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval]))", - "legendFormat": "All", - "range": true, - "refId": "All" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_response_status_code=~\"4..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "4XX", - "range": true, - "refId": "4XX" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_response_status_code=~\"5..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "5XX", - "range": true, - "refId": "5XX" - } - ], - "title": "Errors Rate", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 6, - "x": 0, - "y": 9 - }, - "id": 49, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(kestrel_active_connections{job=\"$job\", instance=\"$instance\"})", - "legendFormat": "__auto", - "range": true, - "refId": "A" - } - ], - "title": "Current Connections", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 9 - }, - "id": 55, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(http_server_active_requests{job=\"$job\", instance=\"$instance\"})", - "legendFormat": "__auto", - "range": true, - "refId": "A" - } - ], - "title": "Current Requests", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "blue", - "mode": "fixed" - }, - "mappings": [], - "noValue": "0", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 12, - "y": 9 - }, - "id": 58, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "text": {}, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "sum(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\"})", - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "A" - } - ], - "title": "Total Requests", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-red", - "mode": "fixed" - }, - "mappings": [], - "noValue": "0", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 18, - "y": 9 - }, - "id": 59, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "text": {}, - "textMode": "value", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "sum(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", error_type!=\"\"})", - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "A" - } - ], - "title": "Total Unhandled Exceptions", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "green", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 12, - "y": 13 - }, - "id": 60, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (url_scheme) (\r\n max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\"}[$__rate_interval])\r\n )", - "legendFormat": "{{scheme}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests Secured", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "purple", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 18, - "y": 13 - }, - "id": 42, - "options": { - "colorMode": "background", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "max" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum by (method_route) (\r\n label_replace(max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\"}[$__rate_interval]), \"method_route\", \"http/$1\", \"network_protocol_version\", \"(.*)\")\r\n )", - "legendFormat": "{{protocol}}", - "range": true, - "refId": "A" - } - ], - "title": "Requests HTTP Protocol", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Requests" - }, - "properties": [ - { - "id": "custom.width", - "value": 300 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "gauge" - } - }, - { - "id": "color", - "value": { - "mode": "continuous-BlPu" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Endpoint" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "targetBlank": false, - "title": "Test", - "url": "/d/NagEsjE4z/asp-net-core-endpoint-details?var-route=${__data.fields.http_route}&var-method=${__data.fields.http_request_method}&${__url_time_range}" - } - ] - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "http_route" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "http_request_method" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 17 - }, - "hideTimeOverride": false, - "id": 51, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Value" - } - ] - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": " topk(10,\r\n sum by (http_route, http_request_method, method_route) (\r\n label_join(max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route!=\"\"}[$__rate_interval]), \"method_route\", \" \", \"http_request_method\", \"http_route\")\r\n ))", - "format": "table", - "instant": true, - "interval": "", - "legendFormat": "{{route}}", - "range": false, - "refId": "A" - } - ], - "title": "Top 10 Requested Endpoints", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "Time": true, - "method": false, - "route": false - }, - "indexByName": { - "Time": 0, - "Value": 4, - "method": 2, - "method_route": 3, - "route": 1 - }, - "renameByName": { - "Value": "Requests", - "method": "", - "method_route": "Endpoint", - "route": "" - } - } - } - ], - "type": "table" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Requests" - }, - "properties": [ - { - "id": "custom.width", - "value": 300 - }, - { - "id": "custom.cellOptions", - "value": { - "mode": "gradient", - "type": "gauge" - } - }, - { - "id": "color", - "value": { - "mode": "continuous-YlRd" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Endpoint" - }, - "properties": [ - { - "id": "links", - "value": [ - { - "title": "", - "url": "/d/NagEsjE4z/asp-net-core-endpoint-details?var-route=${__data.fields.http_route}&var-method=${__data.fields.http_request_method}&${__url_time_range}" - } - ] - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "http_route" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "http_request_method" - }, - "properties": [ - { - "id": "custom.hidden", - "value": true - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 17 - }, - "hideTimeOverride": false, - "id": 54, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "Value" - } - ] - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": " topk(10,\r\n sum by (http_route, http_request_method, method_route) (\r\n label_join(max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route!=\"\", error_type!=\"\"}[$__rate_interval]), \"method_route\", \" \", \"http_request_method\", \"http_route\")\r\n ))", - "format": "table", - "instant": true, - "interval": "", - "legendFormat": "{{route}}", - "range": false, - "refId": "A" - } - ], - "title": "Top 10 Unhandled Exception Endpoints", - "transformations": [ - { - "id": "organize", - "options": { - "excludeByName": { - "Time": true, - "method": false - }, - "indexByName": { - "Time": 0, - "Value": 4, - "method": 2, - "method_route": 3, - "route": 1 - }, - "renameByName": { - "Value": "Requests", - "method": "", - "method_route": "Endpoint", - "route": "" - } - } - } - ], - "type": "table" - } - ], - "refresh": "", - "revision": 1, - "schemaVersion": 39, - "tags": [ - "dotnet", - "prometheus", - "aspnetcore" - ], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "fullstackhero.api", - "value": "fullstackhero.api" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_active_requests,job)", - "hide": 0, - "includeAll": false, - "label": "Job", - "multi": false, - "name": "job", - "options": [], - "query": { - "query": "label_values(http_server_active_requests,job)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "host.docker.internal:5000", - "value": "host.docker.internal:5000" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_server_active_requests{job=~\"$job\"},instance)", - "hide": 0, - "includeAll": false, - "label": "Instance", - "multi": false, - "name": "instance", - "options": [], - "query": { - "query": "label_values(http_server_active_requests{job=~\"$job\"},instance)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - } - ] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "1s", - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ] - }, - "timezone": "", - "title": "ASP.NET Core", - "uid": "KdDACDp4z", - "version": 2, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/grafana/dashboards/dotnet-otel-dashboard.json b/compose/grafana/dashboards/dotnet-otel-dashboard.json deleted file mode 100644 index 1b179c6791..0000000000 --- a/compose/grafana/dashboards/dotnet-otel-dashboard.json +++ /dev/null @@ -1,2031 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "description": "Shows ASP.NET metrics from OpenTelemetry NuGet", - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": 9, - "links": [], - "liveNow": false, - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 15, - "panels": [], - "title": "Process", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 90, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "system" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "user" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-green", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 11, - "x": 0, - "y": 1 - }, - "id": 19, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "irate(process_cpu_time{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "__auto", - "range": true, - "refId": "CPU Usage" - } - ], - "title": "CPU Usage", - "transformations": [ - { - "id": "labelsToFields", - "options": { - "keepLabels": [ - "state" - ], - "valueLabel": "state" - } - } - ], - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 90, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 10, - "x": 11, - "y": 1 - }, - "id": 16, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_memory_usage{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "Memory Usage", - "range": true, - "refId": "Memory Usage" - } - ], - "title": "Memory Usage", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "dark-green", - "value": null - }, - { - "color": "dark-yellow", - "value": 50 - }, - { - "color": "dark-orange", - "value": 100 - }, - { - "color": "dark-red", - "value": 150 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 3, - "x": 21, - "y": 1 - }, - "id": 12, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "value" - }, - "pluginVersion": "10.0.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_threads{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "Threads", - "range": true, - "refId": "Threads" - } - ], - "title": "Threads", - "type": "stat" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 10 - }, - "id": 2, - "panels": [], - "title": "Runtime", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 0, - "y": 11 - }, - "id": 6, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_gc_committed_memory_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "hide": false, - "legendFormat": "Committed Memory Size", - "range": true, - "refId": "Committed Memory Size" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_gc_committed_memory_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "Objects Size", - "range": true, - "refId": "Objects Size" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "irate(process_runtime_dotnet_gc_committed_memory_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "hide": false, - "instant": false, - "legendFormat": "Allocations Size", - "range": true, - "refId": "Allocations Size" - } - ], - "title": "General Memory Usage", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "text", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 60, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "gen0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-green", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen1" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "loh" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "poh" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-blue", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 8, - "y": 11 - }, - "id": 8, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_gc_heap_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "__auto", - "range": true, - "refId": "Heap Size" - } - ], - "title": "Heap Generations (bytes)", - "transformations": [ - { - "id": "labelsToFields", - "options": { - "keepLabels": [ - "generation" - ], - "valueLabel": "generation" - } - } - ], - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "text", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 60, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 0, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "gen0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-green", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen1" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "loh" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "poh" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-blue", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 16, - "y": 11 - }, - "id": 9, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_gc_heap_fragmentation_size{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "__auto", - "range": true, - "refId": "Heap Fragmentation" - } - ], - "title": "Heap Fragmentation (bytes)", - "transformations": [ - { - "id": "labelsToFields", - "options": { - "keepLabels": [ - "generation" - ], - "valueLabel": "generation" - } - } - ], - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "green", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": -1, - "drawStyle": "bars", - "fillOpacity": 100, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 0, - "pointSize": 1, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "0": { - "color": "transparent", - "index": 0, - "text": "None" - } - }, - "type": "value" - } - ], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "gen0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-green", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen1" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "gen2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 0, - "y": 20 - }, - "id": 4, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.3.2", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "exemplar": false, - "expr": "idelta(process_runtime_dotnet_gc_collections_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "hide": false, - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "gc" - } - ], - "title": "GC Collections", - "transformations": [ - { - "id": "labelsToFields", - "options": { - "keepLabels": [ - "generation" - ], - "mode": "columns", - "valueLabel": "generation" - } - } - ], - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-red", - "mode": "fixed" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 90, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 8, - "x": 8, - "y": 20 - }, - "id": 13, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "increase(process_runtime_dotnet_exceptions_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "Exceptions", - "range": true, - "refId": "Exceptions" - } - ], - "title": "Exceptions", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "fixed" - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 4, - "x": 16, - "y": 20 - }, - "id": 11, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "10.0.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_thread_pool_threads_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "ThreadPool Threads", - "range": true, - "refId": "ThreadPool Threads" - } - ], - "title": "ThreadPool Threads", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "fixed" - }, - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 4, - "x": 20, - "y": 20 - }, - "id": 17, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "pluginVersion": "10.0.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "max_over_time(process_runtime_dotnet_thread_pool_queue_length{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])", - "legendFormat": "ThreadPool Threads Queue Length", - "range": true, - "refId": "ThreadPool Threads Queue Length" - } - ], - "title": "ThreadPool Threads Queue Length", - "type": "stat" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 29 - }, - "id": 33, - "panels": [], - "title": "HTTP Server", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "ms" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 30 - }, - "id": 40, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "legendFormat": "p50", - "range": true, - "refId": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.75, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p75", - "range": true, - "refId": "p75" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p90", - "range": true, - "refId": "p90" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p95", - "range": true, - "refId": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.98, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p98", - "range": true, - "refId": "p98" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99", - "range": true, - "refId": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.999, sum(rate(http_server_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99.9", - "range": true, - "refId": "p99.9" - } - ], - "title": "Responses Duration", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "All" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "4XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "5XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 30 - }, - "id": 47, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", http_status_code!~\"2..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval]))", - "legendFormat": "All", - "range": true, - "refId": "All" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", http_status_code=~\"4..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "4XX", - "range": true, - "refId": "4XX" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", http_status_code=~\"5..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_server_duration_count{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "5XX", - "range": true, - "refId": "5XX" - } - ], - "title": "Errors Rate", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 39 - }, - "id": 21, - "panels": [], - "repeat": "http_client_peer_name", - "repeatDirection": "h", - "title": "HTTP Client ($http_client_peer_name)", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "dark-green", - "mode": "continuous-GrYlRd", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "ms" - }, - "overrides": [] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 0, - "y": 40 - }, - "id": 23, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "legendFormat": "p50", - "range": true, - "refId": "p50" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.75, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p75", - "range": true, - "refId": "p75" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p90", - "range": true, - "refId": "p90" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p95", - "range": true, - "refId": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.98, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p98", - "range": true, - "refId": "p98" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99", - "range": true, - "refId": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.999, sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval])) by (le))", - "hide": false, - "legendFormat": "p99.9", - "range": true, - "refId": "p99.9" - } - ], - "title": "Requests Duration", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic", - "seriesBy": "max" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 50, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "All" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-orange", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "4XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-yellow", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "5XX" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 9, - "w": 12, - "x": 12, - "y": 40 - }, - "id": 25, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "min", - "max" - ], - "displayMode": "table", - "placement": "right", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\", http_status_code!~\"2..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval]))", - "legendFormat": "All", - "range": true, - "refId": "All" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\", http_status_code=~\"4..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "4XX", - "range": true, - "refId": "4XX" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "editorMode": "code", - "expr": "sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\", http_status_code=~\"5..\"}[$__rate_interval]) or vector(0)) / sum(rate(http_client_duration_bucket{exported_job=\"$exported_job\", exported_instance=\"$exported_instance\", net_peer_name=\"$http_client_peer_name\"}[$__rate_interval]))", - "hide": false, - "legendFormat": "5XX", - "range": true, - "refId": "5XX" - } - ], - "title": "Errors Rate", - "type": "timeseries" - } - ], - "refresh": "1m", - "schemaVersion": 38, - "style": "dark", - "tags": [], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "FST.TAG.Manager", - "value": "FST.TAG.Manager" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(process_runtime_dotnet_gc_collections_count,exported_job)", - "hide": 0, - "includeAll": false, - "label": "Job", - "multi": false, - "name": "exported_job", - "options": [], - "query": { - "query": "label_values(process_runtime_dotnet_gc_collections_count,exported_job)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "39230ae2-5527-47b3-b546-6f8d4cfc9ab0", - "value": "39230ae2-5527-47b3-b546-6f8d4cfc9ab0" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(process_runtime_dotnet_gc_collections_count{exported_job=~\"$exported_job\"},exported_instance)", - "hide": 0, - "includeAll": false, - "label": "Instance", - "multi": false, - "name": "exported_instance", - "options": [], - "query": { - "query": "label_values(process_runtime_dotnet_gc_collections_count{exported_job=~\"$exported_job\"},exported_instance)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "All", - "value": "$__all" - }, - "datasource": { - "type": "prometheus", - "uid": "prom" - }, - "definition": "label_values(http_client_duration_bucket{exported_job=~\"$exported_job\",exported_instance=~\"$exported_instance\"},net_peer_name)", - "hide": 2, - "includeAll": true, - "label": "HTTP Client Pear Name", - "multi": false, - "name": "http_client_peer_name", - "options": [], - "query": { - "query": "label_values(http_client_duration_bucket{exported_job=~\"$exported_job\",exported_instance=~\"$exported_instance\"},net_peer_name)", - "refId": "PrometheusVariableQueryEditor-VariableQuery" - }, - "refresh": 2, - "regex": "", - "skipUrlSync": false, - "sort": 5, - "type": "query" - } - ] - }, - "time": { - "from": "now-5m", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "ASP.NET OTEL Metrics", - "uid": "bc47b423-0b3c-4538-8e20-f84f84deefe5", - "version": 6, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/grafana/dashboards/logs-dashboard.json b/compose/grafana/dashboards/logs-dashboard.json deleted file mode 100644 index f4ddf3b973..0000000000 --- a/compose/grafana/dashboards/logs-dashboard.json +++ /dev/null @@ -1,334 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "links": [], - "liveNow": false, - "panels": [ - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 6, - "panels": [], - "title": "Logs by Level", - "type": "row" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 1, - "drawStyle": "bars", - "fillOpacity": 100, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepBefore", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 1 - }, - "id": 3, - "interval": "1m", - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (level) (count_over_time({service_name=\"$service_name\", level=~\"$level\"} [$__interval]))", - "legendFormat": "{{level}}", - "queryType": "range", - "refId": "A" - } - ], - "title": "Log Volume", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 9 - }, - "id": 5, - "panels": [], - "title": "Logs Detailed Information", - "type": "row" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "gridPos": { - "h": 11, - "w": 24, - "x": 0, - "y": 10 - }, - "id": 2, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": false - }, - "pluginVersion": "9.3.2", - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "{service_name=\"$service_name\", severity_text=~\"$level\"} |=\"$search\" | line_format `[{{ .severity_text }}] {{ .message_template_text }}`", - "queryType": "range", - "refId": "A" - } - ], - "title": "Logs", - "type": "logs" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 21 - }, - "id": 4, - "panels": [], - "title": "Logs with TraceId Link", - "type": "row" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "gridPos": { - "h": 13, - "w": 24, - "x": 0, - "y": 22 - }, - "id": 7, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": false, - "showTime": false, - "sortOrder": "Descending", - "wrapLogMessage": false - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "{service_name=\"$service_name\", level=~\"$level\"} |=\"$search\" | json ", - "key": "Q-b242453d-acff-49f2-9239-12ceaf57fa43-0", - "queryType": "range", - "refId": "A" - } - ], - "title": "Log Entries with Trace Link", - "type": "logs" - } - ], - "refresh": "", - "schemaVersion": 39, - "tags": [], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "FSH.Starter.WebApi.Host", - "value": "FSH.Starter.WebApi.Host" - }, - "datasource": { - "type": "loki", - "uid": "loki" - }, - "definition": "", - "hide": 0, - "includeAll": false, - "label": "Service", - "multi": false, - "name": "service_name", - "options": [], - "query": { - "label": "service_name", - "refId": "LokiVariableQueryEditor-VariableQuery", - "stream": "", - "type": 1 - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "All", - "value": "$__all" - }, - "datasource": { - "type": "loki", - "uid": "loki" - }, - "definition": "", - "hide": 0, - "includeAll": true, - "multi": false, - "name": "level", - "options": [], - "query": { - "label": "severity_text", - "refId": "LokiVariableQueryEditor-VariableQuery", - "stream": "", - "type": 1 - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 0, - "type": "query" - }, - { - "current": { - "selected": false, - "text": "", - "value": "" - }, - "hide": 0, - "label": "Search Text", - "name": "search", - "options": [ - { - "selected": true, - "text": "", - "value": "" - } - ], - "query": "", - "skipUrlSync": false, - "type": "textbox" - } - ] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Logs", - "uid": "f4463c33-40c8-4def-aac2-95d365040f2e", - "version": 1, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/grafana/dashboards/node-exporter.json b/compose/grafana/dashboards/node-exporter.json deleted file mode 100644 index cb734d8060..0000000000 --- a/compose/grafana/dashboards/node-exporter.json +++ /dev/null @@ -1,23870 +0,0 @@ -{ - "annotations": { - "list": [ - { - "$$hashKey": "object:1058", - "builtIn": 1, - "datasource": { - "type": "datasource", - "uid": "grafana" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "target": { - "limit": 100, - "matchAny": false, - "tags": [], - "type": "dashboard" - }, - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "gnetId": 1860, - "graphTooltip": 1, - "id": 8, - "links": [ - { - "icon": "external link", - "tags": [], - "targetBlank": true, - "title": "GitHub", - "type": "link", - "url": "https://github.com/rfmoz/grafana-dashboards" - }, - { - "icon": "external link", - "tags": [], - "targetBlank": true, - "title": "Grafana", - "type": "link", - "url": "https://grafana.com/grafana/dashboards/1860" - } - ], - "liveNow": false, - "panels": [ - { - "collapsed": false, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 261, - "panels": [], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Quick CPU / Mem / Disk", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Resource pressure via PSI", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "links": [], - "mappings": [], - "max": 1, - "min": 0, - "thresholds": { - "mode": "percentage", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "dark-yellow", - "value": 70 - }, - { - "color": "dark-red", - "value": 90 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 0, - "y": 1 - }, - "id": 323, - "options": { - "displayMode": "basic", - "maxVizHeight": 300, - "minVizHeight": 10, - "minVizWidth": 0, - "namePlacement": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showUnfilled": true, - "sizing": "auto", - "text": {}, - "valueMode": "color" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "irate(node_pressure_cpu_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "instant": true, - "intervalFactor": 1, - "legendFormat": "CPU", - "range": false, - "refId": "CPU some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "irate(node_pressure_memory_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "legendFormat": "Mem", - "range": false, - "refId": "Memory some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "irate(node_pressure_io_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "legendFormat": "I/O", - "range": false, - "refId": "I/O some", - "step": 240 - } - ], - "title": "Pressure", - "type": "bargauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Busy state of all CPU cores together", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 85 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 95 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 3, - "y": 1 - }, - "id": 20, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "100 * (1 - avg(rate(node_cpu_seconds_total{mode=\"idle\", instance=\"$node\"}[$__rate_interval])))", - "hide": false, - "instant": true, - "intervalFactor": 1, - "legendFormat": "", - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "CPU Busy", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "System load over all CPU cores together", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 85 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 95 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 6, - "y": 1 - }, - "id": 155, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "scalar(node_load1{instance=\"$node\",job=\"$job\"}) * 100 / count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu))", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "Sys Load", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Non available RAM memory", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 80 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 90 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 9, - "y": 1 - }, - "hideTimeOverride": false, - "id": 16, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "((node_memory_MemTotal_bytes{instance=\"$node\", job=\"$job\"} - node_memory_MemFree_bytes{instance=\"$node\", job=\"$job\"}) / node_memory_MemTotal_bytes{instance=\"$node\", job=\"$job\"}) * 100", - "format": "time_series", - "hide": true, - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "(1 - (node_memory_MemAvailable_bytes{instance=\"$node\", job=\"$job\"} / node_memory_MemTotal_bytes{instance=\"$node\", job=\"$job\"})) * 100", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "B", - "step": 240 - } - ], - "title": "RAM Used", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Used Swap", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 10 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 25 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 12, - "y": 1 - }, - "id": 21, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "((node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapFree_bytes{instance=\"$node\",job=\"$job\"}) / (node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"})) * 100", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "SWAP Used", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Used Root FS", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 80 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 90 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 3, - "x": 15, - "y": 1 - }, - "id": 154, - "options": { - "minVizHeight": 75, - "minVizWidth": 75, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showThresholdLabels": false, - "showThresholdMarkers": true, - "sizing": "auto" - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "100 - ((node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",mountpoint=\"/\",fstype!=\"rootfs\"} * 100) / node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",mountpoint=\"/\",fstype!=\"rootfs\"})", - "format": "time_series", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "Root FS Used", - "type": "gauge" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Total number of CPU cores", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 2, - "x": 18, - "y": 1 - }, - "id": 14, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu))", - "instant": true, - "legendFormat": "__auto", - "range": false, - "refId": "A" - } - ], - "title": "CPU Cores", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "System uptime", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 4, - "x": 20, - "y": 1 - }, - "hideTimeOverride": true, - "id": 15, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "node_time_seconds{instance=\"$node\",job=\"$job\"} - node_boot_time_seconds{instance=\"$node\",job=\"$job\"}", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "Uptime", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Total RootFS", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 70 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 90 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 2, - "x": 18, - "y": 3 - }, - "id": 23, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",mountpoint=\"/\",fstype!=\"rootfs\"}", - "format": "time_series", - "hide": false, - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "RootFS Total", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Total RAM", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 2, - "x": 20, - "y": 3 - }, - "id": 75, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"}", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "RAM Total", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Total SWAP", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 2, - "w": 2, - "x": 22, - "y": 3 - }, - "id": 18, - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.1.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"}", - "instant": true, - "intervalFactor": 1, - "range": false, - "refId": "A", - "step": 240 - } - ], - "title": "SWAP Total", - "type": "stat" - }, - { - "collapsed": false, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 5 - }, - "id": 263, - "panels": [], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Basic CPU / Mem / Net / Disk", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Basic CPU info", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "percent" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Busy Iowait" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Idle" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Busy Iowait" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Idle" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Busy System" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Busy User" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Busy Other" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 0, - "y": 6 - }, - "id": 77, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true, - "width": 250 - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "exemplar": false, - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"system\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "hide": false, - "instant": false, - "intervalFactor": 1, - "legendFormat": "Busy System", - "range": true, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"user\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Busy User", - "range": true, - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"iowait\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Busy Iowait", - "range": true, - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=~\".*irq\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Busy IRQs", - "range": true, - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode!='idle',mode!='user',mode!='system',mode!='iowait',mode!='irq',mode!='softirq'}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Busy Other", - "range": true, - "refId": "E", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"idle\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Idle", - "range": true, - "refId": "F", - "step": 240 - } - ], - "title": "CPU Basic", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Basic memory usage", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "SWAP Used" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap Used" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - }, - { - "id": "custom.stacking", - "value": { - "group": false, - "mode": "normal" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM Cache + Buffer" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Available" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#DEDAF7", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - }, - { - "id": "custom.stacking", - "value": { - "group": false, - "mode": "normal" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 12, - "y": 6 - }, - "id": 78, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "RAM Total", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"} - (node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"} + node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"} + node_memory_SReclaimable_bytes{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "RAM Used", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"} + node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"} + node_memory_SReclaimable_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "RAM Cache + Buffer", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "RAM Free", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "(node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapFree_bytes{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "SWAP Used", - "refId": "E", - "step": 240 - } - ], - "title": "Memory Basic", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Basic network info per interface", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bps" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Recv_bytes_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_bytes_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_drop_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_drop_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_errs_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Recv_errs_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CCA300", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_bytes_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_bytes_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_drop_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_drop_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_errs_eth2" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Trans_errs_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CCA300", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_bytes_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_drop_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_drop_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#967302", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_errs_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "recv_errs_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_bytes_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_bytes_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_drop_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_drop_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#967302", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_errs_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "trans_errs_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 0, - "y": 13 - }, - "id": 74, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "recv {{device}}", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "trans {{device}} ", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Basic", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Disk space used of all filesystems mounted", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 12, - "y": 13 - }, - "id": 152, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "100 - ((node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'} * 100) / node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'})", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{mountpoint}}", - "refId": "A", - "step": 240 - } - ], - "title": "Disk Space Used Basic", - "type": "timeseries" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 20 - }, - "id": 265, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "percentage", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 70, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "percent" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Idle - Waiting for something to happen" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Iowait - Waiting for I/O to complete" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Irq - Servicing interrupts" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Nice - Niced processes executing in user mode" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Softirq - Servicing softirqs" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Steal - Time spent in other operating systems when running in a virtualized environment" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCE2DE", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "System - Processes executing in kernel mode" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "User - Normal processes executing in user mode" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#5195CE", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 0, - "y": 21 - }, - "id": 3, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 250 - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"system\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "System - Processes executing in kernel mode", - "range": true, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"user\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "User - Normal processes executing in user mode", - "range": true, - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"nice\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Nice - Niced processes executing in user mode", - "range": true, - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum by(instance) (irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"iowait\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Iowait - Waiting for I/O to complete", - "range": true, - "refId": "E", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"irq\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Irq - Servicing interrupts", - "range": true, - "refId": "F", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"softirq\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Softirq - Servicing softirqs", - "range": true, - "refId": "G", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"steal\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Steal - Time spent in other operating systems when running in a virtualized environment", - "range": true, - "refId": "H", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum(irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\", mode=\"idle\"}[$__rate_interval])) / scalar(count(count(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}) by (cpu)))", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Idle - Waiting for something to happen", - "range": true, - "refId": "J", - "step": 240 - } - ], - "title": "CPU", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap - Swap memory usage" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused - Free memory unassigned" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Hardware Corrupted - *./" - }, - "properties": [ - { - "id": "custom.stacking", - "value": { - "group": false, - "mode": "normal" - } - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 12, - "y": 21 - }, - "id": 24, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"} - node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"} - node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"} - node_memory_Slab_bytes{instance=\"$node\",job=\"$job\"} - node_memory_PageTables_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapCached_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Apps - Memory used by user-space applications", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_PageTables_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "PageTables - Memory used to map between virtual and physical memory addresses", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_SwapCached_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "SwapCache - Memory that keeps track of pages that have been fetched from swap but not yet been modified", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Slab_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Slab - Memory used by the kernel to cache data structures for its own use (caches like inode, dentry, etc)", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Cached_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Cache - Parked file data (file content) cache", - "refId": "E", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Buffers_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Buffers - Block device (e.g. harddisk) cache", - "refId": "F", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_MemFree_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Unused - Free memory unassigned", - "refId": "G", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "(node_memory_SwapTotal_bytes{instance=\"$node\",job=\"$job\"} - node_memory_SwapFree_bytes{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Swap - Swap space used", - "refId": "H", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HardwareCorrupted_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working", - "refId": "I", - "step": 240 - } - ], - "title": "Memory Stack", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bits out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bps" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "receive_packets_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "receive_packets_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "transmit_packets_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "transmit_packets_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 0, - "y": 33 - }, - "id": 84, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])*8", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 12, - "y": 33 - }, - "id": 156, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'} - node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{mountpoint}}", - "refId": "A", - "step": 240 - } - ], - "title": "Disk Space Used", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "IO read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "iops" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 0, - "y": 45 - }, - "id": 229, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_reads_completed_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])", - "intervalFactor": 4, - "legendFormat": "{{device}} - Reads completed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_writes_completed_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])", - "intervalFactor": 1, - "legendFormat": "{{device}} - Writes completed", - "refId": "B", - "step": 240 - } - ], - "title": "Disk IOps", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "Bps" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "io time" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*read*./" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byType", - "options": "time" - }, - "properties": [ - { - "id": "custom.axisPlacement", - "value": "hidden" - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 12, - "y": 45 - }, - "id": 42, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_read_bytes_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{device}} - Successfully read bytes", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_written_bytes_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{device}} - Successfully written bytes", - "refId": "B", - "step": 240 - } - ], - "title": "I/O Usage Read / Write", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "%util", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 40, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "io time" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byType", - "options": "time" - }, - "properties": [ - { - "id": "custom.axisPlacement", - "value": "hidden" - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 0, - "y": 57 - }, - "id": 127, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_io_time_seconds_total{instance=\"$node\",job=\"$job\",device=~\"$diskdevices\"} [$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}}", - "refId": "A", - "step": 240 - } - ], - "title": "I/O Utilization", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "percentage", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "bars", - "fillOpacity": 70, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "smooth", - "lineWidth": 2, - "pointSize": 3, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "max": 1, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/^Guest - /" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#5195ce", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/^GuestNice - /" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#c15c17", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 12, - "w": 12, - "x": 12, - "y": 57 - }, - "id": 319, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum by(instance) (irate(node_cpu_guest_seconds_total{instance=\"$node\",job=\"$job\", mode=\"user\"}[1m])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[1m])))", - "hide": false, - "legendFormat": "Guest - Time spent running a virtual CPU for a guest operating system", - "range": true, - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "sum by(instance) (irate(node_cpu_guest_seconds_total{instance=\"$node\",job=\"$job\", mode=\"nice\"}[1m])) / on(instance) group_left sum by (instance)((irate(node_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[1m])))", - "hide": false, - "legendFormat": "GuestNice - Time spent running a niced guest (virtual CPU for guest operating system)", - "range": true, - "refId": "B" - } - ], - "title": "CPU spent seconds in guests (VMs)", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "CPU / Memory / Net / Disk", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 21 - }, - "id": 266, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 22 - }, - "id": 136, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Inactive_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Inactive - Memory which has been less recently used. It is more eligible to be reclaimed for other purposes", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Active_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Active - Memory that has been used more recently and usually not reclaimed unless absolutely necessary", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Active / Inactive", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*CommitLimit - *./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 22 - }, - "id": 135, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Committed_AS_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Committed_AS - Amount of memory presently allocated on the system", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_CommitLimit_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "CommitLimit - Amount of memory currently available to be allocated on the system", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Committed", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 32 - }, - "id": 191, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Inactive_file_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Inactive_file - File-backed memory on inactive LRU list", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Inactive_anon_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Inactive_anon - Anonymous and swap cache on inactive LRU list, including tmpfs (shmem)", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Active_file_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Active_file - File-backed memory on active LRU list", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Active_anon_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Active_anon - Anonymous and swap cache on active least-recently-used (LRU) list, including tmpfs", - "refId": "D", - "step": 240 - } - ], - "title": "Memory Active / Inactive Detail", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 32 - }, - "id": 130, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Writeback_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Writeback - Memory which is actively being written back to disk", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_WritebackTmp_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "WritebackTmp - Memory used by FUSE for temporary writeback buffers", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Dirty_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Dirty - Memory which is waiting to get written back to the disk", - "refId": "C", - "step": 240 - } - ], - "title": "Memory Writeback and Dirty", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "ShmemHugePages - Memory used by shared memory (shmem) and tmpfs allocated with huge pages" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "ShmemHugePages - Memory used by shared memory (shmem) and tmpfs allocated with huge pages" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 42 - }, - "id": 138, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Mapped_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Mapped - Used memory in mapped pages files which have been mapped, such as libraries", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Shmem_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Shmem - Used shared memory (shared between several processes, thus including RAM disks)", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_ShmemHugePages_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "ShmemHugePages - Memory used by shared memory (shmem) and tmpfs allocated with huge pages", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_ShmemPmdMapped_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "ShmemPmdMapped - Amount of shared (shmem/tmpfs) memory backed by huge pages", - "refId": "D", - "step": 240 - } - ], - "title": "Memory Shared and Mapped", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 42 - }, - "id": 131, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_SUnreclaim_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "SUnreclaim - Part of Slab, that cannot be reclaimed on memory pressure", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_SReclaimable_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "SReclaimable - Part of Slab, that might be reclaimed, such as caches", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Slab", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 52 - }, - "id": 70, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_VmallocChunk_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "VmallocChunk - Largest contiguous block of vmalloc area which is free", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_VmallocTotal_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "VmallocTotal - Total size of vmalloc memory area", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_VmallocUsed_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "VmallocUsed - Amount of vmalloc area which is used", - "refId": "C", - "step": 240 - } - ], - "title": "Memory Vmalloc", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 52 - }, - "id": 159, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Bounce_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Bounce - Memory used for block device bounce buffers", - "refId": "A", - "step": 240 - } - ], - "title": "Memory Bounce", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Inactive *./" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 62 - }, - "id": 129, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_AnonHugePages_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "AnonHugePages - Memory in anonymous huge pages", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_AnonPages_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "AnonPages - Memory in user pages not backed by files", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Anonymous", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 62 - }, - "id": 160, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_KernelStack_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "KernelStack - Kernel memory stack. This is not reclaimable", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Percpu_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "PerCPU - Per CPU memory allocated dynamically by loadable modules", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Kernel / CPU", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "pages", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 72 - }, - "id": 140, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HugePages_Free{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "HugePages_Free - Huge pages in the pool that are not yet allocated", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HugePages_Rsvd{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "HugePages_Rsvd - Huge pages for which a commitment to allocate from the pool has been made, but no allocation has yet been made", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HugePages_Surp{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "HugePages_Surp - Huge pages in the pool above the value in /proc/sys/vm/nr_hugepages", - "refId": "C", - "step": 240 - } - ], - "title": "Memory HugePages Counter", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 72 - }, - "id": 71, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_HugePages_Total{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "HugePages - Total size of the pool of huge pages", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Hugepagesize_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Hugepagesize - Huge Page size", - "refId": "B", - "step": 240 - } - ], - "title": "Memory HugePages Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 82 - }, - "id": 128, - "options": { - "legend": { - "calcs": [ - "mean", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_DirectMap1G_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "DirectMap1G - Amount of pages mapped as this size", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_DirectMap2M_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "DirectMap2M - Amount of pages mapped as this size", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_DirectMap4k_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "DirectMap4K - Amount of pages mapped as this size", - "refId": "C", - "step": 240 - } - ], - "title": "Memory DirectMap", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 82 - }, - "id": 137, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Unevictable_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Unevictable - Amount of unevictable memory that can't be swapped out for a variety of reasons", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_Mlocked_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "MLocked - Size of pages locked to memory using the mlock() system call", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Unevictable and MLocked", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 92 - }, - "id": 132, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_memory_NFS_Unstable_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "NFS Unstable - Memory in NFS pages sent to the server, but not yet committed to the storage", - "refId": "A", - "step": 240 - } - ], - "title": "Memory NFS", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Memory Meminfo", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 22 - }, - "id": 267, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "pages out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*out/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 23 - }, - "id": 176, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgpgin{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pagesin - Page in operations", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgpgout{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pagesout - Page out operations", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Pages In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "pages out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*out/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 23 - }, - "id": 22, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pswpin{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pswpin - Pages swapped in", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pswpout{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pswpout - Pages swapped out", - "refId": "B", - "step": 240 - } - ], - "title": "Memory Pages Swap In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "faults", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Apps" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#629E51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A437C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Hardware Corrupted - Amount of RAM that the kernel identified as corrupted / not working" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CFFAFF", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "RAM_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#806EB7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#2F575E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Unused" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Pgfault - Page major and minor fault operations" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - }, - { - "id": "custom.stacking", - "value": { - "group": false, - "mode": "normal" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 33 - }, - "id": 175, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 350 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pgfault - Page major and minor fault operations", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgmajfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pgmajfault - Major page fault operations", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_pgfault{instance=\"$node\",job=\"$job\"}[$__rate_interval]) - irate(node_vmstat_pgmajfault{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Pgminfault - Minor page fault operations", - "refId": "C", - "step": 240 - } - ], - "title": "Memory Page Faults", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#99440A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Buffers" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#58140C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6D1F62", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Cached" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Committed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#508642", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Dirty" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Free" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#B7DBAB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Mapped" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "PageTables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Page_Tables" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Slab_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Swap_Cache" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#511749", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total RAM + Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#052B51", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Total Swap" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "VmallocUsed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 33 - }, - "id": 307, - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_vmstat_oom_kill{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "oom killer invocations ", - "refId": "A", - "step": 240 - } - ], - "title": "OOM Killer", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Memory Vmstat", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 23 - }, - "id": 293, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Variation*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 24 - }, - "id": 260, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_estimated_error_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Estimated error in seconds", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_offset_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Time offset in between local system and reference clock", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_maxerror_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Maximum error in seconds", - "refId": "C", - "step": 240 - } - ], - "title": "Time Synchronized Drift", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 24 - }, - "id": 291, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_loop_time_constant{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Phase-locked loop time adjust", - "refId": "A", - "step": 240 - } - ], - "title": "Time PLL Adjust", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Variation*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 34 - }, - "id": 168, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_sync_status{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Is clock synchronized to a reliable server (1 = yes, 0 = no)", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_frequency_adjustment_ratio{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Local clock frequency adjustment", - "refId": "B", - "step": 240 - } - ], - "title": "Time Synchronized Status", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 34 - }, - "id": 294, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_tick_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Seconds between clock ticks", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_timex_tai_offset_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "International Atomic Time (TAI) offset", - "refId": "B", - "step": 240 - } - ], - "title": "Time Misc", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "System Timesync", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 24 - }, - "id": 312, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 73 - }, - "id": 62, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_procs_blocked{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Processes blocked waiting for I/O to complete", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_procs_running{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Processes in runnable state", - "refId": "B", - "step": 240 - } - ], - "title": "Processes Status", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.processes argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 73 - }, - "id": 315, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_state{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ state }}", - "refId": "A", - "step": 240 - } - ], - "title": "Processes State", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "forks / sec", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 83 - }, - "id": 148, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_forks_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Processes forks second", - "refId": "A", - "step": 240 - } - ], - "title": "Processes Forks", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "decbytes" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max.*/" - }, - "properties": [ - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 83 - }, - "id": 149, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(process_virtual_memory_bytes{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Processes virtual memory size in bytes", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "process_resident_memory_max_bytes{instance=\"$node\",job=\"$job\"}", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Maximum amount of virtual memory available in bytes", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(process_virtual_memory_bytes{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Processes virtual memory size in bytes", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(process_virtual_memory_max_bytes{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Maximum amount of virtual memory available in bytes", - "refId": "D", - "step": 240 - } - ], - "title": "Processes Memory", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.processes argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "PIDs limit" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F2495C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 93 - }, - "id": 313, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_pids{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Number of PIDs", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_max_processes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "PIDs limit", - "refId": "B", - "step": 240 - } - ], - "title": "PIDs Number and Limit", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*waiting.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 93 - }, - "id": 305, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_schedstat_running_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{ cpu }} - seconds spent running a process", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_schedstat_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{ cpu }} - seconds spent by processing waiting for this CPU", - "refId": "B", - "step": 240 - } - ], - "title": "Process schedule stats Running / Waiting", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.processes argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Threads limit" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F2495C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 103 - }, - "id": 314, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_threads{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Allocated threads", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_processes_max_threads{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Threads limit", - "refId": "B", - "step": 240 - } - ], - "title": "Threads Number and Limit", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "System Processes", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 25 - }, - "id": 269, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 26 - }, - "id": 8, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_context_switches_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Context switches", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_intr_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Interrupts", - "refId": "B", - "step": 240 - } - ], - "title": "Context Switches / Interrupts", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 26 - }, - "id": 7, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_load1{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "Load 1m", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_load5{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "Load 5m", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_load15{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "Load 15m", - "refId": "C", - "step": 240 - } - ], - "title": "System Load", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "hertz" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Max" - }, - "properties": [ - { - "id": "custom.lineStyle", - "value": { - "dash": [ - 10, - 10 - ], - "fill": "dash" - } - }, - { - "id": "color", - "value": { - "fixedColor": "blue", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 10 - }, - { - "id": "custom.hideFrom", - "value": { - "legend": true, - "tooltip": false, - "viz": false - } - }, - { - "id": "custom.fillBelowTo", - "value": "Min" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Min" - }, - "properties": [ - { - "id": "custom.lineStyle", - "value": { - "dash": [ - 10, - 10 - ], - "fill": "dash" - } - }, - { - "id": "color", - "value": { - "fixedColor": "blue", - "mode": "fixed" - } - }, - { - "id": "custom.hideFrom", - "value": { - "legend": true, - "tooltip": false, - "viz": false - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 36 - }, - "id": 321, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_cpu_scaling_frequency_hertz{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{ cpu }}", - "range": true, - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "avg(node_cpu_scaling_frequency_max_hertz{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Max", - "range": true, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "avg(node_cpu_scaling_frequency_min_hertz{instance=\"$node\",job=\"$job\"})", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Min", - "range": true, - "refId": "C", - "step": 240 - } - ], - "title": "CPU Frequency Scaling", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "https://docs.kernel.org/accounting/psi.html", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Memory some" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-red", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Memory full" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-red", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "I/O some" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "dark-blue", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "I/O full" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-blue", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 36 - }, - "id": 322, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_cpu_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "CPU some", - "range": true, - "refId": "CPU some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_memory_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Memory some", - "range": true, - "refId": "Memory some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_memory_stalled_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "Memory full", - "range": true, - "refId": "Memory full", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_io_waiting_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "I/O some", - "range": true, - "refId": "I/O some", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "rate(node_pressure_io_stalled_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "I/O full", - "range": true, - "refId": "I/O full", - "step": 240 - } - ], - "title": "Pressure Stall Information", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.interrupts argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Critical*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 46 - }, - "id": 259, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_interrupts_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ type }} - {{ info }}", - "refId": "A", - "step": 240 - } - ], - "title": "Interrupts Detail", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 46 - }, - "id": 306, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_schedstat_timeslices_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{ cpu }}", - "refId": "A", - "step": 240 - } - ], - "title": "Schedule timeslices executed by each cpu", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 56 - }, - "id": 151, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_entropy_available_bits{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Entropy available to random number generators", - "refId": "A", - "step": 240 - } - ], - "title": "Entropy", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 56 - }, - "id": 308, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(process_cpu_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Time spent", - "refId": "A", - "step": 240 - } - ], - "title": "CPU time spent in user and system contexts", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 66 - }, - "id": 64, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "process_max_fds{instance=\"$node\",job=\"$job\"}", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Maximum open file descriptors", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "process_open_fds{instance=\"$node\",job=\"$job\"}", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Open file descriptors", - "refId": "B", - "step": 240 - } - ], - "title": "File Descriptors", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "System Misc", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 26 - }, - "id": 304, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "temperature", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "celsius" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Critical*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 59 - }, - "id": 158, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} temp", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_crit_alarm_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": true, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} Critical Alarm", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_crit_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} Critical", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_crit_hyst_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": true, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} Critical Historical", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_hwmon_temp_max_celsius{instance=\"$node\",job=\"$job\"} * on(chip) group_left(chip_name) node_hwmon_chip_names{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": true, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ chip_name }} {{ sensor }} Max", - "refId": "E", - "step": 240 - } - ], - "title": "Hardware temperature monitor", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Max*./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 59 - }, - "id": 300, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_cooling_device_cur_state{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "Current {{ name }} in {{ type }}", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_cooling_device_max_state{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Max {{ name }} in {{ type }}", - "refId": "B", - "step": 240 - } - ], - "title": "Throttle cooling device", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 69 - }, - "id": 302, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_power_supply_online{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ power_supply }} online", - "refId": "A", - "step": 240 - } - ], - "title": "Power supply", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Hardware Misc", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 27 - }, - "id": 296, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 46 - }, - "id": 297, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_systemd_socket_accepted_connections_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{ name }} Connections", - "refId": "A", - "step": 240 - } - ], - "title": "Systemd Sockets", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Failed" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F2495C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Inactive" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FF9830", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Active" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#73BF69", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Deactivating" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FFCB7D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "Activating" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C8F2C2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 46 - }, - "id": 298, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"activating\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Activating", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"active\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Active", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"deactivating\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Deactivating", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"failed\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Failed", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_systemd_units{instance=\"$node\",job=\"$job\",state=\"inactive\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Inactive", - "refId": "E", - "step": 240 - } - ], - "title": "Systemd Units State", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Systemd", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 28 - }, - "id": 270, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The number (after merges) of I/O requests completed per second for the device", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "IO read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "iops" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 47 - }, - "id": 9, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_reads_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "intervalFactor": 4, - "legendFormat": "{{device}} - Reads completed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_writes_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "intervalFactor": 1, - "legendFormat": "{{device}} - Writes completed", - "refId": "B", - "step": 240 - } - ], - "title": "Disk IOps Completed", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The number of bytes read from or written to the device per second", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "Bps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 47 - }, - "id": 33, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_read_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "{{device}} - Read bytes", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_written_bytes_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Written bytes", - "refId": "B", - "step": 240 - } - ], - "title": "Disk R/W Data", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The average time for requests issued to the device to be served. This includes the time spent by the requests in queue and the time spent servicing them.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "time. read (-) / write (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 30, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 57 - }, - "id": 37, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_read_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval]) / irate(node_disk_reads_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - Read wait time avg", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_write_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval]) / irate(node_disk_writes_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}} - Write wait time avg", - "refId": "B", - "step": 240 - } - ], - "title": "Disk Average Wait Time", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The average queue length of the requests that were issued to the device", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "aqu-sz", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 57 - }, - "id": 35, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_io_time_weighted_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}}", - "refId": "A", - "step": 240 - } - ], - "title": "Average Queue Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The number of read and write requests merged per second that were queued to the device", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "I/Os", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "iops" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Read.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 67 - }, - "id": 133, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_reads_merged_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "intervalFactor": 1, - "legendFormat": "{{device}} - Read merged", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_writes_merged_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "intervalFactor": 1, - "legendFormat": "{{device}} - Write merged", - "refId": "B", - "step": 240 - } - ], - "title": "Disk R/W Merged", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Percentage of elapsed time during which I/O requests were issued to the device (bandwidth utilization for the device). Device saturation occurs when this value is close to 100% for devices serving requests serially. But for devices serving requests in parallel, such as RAID arrays and modern SSDs, this number does not reflect their performance limits.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "%util", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 30, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 67 - }, - "id": 36, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_io_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - IO", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_discard_time_seconds_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - discard", - "refId": "B", - "step": 240 - } - ], - "title": "Time Spent Doing I/Os", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "The number of outstanding requests at the instant the sample was taken. Incremented as requests are given to appropriate struct request_queue and decremented as they finish.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "Outstanding req.", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 77 - }, - "id": 34, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_disk_io_now{instance=\"$node\",job=\"$job\"}", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - IO now", - "refId": "A", - "step": 240 - } - ], - "title": "Instantaneous Queue Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "IOs", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "iops" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EAB839", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#6ED0E0", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EF843C", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#584477", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda2_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BA43A9", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sda3_.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F4D598", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#0A50A1", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdb3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0752D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#962D82", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#614D93", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdc3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#9AC48A", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#65C5DB", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9934E", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#EA6460", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde1.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E0F9D7", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sdd2.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#FCEACA", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*sde3.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9E2D2", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 77 - }, - "id": 301, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_discards_completed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 4, - "legendFormat": "{{device}} - Discards completed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_disk_discards_merged_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}} - Discards merged", - "refId": "B", - "step": 240 - } - ], - "title": "Disk IOps Discards completed / merged", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Storage Disk", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 29 - }, - "id": 271, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 62 - }, - "id": 43, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_avail_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Available", - "metric": "", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_free_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": true, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Free", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_size_bytes{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": true, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Size", - "refId": "C", - "step": 240 - } - ], - "title": "Filesystem space available", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "file nodes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 62 - }, - "id": 41, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_files_free{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Free file nodes", - "refId": "A", - "step": 240 - } - ], - "title": "File Nodes Free", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "files", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 72 - }, - "id": 28, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filefd_maximum{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "Max open files", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filefd_allocated{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "Open files", - "refId": "B", - "step": 240 - } - ], - "title": "File Descriptor", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "file Nodes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 72 - }, - "id": 219, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_files{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - File nodes total", - "refId": "A", - "step": 240 - } - ], - "title": "File Nodes Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "max": 1, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "/ ReadOnly" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 82 - }, - "id": 44, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_readonly{instance=\"$node\",job=\"$job\",device!~'rootfs'}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - ReadOnly", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_filesystem_device_error{instance=\"$node\",job=\"$job\",device!~'rootfs',fstype!~'tmpfs'}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{mountpoint}} - Device error", - "refId": "B", - "step": 240 - } - ], - "title": "Filesystem in ReadOnly / Error", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Storage Filesystem", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 30 - }, - "id": 272, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "receive_packets_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "receive_packets_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "transmit_packets_eth0" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "transmit_packets_lo" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#E24D42", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 47 - }, - "id": 60, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_packets_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_packets_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic by Packets", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 47 - }, - "id": 142, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_errs_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive errors", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_errs_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit errors", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Errors", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 57 - }, - "id": 143, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_drop_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive drop", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_drop_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit drop", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Drop", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 57 - }, - "id": 141, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_compressed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive compressed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_compressed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit compressed", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Compressed", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 67 - }, - "id": 146, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_multicast_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive multicast", - "refId": "A", - "step": 240 - } - ], - "title": "Network Traffic Multicast", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 67 - }, - "id": 144, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_fifo_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive fifo", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_fifo_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit fifo", - "refId": "B", - "step": 240 - } - ], - "title": "Network Traffic Fifo", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "pps" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 77 - }, - "id": 145, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_receive_frame_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "intervalFactor": 1, - "legendFormat": "{{device}} - Receive frame", - "refId": "A", - "step": 240 - } - ], - "title": "Network Traffic Frame", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 77 - }, - "id": 231, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_carrier_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Statistic transmit_carrier", - "refId": "A", - "step": 240 - } - ], - "title": "Network Traffic Carrier", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Trans.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 87 - }, - "id": 232, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_network_transmit_colls_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{device}} - Transmit colls", - "refId": "A", - "step": 240 - } - ], - "title": "Network Traffic Colls", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "entries", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "NF conntrack limit" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 87 - }, - "id": 61, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_nf_conntrack_entries{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "NF conntrack entries", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_nf_conntrack_entries_limit{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "NF conntrack limit", - "refId": "B", - "step": 240 - } - ], - "title": "NF Conntrack", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "Entries", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 97 - }, - "id": 230, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_arp_entries{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{ device }} - ARP entries", - "refId": "A", - "step": 240 - } - ], - "title": "ARP Entries", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 97 - }, - "id": 288, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_mtu_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{ device }} - Bytes", - "refId": "A", - "step": 240 - } - ], - "title": "MTU", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 107 - }, - "id": 280, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_speed_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{ device }} - Speed", - "refId": "A", - "step": 240 - } - ], - "title": "Speed", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packets", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "decimals": 0, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 107 - }, - "id": 289, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_transmit_queue_length{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{ device }} - Interface transmit queue length", - "refId": "A", - "step": 240 - } - ], - "title": "Queue Length", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "packetes drop (-) / process (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Dropped.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 117 - }, - "id": 290, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_softnet_processed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{cpu}} - Processed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_softnet_dropped_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{cpu}} - Dropped", - "refId": "B", - "step": 240 - } - ], - "title": "Softnet Packets", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 117 - }, - "id": 310, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_softnet_times_squeezed_total{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "CPU {{cpu}} - Squeezed", - "refId": "A", - "step": 240 - } - ], - "title": "Softnet Out of Quota", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 127 - }, - "id": 309, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_up{operstate=\"up\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "{{interface}} - Operational state UP", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_network_carrier{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "instant": false, - "legendFormat": "{{device}} - Physical link state", - "refId": "B" - } - ], - "title": "Network Operational Status", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Network Traffic", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 31 - }, - "id": 273, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 48 - }, - "id": 63, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_alloc{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_alloc - Allocated sockets", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_inuse - Tcp sockets currently in use", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_mem{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": true, - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_mem - Used memory for tcp", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_orphan{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_orphan - Orphan sockets", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_tw{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCP_tw - Sockets waiting close", - "refId": "E", - "step": 240 - } - ], - "title": "Sockstat TCP", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 48 - }, - "id": 124, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_UDPLITE_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "UDPLITE_inuse - Udplite sockets currently in use", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_UDP_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "UDP_inuse - Udp sockets currently in use", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_UDP_mem{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "UDP_mem - Used memory for udp", - "refId": "C", - "step": 240 - } - ], - "title": "Sockstat UDP", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 58 - }, - "id": 125, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_FRAG_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "FRAG_inuse - Frag sockets currently in use", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_RAW_inuse{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "RAW_inuse - Raw sockets currently in use", - "refId": "C", - "step": 240 - } - ], - "title": "Sockstat FRAG / RAW", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "bytes", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 58 - }, - "id": 220, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_TCP_mem_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "mem_bytes - TCP sockets in that state", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_UDP_mem_bytes{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "mem_bytes - UDP sockets in that state", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_FRAG_memory{instance=\"$node\",job=\"$job\"}", - "interval": "", - "intervalFactor": 1, - "legendFormat": "FRAG_memory - Used memory for frag", - "refId": "C" - } - ], - "title": "Sockstat Memory Size", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "sockets", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 68 - }, - "id": 126, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_sockstat_sockets_used{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Sockets_used - Sockets currently in use", - "refId": "A", - "step": 240 - } - ], - "title": "Sockstat Used", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Network Sockstat", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 32 - }, - "id": 274, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "octets out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 33 - }, - "id": 221, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_IpExt_InOctets{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InOctets - Received octets", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_IpExt_OutOctets{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "intervalFactor": 1, - "legendFormat": "OutOctets - Sent octets", - "refId": "B", - "step": 240 - } - ], - "title": "Netstat IP In / Out Octets", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "datagrams", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 33 - }, - "id": 81, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true, - "width": 300 - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Ip_Forwarding{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "Forwarding - IP forwarding", - "refId": "A", - "step": 240 - } - ], - "title": "Netstat IP Forwarding", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "messages out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 43 - }, - "id": 115, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Icmp_InMsgs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InMsgs - Messages which the entity received. Note that this counter includes all those counted by icmpInErrors", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Icmp_OutMsgs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "OutMsgs - Messages which this entity attempted to send. Note that this counter includes all those counted by icmpOutErrors", - "refId": "B", - "step": 240 - } - ], - "title": "ICMP In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "messages out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 43 - }, - "id": 50, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Icmp_InErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InErrors - Messages which the entity received but determined as having ICMP-specific errors (bad ICMP checksums, bad length, etc.)", - "refId": "A", - "step": 240 - } - ], - "title": "ICMP Errors", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "datagrams out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Snd.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 53 - }, - "id": 55, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_InDatagrams{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InDatagrams - Datagrams received", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_OutDatagrams{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "OutDatagrams - Datagrams sent", - "refId": "B", - "step": 240 - } - ], - "title": "UDP In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "datagrams", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 53 - }, - "id": 109, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_InErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "InErrors - UDP Datagrams that could not be delivered to an application", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_NoPorts{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "NoPorts - UDP Datagrams received on a port with no listener", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_UdpLite_InErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "legendFormat": "InErrors Lite - UDPLite Datagrams that could not be delivered to an application", - "refId": "C" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_RcvbufErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "RcvbufErrors - UDP buffer errors received", - "refId": "D", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Udp_SndbufErrors{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "SndbufErrors - UDP buffer errors send", - "refId": "E", - "step": 240 - } - ], - "title": "UDP Errors", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "datagrams out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Out.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - }, - { - "matcher": { - "id": "byRegexp", - "options": "/.*Snd.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 63 - }, - "id": 299, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_InSegs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "instant": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "InSegs - Segments received, including those received in error. This count includes segments received on currently established connections", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_OutSegs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "OutSegs - Segments sent, including those on current connections but excluding those containing only retransmitted octets", - "refId": "B", - "step": 240 - } - ], - "title": "TCP In / Out", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 63 - }, - "id": 104, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_ListenOverflows{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "ListenOverflows - Times the listen queue of a socket overflowed", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_ListenDrops{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "ListenDrops - SYNs to LISTEN sockets ignored", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_TCPSynRetrans{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "TCPSynRetrans - SYN-SYN/ACK retransmits to break down retransmissions in SYN, fast/timeout retransmits", - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_RetransSegs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "legendFormat": "RetransSegs - Segments retransmitted - that is, the number of TCP segments transmitted containing one or more previously transmitted octets", - "refId": "D" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_InErrs{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "legendFormat": "InErrs - Segments received in error (e.g., bad TCP checksums)", - "refId": "E" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_OutRsts{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "interval": "", - "legendFormat": "OutRsts - Segments sent with RST flag", - "refId": "F" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "irate(node_netstat_TcpExt_TCPRcvQDrop{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "legendFormat": "TCPRcvQDrop - Packets meant to be queued in rcv queue but dropped because socket rcvbuf limit hit", - "range": true, - "refId": "G" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "irate(node_netstat_TcpExt_TCPOFOQueue{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "hide": false, - "interval": "", - "legendFormat": "TCPOFOQueue - TCP layer receives an out of order packet and has enough memory to queue it", - "range": true, - "refId": "H" - } - ], - "title": "TCP Errors", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "connections", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*MaxConn *./" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - }, - { - "id": "custom.fillOpacity", - "value": 0 - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 73 - }, - "id": 85, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_netstat_Tcp_CurrEstab{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "CurrEstab - TCP connections for which the current state is either ESTABLISHED or CLOSE- WAIT", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_netstat_Tcp_MaxConn{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "MaxConn - Limit on the total number of TCP connections the entity can support (Dynamic is \"-1\")", - "refId": "B", - "step": 240 - } - ], - "title": "TCP Connections", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter out (-) / in (+)", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*Sent.*/" - }, - "properties": [ - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 73 - }, - "id": 91, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_SyncookiesFailed{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "SyncookiesFailed - Invalid SYN cookies received", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_SyncookiesRecv{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "SyncookiesRecv - SYN cookies received", - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_TcpExt_SyncookiesSent{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "SyncookiesSent - SYN cookies sent", - "refId": "C", - "step": 240 - } - ], - "title": "TCP SynCookie", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "connections", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 83 - }, - "id": 82, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_ActiveOpens{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "ActiveOpens - TCP connections that have made a direct transition to the SYN-SENT state from the CLOSED state", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "irate(node_netstat_Tcp_PassiveOpens{instance=\"$node\",job=\"$job\"}[$__rate_interval])", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "PassiveOpens - TCP connections that have made a direct transition to the SYN-RCVD state from the LISTEN state", - "refId": "B", - "step": 240 - } - ], - "title": "TCP Direct Transition", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "Enable with --collector.tcpstat argument on node-exporter", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "connections", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 83 - }, - "id": 320, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_tcp_connection_states{state=\"established\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "interval": "", - "intervalFactor": 1, - "legendFormat": "established - TCP sockets in established state", - "range": true, - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_tcp_connection_states{state=\"fin_wait2\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "fin_wait2 - TCP sockets in fin_wait2 state", - "range": true, - "refId": "B", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_tcp_connection_states{state=\"listen\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "listen - TCP sockets in listen state", - "range": true, - "refId": "C", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "editorMode": "code", - "expr": "node_tcp_connection_states{state=\"time_wait\",instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "time_wait - TCP sockets in time_wait state", - "range": true, - "refId": "D", - "step": 240 - } - ], - "title": "TCP Stat", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Network Netstat", - "type": "row" - }, - { - "collapsed": true, - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 33 - }, - "id": 279, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "seconds", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 0, - "y": 66 - }, - "id": 40, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_scrape_collector_duration_seconds{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{collector}} - Scrape duration", - "refId": "A", - "step": 240 - } - ], - "title": "Node Exporter Scrape Time", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "counter", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 20, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byRegexp", - "options": "/.*error.*/" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F2495C", - "mode": "fixed" - } - }, - { - "id": "custom.transform", - "value": "negative-Y" - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 12, - "x": 12, - "y": 66 - }, - "id": 157, - "links": [], - "options": { - "legend": { - "calcs": [ - "mean", - "lastNotNull", - "max", - "min" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "pluginVersion": "9.2.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_scrape_collector_success{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{collector}} - Scrape success", - "refId": "A", - "step": 240 - }, - { - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "expr": "node_textfile_scrape_error{instance=\"$node\",job=\"$job\"}", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 1, - "legendFormat": "{{collector}} - Scrape textfile error (1 = true)", - "refId": "B", - "step": 240 - } - ], - "title": "Node Exporter Scrape", - "type": "timeseries" - } - ], - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "000000001" - }, - "refId": "A" - } - ], - "title": "Node Exporter", - "type": "row" - } - ], - "refresh": "1m", - "revision": 1, - "schemaVersion": 39, - "tags": [ - "linux" - ], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": "default", - "value": "default" - }, - "hide": 0, - "includeAll": false, - "label": "Datasource", - "multi": false, - "name": "datasource", - "options": [], - "query": "prometheus", - "queryValue": "", - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "type": "datasource" - }, - { - "current": { - "selected": false, - "text": "node-exporter", - "value": "node-exporter" - }, - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "definition": "", - "hide": 0, - "includeAll": false, - "label": "Job", - "multi": false, - "name": "job", - "options": [], - "query": { - "query": "label_values(node_uname_info, job)", - "refId": "Prometheus-job-Variable-Query" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "tagValuesQuery": "", - "tagsQuery": "", - "type": "query", - "useTags": false - }, - { - "current": { - "selected": false, - "text": "node_exporter:9100", - "value": "node_exporter:9100" - }, - "datasource": { - "type": "prometheus", - "uid": "${datasource}" - }, - "definition": "label_values(node_uname_info{job=\"$job\"}, instance)", - "hide": 0, - "includeAll": false, - "label": "Host", - "multi": false, - "name": "node", - "options": [], - "query": { - "query": "label_values(node_uname_info{job=\"$job\"}, instance)", - "refId": "Prometheus-node-Variable-Query" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "tagValuesQuery": "", - "tagsQuery": "", - "type": "query", - "useTags": false - }, - { - "current": { - "selected": false, - "text": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+", - "value": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+" - }, - "hide": 2, - "includeAll": false, - "multi": false, - "name": "diskdevices", - "options": [ - { - "selected": true, - "text": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+", - "value": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+" - } - ], - "query": "[a-z]+|nvme[0-9]+n[0-9]+|mmcblk[0-9]+", - "skipUrlSync": false, - "type": "custom" - } - ] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": { - "refresh_intervals": [ - "5s", - "10s", - "30s", - "1m", - "5m", - "15m", - "30m", - "1h", - "2h", - "1d" - ], - "time_options": [ - "5m", - "15m", - "1h", - "6h", - "12h", - "24h", - "2d", - "7d", - "30d" - ] - }, - "timezone": "browser", - "title": "Node Exporter Full", - "uid": "rYdddlPWk", - "version": 3, - "weekStart": "" -} \ No newline at end of file diff --git a/compose/jaeger/jaeger-ui.json b/compose/jaeger/jaeger-ui.json deleted file mode 100644 index 0f06f2fcda..0000000000 --- a/compose/jaeger/jaeger-ui.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "monitor": { - "menuEnabled": true - }, - "dependencies": { - "menuEnabled": true - } - } \ No newline at end of file diff --git a/compose/loki/loki.yml b/compose/loki/loki.yml deleted file mode 100644 index a63d16c7ff..0000000000 --- a/compose/loki/loki.yml +++ /dev/null @@ -1,44 +0,0 @@ -auth_enabled: false - -limits_config: - allow_structured_metadata: true - -server: - http_listen_port: 3100 - grpc_listen_port: 9096 - -common: - instance_addr: localhost - path_prefix: /tmp/loki - storage: - filesystem: - chunks_directory: /tmp/loki/chunks - rules_directory: /tmp/loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -query_range: - results_cache: - cache: - embedded_cache: - enabled: true - max_size_mb: 100 - -schema_config: - configs: - - from: 2020-10-24 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h - -storage_config: - boltdb: - directory: /tmp/loki/index - - filesystem: - directory: /tmp/loki/chunks diff --git a/compose/otel-collector/otel-config.yaml b/compose/otel-collector/otel-config.yaml deleted file mode 100644 index 191edae04c..0000000000 --- a/compose/otel-collector/otel-config.yaml +++ /dev/null @@ -1,78 +0,0 @@ -extensions: - health_check: - zpages: - endpoint: 0.0.0.0:55679 - -receivers: - otlp: - protocols: - grpc: - endpoint: 0.0.0.0:4317 - http: - endpoint: 0.0.0.0:4318 - zipkin: - endpoint: 0.0.0.0:9411 - -processors: - batch: - - resource: - attributes: - - action: insert - key: service_name - from_attribute: service.name - - action: insert - key: loki.resource.labels - value: service_name - -exporters: - debug: - verbosity: detailed - file/traces: - path: /log/otel/traces.log - file/metrics: - path: /log/otel/metrics.log - file/logs: - path: /log/otel/logs.log - otlp: - endpoint: "${JAEGER_ENDPOINT}" - tls: - insecure: true - prometheus: - endpoint: "0.0.0.0:8889" - otlphttp: - endpoint: "http://loki:3100/otlp" - -service: - telemetry: - logs: - level: debug - pipelines: - traces: - receivers: - - otlp - - zipkin - processors: [batch] - exporters: - - debug - - file/traces - - otlp - metrics: - receivers: - - otlp - processors: [batch] - exporters: - - debug - - file/metrics - - prometheus - logs: - receivers: - - otlp - processors: [batch, resource] - exporters: - - debug - - file/logs - - otlphttp - extensions: - - health_check - - zpages \ No newline at end of file diff --git a/compose/prometheus/prometheus.yml b/compose/prometheus/prometheus.yml deleted file mode 100644 index 647cfda1af..0000000000 --- a/compose/prometheus/prometheus.yml +++ /dev/null @@ -1,24 +0,0 @@ -global: - scrape_interval: 10s - -scrape_configs: - - job_name: 'fullstackhero.api' - static_configs: - - targets: ['host.docker.internal:5000'] - - - job_name: otel - static_configs: - - targets: - - 'otel-collector:8889' - - - job_name: otel-collector - static_configs: - - targets: - - 'otel-collector:8888' - - - job_name: 'node-exporter' - # Override the global default and scrape targets from this job every 5 seconds. - scrape_interval: 5s - static_configs: - - targets: - - 'node_exporter:9100' \ No newline at end of file diff --git a/terraform/modules/s3/main.tf b/deploy/docker/.gitkeep similarity index 100% rename from terraform/modules/s3/main.tf rename to deploy/docker/.gitkeep diff --git a/terraform/modules/s3/variables.tf b/deploy/dokploy/.gitkeep similarity index 100% rename from terraform/modules/s3/variables.tf rename to deploy/dokploy/.gitkeep diff --git a/deploy/terraform/.gitignore b/deploy/terraform/.gitignore new file mode 100644 index 0000000000..cdaf05dace --- /dev/null +++ b/deploy/terraform/.gitignore @@ -0,0 +1,25 @@ +# Terraform local state +*.tfstate +*.tfstate.backup +*.tfstate.*.backup + +# Terraform directories +.terraform/ + +# Crash logs +crash.log +crash.*.log + +# Override files (local developer overrides) +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Sensitive variable files +*.auto.tfvars +secret.tfvars + +# OS files +.DS_Store +Thumbs.db diff --git a/deploy/terraform/.terraform-version b/deploy/terraform/.terraform-version new file mode 100644 index 0000000000..850e742404 --- /dev/null +++ b/deploy/terraform/.terraform-version @@ -0,0 +1 @@ +1.14.0 diff --git a/deploy/terraform/apps/playground/README.md b/deploy/terraform/apps/playground/README.md new file mode 100644 index 0000000000..4aab9c66ee --- /dev/null +++ b/deploy/terraform/apps/playground/README.md @@ -0,0 +1,6 @@ +# Playground App Stack +Terraform stack for the Playground API. Uses shared modules from `../../modules`. + +- Env/region stacks live under `envs///` (backend.tf + *.tfvars + main.tf). +- App composition lives under `app_stack/` (wiring ECS services, ALB, RDS, Redis, S3). +- Images are built from GitHub Actions, pushed to ECR, and referenced in tfvars. diff --git a/deploy/terraform/apps/playground/app_stack/main.tf b/deploy/terraform/apps/playground/app_stack/main.tf new file mode 100644 index 0000000000..9819f36f28 --- /dev/null +++ b/deploy/terraform/apps/playground/app_stack/main.tf @@ -0,0 +1,454 @@ +################################################################################ +# Local Variables +################################################################################ + +locals { + common_tags = merge( + { + Environment = var.environment + Project = "dotnet-starter-kit" + ManagedBy = "terraform" + }, + var.owner != null ? { Owner = var.owner } : {} + ) + aspnetcore_environment = var.environment == "dev" ? "Development" : "Production" + name_prefix = "${var.environment}-${var.region}" + + # Container images constructed from registry, name, and tag + api_container_image = "${var.container_registry}/${var.api_image_name}:${var.container_image_tag}" +} + +################################################################################ +# Network +################################################################################ + +module "network" { + source = "../../../modules/network" + + name = local.name_prefix + cidr_block = var.vpc_cidr_block + + public_subnets = var.public_subnets + private_subnets = var.private_subnets + + enable_nat_gateway = var.enable_nat_gateway + single_nat_gateway = var.single_nat_gateway + + enable_s3_endpoint = var.enable_s3_endpoint + enable_ecr_endpoints = var.enable_ecr_endpoints + enable_logs_endpoint = var.enable_logs_endpoint + enable_secretsmanager_endpoint = var.enable_secretsmanager_endpoint + + enable_flow_logs = var.enable_flow_logs + flow_logs_retention_days = var.flow_logs_retention_days + + tags = local.common_tags +} + +################################################################################ +# ECS Cluster +################################################################################ + +module "ecs_cluster" { + source = "../../../modules/ecs_cluster" + + name = "${local.name_prefix}-cluster" + container_insights = var.enable_container_insights + tags = local.common_tags +} + +################################################################################ +# ALB Security Group +################################################################################ + +resource "aws_security_group" "alb" { + name = "${local.name_prefix}-alb" + description = "ALB security group" + vpc_id = module.network.vpc_id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "alb_http" { + security_group_id = aws_security_group.alb.id + description = "HTTP from anywhere" + from_port = 80 + to_port = 80 + ip_protocol = "tcp" + cidr_ipv4 = "0.0.0.0/0" +} + +resource "aws_vpc_security_group_ingress_rule" "alb_https" { + count = var.enable_https ? 1 : 0 + security_group_id = aws_security_group.alb.id + description = "HTTPS from anywhere" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + cidr_ipv4 = "0.0.0.0/0" +} + +resource "aws_vpc_security_group_egress_rule" "alb_all" { + security_group_id = aws_security_group.alb.id + description = "All outbound traffic" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" +} + +################################################################################ +# Application Load Balancer +################################################################################ + +module "alb" { + source = "../../../modules/alb" + + name = "${local.name_prefix}-alb" + subnet_ids = module.network.public_subnet_ids + security_group_id = aws_security_group.alb.id + + enable_https = var.enable_https + certificate_arn = var.acm_certificate_arn + ssl_policy = var.ssl_policy + + enable_deletion_protection = var.alb_enable_deletion_protection + idle_timeout = var.alb_idle_timeout + + access_logs_bucket = var.alb_access_logs_bucket + access_logs_prefix = var.alb_access_logs_prefix + + tags = local.common_tags +} + +################################################################################ +# WAF (Web Application Firewall) +################################################################################ + +module "waf" { + count = var.enable_waf ? 1 : 0 + source = "../../../modules/waf" + + name = "${local.name_prefix}-waf" + alb_arn = module.alb.arn + + rate_limit = var.waf_rate_limit + enable_sqli_rule_set = var.waf_enable_sqli_rule_set + enable_ip_reputation_rule_set = var.waf_enable_ip_reputation_rule_set + enable_anonymous_ip_rule_set = var.waf_enable_anonymous_ip_rule_set + enable_linux_rule_set = var.waf_enable_linux_rule_set + enable_logging = var.waf_enable_logging + + tags = local.common_tags +} + +################################################################################ +# S3 Bucket for Application Data +################################################################################ + +module "app_s3" { + source = "../../../modules/s3_bucket" + + name = var.app_s3_bucket_name + force_destroy = var.environment == "dev" ? true : false + versioning_enabled = var.app_s3_versioning_enabled + + enable_public_read = var.app_s3_enable_public_read + public_read_prefix = var.app_s3_public_read_prefix + + enable_cloudfront = var.app_s3_enable_cloudfront + cloudfront_price_class = var.app_s3_cloudfront_price_class + cloudfront_aliases = var.app_s3_cloudfront_aliases + cloudfront_acm_certificate_arn = var.app_s3_cloudfront_certificate_arn + + enable_intelligent_tiering = var.app_s3_enable_intelligent_tiering + lifecycle_rules = var.app_s3_lifecycle_rules + + cors_rules = var.enable_https && var.domain_name != null ? [ + { + allowed_methods = ["GET", "PUT", "POST"] + allowed_origins = ["https://${var.domain_name}"] + allowed_headers = ["*"] + expose_headers = ["ETag"] + max_age_seconds = 3600 + } + ] : [] + + tags = local.common_tags +} + +################################################################################ +# IAM Role for API Task +################################################################################ + +data "aws_iam_policy_document" "api_task_assume" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "api_task_s3" { + statement { + sid = "AllowBucketReadWrite" + actions = [ + "s3:PutObject", + "s3:DeleteObject", + "s3:GetObject", + "s3:ListBucket" + ] + resources = [ + module.app_s3.bucket_arn, + "${module.app_s3.bucket_arn}/*" + ] + } +} + +# Note: The api_task role doesn't need secrets access because the connection string +# is injected via ECS secrets (handled by task execution role in ecs_service module). +# This policy document is kept for potential future runtime secret access needs. +data "aws_iam_policy_document" "api_task_secrets" { + count = var.db_manage_master_user_password ? 1 : 0 + + statement { + sid = "AllowSecretsAccess" + actions = [ + "secretsmanager:GetSecretValue" + ] + resources = [ + aws_secretsmanager_secret.db_connection_string[0].arn + ] + } +} + +resource "aws_iam_role" "api_task" { + name = "${var.environment}-api-task" + assume_role_policy = data.aws_iam_policy_document.api_task_assume.json + tags = local.common_tags +} + +resource "aws_iam_role_policy" "api_task_s3" { + name = "${var.environment}-api-task-s3" + role = aws_iam_role.api_task.id + policy = data.aws_iam_policy_document.api_task_s3.json +} + +resource "aws_iam_role_policy" "api_task_secrets" { + count = var.db_manage_master_user_password ? 1 : 0 + name = "${var.environment}-api-task-secrets" + role = aws_iam_role.api_task.id + policy = data.aws_iam_policy_document.api_task_secrets[0].json +} + +################################################################################ +# RDS PostgreSQL +################################################################################ + +module "rds" { + source = "../../../modules/rds_postgres" + + name = "${local.name_prefix}-postgres" + vpc_id = module.network.vpc_id + vpc_cidr_block = var.vpc_cidr_block + subnet_ids = module.network.private_subnet_ids + + # Use CIDR block to allow access from private subnets (ECS services) + allowed_cidr_blocks = [var.vpc_cidr_block] + + db_name = var.db_name + username = var.db_username + + manage_master_user_password = var.db_manage_master_user_password + password = var.db_manage_master_user_password ? null : var.db_password + + instance_class = var.db_instance_class + allocated_storage = var.db_allocated_storage + max_allocated_storage = var.db_max_allocated_storage + storage_type = var.db_storage_type + engine_version = var.db_engine_version + + multi_az = var.db_multi_az + backup_retention_period = var.db_backup_retention_period + deletion_protection = var.db_deletion_protection + skip_final_snapshot = var.environment == "dev" ? true : false + final_snapshot_identifier = var.environment != "dev" ? "${local.name_prefix}-postgres-final" : null + + performance_insights_enabled = var.db_enable_performance_insights + monitoring_interval = var.db_enable_enhanced_monitoring ? var.db_monitoring_interval : 0 + + create_parameter_group = var.db_create_parameter_group + parameters = var.db_parameters + + tags = local.common_tags +} + +################################################################################ +# Connection String Secret (for managed password) +# When using AWS-managed password, we need to create a separate secret that +# contains the full connection string, constructed using the password from +# the RDS-managed secret. +################################################################################ + +# Read the RDS-managed secret to get the password +data "aws_secretsmanager_secret_version" "rds_password" { + count = var.db_manage_master_user_password ? 1 : 0 + secret_id = module.rds.secret_arn +} + +# Create a secret for the full connection string +resource "aws_secretsmanager_secret" "db_connection_string" { + count = var.db_manage_master_user_password ? 1 : 0 + + name = "${var.environment}-db-connection-string" + description = "Full PostgreSQL connection string for .NET application" + + tags = local.common_tags +} + +resource "aws_secretsmanager_secret_version" "db_connection_string" { + count = var.db_manage_master_user_password ? 1 : 0 + secret_id = aws_secretsmanager_secret.db_connection_string[0].id + + secret_string = "Host=${module.rds.endpoint};Port=${module.rds.port};Database=${var.db_name};Username=${var.db_username};Password=${jsondecode(data.aws_secretsmanager_secret_version.rds_password[0].secret_string)["password"]};Pooling=true;SSL Mode=Require;Trust Server Certificate=true;" +} + +locals { + # Connection string for non-managed password (directly in env var) + # Only constructed when db_password is provided (i.e., not using managed password) + db_connection_string_plain = var.db_manage_master_user_password ? "" : "Host=${module.rds.endpoint};Port=${module.rds.port};Database=${var.db_name};Username=${var.db_username};Password=${var.db_password};Pooling=true;SSL Mode=Require;Trust Server Certificate=true;" +} + +################################################################################ +# ElastiCache Redis +################################################################################ + +module "redis" { + source = "../../../modules/elasticache_redis" + + name = "${local.name_prefix}-redis" + vpc_id = module.network.vpc_id + vpc_cidr_block = var.vpc_cidr_block + subnet_ids = module.network.private_subnet_ids + + # Use CIDR block to allow access from private subnets (ECS services) + allowed_cidr_blocks = [var.vpc_cidr_block] + + node_type = var.redis_node_type + num_cache_clusters = var.redis_num_cache_clusters + engine_version = var.redis_engine_version + automatic_failover_enabled = var.redis_automatic_failover_enabled + transit_encryption_enabled = var.redis_transit_encryption_enabled + + tags = local.common_tags +} + +################################################################################ +# API ECS Service +################################################################################ + +module "api_service" { + source = "../../../modules/ecs_service" + + name = "${var.environment}-api" + region = var.region + cluster_arn = module.ecs_cluster.arn + container_image = local.api_container_image + container_port = var.api_container_port + cpu = var.api_cpu + memory = var.api_memory + desired_count = var.api_desired_count + + vpc_id = module.network.vpc_id + vpc_cidr_block = module.network.vpc_cidr_block + subnet_ids = module.network.private_subnet_ids + assign_public_ip = false + + listener_arn = var.enable_https ? module.alb.https_listener_arn : module.alb.http_listener_arn + listener_rule_priority = 10 + path_patterns = ["/api/*", "/scalar*", "/health*", "/swagger*", "/openapi*"] + + health_check_path = "/health/live" + health_check_healthy_threshold = var.api_health_check_healthy_threshold + deregistration_delay = var.api_deregistration_delay + + task_role_arn = aws_iam_role.api_task.arn + + enable_circuit_breaker = var.api_enable_circuit_breaker + use_fargate_spot = var.api_use_fargate_spot + + # Auto-scaling + enable_autoscaling = var.api_enable_autoscaling + autoscaling_min_capacity = var.api_autoscaling_min_capacity + autoscaling_max_capacity = var.api_autoscaling_max_capacity + autoscaling_cpu_target = var.api_autoscaling_cpu_target + + # When using managed password, connection string comes from secrets + # When not using managed password, connection string is set directly in env vars + environment_variables = merge( + { + ASPNETCORE_ENVIRONMENT = local.aspnetcore_environment + CachingOptions__Redis = module.redis.connection_string + Storage__Provider = "s3" + Storage__S3__Bucket = var.app_s3_bucket_name + Storage__S3__PublicBaseUrl = module.app_s3.cloudfront_domain_name != "" ? "https://${module.app_s3.cloudfront_domain_name}" : "" + }, + # Only set connection string in env vars when NOT using managed password + var.db_manage_master_user_password ? {} : { + DatabaseOptions__ConnectionString = local.db_connection_string_plain + }, + var.enable_https && var.domain_name != null ? { + OriginOptions__OriginUrl = "https://${var.domain_name}" + CorsOptions__AllowedOrigins__0 = "https://${var.domain_name}" + } : { + OriginOptions__OriginUrl = "http://${module.alb.dns_name}" + CorsOptions__AllowedOrigins__0 = "http://${module.alb.dns_name}" + }, + var.api_extra_environment_variables + ) + + # When using managed password, inject the full connection string from Secrets Manager + secrets = var.db_manage_master_user_password ? [ + { + name = "DatabaseOptions__ConnectionString" + valueFrom = aws_secretsmanager_secret.db_connection_string[0].arn + } + ] : [] + + tags = local.common_tags +} + +################################################################################ +# CloudWatch Alarms +################################################################################ + +module "alarms" { + count = var.enable_alarms ? 1 : 0 + source = "../../../modules/cloudwatch_alarms" + + name = local.name_prefix + alarm_email_addresses = var.alarm_email_addresses + + ecs_services = { + api = { + cluster_name = module.ecs_cluster.name + service_name = module.api_service.service_name + } + } + + rds_instance_identifier = module.rds.identifier + redis_replication_group_id = module.redis.replication_group_id + alb_arn_suffix = module.alb.arn_suffix + + alb_target_group_arns = { + api = { + target_group_arn_suffix = module.api_service.target_group_arn_suffix + } + } + + tags = local.common_tags +} + diff --git a/deploy/terraform/apps/playground/app_stack/outputs.tf b/deploy/terraform/apps/playground/app_stack/outputs.tf new file mode 100644 index 0000000000..a3553076dd --- /dev/null +++ b/deploy/terraform/apps/playground/app_stack/outputs.tf @@ -0,0 +1,146 @@ +################################################################################ +# Network Outputs +################################################################################ + +output "vpc_id" { + description = "VPC ID." + value = module.network.vpc_id +} + +output "vpc_cidr_block" { + description = "VPC CIDR block." + value = module.network.vpc_cidr_block +} + +output "public_subnet_ids" { + description = "Public subnet IDs." + value = module.network.public_subnet_ids +} + +output "private_subnet_ids" { + description = "Private subnet IDs." + value = module.network.private_subnet_ids +} + +################################################################################ +# ALB Outputs +################################################################################ + +output "alb_arn" { + description = "ALB ARN." + value = module.alb.arn +} + +output "alb_dns_name" { + description = "ALB DNS name." + value = module.alb.dns_name +} + +output "alb_zone_id" { + description = "ALB hosted zone ID." + value = module.alb.zone_id +} + +################################################################################ +# Application URLs +################################################################################ + +output "api_url" { + description = "API URL." + value = var.enable_https && var.domain_name != null ? "https://${var.domain_name}/api" : "http://${module.alb.dns_name}/api" +} + +################################################################################ +# ECS Outputs +################################################################################ + +output "ecs_cluster_id" { + description = "ECS cluster ID." + value = module.ecs_cluster.id +} + +output "ecs_cluster_arn" { + description = "ECS cluster ARN." + value = module.ecs_cluster.arn +} + +output "api_service_name" { + description = "API ECS service name." + value = module.api_service.service_name +} + +################################################################################ +# Database Outputs +################################################################################ + +output "rds_endpoint" { + description = "RDS endpoint." + value = module.rds.endpoint +} + +output "rds_port" { + description = "RDS port." + value = module.rds.port +} + +output "rds_secret_arn" { + description = "RDS Secrets Manager secret ARN (if manage_master_user_password is true)." + value = module.rds.secret_arn +} + +################################################################################ +# Redis Outputs +################################################################################ + +output "redis_endpoint" { + description = "Redis primary endpoint address." + value = module.redis.primary_endpoint_address +} + +output "redis_connection_string" { + description = "Redis connection string for .NET applications." + value = module.redis.connection_string + sensitive = true +} + +################################################################################ +# S3 Outputs +################################################################################ + +output "s3_bucket_name" { + description = "S3 bucket name." + value = module.app_s3.bucket_name +} + +output "s3_bucket_arn" { + description = "S3 bucket ARN." + value = module.app_s3.bucket_arn +} + +output "s3_cloudfront_domain" { + description = "CloudFront distribution domain name." + value = module.app_s3.cloudfront_domain_name +} + +output "s3_cloudfront_distribution_id" { + description = "CloudFront distribution ID." + value = module.app_s3.cloudfront_distribution_id +} + +################################################################################ +# WAF Outputs +################################################################################ + +output "waf_web_acl_arn" { + description = "WAF Web ACL ARN (if WAF enabled)." + value = var.enable_waf ? module.waf[0].web_acl_arn : null +} + +################################################################################ +# Alarm Outputs +################################################################################ + +output "alarm_sns_topic_arn" { + description = "SNS topic ARN for alarm notifications (if alarms enabled)." + value = var.enable_alarms ? module.alarms[0].sns_topic_arn : null +} diff --git a/deploy/terraform/apps/playground/app_stack/variables.tf b/deploy/terraform/apps/playground/app_stack/variables.tf new file mode 100644 index 0000000000..c625bde7bb --- /dev/null +++ b/deploy/terraform/apps/playground/app_stack/variables.tf @@ -0,0 +1,566 @@ +################################################################################ +# General Variables +################################################################################ + +variable "environment" { + type = string + description = "Environment name (dev, staging, prod)." + + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be dev, staging, or prod." + } +} + +variable "region" { + type = string + description = "AWS region." + + validation { + condition = can(regex("^[a-z]{2}-[a-z]+-\\d$", var.region)) + error_message = "Region must be a valid AWS region identifier (e.g., us-east-1)." + } +} + +variable "owner" { + type = string + description = "Owner or team responsible for this infrastructure (used in tags for cost allocation and auditing)." + default = null +} + +variable "domain_name" { + type = string + description = "Domain name for the application (optional)." + default = null +} + +################################################################################ +# Network Variables +################################################################################ + +variable "vpc_cidr_block" { + type = string + description = "CIDR block for the VPC." + + validation { + condition = can(cidrnetmask(var.vpc_cidr_block)) + error_message = "VPC CIDR block must be a valid CIDR notation." + } +} + +variable "public_subnets" { + description = "Public subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "private_subnets" { + description = "Private subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "enable_nat_gateway" { + type = bool + description = "Enable NAT Gateway for private subnets." + default = true +} + +variable "single_nat_gateway" { + type = bool + description = "Use a single NAT Gateway (cost savings for non-prod)." + default = true +} + +variable "enable_s3_endpoint" { + type = bool + description = "Enable S3 VPC Gateway Endpoint." + default = true +} + +variable "enable_ecr_endpoints" { + type = bool + description = "Enable ECR VPC Interface Endpoints." + default = true +} + +variable "enable_logs_endpoint" { + type = bool + description = "Enable CloudWatch Logs VPC Interface Endpoint." + default = true +} + +variable "enable_secretsmanager_endpoint" { + type = bool + description = "Enable Secrets Manager VPC Interface Endpoint." + default = false +} + +variable "enable_flow_logs" { + type = bool + description = "Enable VPC Flow Logs." + default = false +} + +variable "flow_logs_retention_days" { + type = number + description = "Flow logs retention period in days." + default = 14 +} + +################################################################################ +# ECS Cluster Variables +################################################################################ + +variable "enable_container_insights" { + type = bool + description = "Enable Container Insights for ECS cluster." + default = true +} + +################################################################################ +# ALB Variables +################################################################################ + +variable "enable_https" { + type = bool + description = "Enable HTTPS on the ALB." + default = false +} + +variable "acm_certificate_arn" { + type = string + description = "ACM certificate ARN for HTTPS (required if enable_https is true)." + default = null +} + +variable "ssl_policy" { + type = string + description = "SSL policy for the HTTPS listener." + default = "ELBSecurityPolicy-TLS13-1-2-2021-06" +} + +variable "alb_enable_deletion_protection" { + type = bool + description = "Enable deletion protection for the ALB." + default = false +} + +variable "alb_idle_timeout" { + type = number + description = "ALB idle timeout in seconds." + default = 60 +} + +variable "alb_access_logs_bucket" { + type = string + description = "S3 bucket for ALB access logs." + default = null +} + +variable "alb_access_logs_prefix" { + type = string + description = "S3 prefix for ALB access logs." + default = "alb-logs" +} + +################################################################################ +# S3 Variables +################################################################################ + +variable "app_s3_bucket_name" { + type = string + description = "S3 bucket name for application data (must be globally unique)." + + validation { + condition = can(regex("^[a-z0-9][a-z0-9.-]*[a-z0-9]$", var.app_s3_bucket_name)) + error_message = "Bucket name must contain only lowercase letters, numbers, hyphens, and periods." + } +} + +variable "app_s3_versioning_enabled" { + type = bool + description = "Enable versioning on the S3 bucket." + default = true +} + +variable "app_s3_enable_public_read" { + type = bool + description = "Whether to enable public read on uploads prefix." + default = false +} + +variable "app_s3_public_read_prefix" { + type = string + description = "Prefix to allow public read (e.g., uploads/)." + default = "uploads/" +} + +variable "app_s3_enable_cloudfront" { + type = bool + description = "Whether to provision a CloudFront distribution for the app bucket." + default = true +} + +variable "app_s3_cloudfront_price_class" { + type = string + description = "Price class for CloudFront." + default = "PriceClass_100" +} + +variable "app_s3_cloudfront_aliases" { + type = list(string) + description = "Alternative domain names (CNAMEs) for CloudFront." + default = [] +} + +variable "app_s3_cloudfront_certificate_arn" { + type = string + description = "ACM certificate ARN for CloudFront (required if using aliases)." + default = null +} + +variable "app_s3_enable_intelligent_tiering" { + type = bool + description = "Enable automatic transition to Intelligent-Tiering." + default = false +} + +variable "app_s3_lifecycle_rules" { + type = list(object({ + id = string + enabled = optional(bool, true) + prefix = optional(string, "") + expiration_days = optional(number) + noncurrent_version_expiration_days = optional(number) + abort_incomplete_multipart_upload_days = optional(number, 7) + transitions = optional(list(object({ + days = number + storage_class = string + })), []) + noncurrent_version_transitions = optional(list(object({ + days = number + storage_class = string + })), []) + })) + description = "List of lifecycle rules for the S3 bucket." + default = [] +} + +################################################################################ +# Database Variables +################################################################################ + +variable "db_name" { + type = string + description = "Database name." +} + +variable "db_username" { + type = string + description = "Database admin username." +} + +variable "db_password" { + type = string + description = "Database admin password (not used if manage_master_user_password is true)." + sensitive = true + default = null +} + +variable "db_manage_master_user_password" { + type = bool + description = "Let AWS manage the master user password in Secrets Manager." + default = false +} + +variable "db_instance_class" { + type = string + description = "RDS instance class. Use Graviton (t4g) for best price-performance." + default = "db.t4g.micro" +} + +variable "db_allocated_storage" { + type = number + description = "Allocated storage in GB." + default = 20 +} + +variable "db_max_allocated_storage" { + type = number + description = "Maximum allocated storage for autoscaling in GB." + default = 100 +} + +variable "db_storage_type" { + type = string + description = "Storage type (gp2, gp3, io1)." + default = "gp3" +} + +variable "db_engine_version" { + type = string + description = "PostgreSQL engine version." + default = "17" +} + +variable "db_multi_az" { + type = bool + description = "Enable Multi-AZ deployment." + default = false +} + +variable "db_backup_retention_period" { + type = number + description = "Backup retention period in days." + default = 7 +} + +variable "db_deletion_protection" { + type = bool + description = "Enable deletion protection." + default = false +} + +variable "db_enable_performance_insights" { + type = bool + description = "Enable Performance Insights." + default = false +} + +variable "db_enable_enhanced_monitoring" { + type = bool + description = "Enable Enhanced Monitoring." + default = false +} + +variable "db_monitoring_interval" { + type = number + description = "Enhanced Monitoring interval in seconds." + default = 60 +} + +variable "db_create_parameter_group" { + type = bool + description = "Create a custom PostgreSQL parameter group with production-tuned settings." + default = false +} + +variable "db_parameters" { + type = list(object({ + name = string + value = string + apply_method = optional(string, "immediate") + })) + description = "Custom PostgreSQL parameters." + default = [] +} + +################################################################################ +# Redis Variables +################################################################################ + +variable "redis_node_type" { + type = string + description = "ElastiCache node type. Use Graviton (t4g) for best price-performance." + default = "cache.t4g.micro" +} + +variable "redis_num_cache_clusters" { + type = number + description = "Number of cache clusters (nodes)." + default = 1 +} + +variable "redis_engine_version" { + type = string + description = "Redis engine version." + default = "7.2" +} + +variable "redis_automatic_failover_enabled" { + type = bool + description = "Enable automatic failover (requires num_cache_clusters >= 2)." + default = false +} + +variable "redis_transit_encryption_enabled" { + type = bool + description = "Enable in-transit encryption." + default = true +} + +################################################################################ +# WAF Variables +################################################################################ + +variable "enable_waf" { + type = bool + description = "Enable AWS WAF for ALB protection." + default = true +} + +variable "waf_rate_limit" { + type = number + description = "Maximum requests per 5-minute period per IP." + default = 2000 +} + +variable "waf_enable_sqli_rule_set" { + type = bool + description = "Enable SQL injection protection." + default = true +} + +variable "waf_enable_ip_reputation_rule_set" { + type = bool + description = "Enable IP reputation protection." + default = true +} + +variable "waf_enable_anonymous_ip_rule_set" { + type = bool + description = "Enable anonymous IP blocking." + default = false +} + +variable "waf_enable_linux_rule_set" { + type = bool + description = "Enable Linux OS protection rules." + default = true +} + +variable "waf_enable_logging" { + type = bool + description = "Enable WAF logging to CloudWatch." + default = true +} + +################################################################################ +# CloudWatch Alarms Variables +################################################################################ + +variable "enable_alarms" { + type = bool + description = "Enable CloudWatch alarms for ECS, RDS, Redis, and ALB." + default = false +} + +variable "alarm_email_addresses" { + type = list(string) + description = "Email addresses for alarm notifications via SNS." + default = [] +} + +################################################################################ +# Container Image Variables +################################################################################ + +variable "container_registry" { + type = string + description = "Container registry URL (e.g., ghcr.io/fullstackhero)." + default = "ghcr.io/fullstackhero" +} + +variable "container_image_tag" { + type = string + description = "Container image tag (shared across all services). Must be an immutable tag (git SHA or semver)." + + validation { + condition = var.container_image_tag != "latest" && var.container_image_tag != "" && !can(regex("^(dev|staging|prod|main|master)$", var.container_image_tag)) + error_message = "Container image tag must be an immutable identifier (git SHA or semver). Mutable tags like 'latest', 'dev', 'staging', 'prod', 'main', 'master' are not allowed." + } +} + +variable "api_image_name" { + type = string + description = "API container image name (without registry or tag)." + default = "fsh-api" +} + +################################################################################ +# API Service Variables +################################################################################ + +variable "api_container_port" { + type = number + description = "API container port." + default = 8080 +} + +variable "api_cpu" { + type = string + description = "API CPU units." + default = "256" +} + +variable "api_memory" { + type = string + description = "API memory." + default = "512" +} + +variable "api_desired_count" { + type = number + description = "Desired API task count." + default = 1 +} + +variable "api_health_check_healthy_threshold" { + type = number + description = "Number of consecutive health checks required for healthy status." + default = 2 +} + +variable "api_deregistration_delay" { + type = number + description = "Target group deregistration delay in seconds." + default = 30 +} + +variable "api_enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "api_use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity." + default = false +} + +variable "api_enable_autoscaling" { + type = bool + description = "Enable auto-scaling for the API service." + default = false +} + +variable "api_autoscaling_min_capacity" { + type = number + description = "Minimum number of API tasks when auto-scaling." + default = 1 +} + +variable "api_autoscaling_max_capacity" { + type = number + description = "Maximum number of API tasks when auto-scaling." + default = 10 +} + +variable "api_autoscaling_cpu_target" { + type = number + description = "Target CPU utilization percentage for API auto-scaling." + default = 70 +} + +variable "api_extra_environment_variables" { + type = map(string) + description = "Additional environment variables for API." + default = {} +} + diff --git a/deploy/terraform/apps/playground/app_stack/versions.tf b/deploy/terraform/apps/playground/app_stack/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/apps/playground/app_stack/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/deploy/terraform/apps/playground/envs/dev/us-east-1/backend.hcl b/deploy/terraform/apps/playground/envs/dev/us-east-1/backend.hcl new file mode 100644 index 0000000000..13f8301729 --- /dev/null +++ b/deploy/terraform/apps/playground/envs/dev/us-east-1/backend.hcl @@ -0,0 +1,5 @@ +bucket = "fsh-state-bucket" +key = "dev/us-east-1/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deploy/terraform/apps/playground/envs/dev/us-east-1/terraform.tfvars b/deploy/terraform/apps/playground/envs/dev/us-east-1/terraform.tfvars new file mode 100644 index 0000000000..959d6b7744 --- /dev/null +++ b/deploy/terraform/apps/playground/envs/dev/us-east-1/terraform.tfvars @@ -0,0 +1,57 @@ +################################################################################ +# Dev Environment — US East 1 +################################################################################ + +environment = "dev" +region = "us-east-1" + +################################################################################ +# Network +################################################################################ + +vpc_cidr_block = "10.10.0.0/16" + +public_subnets = { + a = { cidr_block = "10.10.0.0/24", az = "us-east-1a" } + b = { cidr_block = "10.10.1.0/24", az = "us-east-1b" } +} + +private_subnets = { + a = { cidr_block = "10.10.10.0/24", az = "us-east-1a" } + b = { cidr_block = "10.10.11.0/24", az = "us-east-1b" } +} + +single_nat_gateway = true + +enable_s3_endpoint = true +enable_ecr_endpoints = true +enable_logs_endpoint = true + +################################################################################ +# S3 +################################################################################ + +app_s3_bucket_name = "dev-fsh-app-bucket" +app_s3_enable_public_read = false +app_s3_enable_cloudfront = true + +################################################################################ +# Database +################################################################################ + +db_name = "fshdb" +db_username = "fshadmin" +db_manage_master_user_password = true + +################################################################################ +# Container Images +################################################################################ + +container_image_tag = "1d2c9f9d3b85bb86229f1bc1b9cd8196054f2166" + +################################################################################ +# Services (Fargate Spot for cost savings) +################################################################################ + +api_desired_count = 1 +api_use_fargate_spot = true diff --git a/deploy/terraform/apps/playground/envs/prod/us-east-1/backend.hcl b/deploy/terraform/apps/playground/envs/prod/us-east-1/backend.hcl new file mode 100644 index 0000000000..24876b7587 --- /dev/null +++ b/deploy/terraform/apps/playground/envs/prod/us-east-1/backend.hcl @@ -0,0 +1,5 @@ +bucket = "fsh-state-bucket" +key = "prod/us-east-1/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deploy/terraform/apps/playground/envs/prod/us-east-1/terraform.tfvars b/deploy/terraform/apps/playground/envs/prod/us-east-1/terraform.tfvars new file mode 100644 index 0000000000..a79cd74cbd --- /dev/null +++ b/deploy/terraform/apps/playground/envs/prod/us-east-1/terraform.tfvars @@ -0,0 +1,128 @@ +################################################################################ +# Production Environment — US East 1 +################################################################################ + +environment = "prod" +region = "us-east-1" + +# Configure with your production domain +# domain_name = "app.example.com" +# enable_https = true +# acm_certificate_arn = "arn:aws:acm:us-east-1:ACCOUNT_ID:certificate/CERT_ID" + +################################################################################ +# Network (3 AZs, NAT per AZ for HA) +################################################################################ + +vpc_cidr_block = "10.30.0.0/16" + +public_subnets = { + a = { cidr_block = "10.30.0.0/24", az = "us-east-1a" } + b = { cidr_block = "10.30.1.0/24", az = "us-east-1b" } + c = { cidr_block = "10.30.2.0/24", az = "us-east-1c" } +} + +private_subnets = { + a = { cidr_block = "10.30.10.0/24", az = "us-east-1a" } + b = { cidr_block = "10.30.11.0/24", az = "us-east-1b" } + c = { cidr_block = "10.30.12.0/24", az = "us-east-1c" } +} + +single_nat_gateway = false +enable_s3_endpoint = true +enable_ecr_endpoints = true +enable_logs_endpoint = true +enable_secretsmanager_endpoint = true +enable_flow_logs = true +flow_logs_retention_days = 90 + +################################################################################ +# WAF +################################################################################ + +enable_waf = true +waf_rate_limit = 2000 +waf_enable_sqli_rule_set = true +waf_enable_ip_reputation_rule_set = true +waf_enable_linux_rule_set = true +waf_enable_logging = true + +################################################################################ +# S3 +################################################################################ + +app_s3_bucket_name = "prod-fsh-app-bucket" +app_s3_versioning_enabled = true +app_s3_enable_public_read = false +app_s3_enable_cloudfront = true +app_s3_cloudfront_price_class = "PriceClass_200" +app_s3_enable_intelligent_tiering = true + +################################################################################ +# Database +################################################################################ + +db_name = "fshdb" +db_username = "fshadmin" +db_manage_master_user_password = true +db_instance_class = "db.t4g.medium" +db_allocated_storage = 50 +db_max_allocated_storage = 200 +db_multi_az = true +db_backup_retention_period = 30 +db_deletion_protection = true +db_enable_performance_insights = true +db_enable_enhanced_monitoring = true + +# Production-tuned PostgreSQL parameters +db_create_parameter_group = true +db_parameters = [ + { name = "log_min_duration_statement", value = "1000", apply_method = "immediate" }, + { name = "shared_preload_libraries", value = "pg_stat_statements", apply_method = "pending-reboot" }, + { name = "pg_stat_statements.track", value = "all", apply_method = "immediate" }, + { name = "log_connections", value = "1", apply_method = "immediate" }, + { name = "log_disconnections", value = "1", apply_method = "immediate" } +] + +################################################################################ +# Redis +################################################################################ + +redis_node_type = "cache.t4g.medium" +redis_num_cache_clusters = 2 +redis_automatic_failover_enabled = true + +################################################################################ +# Container Images +################################################################################ + +# IMPORTANT: Always pin to a specific git SHA or semver tag. +container_image_tag = "v1.0.0" + +################################################################################ +# Services (no Spot for production stability) +################################################################################ + +api_cpu = "1024" +api_memory = "2048" +api_desired_count = 3 +api_use_fargate_spot = false + +api_enable_autoscaling = true +api_autoscaling_min_capacity = 3 +api_autoscaling_max_capacity = 20 + +enable_container_insights = true + +################################################################################ +# ALB +################################################################################ + +alb_enable_deletion_protection = true + +################################################################################ +# Alarms +################################################################################ + +enable_alarms = true +# alarm_email_addresses = ["ops@example.com"] diff --git a/deploy/terraform/apps/playground/envs/staging/us-east-1/backend.hcl b/deploy/terraform/apps/playground/envs/staging/us-east-1/backend.hcl new file mode 100644 index 0000000000..6ff57b3f2a --- /dev/null +++ b/deploy/terraform/apps/playground/envs/staging/us-east-1/backend.hcl @@ -0,0 +1,5 @@ +bucket = "fsh-state-bucket" +key = "staging/us-east-1/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deploy/terraform/apps/playground/envs/staging/us-east-1/terraform.tfvars b/deploy/terraform/apps/playground/envs/staging/us-east-1/terraform.tfvars new file mode 100644 index 0000000000..8ae0d3b4c3 --- /dev/null +++ b/deploy/terraform/apps/playground/envs/staging/us-east-1/terraform.tfvars @@ -0,0 +1,95 @@ +################################################################################ +# Staging Environment — US East 1 +################################################################################ + +environment = "staging" +region = "us-east-1" + +# domain_name = "staging.example.com" +# enable_https = true +# acm_certificate_arn = "arn:aws:acm:us-east-1:ACCOUNT_ID:certificate/CERT_ID" + +################################################################################ +# Network +################################################################################ + +vpc_cidr_block = "10.20.0.0/16" + +public_subnets = { + a = { cidr_block = "10.20.0.0/24", az = "us-east-1a" } + b = { cidr_block = "10.20.1.0/24", az = "us-east-1b" } +} + +private_subnets = { + a = { cidr_block = "10.20.10.0/24", az = "us-east-1a" } + b = { cidr_block = "10.20.11.0/24", az = "us-east-1b" } +} + +single_nat_gateway = true +enable_s3_endpoint = true +enable_ecr_endpoints = true +enable_logs_endpoint = true +enable_secretsmanager_endpoint = true +enable_flow_logs = true +flow_logs_retention_days = 30 + +################################################################################ +# WAF +################################################################################ + +enable_waf = true +waf_rate_limit = 2000 +waf_enable_sqli_rule_set = true +waf_enable_ip_reputation_rule_set = true +waf_enable_logging = true + +################################################################################ +# S3 +################################################################################ + +app_s3_bucket_name = "staging-fsh-app-bucket" +app_s3_enable_public_read = false +app_s3_enable_cloudfront = true + +################################################################################ +# Database +################################################################################ + +db_name = "fshdb" +db_username = "fshadmin" +db_manage_master_user_password = true +db_instance_class = "db.t4g.small" +db_enable_performance_insights = true +db_deletion_protection = true + +################################################################################ +# Redis +################################################################################ + +redis_node_type = "cache.t4g.small" + +################################################################################ +# Container Images +################################################################################ + +container_image_tag = "v0.1.0-rc1" + +################################################################################ +# Services +################################################################################ + +api_desired_count = 2 +api_use_fargate_spot = true + +api_enable_autoscaling = true +api_autoscaling_min_capacity = 2 +api_autoscaling_max_capacity = 6 + +enable_container_insights = true + +################################################################################ +# Alarms +################################################################################ + +enable_alarms = true +# alarm_email_addresses = ["ops@example.com"] diff --git a/deploy/terraform/apps/playground/shared/backend.tf b/deploy/terraform/apps/playground/shared/backend.tf new file mode 100644 index 0000000000..e55a42ce6a --- /dev/null +++ b/deploy/terraform/apps/playground/shared/backend.tf @@ -0,0 +1,5 @@ +# Partial backend configuration. +# Complete it per environment using: terraform init -backend-config=../envs///backend.hcl +terraform { + backend "s3" {} +} diff --git a/deploy/terraform/apps/playground/shared/main.tf b/deploy/terraform/apps/playground/shared/main.tf new file mode 100644 index 0000000000..75198471af --- /dev/null +++ b/deploy/terraform/apps/playground/shared/main.tf @@ -0,0 +1,114 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + Environment = var.environment + Project = "dotnet-starter-kit" + ManagedBy = "terraform" + } + } +} + +module "app" { + source = "../app_stack" + + # General + environment = var.environment + region = var.region + domain_name = var.domain_name + owner = var.owner + + # Network + vpc_cidr_block = var.vpc_cidr_block + public_subnets = var.public_subnets + private_subnets = var.private_subnets + enable_nat_gateway = var.enable_nat_gateway + single_nat_gateway = var.single_nat_gateway + enable_s3_endpoint = var.enable_s3_endpoint + enable_ecr_endpoints = var.enable_ecr_endpoints + enable_logs_endpoint = var.enable_logs_endpoint + enable_secretsmanager_endpoint = var.enable_secretsmanager_endpoint + enable_flow_logs = var.enable_flow_logs + flow_logs_retention_days = var.flow_logs_retention_days + + # ECS + enable_container_insights = var.enable_container_insights + + # ALB + enable_https = var.enable_https + acm_certificate_arn = var.acm_certificate_arn + alb_enable_deletion_protection = var.alb_enable_deletion_protection + + # Alarms + enable_alarms = var.enable_alarms + alarm_email_addresses = var.alarm_email_addresses + + # WAF + enable_waf = var.enable_waf + waf_rate_limit = var.waf_rate_limit + waf_enable_sqli_rule_set = var.waf_enable_sqli_rule_set + waf_enable_ip_reputation_rule_set = var.waf_enable_ip_reputation_rule_set + waf_enable_anonymous_ip_rule_set = var.waf_enable_anonymous_ip_rule_set + waf_enable_linux_rule_set = var.waf_enable_linux_rule_set + waf_enable_logging = var.waf_enable_logging + + # S3 + app_s3_bucket_name = var.app_s3_bucket_name + app_s3_versioning_enabled = var.app_s3_versioning_enabled + app_s3_enable_public_read = var.app_s3_enable_public_read + app_s3_enable_cloudfront = var.app_s3_enable_cloudfront + app_s3_cloudfront_price_class = var.app_s3_cloudfront_price_class + app_s3_enable_intelligent_tiering = var.app_s3_enable_intelligent_tiering + + # Database + db_name = var.db_name + db_username = var.db_username + db_password = var.db_password + db_manage_master_user_password = var.db_manage_master_user_password + db_instance_class = var.db_instance_class + db_allocated_storage = var.db_allocated_storage + db_max_allocated_storage = var.db_max_allocated_storage + db_engine_version = var.db_engine_version + db_multi_az = var.db_multi_az + db_backup_retention_period = var.db_backup_retention_period + db_deletion_protection = var.db_deletion_protection + db_enable_performance_insights = var.db_enable_performance_insights + db_enable_enhanced_monitoring = var.db_enable_enhanced_monitoring + db_create_parameter_group = var.db_create_parameter_group + db_parameters = var.db_parameters + + # Redis + redis_node_type = var.redis_node_type + redis_num_cache_clusters = var.redis_num_cache_clusters + redis_automatic_failover_enabled = var.redis_automatic_failover_enabled + + # Container Images + container_registry = var.container_registry + container_image_tag = var.container_image_tag + api_image_name = var.api_image_name + + # API Service + api_container_port = var.api_container_port + api_cpu = var.api_cpu + api_memory = var.api_memory + api_desired_count = var.api_desired_count + api_enable_circuit_breaker = var.api_enable_circuit_breaker + api_use_fargate_spot = var.api_use_fargate_spot + api_enable_autoscaling = var.api_enable_autoscaling + api_autoscaling_min_capacity = var.api_autoscaling_min_capacity + api_autoscaling_max_capacity = var.api_autoscaling_max_capacity + api_autoscaling_cpu_target = var.api_autoscaling_cpu_target + +} diff --git a/deploy/terraform/apps/playground/shared/outputs.tf b/deploy/terraform/apps/playground/shared/outputs.tf new file mode 100644 index 0000000000..3f23df3bca --- /dev/null +++ b/deploy/terraform/apps/playground/shared/outputs.tf @@ -0,0 +1,69 @@ +################################################################################ +# Network +################################################################################ + +output "vpc_id" { + description = "VPC ID." + value = module.app.vpc_id +} + +output "alb_dns_name" { + description = "ALB DNS name." + value = module.app.alb_dns_name +} + +output "alb_zone_id" { + description = "ALB hosted zone ID (for Route53 alias records)." + value = module.app.alb_zone_id +} + +################################################################################ +# Application URLs +################################################################################ + +output "api_url" { + description = "API URL." + value = module.app.api_url +} + +################################################################################ +# Database +################################################################################ + +output "rds_endpoint" { + description = "RDS endpoint." + value = module.app.rds_endpoint +} + +output "rds_secret_arn" { + description = "RDS secret ARN (if using managed password)." + value = module.app.rds_secret_arn +} + +################################################################################ +# Redis +################################################################################ + +output "redis_endpoint" { + description = "Redis endpoint." + value = module.app.redis_endpoint +} + +################################################################################ +# S3 +################################################################################ + +output "s3_bucket_name" { + description = "S3 bucket name." + value = module.app.s3_bucket_name +} + +output "s3_cloudfront_domain" { + description = "CloudFront domain." + value = module.app.s3_cloudfront_domain != "" ? "https://${module.app.s3_cloudfront_domain}" : "" +} + +output "s3_cloudfront_distribution_id" { + description = "CloudFront distribution ID." + value = module.app.s3_cloudfront_distribution_id +} diff --git a/deploy/terraform/apps/playground/shared/variables.tf b/deploy/terraform/apps/playground/shared/variables.tf new file mode 100644 index 0000000000..7f6007b7f0 --- /dev/null +++ b/deploy/terraform/apps/playground/shared/variables.tf @@ -0,0 +1,381 @@ +################################################################################ +# General +################################################################################ + +variable "environment" { + type = string + description = "Environment name (dev, staging, prod)." +} + +variable "region" { + type = string + description = "AWS region." +} + +variable "domain_name" { + type = string + description = "Domain name for the application (optional)." + default = null +} + +variable "owner" { + type = string + description = "Owner or team responsible for this infrastructure." + default = null +} + +################################################################################ +# Network +################################################################################ + +variable "vpc_cidr_block" { + type = string + description = "CIDR block for the VPC." +} + +variable "public_subnets" { + description = "Public subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "private_subnets" { + description = "Private subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) +} + +variable "enable_nat_gateway" { + type = bool + default = true +} + +variable "single_nat_gateway" { + type = bool + default = true +} + +variable "enable_s3_endpoint" { + type = bool + default = true +} + +variable "enable_ecr_endpoints" { + type = bool + default = true +} + +variable "enable_logs_endpoint" { + type = bool + default = true +} + +variable "enable_secretsmanager_endpoint" { + type = bool + default = false +} + +variable "enable_flow_logs" { + type = bool + default = false +} + +variable "flow_logs_retention_days" { + type = number + default = 14 +} + +################################################################################ +# ECS +################################################################################ + +variable "enable_container_insights" { + type = bool + default = false +} + +################################################################################ +# ALB +################################################################################ + +variable "enable_https" { + type = bool + default = false +} + +variable "acm_certificate_arn" { + type = string + default = null +} + +variable "alb_enable_deletion_protection" { + type = bool + default = false +} + +################################################################################ +# Alarms +################################################################################ + +variable "enable_alarms" { + type = bool + default = false +} + +variable "alarm_email_addresses" { + type = list(string) + default = [] +} + +################################################################################ +# WAF +################################################################################ + +variable "enable_waf" { + type = bool + default = false +} + +variable "waf_rate_limit" { + type = number + default = 2000 +} + +variable "waf_enable_sqli_rule_set" { + type = bool + default = true +} + +variable "waf_enable_ip_reputation_rule_set" { + type = bool + default = true +} + +variable "waf_enable_anonymous_ip_rule_set" { + type = bool + default = false +} + +variable "waf_enable_linux_rule_set" { + type = bool + default = true +} + +variable "waf_enable_logging" { + type = bool + default = true +} + +################################################################################ +# S3 +################################################################################ + +variable "app_s3_bucket_name" { + type = string + description = "S3 bucket name for application data (must be globally unique)." +} + +variable "app_s3_versioning_enabled" { + type = bool + default = true +} + +variable "app_s3_enable_public_read" { + type = bool + default = false +} + +variable "app_s3_enable_cloudfront" { + type = bool + default = true +} + +variable "app_s3_cloudfront_price_class" { + type = string + default = "PriceClass_100" +} + +variable "app_s3_enable_intelligent_tiering" { + type = bool + default = false +} + +################################################################################ +# Database +################################################################################ + +variable "db_name" { + type = string +} + +variable "db_username" { + type = string + sensitive = true +} + +variable "db_password" { + type = string + sensitive = true + default = null +} + +variable "db_manage_master_user_password" { + type = bool + default = true +} + +variable "db_instance_class" { + type = string + default = "db.t4g.micro" +} + +variable "db_allocated_storage" { + type = number + default = 20 +} + +variable "db_max_allocated_storage" { + type = number + default = 100 +} + +variable "db_engine_version" { + type = string + default = "17" +} + +variable "db_multi_az" { + type = bool + default = false +} + +variable "db_backup_retention_period" { + type = number + default = 7 +} + +variable "db_deletion_protection" { + type = bool + default = false +} + +variable "db_enable_performance_insights" { + type = bool + default = false +} + +variable "db_enable_enhanced_monitoring" { + type = bool + default = false +} + +variable "db_create_parameter_group" { + type = bool + default = false +} + +variable "db_parameters" { + type = list(object({ + name = string + value = string + apply_method = optional(string, "immediate") + })) + default = [] +} + +################################################################################ +# Redis +################################################################################ + +variable "redis_node_type" { + type = string + default = "cache.t4g.micro" +} + +variable "redis_num_cache_clusters" { + type = number + default = 1 +} + +variable "redis_automatic_failover_enabled" { + type = bool + default = false +} + +################################################################################ +# Container Images +################################################################################ + +variable "container_registry" { + type = string + default = "ghcr.io/fullstackhero" +} + +variable "container_image_tag" { + type = string + description = "Container image tag. Must be immutable (git SHA or semver)." +} + +variable "api_image_name" { + type = string + default = "fsh-api" +} + +################################################################################ +# API Service +################################################################################ + +variable "api_container_port" { + type = number + default = 8080 +} + +variable "api_cpu" { + type = string + default = "256" +} + +variable "api_memory" { + type = string + default = "512" +} + +variable "api_desired_count" { + type = number + default = 1 +} + +variable "api_enable_circuit_breaker" { + type = bool + default = true +} + +variable "api_use_fargate_spot" { + type = bool + default = false +} + +variable "api_enable_autoscaling" { + type = bool + default = false +} + +variable "api_autoscaling_min_capacity" { + type = number + default = 1 +} + +variable "api_autoscaling_max_capacity" { + type = number + default = 10 +} + +variable "api_autoscaling_cpu_target" { + type = number + default = 70 +} + diff --git a/deploy/terraform/bootstrap/main.tf b/deploy/terraform/bootstrap/main.tf new file mode 100644 index 0000000000..973b1624af --- /dev/null +++ b/deploy/terraform/bootstrap/main.tf @@ -0,0 +1,212 @@ +################################################################################ +# Terraform Bootstrap - State Backend Infrastructure +# +# This module creates an S3 bucket for storing Terraform state. +# Starting with Terraform 1.10+, S3 native locking is used via use_lockfile. +# DynamoDB is no longer required for state locking. +################################################################################ + +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } + + backend "local" {} +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + ManagedBy = "terraform" + Purpose = "terraform-state" + } + } +} + +################################################################################ +# S3 Bucket for Terraform State +################################################################################ + +resource "aws_s3_bucket" "tf_state" { + bucket = var.bucket_name + + lifecycle { + prevent_destroy = true + } + + tags = { + Name = var.bucket_name + Description = "Terraform state storage" + } +} + +resource "aws_s3_bucket_versioning" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = var.kms_key_arn != null ? "aws:kms" : "AES256" + kms_master_key_id = var.kms_key_arn + } + bucket_key_enabled = var.kms_key_arn != null + } +} + +resource "aws_s3_bucket_public_access_block" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + rule { + id = "abort-incomplete-uploads" + status = "Enabled" + + filter { + prefix = "" + } + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } + + rule { + id = "noncurrent-version-expiration" + status = "Enabled" + + filter { + prefix = "" + } + + noncurrent_version_expiration { + noncurrent_days = var.state_version_retention_days + } + } + + rule { + id = "lockfile-cleanup" + status = "Enabled" + + filter { + prefix = "" + } + + expiration { + expired_object_delete_marker = true + } + } + + depends_on = [aws_s3_bucket_versioning.tf_state] +} + +################################################################################ +# S3 Bucket Policy - Enforce SSL and Required Permissions for Native Locking +################################################################################ + +resource "aws_s3_bucket_policy" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnforceSSLOnly" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.tf_state.arn, + "${aws_s3_bucket.tf_state.arn}/*" + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + }, + { + Sid = "EnforceTLSVersion" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.tf_state.arn, + "${aws_s3_bucket.tf_state.arn}/*" + ] + Condition = { + NumericLessThan = { + "s3:TlsVersion" = "1.2" + } + } + } + ] + }) + + depends_on = [aws_s3_bucket_public_access_block.tf_state] +} + +################################################################################ +# Outputs +################################################################################ + +output "state_bucket_name" { + description = "Name of the S3 bucket for Terraform state" + value = aws_s3_bucket.tf_state.id +} + +output "state_bucket_arn" { + description = "ARN of the S3 bucket for Terraform state" + value = aws_s3_bucket.tf_state.arn +} + +output "state_bucket_region" { + description = "Region of the S3 bucket for Terraform state" + value = var.region +} + +output "backend_config" { + description = "Backend configuration to use in other Terraform configurations (Terraform 1.10+ with S3 native locking)" + value = { + bucket = aws_s3_bucket.tf_state.id + region = var.region + encrypt = true + use_lockfile = true + } +} + +output "backend_config_hcl" { + description = "Example backend configuration block for terraform files" + value = <<-EOT + terraform { + backend "s3" { + bucket = "${aws_s3_bucket.tf_state.id}" + key = "//terraform.tfstate" + region = "${var.region}" + encrypt = true + use_lockfile = true + } + } + EOT +} diff --git a/deploy/terraform/bootstrap/variables.tf b/deploy/terraform/bootstrap/variables.tf new file mode 100644 index 0000000000..106b86b58b --- /dev/null +++ b/deploy/terraform/bootstrap/variables.tf @@ -0,0 +1,36 @@ +variable "region" { + type = string + description = "AWS region where the state bucket is created." + + validation { + condition = can(regex("^[a-z]{2}-[a-z]+-\\d$", var.region)) + error_message = "Region must be a valid AWS region identifier (e.g., us-east-1)." + } +} + +variable "bucket_name" { + type = string + description = "Name of the S3 bucket for Terraform remote state (must be globally unique)." + + validation { + condition = can(regex("^[a-z0-9][a-z0-9.-]*[a-z0-9]$", var.bucket_name)) + error_message = "Bucket name must contain only lowercase letters, numbers, hyphens, and periods." + } +} + +variable "kms_key_arn" { + type = string + description = "KMS key ARN for encryption. Uses AWS-managed key if not specified." + default = null +} + +variable "state_version_retention_days" { + type = number + description = "Number of days to retain non-current state file versions." + default = 90 + + validation { + condition = var.state_version_retention_days >= 1 + error_message = "State version retention must be at least 1 day." + } +} diff --git a/deploy/terraform/modules/alb/main.tf b/deploy/terraform/modules/alb/main.tf new file mode 100644 index 0000000000..56acd1ac96 --- /dev/null +++ b/deploy/terraform/modules/alb/main.tf @@ -0,0 +1,112 @@ +################################################################################ +# Application Load Balancer +################################################################################ + +resource "aws_lb" "this" { + name = var.name + internal = var.internal + load_balancer_type = "application" + security_groups = [var.security_group_id] + subnets = var.subnet_ids + + enable_deletion_protection = var.enable_deletion_protection + enable_http2 = var.enable_http2 + idle_timeout = var.idle_timeout + drop_invalid_header_fields = var.drop_invalid_header_fields + desync_mitigation_mode = var.desync_mitigation_mode + preserve_host_header = var.preserve_host_header + xff_header_processing_mode = var.xff_header_processing_mode + + dynamic "access_logs" { + for_each = var.access_logs_bucket != null ? [1] : [] + content { + bucket = var.access_logs_bucket + prefix = var.access_logs_prefix + enabled = true + } + } + + dynamic "connection_logs" { + for_each = var.connection_logs_bucket != null ? [1] : [] + content { + bucket = var.connection_logs_bucket + prefix = var.connection_logs_prefix + enabled = true + } + } + + tags = merge(var.tags, { + Name = var.name + }) +} + +################################################################################ +# HTTP Listener +################################################################################ + +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.this.arn + port = 80 + protocol = "HTTP" + + default_action { + type = var.enable_https ? "redirect" : "fixed-response" + + dynamic "redirect" { + for_each = var.enable_https ? [1] : [] + content { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + dynamic "fixed_response" { + for_each = var.enable_https ? [] : [1] + content { + content_type = "text/plain" + message_body = "Not configured" + status_code = "404" + } + } + } + + tags = var.tags +} + +################################################################################ +# HTTPS Listener (Optional) +################################################################################ + +resource "aws_lb_listener" "https" { + count = var.enable_https ? 1 : 0 + + load_balancer_arn = aws_lb.this.arn + port = 443 + protocol = "HTTPS" + ssl_policy = var.ssl_policy + certificate_arn = var.certificate_arn + + default_action { + type = "fixed-response" + + fixed_response { + content_type = "text/plain" + message_body = "Not configured" + status_code = "404" + } + } + + tags = var.tags +} + +################################################################################ +# Additional Certificates (Optional) +################################################################################ + +resource "aws_lb_listener_certificate" "additional" { + for_each = var.enable_https ? toset(var.additional_certificate_arns) : [] + + listener_arn = aws_lb_listener.https[0].arn + certificate_arn = each.value +} diff --git a/deploy/terraform/modules/alb/outputs.tf b/deploy/terraform/modules/alb/outputs.tf new file mode 100644 index 0000000000..8721fd7558 --- /dev/null +++ b/deploy/terraform/modules/alb/outputs.tf @@ -0,0 +1,39 @@ +output "arn" { + description = "The ARN of the load balancer" + value = aws_lb.this.arn +} + +output "id" { + description = "The ID of the load balancer" + value = aws_lb.this.id +} + +output "dns_name" { + description = "The DNS name of the load balancer" + value = aws_lb.this.dns_name +} + +output "zone_id" { + description = "The canonical hosted zone ID of the load balancer" + value = aws_lb.this.zone_id +} + +output "http_listener_arn" { + description = "The ARN of the HTTP listener" + value = aws_lb_listener.http.arn +} + +output "https_listener_arn" { + description = "The ARN of the HTTPS listener (if enabled)" + value = var.enable_https ? aws_lb_listener.https[0].arn : null +} + +output "listener_arn" { + description = "The ARN of the primary listener (HTTPS if enabled, otherwise HTTP)" + value = var.enable_https ? aws_lb_listener.https[0].arn : aws_lb_listener.http.arn +} + +output "arn_suffix" { + description = "The ARN suffix of the load balancer (for auto-scaling and CloudWatch)" + value = aws_lb.this.arn_suffix +} diff --git a/deploy/terraform/modules/alb/variables.tf b/deploy/terraform/modules/alb/variables.tf new file mode 100644 index 0000000000..5a2dbea745 --- /dev/null +++ b/deploy/terraform/modules/alb/variables.tf @@ -0,0 +1,173 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name of the ALB." + + validation { + condition = can(regex("^[a-zA-Z0-9-]+$", var.name)) + error_message = "ALB name must contain only alphanumeric characters and hyphens." + } +} + +variable "subnet_ids" { + type = list(string) + description = "Subnets for the ALB." + + validation { + condition = length(var.subnet_ids) >= 2 + error_message = "At least two subnets are required for ALB." + } +} + +variable "security_group_id" { + type = string + description = "Security group for the ALB." +} + +################################################################################ +# ALB Configuration +################################################################################ + +variable "internal" { + type = bool + description = "Whether the ALB is internal." + default = false +} + +variable "enable_deletion_protection" { + type = bool + description = "Enable deletion protection." + default = false +} + +variable "enable_http2" { + type = bool + description = "Enable HTTP/2." + default = true +} + +variable "idle_timeout" { + type = number + description = "Idle timeout in seconds." + default = 60 + + validation { + condition = var.idle_timeout >= 1 && var.idle_timeout <= 4000 + error_message = "Idle timeout must be between 1 and 4000 seconds." + } +} + +variable "drop_invalid_header_fields" { + type = bool + description = "Drop invalid HTTP headers." + default = true +} + +variable "desync_mitigation_mode" { + type = string + description = "How the ALB handles requests that might pose a security risk due to HTTP desync (monitor, defensive, strictest)." + default = "defensive" + + validation { + condition = contains(["monitor", "defensive", "strictest"], var.desync_mitigation_mode) + error_message = "Desync mitigation mode must be monitor, defensive, or strictest." + } +} + +variable "preserve_host_header" { + type = bool + description = "Preserve the Host header in the HTTP request and send it to the target without modification." + default = false +} + +variable "xff_header_processing_mode" { + type = string + description = "How the X-Forwarded-For header is processed (append, preserve, remove)." + default = "append" + + validation { + condition = contains(["append", "preserve", "remove"], var.xff_header_processing_mode) + error_message = "XFF header processing mode must be append, preserve, or remove." + } +} + +################################################################################ +# HTTPS Configuration +################################################################################ + +variable "enable_https" { + type = bool + description = "Enable HTTPS listener (requires certificate_arn)." + default = false +} + +variable "certificate_arn" { + type = string + description = "ARN of the default SSL certificate." + default = null +} + +variable "additional_certificate_arns" { + type = list(string) + description = "Additional SSL certificate ARNs for SNI." + default = [] +} + +variable "ssl_policy" { + type = string + description = "SSL policy for HTTPS listener. Use TLS 1.3 policies for best security." + default = "ELBSecurityPolicy-TLS13-1-2-2021-06" + + validation { + condition = contains([ + "ELBSecurityPolicy-TLS13-1-2-2021-06", + "ELBSecurityPolicy-TLS13-1-3-2021-06", + "ELBSecurityPolicy-TLS13-1-2-Ext1-2021-06", + "ELBSecurityPolicy-TLS13-1-2-Ext2-2021-06", + "ELBSecurityPolicy-FS-1-2-Res-2020-10", + "ELBSecurityPolicy-FS-1-2-2019-08" + ], var.ssl_policy) + error_message = "SSL policy must be a valid ELB security policy supporting TLS 1.2+." + } +} + +################################################################################ +# Access Logs +################################################################################ + +variable "access_logs_bucket" { + type = string + description = "S3 bucket for access logs." + default = null +} + +variable "access_logs_prefix" { + type = string + description = "S3 prefix for access logs." + default = "alb-logs" +} + +variable "connection_logs_bucket" { + type = string + description = "S3 bucket for connection logs." + default = null +} + +variable "connection_logs_prefix" { + type = string + description = "S3 prefix for connection logs." + default = "alb-connection-logs" +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to ALB resources." + default = {} +} diff --git a/deploy/terraform/modules/alb/versions.tf b/deploy/terraform/modules/alb/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/modules/alb/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/deploy/terraform/modules/cloudwatch_alarms/main.tf b/deploy/terraform/modules/cloudwatch_alarms/main.tf new file mode 100644 index 0000000000..927243871a --- /dev/null +++ b/deploy/terraform/modules/cloudwatch_alarms/main.tf @@ -0,0 +1,374 @@ +################################################################################ +# SNS Topic for Alarm Notifications +################################################################################ + +resource "aws_sns_topic" "alarms" { + name = "${var.name}-alarms" + kms_master_key_id = var.kms_key_id + + tags = var.tags +} + +resource "aws_sns_topic_subscription" "email" { + for_each = toset(var.alarm_email_addresses) + + topic_arn = aws_sns_topic.alarms.arn + protocol = "email" + endpoint = each.value +} + +################################################################################ +# ECS Service Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "ecs_cpu_high" { + for_each = var.ecs_services + + alarm_name = "${each.key}-cpu-high" + alarm_description = "ECS service ${each.key} CPU utilization above ${var.ecs_cpu_threshold}%" + namespace = "AWS/ECS" + metric_name = "CPUUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 3 + threshold = var.ecs_cpu_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "breaching" + + dimensions = { + ClusterName = each.value.cluster_name + ServiceName = each.value.service_name + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "ecs_memory_high" { + for_each = var.ecs_services + + alarm_name = "${each.key}-memory-high" + alarm_description = "ECS service ${each.key} memory utilization above ${var.ecs_memory_threshold}%" + namespace = "AWS/ECS" + metric_name = "MemoryUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 3 + threshold = var.ecs_memory_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "breaching" + + dimensions = { + ClusterName = each.value.cluster_name + ServiceName = each.value.service_name + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "ecs_running_tasks" { + for_each = var.ecs_services + + alarm_name = "${each.key}-no-running-tasks" + alarm_description = "ECS service ${each.key} has zero running tasks" + namespace = "AWS/ECS" + metric_name = "RunningTaskCount" + statistic = "Average" + period = 60 + evaluation_periods = 2 + threshold = 1 + comparison_operator = "LessThanThreshold" + treat_missing_data = "breaching" + + dimensions = { + ClusterName = each.value.cluster_name + ServiceName = each.value.service_name + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +################################################################################ +# RDS Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" { + count = var.rds_instance_identifier != null ? 1 : 0 + + alarm_name = "${var.name}-rds-cpu-high" + alarm_description = "RDS CPU utilization above ${var.rds_cpu_threshold}%" + namespace = "AWS/RDS" + metric_name = "CPUUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 3 + threshold = var.rds_cpu_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "breaching" + + dimensions = { + DBInstanceIdentifier = var.rds_instance_identifier + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "rds_free_storage_low" { + count = var.rds_instance_identifier != null ? 1 : 0 + + alarm_name = "${var.name}-rds-storage-low" + alarm_description = "RDS free storage below ${var.rds_free_storage_threshold_gb} GB" + namespace = "AWS/RDS" + metric_name = "FreeStorageSpace" + statistic = "Average" + period = 300 + evaluation_periods = 2 + threshold = var.rds_free_storage_threshold_gb * 1073741824 # Convert GB to bytes + comparison_operator = "LessThanThreshold" + treat_missing_data = "breaching" + + dimensions = { + DBInstanceIdentifier = var.rds_instance_identifier + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "rds_connections_high" { + count = var.rds_instance_identifier != null ? 1 : 0 + + alarm_name = "${var.name}-rds-connections-high" + alarm_description = "RDS connections above ${var.rds_connections_threshold}" + namespace = "AWS/RDS" + metric_name = "DatabaseConnections" + statistic = "Average" + period = 300 + evaluation_periods = 3 + threshold = var.rds_connections_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + + dimensions = { + DBInstanceIdentifier = var.rds_instance_identifier + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "rds_read_latency" { + count = var.rds_instance_identifier != null ? 1 : 0 + + alarm_name = "${var.name}-rds-read-latency-high" + alarm_description = "RDS read latency above ${var.rds_read_latency_threshold_ms} ms" + namespace = "AWS/RDS" + metric_name = "ReadLatency" + statistic = "Average" + period = 300 + evaluation_periods = 3 + threshold = var.rds_read_latency_threshold_ms / 1000 # Convert ms to seconds + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + + dimensions = { + DBInstanceIdentifier = var.rds_instance_identifier + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +################################################################################ +# ElastiCache Redis Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "redis_cpu_high" { + count = var.redis_replication_group_id != null ? 1 : 0 + + alarm_name = "${var.name}-redis-cpu-high" + alarm_description = "Redis engine CPU utilization above ${var.redis_cpu_threshold}%" + namespace = "AWS/ElastiCache" + metric_name = "EngineCPUUtilization" + statistic = "Average" + period = 300 + evaluation_periods = 3 + threshold = var.redis_cpu_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "breaching" + + dimensions = { + ReplicationGroupId = var.redis_replication_group_id + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "redis_memory_high" { + count = var.redis_replication_group_id != null ? 1 : 0 + + alarm_name = "${var.name}-redis-memory-high" + alarm_description = "Redis memory usage above ${var.redis_memory_threshold}%" + namespace = "AWS/ElastiCache" + metric_name = "DatabaseMemoryUsagePercentage" + statistic = "Average" + period = 300 + evaluation_periods = 3 + threshold = var.redis_memory_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "breaching" + + dimensions = { + ReplicationGroupId = var.redis_replication_group_id + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "redis_evictions" { + count = var.redis_replication_group_id != null ? 1 : 0 + + alarm_name = "${var.name}-redis-evictions" + alarm_description = "Redis evictions detected (above ${var.redis_evictions_threshold})" + namespace = "AWS/ElastiCache" + metric_name = "Evictions" + statistic = "Sum" + period = 300 + evaluation_periods = 2 + threshold = var.redis_evictions_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + + dimensions = { + ReplicationGroupId = var.redis_replication_group_id + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +################################################################################ +# ALB Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "alb_5xx_errors" { + count = var.alb_arn_suffix != null ? 1 : 0 + + alarm_name = "${var.name}-alb-5xx-high" + alarm_description = "ALB 5xx error count above ${var.alb_5xx_threshold}" + namespace = "AWS/ApplicationELB" + metric_name = "HTTPCode_ELB_5XX_Count" + statistic = "Sum" + period = 300 + evaluation_periods = 2 + threshold = var.alb_5xx_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + + dimensions = { + LoadBalancer = var.alb_arn_suffix + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "alb_target_5xx_errors" { + count = var.alb_arn_suffix != null ? 1 : 0 + + alarm_name = "${var.name}-alb-target-5xx-high" + alarm_description = "ALB target 5xx error count above ${var.alb_target_5xx_threshold}" + namespace = "AWS/ApplicationELB" + metric_name = "HTTPCode_Target_5XX_Count" + statistic = "Sum" + period = 300 + evaluation_periods = 2 + threshold = var.alb_target_5xx_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + + dimensions = { + LoadBalancer = var.alb_arn_suffix + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "alb_response_time" { + count = var.alb_arn_suffix != null ? 1 : 0 + + alarm_name = "${var.name}-alb-response-time-high" + alarm_description = "ALB target response time above ${var.alb_response_time_threshold}s" + namespace = "AWS/ApplicationELB" + metric_name = "TargetResponseTime" + statistic = "Average" + period = 300 + evaluation_periods = 3 + threshold = var.alb_response_time_threshold + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + + dimensions = { + LoadBalancer = var.alb_arn_suffix + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} + +resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_hosts" { + for_each = var.alb_target_group_arns + + alarm_name = "${each.key}-unhealthy-hosts" + alarm_description = "Unhealthy targets detected for ${each.key}" + namespace = "AWS/ApplicationELB" + metric_name = "UnHealthyHostCount" + statistic = "Maximum" + period = 60 + evaluation_periods = 3 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + + dimensions = { + TargetGroup = each.value.target_group_arn_suffix + LoadBalancer = var.alb_arn_suffix + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = var.tags +} diff --git a/deploy/terraform/modules/cloudwatch_alarms/outputs.tf b/deploy/terraform/modules/cloudwatch_alarms/outputs.tf new file mode 100644 index 0000000000..78f08dcdec --- /dev/null +++ b/deploy/terraform/modules/cloudwatch_alarms/outputs.tf @@ -0,0 +1,9 @@ +output "sns_topic_arn" { + description = "The ARN of the SNS topic for alarm notifications" + value = aws_sns_topic.alarms.arn +} + +output "sns_topic_name" { + description = "The name of the SNS topic for alarm notifications" + value = aws_sns_topic.alarms.name +} diff --git a/deploy/terraform/modules/cloudwatch_alarms/variables.tf b/deploy/terraform/modules/cloudwatch_alarms/variables.tf new file mode 100644 index 0000000000..c74dc0d34a --- /dev/null +++ b/deploy/terraform/modules/cloudwatch_alarms/variables.tf @@ -0,0 +1,162 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name prefix for alarm resources." + + validation { + condition = can(regex("^[a-zA-Z0-9-]+$", var.name)) + error_message = "Name must contain only alphanumeric characters and hyphens." + } +} + +################################################################################ +# Notification Configuration +################################################################################ + +variable "alarm_email_addresses" { + type = list(string) + description = "Email addresses for alarm notifications." + default = [] +} + +variable "kms_key_id" { + type = string + description = "KMS key ID for SNS topic encryption." + default = null +} + +################################################################################ +# ECS Configuration +################################################################################ + +variable "ecs_services" { + type = map(object({ + cluster_name = string + service_name = string + })) + description = "Map of ECS services to monitor." + default = {} +} + +variable "ecs_cpu_threshold" { + type = number + description = "CPU utilization threshold for ECS alarms." + default = 85 +} + +variable "ecs_memory_threshold" { + type = number + description = "Memory utilization threshold for ECS alarms." + default = 85 +} + +################################################################################ +# RDS Configuration +################################################################################ + +variable "rds_instance_identifier" { + type = string + description = "RDS instance identifier to monitor." + default = null +} + +variable "rds_cpu_threshold" { + type = number + description = "CPU utilization threshold for RDS alarms." + default = 80 +} + +variable "rds_free_storage_threshold_gb" { + type = number + description = "Free storage threshold in GB." + default = 5 +} + +variable "rds_connections_threshold" { + type = number + description = "Database connection count threshold." + default = 100 +} + +variable "rds_read_latency_threshold_ms" { + type = number + description = "Read latency threshold in milliseconds." + default = 20 +} + +################################################################################ +# ElastiCache Redis Configuration +################################################################################ + +variable "redis_replication_group_id" { + type = string + description = "ElastiCache replication group ID to monitor." + default = null +} + +variable "redis_cpu_threshold" { + type = number + description = "Engine CPU utilization threshold for Redis alarms." + default = 75 +} + +variable "redis_memory_threshold" { + type = number + description = "Memory usage percentage threshold for Redis alarms." + default = 80 +} + +variable "redis_evictions_threshold" { + type = number + description = "Evictions threshold for Redis alarms." + default = 100 +} + +################################################################################ +# ALB Configuration +################################################################################ + +variable "alb_arn_suffix" { + type = string + description = "ALB ARN suffix to monitor." + default = null +} + +variable "alb_5xx_threshold" { + type = number + description = "ALB 5xx error count threshold." + default = 10 +} + +variable "alb_target_5xx_threshold" { + type = number + description = "ALB target 5xx error count threshold." + default = 25 +} + +variable "alb_response_time_threshold" { + type = number + description = "ALB target response time threshold in seconds." + default = 2 +} + +variable "alb_target_group_arns" { + type = map(object({ + target_group_arn_suffix = string + })) + description = "Map of target groups to monitor for unhealthy hosts." + default = {} +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to alarm resources." + default = {} +} diff --git a/deploy/terraform/modules/cloudwatch_alarms/versions.tf b/deploy/terraform/modules/cloudwatch_alarms/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/modules/cloudwatch_alarms/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/deploy/terraform/modules/ecs_cluster/main.tf b/deploy/terraform/modules/ecs_cluster/main.tf new file mode 100644 index 0000000000..fbc0474a56 --- /dev/null +++ b/deploy/terraform/modules/ecs_cluster/main.tf @@ -0,0 +1,60 @@ +################################################################################ +# ECS Cluster +################################################################################ + +resource "aws_ecs_cluster" "this" { + name = var.name + + setting { + name = "containerInsights" + value = var.container_insights ? "enabled" : "disabled" + } + + configuration { + execute_command_configuration { + logging = var.enable_execute_command_logging ? "OVERRIDE" : "DEFAULT" + + dynamic "log_configuration" { + for_each = var.enable_execute_command_logging ? [1] : [] + content { + cloud_watch_log_group_name = aws_cloudwatch_log_group.execute_command[0].name + } + } + } + } + + tags = var.tags +} + +################################################################################ +# Cluster Capacity Providers +################################################################################ + +resource "aws_ecs_cluster_capacity_providers" "this" { + cluster_name = aws_ecs_cluster.this.name + + capacity_providers = var.capacity_providers + + dynamic "default_capacity_provider_strategy" { + for_each = var.default_capacity_provider_strategy + content { + capacity_provider = default_capacity_provider_strategy.value.capacity_provider + weight = default_capacity_provider_strategy.value.weight + base = default_capacity_provider_strategy.value.base + } + } +} + +################################################################################ +# Execute Command Logging +################################################################################ + +resource "aws_cloudwatch_log_group" "execute_command" { + count = var.enable_execute_command_logging ? 1 : 0 + + name = "/aws/ecs/${var.name}/execute-command" + retention_in_days = var.log_retention_in_days + kms_key_id = var.kms_key_id + + tags = var.tags +} diff --git a/deploy/terraform/modules/ecs_cluster/outputs.tf b/deploy/terraform/modules/ecs_cluster/outputs.tf new file mode 100644 index 0000000000..657e6942c3 --- /dev/null +++ b/deploy/terraform/modules/ecs_cluster/outputs.tf @@ -0,0 +1,14 @@ +output "id" { + description = "The ID of the ECS cluster" + value = aws_ecs_cluster.this.id +} + +output "arn" { + description = "The ARN of the ECS cluster" + value = aws_ecs_cluster.this.arn +} + +output "name" { + description = "The name of the ECS cluster" + value = aws_ecs_cluster.this.name +} diff --git a/deploy/terraform/modules/ecs_cluster/variables.tf b/deploy/terraform/modules/ecs_cluster/variables.tf new file mode 100644 index 0000000000..f43643ac31 --- /dev/null +++ b/deploy/terraform/modules/ecs_cluster/variables.tf @@ -0,0 +1,66 @@ +variable "name" { + type = string + description = "Name of the ECS cluster." + + validation { + condition = can(regex("^[a-zA-Z0-9-_]+$", var.name)) + error_message = "Cluster name must contain only alphanumeric characters, hyphens, and underscores." + } +} + +variable "container_insights" { + type = bool + description = "Enable CloudWatch Container Insights for the cluster." + default = true +} + +variable "capacity_providers" { + type = list(string) + description = "List of capacity providers to associate with the cluster." + default = ["FARGATE", "FARGATE_SPOT"] +} + +variable "default_capacity_provider_strategy" { + type = list(object({ + capacity_provider = string + weight = number + base = optional(number, 0) + })) + description = "Default capacity provider strategy for the cluster." + default = [ + { + capacity_provider = "FARGATE" + weight = 1 + base = 1 + } + ] +} + +variable "enable_execute_command_logging" { + type = bool + description = "Enable logging for ECS Exec commands." + default = false +} + +variable "log_retention_in_days" { + type = number + description = "Number of days to retain execute command logs." + default = 30 + + validation { + condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.log_retention_in_days) + error_message = "Log retention must be a valid CloudWatch Logs retention value." + } +} + +variable "kms_key_id" { + type = string + description = "KMS key ID for encrypting CloudWatch log groups." + default = null +} + +variable "tags" { + type = map(string) + description = "Tags to apply to the ECS cluster." + default = {} +} diff --git a/deploy/terraform/modules/ecs_cluster/versions.tf b/deploy/terraform/modules/ecs_cluster/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/modules/ecs_cluster/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/deploy/terraform/modules/ecs_service/main.tf b/deploy/terraform/modules/ecs_service/main.tf new file mode 100644 index 0000000000..d22031e4c4 --- /dev/null +++ b/deploy/terraform/modules/ecs_service/main.tf @@ -0,0 +1,369 @@ +################################################################################ +# CloudWatch Log Group +################################################################################ + +resource "aws_cloudwatch_log_group" "this" { + name = "/ecs/${var.name}" + retention_in_days = var.log_retention_in_days + kms_key_id = var.kms_key_id + + tags = var.tags +} + +################################################################################ +# Security Group +################################################################################ + +resource "aws_security_group" "ecs_service" { + name = "${var.name}-ecs" + description = "Security group for ECS service ${var.name}" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name}-ecs-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "container" { + security_group_id = aws_security_group.ecs_service.id + description = "Allow traffic to container port from VPC" + from_port = var.container_port + to_port = var.container_port + ip_protocol = "tcp" + cidr_ipv4 = var.vpc_cidr_block + + tags = var.tags +} + +resource "aws_vpc_security_group_egress_rule" "all" { + security_group_id = aws_security_group.ecs_service.id + description = "Allow all outbound traffic" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = var.tags +} + +################################################################################ +# Target Group +################################################################################ + +resource "aws_lb_target_group" "this" { + name = "${var.name}-tg" + port = var.container_port + protocol = "HTTP" + target_type = "ip" + vpc_id = var.vpc_id + deregistration_delay = var.deregistration_delay + + health_check { + enabled = true + path = var.health_check_path + matcher = var.health_check_matcher + healthy_threshold = var.health_check_healthy_threshold + unhealthy_threshold = var.health_check_unhealthy_threshold + interval = var.health_check_interval + timeout = var.health_check_timeout + } + + tags = var.tags + + lifecycle { + create_before_destroy = true + } +} + +################################################################################ +# Listener Rule +################################################################################ + +resource "aws_lb_listener_rule" "this" { + listener_arn = var.listener_arn + priority = var.listener_rule_priority + + action { + type = "forward" + target_group_arn = aws_lb_target_group.this.arn + } + + condition { + path_pattern { + values = var.path_patterns + } + } + + tags = var.tags +} + +################################################################################ +# IAM - Task Execution Role +################################################################################ + +resource "aws_iam_role" "task_execution" { + name = "${var.name}-task-execution" + assume_role_policy = data.aws_iam_policy_document.task_execution_assume.json + + tags = var.tags +} + +data "aws_iam_policy_document" "task_execution_assume" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role_policy_attachment" "task_execution" { + role = aws_iam_role.task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy" "task_execution_secrets" { + count = length(var.secrets) > 0 ? 1 : 0 + + name = "${var.name}-secrets-access" + role = aws_iam_role.task_execution.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue" + ] + # Use the secret ARN directly - callers should provide the base secret ARN + Resource = [for s in var.secrets : s.valueFrom] + } + ] + }) +} + +################################################################################ +# Task Definition +################################################################################ + +resource "aws_ecs_task_definition" "this" { + family = var.name + cpu = var.cpu + memory = var.memory + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + execution_role_arn = aws_iam_role.task_execution.arn + task_role_arn = var.task_role_arn + + container_definitions = jsonencode([ + merge( + { + name = var.name + image = var.container_image + essential = true + + portMappings = [ + { + containerPort = var.container_port + hostPort = var.container_port + protocol = "tcp" + name = var.name + } + ] + + environment = [ + for k, v in var.environment_variables : + { + name = k + value = v + } + ] + + secrets = length(var.secrets) > 0 ? [ + for s in var.secrets : + { + name = s.name + valueFrom = s.valueFrom + } + ] : [] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.this.name + awslogs-region = var.region + awslogs-stream-prefix = var.name + } + } + + # Security hardening + readonlyRootFilesystem = var.readonly_root_filesystem + + # Enable init process for proper signal handling (PID 1 zombie reaping) + linuxParameters = { + initProcessEnabled = true + } + }, + var.container_health_check != null ? { + healthCheck = { + command = var.container_health_check.command + interval = var.container_health_check.interval + timeout = var.container_health_check.timeout + retries = var.container_health_check.retries + startPeriod = var.container_health_check.start_period + } + } : {} + ) + ]) + + runtime_platform { + cpu_architecture = var.cpu_architecture + operating_system_family = "LINUX" + } + + tags = var.tags +} + +################################################################################ +# Local Variables +################################################################################ + +locals { + # Determine if we should use capacity provider strategy + use_capacity_providers = var.use_fargate_spot || var.use_capacity_provider_strategy + + # Build the capacity provider strategy based on use_fargate_spot or custom strategy + effective_capacity_provider_strategy = var.use_fargate_spot ? [ + { + capacity_provider = "FARGATE_SPOT" + weight = 1 + base = 0 + } + ] : var.capacity_provider_strategy +} + +################################################################################ +# ECS Service +################################################################################ + +resource "aws_ecs_service" "this" { + name = var.name + cluster = var.cluster_arn + task_definition = aws_ecs_task_definition.this.arn + desired_count = var.desired_count + launch_type = local.use_capacity_providers ? null : "FARGATE" + enable_execute_command = var.enable_execute_command + health_check_grace_period_seconds = var.health_check_grace_period_seconds + deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent + deployment_maximum_percent = var.deployment_maximum_percent + + dynamic "capacity_provider_strategy" { + for_each = local.use_capacity_providers ? local.effective_capacity_provider_strategy : [] + content { + capacity_provider = capacity_provider_strategy.value.capacity_provider + weight = capacity_provider_strategy.value.weight + base = capacity_provider_strategy.value.base + } + } + + network_configuration { + subnets = var.subnet_ids + security_groups = [aws_security_group.ecs_service.id] + assign_public_ip = var.assign_public_ip + } + + load_balancer { + target_group_arn = aws_lb_target_group.this.arn + container_name = var.name + container_port = var.container_port + } + + deployment_circuit_breaker { + enable = var.enable_circuit_breaker + rollback = var.enable_circuit_breaker_rollback + } + + propagate_tags = "SERVICE" + wait_for_steady_state = var.wait_for_steady_state + + lifecycle { + ignore_changes = [desired_count] + } + + depends_on = [aws_lb_listener_rule.this] + + tags = var.tags +} + +################################################################################ +# Application Auto Scaling +################################################################################ + +resource "aws_appautoscaling_target" "this" { + count = var.enable_autoscaling ? 1 : 0 + + max_capacity = var.autoscaling_max_capacity + min_capacity = var.autoscaling_min_capacity + resource_id = "service/${split("/", var.cluster_arn)[1]}/${aws_ecs_service.this.name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +resource "aws_appautoscaling_policy" "cpu" { + count = var.enable_autoscaling ? 1 : 0 + + name = "${var.name}-cpu-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.this[0].resource_id + scalable_dimension = aws_appautoscaling_target.this[0].scalable_dimension + service_namespace = aws_appautoscaling_target.this[0].service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + target_value = var.autoscaling_cpu_target + scale_in_cooldown = var.autoscaling_scale_in_cooldown + scale_out_cooldown = var.autoscaling_scale_out_cooldown + } +} + +resource "aws_appautoscaling_policy" "memory" { + count = var.enable_autoscaling ? 1 : 0 + + name = "${var.name}-memory-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.this[0].resource_id + scalable_dimension = aws_appautoscaling_target.this[0].scalable_dimension + service_namespace = aws_appautoscaling_target.this[0].service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageMemoryUtilization" + } + target_value = var.autoscaling_memory_target + scale_in_cooldown = var.autoscaling_scale_in_cooldown + scale_out_cooldown = var.autoscaling_scale_out_cooldown + } +} + +resource "aws_appautoscaling_policy" "requests" { + count = var.enable_autoscaling && var.autoscaling_requests_per_target > 0 ? 1 : 0 + + name = "${var.name}-request-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.this[0].resource_id + scalable_dimension = aws_appautoscaling_target.this[0].scalable_dimension + service_namespace = aws_appautoscaling_target.this[0].service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ALBRequestCountPerTarget" + resource_label = "${var.alb_arn_suffix}/${aws_lb_target_group.this.arn_suffix}" + } + target_value = var.autoscaling_requests_per_target + scale_in_cooldown = var.autoscaling_scale_in_cooldown + scale_out_cooldown = var.autoscaling_scale_out_cooldown + } +} diff --git a/deploy/terraform/modules/ecs_service/outputs.tf b/deploy/terraform/modules/ecs_service/outputs.tf new file mode 100644 index 0000000000..604a48d54c --- /dev/null +++ b/deploy/terraform/modules/ecs_service/outputs.tf @@ -0,0 +1,49 @@ +output "service_id" { + description = "The ID of the ECS service" + value = aws_ecs_service.this.id +} + +output "service_name" { + description = "The name of the ECS service" + value = aws_ecs_service.this.name +} + +output "task_definition_arn" { + description = "The ARN of the task definition" + value = aws_ecs_task_definition.this.arn +} + +output "task_definition_family" { + description = "The family of the task definition" + value = aws_ecs_task_definition.this.family +} + +output "security_group_id" { + description = "The ID of the ECS service security group" + value = aws_security_group.ecs_service.id +} + +output "target_group_arn" { + description = "The ARN of the target group" + value = aws_lb_target_group.this.arn +} + +output "cloudwatch_log_group_name" { + description = "The name of the CloudWatch log group" + value = aws_cloudwatch_log_group.this.name +} + +output "cloudwatch_log_group_arn" { + description = "The ARN of the CloudWatch log group" + value = aws_cloudwatch_log_group.this.arn +} + +output "target_group_arn_suffix" { + description = "The ARN suffix of the target group (for CloudWatch alarms)" + value = aws_lb_target_group.this.arn_suffix +} + +output "execution_role_arn" { + description = "The ARN of the task execution role" + value = aws_iam_role.task_execution.arn +} diff --git a/deploy/terraform/modules/ecs_service/variables.tf b/deploy/terraform/modules/ecs_service/variables.tf new file mode 100644 index 0000000000..8a6747c6f6 --- /dev/null +++ b/deploy/terraform/modules/ecs_service/variables.tf @@ -0,0 +1,411 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name of the ECS service." + + validation { + condition = can(regex("^[a-zA-Z0-9-_]+$", var.name)) + error_message = "Service name must contain only alphanumeric characters, hyphens, and underscores." + } +} + +variable "region" { + type = string + description = "AWS region." +} + +variable "cluster_arn" { + type = string + description = "ARN of the ECS cluster." +} + +variable "container_image" { + type = string + description = "Container image to deploy." + + validation { + condition = length(var.container_image) > 0 + error_message = "Container image must not be empty." + } +} + +variable "container_port" { + type = number + description = "Container port exposed by the service." + + validation { + condition = var.container_port > 0 && var.container_port <= 65535 + error_message = "Container port must be between 1 and 65535." + } +} + +variable "cpu" { + type = string + description = "Fargate CPU units (256, 512, 1024, 2048, 4096, 8192, 16384)." + + validation { + condition = contains(["256", "512", "1024", "2048", "4096", "8192", "16384"], var.cpu) + error_message = "CPU must be one of: 256, 512, 1024, 2048, 4096, 8192, 16384." + } +} + +variable "memory" { + type = string + description = "Fargate memory in MiB." +} + +variable "vpc_id" { + type = string + description = "VPC ID for the service." +} + +variable "vpc_cidr_block" { + type = string + description = "CIDR block of the VPC." +} + +variable "subnet_ids" { + type = list(string) + description = "Subnets for ECS tasks." + + validation { + condition = length(var.subnet_ids) >= 1 + error_message = "At least one subnet must be specified." + } +} + +variable "listener_arn" { + type = string + description = "ALB listener ARN." +} + +variable "listener_rule_priority" { + type = number + description = "Priority for the ALB listener rule." + + validation { + condition = var.listener_rule_priority >= 1 && var.listener_rule_priority <= 50000 + error_message = "Listener rule priority must be between 1 and 50000." + } +} + +################################################################################ +# Optional Variables - Deployment +################################################################################ + +variable "desired_count" { + type = number + description = "Desired number of tasks." + default = 1 + + validation { + condition = var.desired_count >= 0 + error_message = "Desired count must be non-negative." + } +} + +variable "assign_public_ip" { + type = bool + description = "Assign public IP to tasks." + default = false +} + +variable "cpu_architecture" { + type = string + description = "CPU architecture (X86_64 or ARM64)." + default = "X86_64" + + validation { + condition = contains(["X86_64", "ARM64"], var.cpu_architecture) + error_message = "CPU architecture must be X86_64 or ARM64." + } +} + +variable "enable_execute_command" { + type = bool + description = "Enable ECS Exec for debugging." + default = false +} + +variable "use_capacity_provider_strategy" { + type = bool + description = "Use capacity provider strategy instead of launch type. Automatically set to true if use_fargate_spot is true." + default = false +} + +variable "use_fargate_spot" { + type = bool + description = "Use Fargate Spot capacity (convenience variable that automatically configures capacity provider strategy)." + default = false +} + +variable "capacity_provider_strategy" { + type = list(object({ + capacity_provider = string + weight = number + base = optional(number, 0) + })) + description = "Capacity provider strategy (requires use_capacity_provider_strategy = true). Ignored if use_fargate_spot is true." + default = [ + { + capacity_provider = "FARGATE" + weight = 1 + base = 1 + } + ] +} + +################################################################################ +# Optional Variables - Deployment Strategy +################################################################################ + +variable "deployment_minimum_healthy_percent" { + type = number + description = "Minimum healthy percent during deployment." + default = 100 +} + +variable "deployment_maximum_percent" { + type = number + description = "Maximum percent during deployment." + default = 200 +} + +variable "enable_circuit_breaker" { + type = bool + description = "Enable deployment circuit breaker." + default = true +} + +variable "enable_circuit_breaker_rollback" { + type = bool + description = "Enable automatic rollback on deployment failure." + default = true +} + +variable "wait_for_steady_state" { + type = bool + description = "Wait for the ECS service to reach a steady state after deployment. Recommended for production." + default = false +} + +################################################################################ +# Optional Variables - Health Check +################################################################################ + +variable "path_patterns" { + type = list(string) + description = "Path patterns for ALB listener rule." + default = ["/*"] +} + +variable "health_check_path" { + type = string + description = "Health check path for the target group." + default = "/" +} + +variable "health_check_matcher" { + type = string + description = "HTTP status codes for healthy response." + default = "200-399" +} + +variable "health_check_interval" { + type = number + description = "Health check interval in seconds." + default = 30 +} + +variable "health_check_timeout" { + type = number + description = "Health check timeout in seconds." + default = 5 +} + +variable "health_check_healthy_threshold" { + type = number + description = "Number of consecutive successful health checks." + default = 2 +} + +variable "health_check_unhealthy_threshold" { + type = number + description = "Number of consecutive failed health checks." + default = 5 +} + +variable "health_check_grace_period_seconds" { + type = number + description = "Seconds to wait before health checks start." + default = 60 +} + +variable "deregistration_delay" { + type = number + description = "Time to wait for in-flight requests before deregistering." + default = 30 +} + +variable "container_health_check" { + type = object({ + command = list(string) + interval = optional(number, 30) + timeout = optional(number, 5) + retries = optional(number, 3) + start_period = optional(number, 60) + }) + description = "Container health check configuration." + default = null +} + +################################################################################ +# Optional Variables - Security +################################################################################ + +variable "readonly_root_filesystem" { + type = bool + description = "When true, the container root filesystem is read-only. Requires writable tmpfs mounts for /tmp etc." + default = false +} + +variable "kms_key_id" { + type = string + description = "KMS key ID for encrypting the CloudWatch log group." + default = null +} + +################################################################################ +# Optional Variables - Logging +################################################################################ + +variable "log_retention_in_days" { + type = number + description = "CloudWatch log retention in days." + default = 30 + + validation { + condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.log_retention_in_days) + error_message = "Log retention must be a valid CloudWatch Logs retention value." + } +} + +################################################################################ +# Optional Variables - Environment & Secrets +################################################################################ + +variable "environment_variables" { + type = map(string) + description = "Plain environment variables for the container. Sensitive values should use the secrets variable instead." + default = {} +} + +variable "secrets" { + type = list(object({ + name = string + valueFrom = string + })) + description = "Secrets from Secrets Manager or Parameter Store." + default = [] +} + +################################################################################ +# Optional Variables - IAM +################################################################################ + +variable "task_role_arn" { + type = string + description = "Optional task role ARN to attach to the task definition." + default = null +} + +################################################################################ +# Auto Scaling +################################################################################ + +variable "enable_autoscaling" { + type = bool + description = "Enable Application Auto Scaling for the ECS service." + default = false +} + +variable "autoscaling_min_capacity" { + type = number + description = "Minimum number of tasks when auto-scaling." + default = 1 + + validation { + condition = var.autoscaling_min_capacity >= 1 + error_message = "Minimum capacity must be at least 1." + } +} + +variable "autoscaling_max_capacity" { + type = number + description = "Maximum number of tasks when auto-scaling." + default = 10 + + validation { + condition = var.autoscaling_max_capacity >= 1 + error_message = "Maximum capacity must be at least 1." + } +} + +variable "autoscaling_cpu_target" { + type = number + description = "Target CPU utilization percentage for auto-scaling." + default = 70 + + validation { + condition = var.autoscaling_cpu_target > 0 && var.autoscaling_cpu_target <= 100 + error_message = "CPU target must be between 1 and 100." + } +} + +variable "autoscaling_memory_target" { + type = number + description = "Target memory utilization percentage for auto-scaling." + default = 80 + + validation { + condition = var.autoscaling_memory_target > 0 && var.autoscaling_memory_target <= 100 + error_message = "Memory target must be between 1 and 100." + } +} + +variable "autoscaling_requests_per_target" { + type = number + description = "Target ALB request count per task for auto-scaling. Set to 0 to disable request-based scaling." + default = 0 +} + +variable "autoscaling_scale_in_cooldown" { + type = number + description = "Cooldown period in seconds after a scale-in event." + default = 300 +} + +variable "autoscaling_scale_out_cooldown" { + type = number + description = "Cooldown period in seconds after a scale-out event." + default = 60 +} + +variable "alb_arn_suffix" { + type = string + description = "ALB ARN suffix (required for request-based auto-scaling)." + default = "" +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to resources." + default = {} +} diff --git a/deploy/terraform/modules/ecs_service/versions.tf b/deploy/terraform/modules/ecs_service/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/modules/ecs_service/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/deploy/terraform/modules/elasticache_redis/main.tf b/deploy/terraform/modules/elasticache_redis/main.tf new file mode 100644 index 0000000000..5fde04ae38 --- /dev/null +++ b/deploy/terraform/modules/elasticache_redis/main.tf @@ -0,0 +1,181 @@ +################################################################################ +# Security Group +################################################################################ + +resource "aws_security_group" "this" { + name = "${var.name}-sg" + description = "Security group for ElastiCache Redis ${var.name}" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name}-redis-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "redis_sg" { + for_each = toset(var.allowed_security_group_ids) + + security_group_id = aws_security_group.this.id + description = "Redis access from allowed security group" + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + referenced_security_group_id = each.value + + tags = var.tags +} + +resource "aws_vpc_security_group_ingress_rule" "redis_cidr" { + count = length(var.allowed_cidr_blocks) + + security_group_id = aws_security_group.this.id + description = "Redis access from allowed CIDR block" + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + cidr_ipv4 = var.allowed_cidr_blocks[count.index] + + tags = var.tags +} + +resource "aws_vpc_security_group_egress_rule" "vpc" { + security_group_id = aws_security_group.this.id + description = "Allow outbound traffic within VPC only" + ip_protocol = "-1" + cidr_ipv4 = var.vpc_cidr_block + + tags = var.tags +} + +################################################################################ +# Subnet Group +################################################################################ + +resource "aws_elasticache_subnet_group" "this" { + name = "${var.name}-subnets" + subnet_ids = var.subnet_ids + + tags = merge(var.tags, { + Name = "${var.name}-subnet-group" + }) +} + +################################################################################ +# Parameter Group (Optional) +################################################################################ + +resource "aws_elasticache_parameter_group" "this" { + count = var.create_parameter_group ? 1 : 0 + + name = "${var.name}-params" + family = "redis${split(".", var.engine_version)[0]}" + + dynamic "parameter" { + for_each = var.parameters + content { + name = parameter.value.name + value = parameter.value.value + } + } + + tags = var.tags + + lifecycle { + create_before_destroy = true + } +} + +################################################################################ +# Replication Group +################################################################################ + +resource "aws_elasticache_replication_group" "this" { + replication_group_id = var.name + description = var.description != "" ? var.description : "Redis for ${var.name}" + + # Engine + engine = "redis" + engine_version = var.engine_version + node_type = var.node_type + + # Cluster Configuration + num_cache_clusters = var.num_cache_clusters + automatic_failover_enabled = var.automatic_failover_enabled + multi_az_enabled = var.multi_az_enabled + + # Network + port = 6379 + subnet_group_name = aws_elasticache_subnet_group.this.name + security_group_ids = [aws_security_group.this.id] + parameter_group_name = var.create_parameter_group ? aws_elasticache_parameter_group.this[0].name : null + + # Security + at_rest_encryption_enabled = true + transit_encryption_enabled = var.transit_encryption_enabled + auth_token = var.auth_token + kms_key_id = var.kms_key_id + + # Maintenance + auto_minor_version_upgrade = var.auto_minor_version_upgrade + apply_immediately = var.apply_immediately + maintenance_window = var.maintenance_window + + # Snapshots + snapshot_retention_limit = var.snapshot_retention_limit + snapshot_window = var.snapshot_window + final_snapshot_identifier = var.skip_final_snapshot ? null : "${var.name}-final-snapshot" + + # Notifications + notification_topic_arn = var.notification_topic_arn + + # Log Delivery + dynamic "log_delivery_configuration" { + for_each = var.enable_slow_log ? [1] : [] + content { + destination = aws_cloudwatch_log_group.slow_log[0].name + destination_type = "cloudwatch-logs" + log_format = "json" + log_type = "slow-log" + } + } + + dynamic "log_delivery_configuration" { + for_each = var.enable_engine_log ? [1] : [] + content { + destination = aws_cloudwatch_log_group.engine_log[0].name + destination_type = "cloudwatch-logs" + log_format = "json" + log_type = "engine-log" + } + } + + tags = var.tags + + lifecycle { + ignore_changes = [auth_token] + } +} + +################################################################################ +# Log Groups +################################################################################ + +resource "aws_cloudwatch_log_group" "slow_log" { + count = var.enable_slow_log ? 1 : 0 + + name = "/aws/elasticache/${var.name}/slow-log" + retention_in_days = var.log_retention_in_days + kms_key_id = var.kms_key_id + + tags = var.tags +} + +resource "aws_cloudwatch_log_group" "engine_log" { + count = var.enable_engine_log ? 1 : 0 + + name = "/aws/elasticache/${var.name}/engine-log" + retention_in_days = var.log_retention_in_days + kms_key_id = var.kms_key_id + + tags = var.tags +} diff --git a/deploy/terraform/modules/elasticache_redis/outputs.tf b/deploy/terraform/modules/elasticache_redis/outputs.tf new file mode 100644 index 0000000000..367586a4d4 --- /dev/null +++ b/deploy/terraform/modules/elasticache_redis/outputs.tf @@ -0,0 +1,39 @@ +output "primary_endpoint_address" { + description = "The primary endpoint address for the Redis replication group" + value = aws_elasticache_replication_group.this.primary_endpoint_address +} + +output "reader_endpoint_address" { + description = "The reader endpoint address for the Redis replication group" + value = aws_elasticache_replication_group.this.reader_endpoint_address +} + +output "port" { + description = "The Redis port" + value = aws_elasticache_replication_group.this.port +} + +output "replication_group_id" { + description = "The ID of the ElastiCache replication group" + value = aws_elasticache_replication_group.this.id +} + +output "arn" { + description = "The ARN of the ElastiCache replication group" + value = aws_elasticache_replication_group.this.arn +} + +output "security_group_id" { + description = "The ID of the Redis security group" + value = aws_security_group.this.id +} + +output "subnet_group_name" { + description = "The name of the ElastiCache subnet group" + value = aws_elasticache_subnet_group.this.name +} + +output "connection_string" { + description = "Redis connection string for .NET applications" + value = "${aws_elasticache_replication_group.this.primary_endpoint_address}:${aws_elasticache_replication_group.this.port},ssl=True,abortConnect=False" +} diff --git a/deploy/terraform/modules/elasticache_redis/variables.tf b/deploy/terraform/modules/elasticache_redis/variables.tf new file mode 100644 index 0000000000..b9d153461e --- /dev/null +++ b/deploy/terraform/modules/elasticache_redis/variables.tf @@ -0,0 +1,232 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name identifier for the Redis replication group." + + validation { + condition = can(regex("^[a-z][a-z0-9-]*$", var.name)) + error_message = "Name must start with a letter and contain only lowercase letters, numbers, and hyphens." + } +} + +variable "vpc_id" { + type = string + description = "VPC ID." +} + +variable "subnet_ids" { + type = list(string) + description = "Subnets for ElastiCache." + + validation { + condition = length(var.subnet_ids) >= 1 + error_message = "At least one subnet must be specified." + } +} + +variable "allowed_security_group_ids" { + type = list(string) + description = "Security groups allowed to access Redis (use when SG IDs are known at plan time)." + default = [] +} + +variable "allowed_cidr_blocks" { + type = list(string) + description = "CIDR blocks allowed to access Redis (use when security groups are not yet created)." + default = [] +} + +variable "vpc_cidr_block" { + type = string + description = "VPC CIDR block (used to restrict egress to VPC only)." +} + +################################################################################ +# Engine Configuration +################################################################################ + +variable "engine_version" { + type = string + description = "Redis engine version." + default = "7.2" +} + +variable "node_type" { + type = string + description = "Node instance type." + default = "cache.t4g.micro" +} + +variable "description" { + type = string + description = "Description of the replication group." + default = "" +} + +################################################################################ +# Cluster Configuration +################################################################################ + +variable "num_cache_clusters" { + type = number + description = "Number of cache clusters (nodes)." + default = 1 + + validation { + condition = var.num_cache_clusters >= 1 && var.num_cache_clusters <= 6 + error_message = "Number of cache clusters must be between 1 and 6." + } +} + +variable "automatic_failover_enabled" { + type = bool + description = "Enable automatic failover (requires num_cache_clusters >= 2)." + default = false +} + +variable "multi_az_enabled" { + type = bool + description = "Enable Multi-AZ (requires automatic_failover_enabled)." + default = false +} + +################################################################################ +# Security Configuration +################################################################################ + +variable "transit_encryption_enabled" { + type = bool + description = "Enable encryption in transit." + default = true +} + +variable "auth_token" { + type = string + description = "Auth token for Redis AUTH (requires transit_encryption_enabled)." + default = null + sensitive = true +} + +variable "kms_key_id" { + type = string + description = "KMS key ID for at-rest encryption. Uses default AWS key if not specified." + default = null +} + +################################################################################ +# Maintenance Configuration +################################################################################ + +variable "auto_minor_version_upgrade" { + type = bool + description = "Enable automatic minor version upgrades." + default = true +} + +variable "apply_immediately" { + type = bool + description = "Apply changes immediately instead of during maintenance window." + default = false +} + +variable "maintenance_window" { + type = string + description = "Maintenance window (UTC)." + default = "sun:05:00-sun:06:00" +} + +################################################################################ +# Snapshot Configuration +################################################################################ + +variable "snapshot_retention_limit" { + type = number + description = "Days to retain snapshots (0 to disable)." + default = 7 + + validation { + condition = var.snapshot_retention_limit >= 0 && var.snapshot_retention_limit <= 35 + error_message = "Snapshot retention must be between 0 and 35 days." + } +} + +variable "snapshot_window" { + type = string + description = "Daily snapshot window (UTC)." + default = "03:00-04:00" +} + +variable "skip_final_snapshot" { + type = bool + description = "Skip final snapshot on destroy." + default = false +} + +################################################################################ +# Parameter Group Configuration +################################################################################ + +variable "create_parameter_group" { + type = bool + description = "Create a custom parameter group." + default = false +} + +variable "parameters" { + type = list(object({ + name = string + value = string + })) + description = "List of Redis parameters to apply." + default = [] +} + +################################################################################ +# Logging +################################################################################ + +variable "enable_slow_log" { + type = bool + description = "Enable slow log delivery to CloudWatch Logs." + default = false +} + +variable "enable_engine_log" { + type = bool + description = "Enable engine log delivery to CloudWatch Logs." + default = false +} + +variable "log_retention_in_days" { + type = number + description = "CloudWatch log retention in days." + default = 30 + + validation { + condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.log_retention_in_days) + error_message = "Log retention must be a valid CloudWatch Logs retention value." + } +} + +################################################################################ +# Notifications +################################################################################ + +variable "notification_topic_arn" { + type = string + description = "SNS topic ARN for notifications." + default = null +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to Redis resources." + default = {} +} diff --git a/deploy/terraform/modules/elasticache_redis/versions.tf b/deploy/terraform/modules/elasticache_redis/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/modules/elasticache_redis/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/deploy/terraform/modules/network/main.tf b/deploy/terraform/modules/network/main.tf new file mode 100644 index 0000000000..dad55bf976 --- /dev/null +++ b/deploy/terraform/modules/network/main.tf @@ -0,0 +1,332 @@ +################################################################################ +# VPC +################################################################################ + +resource "aws_vpc" "this" { + cidr_block = var.cidr_block + enable_dns_support = true + enable_dns_hostnames = true + + tags = merge(var.tags, { + Name = "${var.name}-vpc" + }) +} + +################################################################################ +# Internet Gateway +################################################################################ + +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { + Name = "${var.name}-igw" + }) +} + +################################################################################ +# Public Subnets +################################################################################ + +resource "aws_subnet" "public" { + for_each = var.public_subnets + + vpc_id = aws_vpc.this.id + cidr_block = each.value.cidr_block + availability_zone = each.value.az + map_public_ip_on_launch = true + + tags = merge(var.tags, { + Name = "${var.name}-public-${each.key}" + Type = "public" + }) +} + +################################################################################ +# Private Subnets +################################################################################ + +resource "aws_subnet" "private" { + for_each = var.private_subnets + + vpc_id = aws_vpc.this.id + cidr_block = each.value.cidr_block + availability_zone = each.value.az + + tags = merge(var.tags, { + Name = "${var.name}-private-${each.key}" + Type = "private" + }) +} + +################################################################################ +# NAT Gateways +################################################################################ + +resource "aws_eip" "nat" { + for_each = var.enable_nat_gateway ? (var.single_nat_gateway ? { "single" = aws_subnet.public[keys(aws_subnet.public)[0]] } : aws_subnet.public) : {} + + domain = "vpc" + + tags = merge(var.tags, { + Name = "${var.name}-nat-${each.key}" + }) + + depends_on = [aws_internet_gateway.this] +} + +resource "aws_nat_gateway" "this" { + for_each = var.enable_nat_gateway ? (var.single_nat_gateway ? { "single" = aws_subnet.public[keys(aws_subnet.public)[0]] } : aws_subnet.public) : {} + + allocation_id = aws_eip.nat[each.key].id + subnet_id = each.value.id + + tags = merge(var.tags, { + Name = "${var.name}-nat-${each.key}" + }) + + depends_on = [aws_internet_gateway.this] +} + +################################################################################ +# Route Tables +################################################################################ + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { + Name = "${var.name}-public" + }) +} + +resource "aws_route" "public_internet_gateway" { + route_table_id = aws_route_table.public.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id +} + +resource "aws_route_table_association" "public" { + for_each = aws_subnet.public + subnet_id = each.value.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + for_each = var.enable_nat_gateway ? (var.single_nat_gateway ? { "single" = null } : aws_subnet.private) : aws_subnet.private + + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { + Name = "${var.name}-private-${each.key}" + }) +} + +resource "aws_route" "private_nat_gateway" { + for_each = var.enable_nat_gateway ? aws_route_table.private : {} + + route_table_id = each.value.id + destination_cidr_block = "0.0.0.0/0" + nat_gateway_id = var.single_nat_gateway ? aws_nat_gateway.this["single"].id : aws_nat_gateway.this[each.key].id +} + +resource "aws_route_table_association" "private" { + for_each = aws_subnet.private + subnet_id = each.value.id + route_table_id = var.single_nat_gateway && var.enable_nat_gateway ? aws_route_table.private["single"].id : aws_route_table.private[each.key].id +} + +################################################################################ +# VPC Endpoints (Cost Optimization) +################################################################################ + +resource "aws_vpc_endpoint" "s3" { + count = var.enable_s3_endpoint ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = concat([aws_route_table.public.id], [for rt in aws_route_table.private : rt.id]) + + tags = merge(var.tags, { + Name = "${var.name}-s3-endpoint" + }) +} + +resource "aws_vpc_endpoint" "ecr_api" { + count = var.enable_ecr_endpoints ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.ecr.api" + vpc_endpoint_type = "Interface" + subnet_ids = [for s in aws_subnet.private : s.id] + security_group_ids = [aws_security_group.vpc_endpoints[0].id] + private_dns_enabled = true + + tags = merge(var.tags, { + Name = "${var.name}-ecr-api-endpoint" + }) +} + +resource "aws_vpc_endpoint" "ecr_dkr" { + count = var.enable_ecr_endpoints ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.ecr.dkr" + vpc_endpoint_type = "Interface" + subnet_ids = [for s in aws_subnet.private : s.id] + security_group_ids = [aws_security_group.vpc_endpoints[0].id] + private_dns_enabled = true + + tags = merge(var.tags, { + Name = "${var.name}-ecr-dkr-endpoint" + }) +} + +resource "aws_vpc_endpoint" "logs" { + count = var.enable_logs_endpoint ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.logs" + vpc_endpoint_type = "Interface" + subnet_ids = [for s in aws_subnet.private : s.id] + security_group_ids = [aws_security_group.vpc_endpoints[0].id] + private_dns_enabled = true + + tags = merge(var.tags, { + Name = "${var.name}-logs-endpoint" + }) +} + +resource "aws_vpc_endpoint" "secretsmanager" { + count = var.enable_secretsmanager_endpoint ? 1 : 0 + + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.secretsmanager" + vpc_endpoint_type = "Interface" + subnet_ids = [for s in aws_subnet.private : s.id] + security_group_ids = [aws_security_group.vpc_endpoints[0].id] + private_dns_enabled = true + + tags = merge(var.tags, { + Name = "${var.name}-secretsmanager-endpoint" + }) +} + +resource "aws_security_group" "vpc_endpoints" { + count = var.enable_ecr_endpoints || var.enable_logs_endpoint || var.enable_secretsmanager_endpoint ? 1 : 0 + + name = "${var.name}-vpc-endpoints" + description = "Security group for VPC endpoints" + vpc_id = aws_vpc.this.id + + ingress { + description = "HTTPS from VPC" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [aws_vpc.this.cidr_block] + } + + egress { + description = "HTTPS to VPC" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [aws_vpc.this.cidr_block] + } + + tags = merge(var.tags, { + Name = "${var.name}-vpc-endpoints-sg" + }) +} + +################################################################################ +# Default Security Group - Deny All (AWS Best Practice) +################################################################################ + +resource "aws_default_security_group" "this" { + vpc_id = aws_vpc.this.id + + # No ingress or egress rules = deny all traffic on the default SG + tags = merge(var.tags, { + Name = "${var.name}-default-sg-DO-NOT-USE" + }) +} + +################################################################################ +# Flow Logs (Optional) +################################################################################ + +resource "aws_flow_log" "this" { + count = var.enable_flow_logs ? 1 : 0 + + vpc_id = aws_vpc.this.id + traffic_type = "ALL" + iam_role_arn = aws_iam_role.flow_logs[0].arn + log_destination_type = "cloud-watch-logs" + log_destination = aws_cloudwatch_log_group.flow_logs[0].arn + max_aggregation_interval = 60 + + tags = merge(var.tags, { + Name = "${var.name}-flow-logs" + }) +} + +resource "aws_cloudwatch_log_group" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + + name = "/aws/vpc/${var.name}/flow-logs" + retention_in_days = var.flow_logs_retention_days + kms_key_id = var.kms_key_id + + tags = var.tags +} + +resource "aws_iam_role" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + + name = "${var.name}-flow-logs-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "vpc-flow-logs.amazonaws.com" + } + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + + name = "${var.name}-flow-logs-policy" + role = aws_iam_role.flow_logs[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams" + ] + Effect = "Allow" + Resource = [ + aws_cloudwatch_log_group.flow_logs[0].arn, + "${aws_cloudwatch_log_group.flow_logs[0].arn}:*" + ] + }] + }) +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_region" "current" {} diff --git a/deploy/terraform/modules/network/outputs.tf b/deploy/terraform/modules/network/outputs.tf new file mode 100644 index 0000000000..1ca8354cb1 --- /dev/null +++ b/deploy/terraform/modules/network/outputs.tf @@ -0,0 +1,54 @@ +output "vpc_id" { + description = "The ID of the VPC" + value = aws_vpc.this.id +} + +output "vpc_arn" { + description = "The ARN of the VPC" + value = aws_vpc.this.arn +} + +output "vpc_cidr_block" { + description = "The CIDR block of the VPC" + value = aws_vpc.this.cidr_block +} + +output "public_subnet_ids" { + description = "List of public subnet IDs" + value = [for s in aws_subnet.public : s.id] +} + +output "private_subnet_ids" { + description = "List of private subnet IDs" + value = [for s in aws_subnet.private : s.id] +} + +output "public_subnets" { + description = "Map of public subnet objects" + value = aws_subnet.public +} + +output "private_subnets" { + description = "Map of private subnet objects" + value = aws_subnet.private +} + +output "nat_gateway_ids" { + description = "List of NAT Gateway IDs" + value = [for nat in aws_nat_gateway.this : nat.id] +} + +output "internet_gateway_id" { + description = "The ID of the Internet Gateway" + value = aws_internet_gateway.this.id +} + +output "public_route_table_id" { + description = "The ID of the public route table" + value = aws_route_table.public.id +} + +output "private_route_table_ids" { + description = "Map of private route table IDs" + value = { for k, rt in aws_route_table.private : k => rt.id } +} diff --git a/deploy/terraform/modules/network/variables.tf b/deploy/terraform/modules/network/variables.tf new file mode 100644 index 0000000000..b222a14715 --- /dev/null +++ b/deploy/terraform/modules/network/variables.tf @@ -0,0 +1,122 @@ +variable "name" { + type = string + description = "Name prefix for networking resources." + + validation { + condition = can(regex("^[a-z0-9-]+$", var.name)) + error_message = "Name must contain only lowercase letters, numbers, and hyphens." + } +} + +variable "cidr_block" { + type = string + description = "CIDR block for the VPC." + + validation { + condition = can(cidrhost(var.cidr_block, 0)) + error_message = "Must be a valid CIDR block." + } +} + +variable "public_subnets" { + description = "Map of public subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) + + validation { + condition = length(var.public_subnets) >= 1 + error_message = "At least one public subnet must be defined." + } +} + +variable "private_subnets" { + description = "Map of private subnet definitions." + type = map(object({ + cidr_block = string + az = string + })) + + validation { + condition = length(var.private_subnets) >= 1 + error_message = "At least one private subnet must be defined." + } +} + +variable "tags" { + type = map(string) + description = "Tags to apply to networking resources." + default = {} +} + +################################################################################ +# NAT Gateway Options +################################################################################ + +variable "enable_nat_gateway" { + type = bool + description = "Enable NAT Gateway for private subnets." + default = true +} + +variable "single_nat_gateway" { + type = bool + description = "Use a single NAT Gateway for all private subnets (cost saving for non-prod)." + default = false +} + +################################################################################ +# VPC Endpoints (Cost Optimization) +################################################################################ + +variable "enable_s3_endpoint" { + type = bool + description = "Enable S3 Gateway endpoint (free, reduces NAT costs)." + default = true +} + +variable "enable_ecr_endpoints" { + type = bool + description = "Enable ECR Interface endpoints for private container pulls." + default = false +} + +variable "enable_logs_endpoint" { + type = bool + description = "Enable CloudWatch Logs Interface endpoint." + default = false +} + +variable "enable_secretsmanager_endpoint" { + type = bool + description = "Enable Secrets Manager Interface endpoint." + default = false +} + +################################################################################ +# Flow Logs +################################################################################ + +variable "enable_flow_logs" { + type = bool + description = "Enable VPC Flow Logs." + default = false +} + +variable "kms_key_id" { + type = string + description = "KMS key ID for encrypting CloudWatch log groups. Uses default AWS key if not specified." + default = null +} + +variable "flow_logs_retention_days" { + type = number + description = "Number of days to retain flow logs in CloudWatch." + default = 14 + + validation { + condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.flow_logs_retention_days) + error_message = "Flow logs retention must be a valid CloudWatch Logs retention value." + } +} diff --git a/deploy/terraform/modules/network/versions.tf b/deploy/terraform/modules/network/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/modules/network/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/deploy/terraform/modules/rds_postgres/main.tf b/deploy/terraform/modules/rds_postgres/main.tf new file mode 100644 index 0000000000..6908f84dc7 --- /dev/null +++ b/deploy/terraform/modules/rds_postgres/main.tf @@ -0,0 +1,195 @@ +################################################################################ +# DB Subnet Group +################################################################################ + +resource "aws_db_subnet_group" "this" { + name = "${var.name}-subnets" + subnet_ids = var.subnet_ids + + tags = merge(var.tags, { + Name = "${var.name}-subnet-group" + }) +} + +################################################################################ +# Security Group +################################################################################ + +resource "aws_security_group" "this" { + name = "${var.name}-sg" + description = "Security group for RDS PostgreSQL ${var.name}" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name}-rds-sg" + }) +} + +resource "aws_vpc_security_group_ingress_rule" "postgres_sg" { + for_each = toset(var.allowed_security_group_ids) + + security_group_id = aws_security_group.this.id + description = "PostgreSQL access from allowed security group" + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" + referenced_security_group_id = each.value + + tags = var.tags +} + +resource "aws_vpc_security_group_ingress_rule" "postgres_cidr" { + count = length(var.allowed_cidr_blocks) + + security_group_id = aws_security_group.this.id + description = "PostgreSQL access from allowed CIDR block" + from_port = 5432 + to_port = 5432 + ip_protocol = "tcp" + cidr_ipv4 = var.allowed_cidr_blocks[count.index] + + tags = var.tags +} + +resource "aws_vpc_security_group_egress_rule" "vpc" { + security_group_id = aws_security_group.this.id + description = "Allow outbound traffic within VPC only" + ip_protocol = "-1" + cidr_ipv4 = var.vpc_cidr_block + + tags = var.tags +} + +################################################################################ +# Parameter Group (Optional) +################################################################################ + +resource "aws_db_parameter_group" "this" { + count = var.create_parameter_group ? 1 : 0 + + name = "${var.name}-params" + family = "postgres${split(".", var.engine_version)[0]}" + + dynamic "parameter" { + for_each = var.parameters + content { + name = parameter.value.name + value = parameter.value.value + apply_method = parameter.value.apply_method + } + } + + tags = var.tags + + lifecycle { + create_before_destroy = true + } +} + +################################################################################ +# RDS Instance +################################################################################ + +resource "aws_db_instance" "this" { + identifier = var.name + + # Engine + engine = "postgres" + engine_version = var.engine_version + + # Instance + instance_class = var.instance_class + allocated_storage = var.allocated_storage + max_allocated_storage = var.max_allocated_storage + storage_type = var.storage_type + iops = var.storage_type == "io1" || var.storage_type == "io2" ? var.iops : null + + # Database + db_name = var.db_name + + # Credentials - Support both managed and manual password + username = var.username + password = var.manage_master_user_password ? null : var.password + manage_master_user_password = var.manage_master_user_password + + # Network + db_subnet_group_name = aws_db_subnet_group.this.name + vpc_security_group_ids = [aws_security_group.this.id] + publicly_accessible = false + port = 5432 + + # Parameters + parameter_group_name = var.create_parameter_group ? aws_db_parameter_group.this[0].name : null + + # Storage Encryption + storage_encrypted = true + kms_key_id = var.kms_key_id + + # High Availability + multi_az = var.multi_az + + # Backup + backup_retention_period = var.backup_retention_period + backup_window = var.backup_window + maintenance_window = var.maintenance_window + copy_tags_to_snapshot = true + delete_automated_backups = var.delete_automated_backups + final_snapshot_identifier = var.skip_final_snapshot ? null : coalesce(var.final_snapshot_identifier, "${var.name}-final-snapshot") + skip_final_snapshot = var.skip_final_snapshot + + # Monitoring + performance_insights_enabled = var.performance_insights_enabled + performance_insights_retention_period = var.performance_insights_enabled ? var.performance_insights_retention_period : null + monitoring_interval = var.monitoring_interval + monitoring_role_arn = var.monitoring_interval > 0 ? aws_iam_role.rds_monitoring[0].arn : null + + # Upgrades + auto_minor_version_upgrade = var.auto_minor_version_upgrade + allow_major_version_upgrade = var.allow_major_version_upgrade + apply_immediately = var.apply_immediately + + # IAM Authentication + iam_database_authentication_enabled = var.iam_database_authentication_enabled + + # CloudWatch Log Exports + enabled_cloudwatch_logs_exports = var.cloudwatch_log_exports + + # Deletion Protection + deletion_protection = var.deletion_protection + + tags = var.tags + + lifecycle { + ignore_changes = [password] + } +} + +################################################################################ +# Enhanced Monitoring IAM Role +################################################################################ + +resource "aws_iam_role" "rds_monitoring" { + count = var.monitoring_interval > 0 ? 1 : 0 + + name = "${var.name}-rds-monitoring" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "monitoring.rds.amazonaws.com" + } + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "rds_monitoring" { + count = var.monitoring_interval > 0 ? 1 : 0 + + role = aws_iam_role.rds_monitoring[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole" +} diff --git a/deploy/terraform/modules/rds_postgres/outputs.tf b/deploy/terraform/modules/rds_postgres/outputs.tf new file mode 100644 index 0000000000..0d71862e32 --- /dev/null +++ b/deploy/terraform/modules/rds_postgres/outputs.tf @@ -0,0 +1,45 @@ +output "endpoint" { + description = "The connection endpoint address" + value = aws_db_instance.this.address +} + +output "port" { + description = "The database port" + value = aws_db_instance.this.port +} + +output "identifier" { + description = "The RDS instance identifier" + value = aws_db_instance.this.identifier +} + +output "arn" { + description = "The ARN of the RDS instance" + value = aws_db_instance.this.arn +} + +output "db_name" { + description = "The database name" + value = aws_db_instance.this.db_name +} + +output "security_group_id" { + description = "The ID of the RDS security group" + value = aws_security_group.this.id +} + +output "db_subnet_group_name" { + description = "The name of the DB subnet group" + value = aws_db_subnet_group.this.name +} + +output "connection_string" { + description = "PostgreSQL connection string (without password)" + value = "Host=${aws_db_instance.this.address};Port=${aws_db_instance.this.port};Database=${aws_db_instance.this.db_name};Username=${var.username}" + sensitive = true +} + +output "secret_arn" { + description = "The ARN of the Secrets Manager secret (if managed password is enabled)" + value = var.manage_master_user_password ? aws_db_instance.this.master_user_secret[0].secret_arn : null +} diff --git a/deploy/terraform/modules/rds_postgres/variables.tf b/deploy/terraform/modules/rds_postgres/variables.tf new file mode 100644 index 0000000000..a4e6df8454 --- /dev/null +++ b/deploy/terraform/modules/rds_postgres/variables.tf @@ -0,0 +1,317 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name identifier for the RDS instance." + + validation { + condition = can(regex("^[a-z][a-z0-9-]*$", var.name)) + error_message = "Name must start with a letter and contain only lowercase letters, numbers, and hyphens." + } +} + +variable "vpc_id" { + type = string + description = "VPC ID for RDS." +} + +variable "subnet_ids" { + type = list(string) + description = "Subnets for RDS subnet group." + + validation { + condition = length(var.subnet_ids) >= 2 + error_message = "At least two subnets in different AZs are required." + } +} + +variable "allowed_security_group_ids" { + type = list(string) + description = "Security groups allowed to access RDS (use when SG IDs are known at plan time)." + default = [] +} + +variable "allowed_cidr_blocks" { + type = list(string) + description = "CIDR blocks allowed to access RDS (use when security groups are not yet created)." + default = [] +} + +variable "vpc_cidr_block" { + type = string + description = "VPC CIDR block (used to restrict egress to VPC only)." +} + +variable "db_name" { + type = string + description = "Database name." + + validation { + condition = can(regex("^[a-zA-Z][a-zA-Z0-9_]*$", var.db_name)) + error_message = "Database name must start with a letter and contain only alphanumeric characters and underscores." + } +} + +variable "username" { + type = string + description = "Database admin username." + sensitive = true + + validation { + condition = can(regex("^[a-zA-Z][a-zA-Z0-9_]*$", var.username)) + error_message = "Username must start with a letter and contain only alphanumeric characters and underscores." + } +} + +################################################################################ +# Password Options +################################################################################ + +variable "password" { + type = string + description = "Database admin password. Required if manage_master_user_password is false." + default = null + sensitive = true +} + +variable "manage_master_user_password" { + type = bool + description = "Use AWS Secrets Manager to manage the master password." + default = false +} + +################################################################################ +# Engine Configuration +################################################################################ + +variable "engine_version" { + type = string + description = "PostgreSQL engine version." + default = "17" +} + +variable "instance_class" { + type = string + description = "RDS instance class." + default = "db.t4g.micro" +} + +################################################################################ +# Storage Configuration +################################################################################ + +variable "allocated_storage" { + type = number + description = "Allocated storage in GB." + default = 20 + + validation { + condition = var.allocated_storage >= 20 + error_message = "Allocated storage must be at least 20 GB." + } +} + +variable "max_allocated_storage" { + type = number + description = "Maximum allocated storage for autoscaling (0 to disable)." + default = 100 +} + +variable "storage_type" { + type = string + description = "Storage type (gp2, gp3, io1, io2)." + default = "gp3" + + validation { + condition = contains(["gp2", "gp3", "io1", "io2"], var.storage_type) + error_message = "Storage type must be gp2, gp3, io1, or io2." + } +} + +variable "iops" { + type = number + description = "Provisioned IOPS (for io1/io2 storage)." + default = null +} + +variable "kms_key_id" { + type = string + description = "KMS key ID for storage encryption. Uses default AWS key if not specified." + default = null +} + +################################################################################ +# Backup Configuration +################################################################################ + +variable "backup_retention_period" { + type = number + description = "Backup retention period in days." + default = 7 + + validation { + condition = var.backup_retention_period >= 0 && var.backup_retention_period <= 35 + error_message = "Backup retention period must be between 0 and 35 days." + } +} + +variable "backup_window" { + type = string + description = "Preferred backup window (UTC)." + default = "03:00-04:00" +} + +variable "maintenance_window" { + type = string + description = "Preferred maintenance window (UTC)." + default = "sun:05:00-sun:06:00" +} + +variable "skip_final_snapshot" { + type = bool + description = "Skip final snapshot on destroy." + default = false +} + +variable "final_snapshot_identifier" { + type = string + description = "Name of the final snapshot. Auto-generated from instance name if not specified." + default = null +} + +variable "delete_automated_backups" { + type = bool + description = "Delete automated backups on instance deletion." + default = true +} + +################################################################################ +# High Availability Configuration +################################################################################ + +variable "multi_az" { + type = bool + description = "Enable Multi-AZ deployment for high availability." + default = false +} + +################################################################################ +# Monitoring Configuration +################################################################################ + +variable "performance_insights_enabled" { + type = bool + description = "Enable Performance Insights." + default = true +} + +variable "performance_insights_retention_period" { + type = number + description = "Performance Insights retention period (7 or 731 days)." + default = 7 + + validation { + condition = contains([7, 731], var.performance_insights_retention_period) + error_message = "Performance Insights retention must be 7 or 731 days." + } +} + +variable "monitoring_interval" { + type = number + description = "Enhanced Monitoring interval in seconds (0 to disable, 1, 5, 10, 15, 30, or 60)." + default = 0 + + validation { + condition = contains([0, 1, 5, 10, 15, 30, 60], var.monitoring_interval) + error_message = "Monitoring interval must be 0, 1, 5, 10, 15, 30, or 60 seconds." + } +} + +################################################################################ +# Upgrade Configuration +################################################################################ + +variable "auto_minor_version_upgrade" { + type = bool + description = "Enable automatic minor version upgrades." + default = true +} + +variable "allow_major_version_upgrade" { + type = bool + description = "Allow major version upgrades." + default = false +} + +variable "apply_immediately" { + type = bool + description = "Apply changes immediately instead of during maintenance window." + default = false +} + +################################################################################ +# IAM Authentication +################################################################################ + +variable "iam_database_authentication_enabled" { + type = bool + description = "Enable IAM database authentication for passwordless access." + default = false +} + +################################################################################ +# CloudWatch Log Exports +################################################################################ + +variable "cloudwatch_log_exports" { + type = list(string) + description = "List of log types to export to CloudWatch (postgresql, upgrade)." + default = ["postgresql"] + + validation { + condition = alltrue([for log in var.cloudwatch_log_exports : contains(["postgresql", "upgrade"], log)]) + error_message = "Valid log types are: postgresql, upgrade." + } +} + +################################################################################ +# Protection Configuration +################################################################################ + +variable "deletion_protection" { + type = bool + description = "Enable deletion protection." + default = false +} + +################################################################################ +# Parameter Group Configuration +################################################################################ + +variable "create_parameter_group" { + type = bool + description = "Create a custom parameter group." + default = false +} + +variable "parameters" { + type = list(object({ + name = string + value = string + apply_method = optional(string, "immediate") + })) + description = "List of DB parameters to apply." + default = [] +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to RDS resources." + default = {} +} diff --git a/deploy/terraform/modules/rds_postgres/versions.tf b/deploy/terraform/modules/rds_postgres/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/modules/rds_postgres/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/deploy/terraform/modules/s3_bucket/main.tf b/deploy/terraform/modules/s3_bucket/main.tf new file mode 100644 index 0000000000..d38c450eb3 --- /dev/null +++ b/deploy/terraform/modules/s3_bucket/main.tf @@ -0,0 +1,312 @@ +################################################################################ +# S3 Bucket +################################################################################ + +resource "aws_s3_bucket" "this" { + bucket = var.name + force_destroy = var.force_destroy + + tags = merge(var.tags, { + Name = var.name + }) +} + +################################################################################ +# Bucket Ownership Controls +################################################################################ + +resource "aws_s3_bucket_ownership_controls" "this" { + bucket = aws_s3_bucket.this.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +################################################################################ +# Versioning +################################################################################ + +resource "aws_s3_bucket_versioning" "this" { + bucket = aws_s3_bucket.this.id + + versioning_configuration { + status = var.versioning_enabled ? "Enabled" : "Suspended" + } +} + +################################################################################ +# Server-Side Encryption +################################################################################ + +resource "aws_s3_bucket_server_side_encryption_configuration" "this" { + bucket = aws_s3_bucket.this.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = var.kms_key_arn != null ? "aws:kms" : "AES256" + kms_master_key_id = var.kms_key_arn + } + bucket_key_enabled = var.kms_key_arn != null + } +} + +################################################################################ +# Public Access Block +################################################################################ + +resource "aws_s3_bucket_public_access_block" "this" { + bucket = aws_s3_bucket.this.id + block_public_acls = true + ignore_public_acls = true + block_public_policy = var.enable_public_read ? false : true + restrict_public_buckets = var.enable_public_read ? false : true +} + +################################################################################ +# Lifecycle Rules +################################################################################ + +resource "aws_s3_bucket_lifecycle_configuration" "this" { + count = length(var.lifecycle_rules) > 0 || var.enable_intelligent_tiering ? 1 : 0 + + bucket = aws_s3_bucket.this.id + + dynamic "rule" { + for_each = var.lifecycle_rules + content { + id = rule.value.id + status = rule.value.enabled ? "Enabled" : "Disabled" + + filter { + prefix = rule.value.prefix + } + + dynamic "transition" { + for_each = rule.value.transitions + content { + days = transition.value.days + storage_class = transition.value.storage_class + } + } + + dynamic "expiration" { + for_each = rule.value.expiration_days != null ? [1] : [] + content { + days = rule.value.expiration_days + } + } + + dynamic "noncurrent_version_transition" { + for_each = rule.value.noncurrent_version_transitions + content { + noncurrent_days = noncurrent_version_transition.value.days + storage_class = noncurrent_version_transition.value.storage_class + } + } + + dynamic "noncurrent_version_expiration" { + for_each = rule.value.noncurrent_version_expiration_days != null ? [1] : [] + content { + noncurrent_days = rule.value.noncurrent_version_expiration_days + } + } + + dynamic "abort_incomplete_multipart_upload" { + for_each = rule.value.abort_incomplete_multipart_upload_days != null ? [1] : [] + content { + days_after_initiation = rule.value.abort_incomplete_multipart_upload_days + } + } + } + } + + dynamic "rule" { + for_each = var.enable_intelligent_tiering ? [1] : [] + content { + id = "intelligent-tiering" + status = "Enabled" + + filter { + prefix = "" + } + + transition { + storage_class = "INTELLIGENT_TIERING" + } + } + } + + depends_on = [aws_s3_bucket_versioning.this] +} + +################################################################################ +# CORS Configuration +################################################################################ + +resource "aws_s3_bucket_cors_configuration" "this" { + count = length(var.cors_rules) > 0 ? 1 : 0 + + bucket = aws_s3_bucket.this.id + + dynamic "cors_rule" { + for_each = var.cors_rules + content { + allowed_headers = cors_rule.value.allowed_headers + allowed_methods = cors_rule.value.allowed_methods + allowed_origins = cors_rule.value.allowed_origins + expose_headers = cors_rule.value.expose_headers + max_age_seconds = cors_rule.value.max_age_seconds + } + } +} + +################################################################################ +# CloudFront Origin Access Control +################################################################################ + +resource "aws_cloudfront_origin_access_control" "this" { + count = var.enable_cloudfront ? 1 : 0 + + name = "${aws_s3_bucket.this.bucket}-oac" + description = "Access control for ${aws_s3_bucket.this.bucket}" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +################################################################################ +# CloudFront Distribution +################################################################################ + +resource "aws_cloudfront_distribution" "this" { + count = var.enable_cloudfront ? 1 : 0 + + enabled = true + comment = var.cloudfront_comment != "" ? var.cloudfront_comment : "Public assets for ${aws_s3_bucket.this.bucket}" + price_class = var.cloudfront_price_class + default_root_object = var.cloudfront_default_root_object + aliases = var.cloudfront_aliases + http_version = "http2and3" + + origin { + domain_name = aws_s3_bucket.this.bucket_regional_domain_name + origin_id = "s3-${aws_s3_bucket.this.bucket}" + origin_access_control_id = aws_cloudfront_origin_access_control.this[0].id + } + + default_cache_behavior { + target_origin_id = "s3-${aws_s3_bucket.this.bucket}" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + compress = true + + cache_policy_id = var.cloudfront_cache_policy_id + origin_request_policy_id = var.cloudfront_origin_request_policy_id + + dynamic "forwarded_values" { + for_each = var.cloudfront_cache_policy_id == null ? [1] : [] + content { + query_string = false + cookies { + forward = "none" + } + } + } + } + + restrictions { + geo_restriction { + restriction_type = var.cloudfront_geo_restriction_type + locations = var.cloudfront_geo_restriction_locations + } + } + + viewer_certificate { + cloudfront_default_certificate = var.cloudfront_acm_certificate_arn == null + acm_certificate_arn = var.cloudfront_acm_certificate_arn + ssl_support_method = var.cloudfront_acm_certificate_arn != null ? "sni-only" : null + minimum_protocol_version = var.cloudfront_acm_certificate_arn != null ? "TLSv1.2_2021" : null + } + + tags = var.tags +} + +################################################################################ +# Bucket Policy +################################################################################ + +locals { + bucket_policy_statements = concat( + # Enforce SSL/TLS for all requests + [ + { + Sid = "EnforceSSLOnly" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + "arn:aws:s3:::${aws_s3_bucket.this.bucket}", + "arn:aws:s3:::${aws_s3_bucket.this.bucket}/*" + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + }, + { + Sid = "EnforceTLSVersion" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + "arn:aws:s3:::${aws_s3_bucket.this.bucket}", + "arn:aws:s3:::${aws_s3_bucket.this.bucket}/*" + ] + Condition = { + NumericLessThan = { + "s3:TlsVersion" = "1.2" + } + } + } + ], + var.enable_public_read && length(var.public_read_prefix) > 0 ? [ + { + Sid = "AllowPublicReadUploads" + Effect = "Allow" + Principal = "*" + Action = ["s3:GetObject"] + Resource = "arn:aws:s3:::${aws_s3_bucket.this.bucket}/${var.public_read_prefix}*" + } + ] : [], + var.enable_cloudfront ? [ + { + Sid = "AllowCloudFrontRead" + Effect = "Allow" + Principal = { + Service = "cloudfront.amazonaws.com" + } + Action = ["s3:GetObject"] + Resource = "arn:aws:s3:::${aws_s3_bucket.this.bucket}/*" + Condition = { + StringEquals = { + "AWS:SourceArn" = aws_cloudfront_distribution.this[0].arn + } + } + } + ] : [], + var.additional_bucket_policy_statements + ) +} + +resource "aws_s3_bucket_policy" "this" { + bucket = aws_s3_bucket.this.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = local.bucket_policy_statements + }) + + depends_on = [aws_s3_bucket_public_access_block.this] +} diff --git a/deploy/terraform/modules/s3_bucket/outputs.tf b/deploy/terraform/modules/s3_bucket/outputs.tf new file mode 100644 index 0000000000..0c01145ea0 --- /dev/null +++ b/deploy/terraform/modules/s3_bucket/outputs.tf @@ -0,0 +1,34 @@ +output "bucket_name" { + description = "The name of the S3 bucket" + value = aws_s3_bucket.this.id +} + +output "bucket_arn" { + description = "The ARN of the S3 bucket" + value = aws_s3_bucket.this.arn +} + +output "bucket_domain_name" { + description = "The bucket domain name" + value = aws_s3_bucket.this.bucket_domain_name +} + +output "bucket_regional_domain_name" { + description = "The bucket region-specific domain name" + value = aws_s3_bucket.this.bucket_regional_domain_name +} + +output "cloudfront_domain_name" { + description = "CloudFront domain for public access (when enabled)" + value = var.enable_cloudfront ? aws_cloudfront_distribution.this[0].domain_name : "" +} + +output "cloudfront_distribution_id" { + description = "CloudFront distribution ID (when enabled)" + value = var.enable_cloudfront ? aws_cloudfront_distribution.this[0].id : "" +} + +output "cloudfront_distribution_arn" { + description = "CloudFront distribution ARN (when enabled)" + value = var.enable_cloudfront ? aws_cloudfront_distribution.this[0].arn : "" +} diff --git a/deploy/terraform/modules/s3_bucket/variables.tf b/deploy/terraform/modules/s3_bucket/variables.tf new file mode 100644 index 0000000000..610320448e --- /dev/null +++ b/deploy/terraform/modules/s3_bucket/variables.tf @@ -0,0 +1,192 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Bucket name (must be globally unique)." + + validation { + condition = can(regex("^[a-z0-9][a-z0-9.-]*[a-z0-9]$", var.name)) + error_message = "Bucket name must contain only lowercase letters, numbers, hyphens, and periods, and must start and end with a letter or number." + } +} + +################################################################################ +# Bucket Configuration +################################################################################ + +variable "force_destroy" { + type = bool + description = "Allow bucket destruction even if not empty." + default = false +} + +variable "versioning_enabled" { + type = bool + description = "Enable versioning." + default = true +} + +variable "kms_key_arn" { + type = string + description = "KMS key ARN for server-side encryption. Uses AES256 if not specified." + default = null +} + +################################################################################ +# Public Access Configuration +################################################################################ + +variable "enable_public_read" { + type = bool + description = "Set to true to allow public read on the specified prefix via bucket policy." + default = false +} + +variable "public_read_prefix" { + type = string + description = "Prefix to allow public read (e.g., uploads/). Leave empty to disable public policy." + default = "uploads/" +} + +################################################################################ +# Lifecycle Rules +################################################################################ + +variable "enable_intelligent_tiering" { + type = bool + description = "Enable automatic transition to Intelligent-Tiering." + default = false +} + +variable "lifecycle_rules" { + type = list(object({ + id = string + enabled = optional(bool, true) + prefix = optional(string, "") + expiration_days = optional(number) + noncurrent_version_expiration_days = optional(number) + abort_incomplete_multipart_upload_days = optional(number, 7) + transitions = optional(list(object({ + days = number + storage_class = string + })), []) + noncurrent_version_transitions = optional(list(object({ + days = number + storage_class = string + })), []) + })) + description = "List of lifecycle rules." + default = [] +} + +################################################################################ +# CORS Configuration +################################################################################ + +variable "cors_rules" { + type = list(object({ + allowed_headers = optional(list(string), ["*"]) + allowed_methods = list(string) + allowed_origins = list(string) + expose_headers = optional(list(string), []) + max_age_seconds = optional(number, 3000) + })) + description = "List of CORS rules." + default = [] +} + +################################################################################ +# CloudFront Configuration +################################################################################ + +variable "enable_cloudfront" { + type = bool + description = "Set to true to provision a CloudFront distribution in front of the bucket." + default = false +} + +variable "cloudfront_price_class" { + type = string + description = "CloudFront price class." + default = "PriceClass_100" + + validation { + condition = contains(["PriceClass_All", "PriceClass_200", "PriceClass_100"], var.cloudfront_price_class) + error_message = "Price class must be PriceClass_All, PriceClass_200, or PriceClass_100." + } +} + +variable "cloudfront_comment" { + type = string + description = "Optional comment for the CloudFront distribution." + default = "" +} + +variable "cloudfront_default_root_object" { + type = string + description = "Default root object for CloudFront." + default = "" +} + +variable "cloudfront_aliases" { + type = list(string) + description = "Alternative domain names (CNAMEs) for CloudFront." + default = [] +} + +variable "cloudfront_acm_certificate_arn" { + type = string + description = "ACM certificate ARN for CloudFront (required if using aliases)." + default = null +} + +variable "cloudfront_cache_policy_id" { + type = string + description = "CloudFront cache policy ID. Uses default if not specified." + default = null +} + +variable "cloudfront_origin_request_policy_id" { + type = string + description = "CloudFront origin request policy ID." + default = null +} + +variable "cloudfront_geo_restriction_type" { + type = string + description = "CloudFront geo restriction type (none, whitelist, blacklist)." + default = "none" + + validation { + condition = contains(["none", "whitelist", "blacklist"], var.cloudfront_geo_restriction_type) + error_message = "Geo restriction type must be none, whitelist, or blacklist." + } +} + +variable "cloudfront_geo_restriction_locations" { + type = list(string) + description = "Country codes for geo restriction." + default = [] +} + +################################################################################ +# Additional Bucket Policy +################################################################################ + +variable "additional_bucket_policy_statements" { + type = any + description = "Additional bucket policy statements." + default = [] +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to the bucket." + default = {} +} diff --git a/deploy/terraform/modules/s3_bucket/versions.tf b/deploy/terraform/modules/s3_bucket/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/modules/s3_bucket/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/deploy/terraform/modules/waf/main.tf b/deploy/terraform/modules/waf/main.tf new file mode 100644 index 0000000000..4989a52d1f --- /dev/null +++ b/deploy/terraform/modules/waf/main.tf @@ -0,0 +1,270 @@ +################################################################################ +# AWS WAFv2 Web ACL +################################################################################ + +resource "aws_wafv2_web_acl" "this" { + name = var.name + description = var.description != "" ? var.description : "WAF for ${var.name}" + scope = "REGIONAL" + + default_action { + allow {} + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${replace(var.name, "-", "")}WebAcl" + sampled_requests_enabled = true + } + + ############################################################################ + # Rate Limiting Rule + ############################################################################ + + rule { + name = "RateLimit" + priority = 1 + + action { + block {} + } + + statement { + rate_based_statement { + limit = var.rate_limit + aggregate_key_type = "IP" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${replace(var.name, "-", "")}RateLimit" + sampled_requests_enabled = true + } + } + + ############################################################################ + # AWS Managed Rules - Common Rule Set + ############################################################################ + + rule { + name = "AWSManagedRulesCommonRuleSet" + priority = 10 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + + dynamic "rule_action_override" { + for_each = var.common_ruleset_excluded_rules + content { + name = rule_action_override.value + action_to_use { + count {} + } + } + } + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${replace(var.name, "-", "")}CommonRuleSet" + sampled_requests_enabled = true + } + } + + ############################################################################ + # AWS Managed Rules - Known Bad Inputs + ############################################################################ + + rule { + name = "AWSManagedRulesKnownBadInputsRuleSet" + priority = 20 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesKnownBadInputsRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${replace(var.name, "-", "")}KnownBadInputs" + sampled_requests_enabled = true + } + } + + ############################################################################ + # AWS Managed Rules - SQL Injection + ############################################################################ + + dynamic "rule" { + for_each = var.enable_sqli_rule_set ? [1] : [] + content { + name = "AWSManagedRulesSQLiRuleSet" + priority = 30 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesSQLiRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${replace(var.name, "-", "")}SQLiRuleSet" + sampled_requests_enabled = true + } + } + } + + ############################################################################ + # AWS Managed Rules - IP Reputation + ############################################################################ + + dynamic "rule" { + for_each = var.enable_ip_reputation_rule_set ? [1] : [] + content { + name = "AWSManagedRulesAmazonIpReputationList" + priority = 40 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesAmazonIpReputationList" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${replace(var.name, "-", "")}IpReputation" + sampled_requests_enabled = true + } + } + } + + ############################################################################ + # AWS Managed Rules - Anonymous IP List + ############################################################################ + + dynamic "rule" { + for_each = var.enable_anonymous_ip_rule_set ? [1] : [] + content { + name = "AWSManagedRulesAnonymousIpList" + priority = 50 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesAnonymousIpList" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${replace(var.name, "-", "")}AnonymousIp" + sampled_requests_enabled = true + } + } + } + + ############################################################################ + # AWS Managed Rules - Linux OS + ############################################################################ + + dynamic "rule" { + for_each = var.enable_linux_rule_set ? [1] : [] + content { + name = "AWSManagedRulesLinuxRuleSet" + priority = 60 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesLinuxRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${replace(var.name, "-", "")}LinuxRuleSet" + sampled_requests_enabled = true + } + } + } + + tags = var.tags +} + +################################################################################ +# WAF Association with ALB +################################################################################ + +resource "aws_wafv2_web_acl_association" "this" { + count = var.alb_arn != null ? 1 : 0 + + resource_arn = var.alb_arn + web_acl_arn = aws_wafv2_web_acl.this.arn +} + +################################################################################ +# WAF Logging (Optional) +################################################################################ + +resource "aws_cloudwatch_log_group" "waf" { + count = var.enable_logging ? 1 : 0 + + # WAF logging requires log group name to start with aws-waf-logs- + name = "aws-waf-logs-${var.name}" + retention_in_days = var.log_retention_in_days + kms_key_id = var.kms_key_id + + tags = var.tags +} + +resource "aws_wafv2_web_acl_logging_configuration" "this" { + count = var.enable_logging ? 1 : 0 + + log_destination_configs = [aws_cloudwatch_log_group.waf[0].arn] + resource_arn = aws_wafv2_web_acl.this.arn + + dynamic "redacted_fields" { + for_each = var.redacted_fields + content { + dynamic "single_header" { + for_each = redacted_fields.value.type == "single_header" ? [1] : [] + content { + name = redacted_fields.value.name + } + } + } + } +} diff --git a/deploy/terraform/modules/waf/outputs.tf b/deploy/terraform/modules/waf/outputs.tf new file mode 100644 index 0000000000..301fa1220f --- /dev/null +++ b/deploy/terraform/modules/waf/outputs.tf @@ -0,0 +1,19 @@ +output "web_acl_arn" { + description = "The ARN of the WAF Web ACL" + value = aws_wafv2_web_acl.this.arn +} + +output "web_acl_id" { + description = "The ID of the WAF Web ACL" + value = aws_wafv2_web_acl.this.id +} + +output "web_acl_capacity" { + description = "The web ACL capacity units (WCU) currently being used" + value = aws_wafv2_web_acl.this.capacity +} + +output "log_group_arn" { + description = "The ARN of the WAF CloudWatch log group (if logging enabled)" + value = var.enable_logging ? aws_cloudwatch_log_group.waf[0].arn : null +} diff --git a/deploy/terraform/modules/waf/variables.tf b/deploy/terraform/modules/waf/variables.tf new file mode 100644 index 0000000000..4f8ca6a208 --- /dev/null +++ b/deploy/terraform/modules/waf/variables.tf @@ -0,0 +1,129 @@ +################################################################################ +# Required Variables +################################################################################ + +variable "name" { + type = string + description = "Name of the WAF Web ACL." + + validation { + condition = can(regex("^[a-zA-Z0-9-]+$", var.name)) + error_message = "WAF name must contain only alphanumeric characters and hyphens." + } +} + +################################################################################ +# Association +################################################################################ + +variable "alb_arn" { + type = string + description = "ARN of the ALB to associate with the WAF. Set to null to skip association." + default = null +} + +################################################################################ +# Rule Configuration +################################################################################ + +variable "description" { + type = string + description = "Description of the WAF Web ACL." + default = "" +} + +variable "rate_limit" { + type = number + description = "Maximum number of requests per 5-minute period per IP address." + default = 2000 + + validation { + condition = var.rate_limit >= 100 && var.rate_limit <= 20000000 + error_message = "Rate limit must be between 100 and 20,000,000." + } +} + +variable "common_ruleset_excluded_rules" { + type = list(string) + description = "List of rule names to exclude (count instead of block) from the Common Rule Set." + default = [] +} + +variable "enable_sqli_rule_set" { + type = bool + description = "Enable AWS Managed SQL Injection rule set." + default = true +} + +variable "enable_ip_reputation_rule_set" { + type = bool + description = "Enable AWS Managed IP Reputation rule set." + default = true +} + +variable "enable_anonymous_ip_rule_set" { + type = bool + description = "Enable AWS Managed Anonymous IP List rule set." + default = false +} + +variable "enable_linux_rule_set" { + type = bool + description = "Enable AWS Managed Linux OS rule set." + default = true +} + +################################################################################ +# Logging +################################################################################ + +variable "enable_logging" { + type = bool + description = "Enable WAF logging to CloudWatch Logs." + default = true +} + +variable "log_retention_in_days" { + type = number + description = "CloudWatch log retention in days." + default = 30 + + validation { + condition = contains([0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.log_retention_in_days) + error_message = "Log retention must be a valid CloudWatch Logs retention value." + } +} + +variable "kms_key_id" { + type = string + description = "KMS key ID for log group encryption." + default = null +} + +variable "redacted_fields" { + type = list(object({ + type = string + name = string + })) + description = "Fields to redact from WAF logs (e.g., Authorization header)." + default = [ + { + type = "single_header" + name = "authorization" + }, + { + type = "single_header" + name = "cookie" + } + ] +} + +################################################################################ +# Tags +################################################################################ + +variable "tags" { + type = map(string) + description = "Tags to apply to WAF resources." + default = {} +} diff --git a/deploy/terraform/modules/waf/versions.tf b/deploy/terraform/modules/waf/versions.tf new file mode 100644 index 0000000000..90a6d60528 --- /dev/null +++ b/deploy/terraform/modules/waf/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.90.0" + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..2825d12215 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres:17 + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: fsh + volumes: + - postgres-data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + api: + build: + context: . + dockerfile: src/Playground/FSH.Starter.Api/Dockerfile + ports: + - "8080:8080" + depends_on: + - postgres + - redis + environment: + ASPNETCORE_ENVIRONMENT: Development + DatabaseOptions__ConnectionString: "Host=postgres;Port=5432;Database=fsh;Username=postgres;Password=password" + CachingOptions__Redis: "redis:6379" + + otel-collector: + image: otel/opentelemetry-collector:latest + ports: + - "4317:4317" + +volumes: + postgres-data: diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000000..6240da8b10 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,21 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/docs/.vscode/extensions.json b/docs/.vscode/extensions.json new file mode 100644 index 0000000000..22a15055d6 --- /dev/null +++ b/docs/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/docs/.vscode/launch.json b/docs/.vscode/launch.json new file mode 100644 index 0000000000..d642209762 --- /dev/null +++ b/docs/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/docs/.wrangler/deploy/config.json b/docs/.wrangler/deploy/config.json new file mode 100644 index 0000000000..a1616fd99f --- /dev/null +++ b/docs/.wrangler/deploy/config.json @@ -0,0 +1 @@ +{"configPath":"..\\..\\dist\\server\\wrangler.json","auxiliaryWorkers":[],"prerenderWorkerConfigPath":"..\\..\\dist\\server\\.prerender\\wrangler.json"} \ No newline at end of file diff --git a/docs/.wrangler/state/v3/cache/default/blobs/05ed23cd2f9d938660bcd3a9b5327dfea3d9b048b3e8089669e0eefcaee6437c0000019d2bc2d79a b/docs/.wrangler/state/v3/cache/default/blobs/05ed23cd2f9d938660bcd3a9b5327dfea3d9b048b3e8089669e0eefcaee6437c0000019d2bc2d79a new file mode 100644 index 0000000000..dc22e1e4b8 Binary files /dev/null and b/docs/.wrangler/state/v3/cache/default/blobs/05ed23cd2f9d938660bcd3a9b5327dfea3d9b048b3e8089669e0eefcaee6437c0000019d2bc2d79a differ diff --git a/docs/.wrangler/state/v3/cache/default/blobs/06b4b9847cfa9433fd85181f4439f62f54868e6f8ab3f1f466fbd34204285e9e0000019d2d779f24 b/docs/.wrangler/state/v3/cache/default/blobs/06b4b9847cfa9433fd85181f4439f62f54868e6f8ab3f1f466fbd34204285e9e0000019d2d779f24 new file mode 100644 index 0000000000..b20b1f6562 Binary files /dev/null and b/docs/.wrangler/state/v3/cache/default/blobs/06b4b9847cfa9433fd85181f4439f62f54868e6f8ab3f1f466fbd34204285e9e0000019d2d779f24 differ diff --git a/docs/.wrangler/state/v3/cache/default/blobs/2af75e6c15d9631e1e579d1c78bfe27a34560af4c70edb2a356577476b55dd0e0000019d2b787b4c b/docs/.wrangler/state/v3/cache/default/blobs/2af75e6c15d9631e1e579d1c78bfe27a34560af4c70edb2a356577476b55dd0e0000019d2b787b4c new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/.wrangler/state/v3/cache/default/blobs/59500f06509d47f888ba826d2d6e867f16a49b268ec678187f6b9edd273622920000019d2b787b37 b/docs/.wrangler/state/v3/cache/default/blobs/59500f06509d47f888ba826d2d6e867f16a49b268ec678187f6b9edd273622920000019d2b787b37 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/.wrangler/state/v3/cache/default/blobs/799b9ff4e6deca1bc101a155c6f76cb6a805b8cd8f76b98bb3e14b24200906000000019d2b787b49 b/docs/.wrangler/state/v3/cache/default/blobs/799b9ff4e6deca1bc101a155c6f76cb6a805b8cd8f76b98bb3e14b24200906000000019d2b787b49 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/.wrangler/state/v3/cache/default/blobs/b6adda5ef8833dd4745181798942c60cab03b9d1af2e5fc19a403e8361420dbd0000019d2b6f6b34 b/docs/.wrangler/state/v3/cache/default/blobs/b6adda5ef8833dd4745181798942c60cab03b9d1af2e5fc19a403e8361420dbd0000019d2b6f6b34 new file mode 100644 index 0000000000..667d961104 Binary files /dev/null and b/docs/.wrangler/state/v3/cache/default/blobs/b6adda5ef8833dd4745181798942c60cab03b9d1af2e5fc19a403e8361420dbd0000019d2b6f6b34 differ diff --git a/docs/.wrangler/state/v3/cache/default/blobs/b7b8dce2242c9ee20864b0ef5322f4868eceb78d2669fe5f72696db8c0c039610000019d2d882fb4 b/docs/.wrangler/state/v3/cache/default/blobs/b7b8dce2242c9ee20864b0ef5322f4868eceb78d2669fe5f72696db8c0c039610000019d2d882fb4 new file mode 100644 index 0000000000..dc92a3f0d6 Binary files /dev/null and b/docs/.wrangler/state/v3/cache/default/blobs/b7b8dce2242c9ee20864b0ef5322f4868eceb78d2669fe5f72696db8c0c039610000019d2d882fb4 differ diff --git a/docs/.wrangler/state/v3/cache/default/blobs/b934939b8485eff0a7509ea1a177ca8bca77661890ec35b3fe419b0f5a9a70530000019d2b787b3e b/docs/.wrangler/state/v3/cache/default/blobs/b934939b8485eff0a7509ea1a177ca8bca77661890ec35b3fe419b0f5a9a70530000019d2b787b3e new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/.wrangler/state/v3/cache/default/blobs/c389cac491accf22c1abb0bf9b7a0fcb03c81e08bf76c8397cee5a255cfd5e6c0000019d2d7557d5 b/docs/.wrangler/state/v3/cache/default/blobs/c389cac491accf22c1abb0bf9b7a0fcb03c81e08bf76c8397cee5a255cfd5e6c0000019d2d7557d5 new file mode 100644 index 0000000000..dc92a3f0d6 Binary files /dev/null and b/docs/.wrangler/state/v3/cache/default/blobs/c389cac491accf22c1abb0bf9b7a0fcb03c81e08bf76c8397cee5a255cfd5e6c0000019d2d7557d5 differ diff --git a/docs/.wrangler/state/v3/cache/default/blobs/cbc5380df21002512f877388af29dc43bc631678ad423c786c0b2be9db5d41bf0000019d2b787b46 b/docs/.wrangler/state/v3/cache/default/blobs/cbc5380df21002512f877388af29dc43bc631678ad423c786c0b2be9db5d41bf0000019d2b787b46 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/.wrangler/state/v3/cache/default/blobs/df071845a215131e82876d3c2537f1dea4fc2b09ae9d9c5746c6fead4b0d46be0000019d2b9161e3 b/docs/.wrangler/state/v3/cache/default/blobs/df071845a215131e82876d3c2537f1dea4fc2b09ae9d9c5746c6fead4b0d46be0000019d2b9161e3 new file mode 100644 index 0000000000..70b7ad3db6 Binary files /dev/null and b/docs/.wrangler/state/v3/cache/default/blobs/df071845a215131e82876d3c2537f1dea4fc2b09ae9d9c5746c6fead4b0d46be0000019d2b9161e3 differ diff --git a/docs/.wrangler/state/v3/cache/default/blobs/f6e660227fb82e9cec79d07282b502662f5766480006eb7d9cbd8f62b3a684630000019d2b787b42 b/docs/.wrangler/state/v3/cache/default/blobs/f6e660227fb82e9cec79d07282b502662f5766480006eb7d9cbd8f62b3a684630000019d2b787b42 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/.wrangler/state/v3/cache/miniflare-CacheObject/9f458c07675338a7426a7b81ac4fb1baf92d034efbcaaf4336379640ed744ded.sqlite b/docs/.wrangler/state/v3/cache/miniflare-CacheObject/9f458c07675338a7426a7b81ac4fb1baf92d034efbcaaf4336379640ed744ded.sqlite new file mode 100644 index 0000000000..1cdb9f5c91 Binary files /dev/null and b/docs/.wrangler/state/v3/cache/miniflare-CacheObject/9f458c07675338a7426a7b81ac4fb1baf92d034efbcaaf4336379640ed744ded.sqlite differ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..1b7f5c3d79 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,49 @@ +# Starlight Starter Kit: Basics + +[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) + +``` +npm create astro@latest -- --template starlight +``` + +> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! + +## 🚀 Project Structure + +Inside of your Astro + Starlight project, you'll see the following folders and files: + +``` +. +├── public/ +├── src/ +│ ├── assets/ +│ ├── content/ +│ │ └── docs/ +│ └── content.config.ts +├── astro.config.mjs +├── package.json +└── tsconfig.json +``` + +Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. + +Images can be added to `src/assets/` and embedded in Markdown with a relative link. + +Static assets, like favicons, can be placed in the `public/` directory. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :------------------------ | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:4321` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro -- --help` | Get help using the Astro CLI | + +## 👀 Want to learn more? + +Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs new file mode 100644 index 0000000000..7fc0174e9d --- /dev/null +++ b/docs/astro.config.mjs @@ -0,0 +1,25 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import cloudflare from '@astrojs/cloudflare'; +import sitemap from '@astrojs/sitemap'; +import mdx from '@astrojs/mdx'; + +export default defineConfig({ + devToolbar: { + enabled: false, + }, + site: 'https://fullstackhero.net', + adapter: cloudflare({ + imageService: 'compile', + }), + integrations: [mdx(), sitemap()], + markdown: { + shikiConfig: { + themes: { + light: 'github-light', + dark: 'github-dark', + }, + defaultColor: false, + }, + }, +}); diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000000..b2958b65bb --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,6485 @@ +{ + "name": "fullstackhero", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fullstackhero", + "version": "0.0.1", + "dependencies": { + "@astrojs/cloudflare": "^13.1.4", + "@astrojs/mdx": "^5.0.3", + "@astrojs/sitemap": "^3.3.0", + "astro": "^6.0.8", + "lucide-static": "^1.7.0", + "pagefind": "^1.4.0", + "sharp": "^0.34.5" + } + }, + "node_modules/@astrojs/cloudflare": { + "version": "13.1.4", + "resolved": "https://registry.npmjs.org/@astrojs/cloudflare/-/cloudflare-13.1.4.tgz", + "integrity": "sha512-4IrT0YaD9/N6LZYY6Q3rmJpCFcxFnuWKxLqFZpTi62G9gYK4HITvNL0QmzoO2VEkWjV4NPtvZTSyq4G1oPkA4Q==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.8.0", + "@astrojs/underscore-redirects": "1.0.2", + "@cloudflare/vite-plugin": "^1.25.6", + "piccolore": "^0.1.3", + "tinyglobby": "^0.2.15", + "vite": "^7.3.1" + }, + "peerDependencies": { + "astro": "^6.0.0", + "wrangler": "^4.61.1" + } + }, + "node_modules/@astrojs/compiler": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-3.0.1.tgz", + "integrity": "sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.8.0.tgz", + "integrity": "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w==", + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.3" + } + }, + "node_modules/@astrojs/markdown-remark": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.0.tgz", + "integrity": "sha512-P+HnCsu2js3BoTc8kFmu+E9gOcFeMdPris75g+Zl4sY8+bBRbSQV6xzcBDbZ27eE7yBGEGQoqjpChx+KJYIPYQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.8.0", + "@astrojs/prism": "4.0.1", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/mdx": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-5.0.3.tgz", + "integrity": "sha512-zv/OlM5sZZvyjHqJjR3FjJvoCgbxdqj3t4jO/gSEUNcck3BjdtMgNQw8UgPfAGe4yySdG4vjZ3OC5wUxhu7ckg==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-remark": "7.1.0", + "@mdx-js/mdx": "^3.1.1", + "acorn": "^8.16.0", + "es-module-lexer": "^2.0.0", + "estree-util-visit": "^2.0.0", + "hast-util-to-html": "^9.0.5", + "piccolore": "^0.1.3", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-smartypants": "^3.0.2", + "source-map": "^0.7.6", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "astro": "^6.0.0" + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.1.tgz", + "integrity": "sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.2.tgz", + "integrity": "sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==", + "license": "MIT", + "dependencies": { + "sitemap": "^9.0.0", + "stream-replace-string": "^2.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/underscore-redirects": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/underscore-redirects/-/underscore-redirects-1.0.2.tgz", + "integrity": "sha512-S3gTcWr3DVk2zvY7hEoEYjqpDe9fs0EL077EYIvvE7hEoPEaCOWRK+qzzm02Qm0T06vqzqHHDa3BD0/nhWXjvg==", + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", + "integrity": "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==", + "license": "MIT", + "dependencies": { + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.1.0.tgz", + "integrity": "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.1.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", + "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vite-plugin": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.30.1.tgz", + "integrity": "sha512-gDWf2VJNRDp3ktWsTapx3gzffVfE2mkLiziiQOZGPgipvVBgWsCHO4UGqCDoLkXtB2gw4zgbGUKKqxBOn7WTSg==", + "license": "MIT", + "dependencies": { + "@cloudflare/unenv-preset": "2.16.0", + "miniflare": "4.20260317.2", + "unenv": "2.0.0-rc.24", + "wrangler": "4.77.0", + "ws": "8.18.0" + }, + "peerDependencies": { + "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", + "wrangler": "^4.77.0" + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260317.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260317.1.tgz", + "integrity": "sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260317.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260317.1.tgz", + "integrity": "sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260317.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260317.1.tgz", + "integrity": "sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260317.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260317.1.tgz", + "integrity": "sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260317.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260317.1.tgz", + "integrity": "sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.4.0.tgz", + "integrity": "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.4.0.tgz", + "integrity": "sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.4.0.tgz", + "integrity": "sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.4.0.tgz", + "integrity": "sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.4.0.tgz", + "integrity": "sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.4.0.tgz", + "integrity": "sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "license": "CC0-1.0" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/astro": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.1.0.tgz", + "integrity": "sha512-J8XGJQo5+W2wJLdUbQHVho4DHWDM6V4Dp8s+z0Fs3O/mcu3WjbTBELOv/MC7ueoqmQ/Jts6Bz7FJwbAopbFd+g==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^3.0.1", + "@astrojs/internal-helpers": "0.8.0", + "@astrojs/markdown-remark": "7.1.0", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^1.1.1", + "devalue": "^5.6.3", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.27.3", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "rehype": "^13.0.2", + "semver": "^7.7.4", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.4", + "unist-util-visit": "^5.1.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^7.3.1", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT" + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", + "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-static": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.7.0.tgz", + "integrity": "sha512-d2H0iplJoHN67x9mPUvIfsHhtsI3BRvhhzfreccj+0sHHf4TjHvPEaWHNMR5iMKAFq3HKVfzx3kb82L8sPp2rA==", + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/miniflare": { + "version": "4.20260317.2", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260317.2.tgz", + "integrity": "sha512-qNL+yWAFMX6fr0pWU6Lx1vNpPobpnDSF1V8eunIckWvoIQl8y1oBjL2RJFEGY3un+l3f9gwW9dirDPP26usYJQ==", + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.4", + "workerd": "1.20260317.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", + "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/pagefind": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.4.0.tgz", + "integrity": "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==", + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.4.0", + "@pagefind/darwin-x64": "1.4.0", + "@pagefind/freebsd-x64": "1.4.0", + "@pagefind/linux-arm64": "1.4.0", + "@pagefind/linux-x64": "1.4.0", + "@pagefind/windows-x64": "1.4.0" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.9.2", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/esm/cli.js" + }, + "engines": { + "node": ">=20.19.5", + "npm": ">=10.8.2" + } + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stream-replace-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", + "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", + "integrity": "sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", + "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.5", + "lru-cache": "^11.2.0", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/workerd": { + "version": "1.20260317.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260317.1.tgz", + "integrity": "sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260317.1", + "@cloudflare/workerd-darwin-arm64": "1.20260317.1", + "@cloudflare/workerd-linux-64": "1.20260317.1", + "@cloudflare/workerd-linux-arm64": "1.20260317.1", + "@cloudflare/workerd-windows-64": "1.20260317.1" + } + }, + "node_modules/wrangler": { + "version": "4.77.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.77.0.tgz", + "integrity": "sha512-E2Gm69+K++BFd3QvoWjC290RPQj1vDOUotA++sNHmtKPb7EP6C8Qv+1D5Ii73tfZtyNgakpqHlh8lBBbVWTKAQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.16.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260317.2", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260317.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.3.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260317.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000000..238c95ded9 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,21 @@ +{ + "name": "fullstackhero", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build && npx pagefind --site dist/client", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/cloudflare": "^13.1.4", + "@astrojs/mdx": "^5.0.3", + "@astrojs/sitemap": "^3.3.0", + "astro": "^6.0.8", + "lucide-static": "^1.7.0", + "pagefind": "^1.4.0", + "sharp": "^0.34.5" + } +} diff --git a/docs/public/apple-touch-icon.png b/docs/public/apple-touch-icon.png new file mode 100644 index 0000000000..e8bf99c828 Binary files /dev/null and b/docs/public/apple-touch-icon.png differ diff --git a/docs/public/favicon-96x96.png b/docs/public/favicon-96x96.png new file mode 100644 index 0000000000..3170ed8ceb Binary files /dev/null and b/docs/public/favicon-96x96.png differ diff --git a/docs/public/favicon.ico b/docs/public/favicon.ico new file mode 100644 index 0000000000..14140c1b93 Binary files /dev/null and b/docs/public/favicon.ico differ diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg new file mode 100644 index 0000000000..67bbefbcba --- /dev/null +++ b/docs/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt new file mode 100644 index 0000000000..f2272c58e4 --- /dev/null +++ b/docs/public/llms-full.txt @@ -0,0 +1,89 @@ +# fullstackhero .NET Starter Kit - Complete Reference + +> Production-ready, open-source .NET 10 modular monolith starter kit for building multi-tenant SaaS and enterprise applications. MIT licensed. 6,400+ GitHub stars. + +## What is fullstackhero? + +fullstackhero is a production-ready .NET 10 framework that gives development teams a complete foundation for building enterprise SaaS applications. Instead of spending weeks wiring up authentication, multitenancy, CQRS, caching, background jobs, and observability, teams clone the starter kit and start building business features immediately. + +The architecture is a Modular Monolith with Vertical Slice Architecture (VSA). Each module is an independent bounded context (Identity, Multitenancy, Auditing) that communicates through Contracts projects only. Features are organized as vertical slices - each feature folder contains its endpoint, handler, validator, and response DTO. + +## Architecture + +- **Modular Monolith**: Independent modules with enforced boundaries (NetArchTest) +- **Vertical Slice Architecture**: Features organized by business capability, not technical layer +- **CQRS**: Commands and queries separated using source-generated Mediator (not MediatR) +- **Domain-Driven Design**: BaseEntity, AggregateRoot, DomainEvent, value objects +- **Outbox/Inbox Pattern**: Reliable integration events with at-least-once delivery +- **Specification Pattern**: Composable, reusable EF Core query objects + +## Tech Stack + +| Concern | Technology | +|---------|-----------| +| Framework | .NET 10 / C# latest | +| CQRS/Mediator | Mediator 3.x (source generator) | +| Validation | FluentValidation 12.x | +| ORM | Entity Framework Core 10.x | +| Database | PostgreSQL (Npgsql) | +| Auth | JWT Bearer + ASP.NET Identity | +| Multitenancy | Finbuckle.MultiTenant 10.x | +| Caching | Redis (StackExchange) | +| Jobs | Hangfire | +| Logging | Serilog + OpenTelemetry | +| API docs | OpenAPI + Scalar | +| Hosting | .NET Aspire | +| Testing | xUnit, Shouldly, NSubstitute, NetArchTest | +| IaC | Terraform (AWS ECS, RDS, ElastiCache) | + +## Modules + +### Identity Module +User management, JWT authentication, role-based access control, permission-based authorization, session tracking, group management, password policies with history and expiry. 30+ API endpoints including registration, login, token refresh, user CRUD, role management, and session revocation. + +### Multitenancy Module +SaaS multitenancy built on Finbuckle.MultiTenant. Four tenant resolution strategies (claim, header, query string, delegate). Per-tenant database isolation via EF Core global query filters. Tenant provisioning with automatic database migration and seeding. Theme management per tenant. + +### Auditing Module +Enterprise audit trails capturing four event types: entity changes (CRUD with before/after diffs), security events (login, token operations), activity events (HTTP requests), and exception events (with stack traces). All events are tenant-aware with correlation ID tracking. Queryable via REST API with filtering by correlation, OpenTelemetry trace, severity, and date range. + +## Building Blocks (10 shared libraries) + +1. **Core** - DDD primitives (BaseEntity, AggregateRoot, DomainEvent), exceptions (CustomException, NotFoundException, ForbiddenException), ICurrentUser +2. **Persistence** - EF Core abstractions, Specification pattern, pagination, domain events interceptor, auditable entity interceptor +3. **Web** - Module system (IModule, ModuleLoader), GlobalExceptionHandler (ProblemDetails RFC 9457), API versioning, rate limiting, security headers, OpenAPI/Scalar, health checks, Serilog + OpenTelemetry +4. **Caching** - ICacheService with distributed (Redis) and hybrid (L1 memory + L2 Redis) implementations +5. **Eventing** - IEventBus (InMemory + RabbitMQ), outbox/inbox pattern, integration events, JsonEventSerializer +6. **Jobs** - IJobService wrapping Hangfire, tenant-aware job processing, OpenTelemetry telemetry filter +7. **Mailing** - IMailService with SMTP (MailKit) and SendGrid providers +8. **Storage** - IStorageService with local filesystem and AWS S3 providers +9. **Shared** - Permission constants, claim helpers, multitenancy constants, pagination DTOs +10. **Shared** - Permission constants, claim helpers, multitenancy constants, pagination DTOs + +## Getting Started + +```bash +git clone https://github.com/fullstackhero/dotnet-starter-kit.git +cd dotnet-starter-kit +dotnet build src/FSH.Framework.slnx +dotnet run --project src/Playground/FSH.Playground.AppHost # Aspire (recommended) +``` + +Default credentials: admin@root.com / 123Pa$$word! (root tenant) +API docs: https://localhost:5001/scalar/v1 + +## Deployment Options + +- **Docker**: Multi-stage Dockerfile + docker-compose (PostgreSQL, Redis, OTEL collector) +- **.NET Aspire**: Local development orchestration with dashboard +- **AWS Terraform**: Full IaC - VPC, ECS Fargate, ALB, RDS PostgreSQL, ElastiCache Redis, S3 +- **CI/CD**: GitHub Actions (build, test, publish to GHCR + NuGet) + +## Links + +- Website: https://fullstackhero.net +- GitHub: https://github.com/fullstackhero/dotnet-starter-kit +- Author: Mukesh Murugan (https://codewithmukesh.com) +- Twitter: https://twitter.com/iammukeshm +- LinkedIn: https://www.linkedin.com/in/iammukeshm/ +- YouTube: https://youtube.com/@codewithmukesh diff --git a/docs/public/llms.txt b/docs/public/llms.txt new file mode 100644 index 0000000000..2ba39444bb --- /dev/null +++ b/docs/public/llms.txt @@ -0,0 +1,51 @@ +# fullstackhero + +> Production-ready, open-source .NET 10 starter kit for building multi-tenant SaaS and enterprise applications. Modular monolith architecture with vertical slice design, CQRS, identity management, and operational infrastructure. MIT licensed. 6,400+ GitHub stars. + +## Getting Started +- [Introduction](https://fullstackhero.net/dotnet-starter-kit/introduction/): What fullstackhero is, key features, and tech stack +- [Prerequisites](https://fullstackhero.net/dotnet-starter-kit/prerequisites/): .NET 10 SDK, PostgreSQL, Redis setup +- [Quick Start](https://fullstackhero.net/dotnet-starter-kit/quick-start/): Clone, build, and run in under 5 minutes +- [Project Structure](https://fullstackhero.net/dotnet-starter-kit/project-structure/): Solution layout with 27 projects + +## Architecture +- [Architecture Overview](https://fullstackhero.net/dotnet-starter-kit/architecture/): Modular monolith + vertical slice architecture +- [CQRS Pattern](https://fullstackhero.net/dotnet-starter-kit/cqrs/): Commands, queries, source-generated Mediator pipeline +- [Domain Events](https://fullstackhero.net/dotnet-starter-kit/domain-events/): Domain and integration event patterns +- [Specifications](https://fullstackhero.net/dotnet-starter-kit/specification-pattern/): Composable query specifications for EF Core +- [Outbox & Inbox](https://fullstackhero.net/dotnet-starter-kit/outbox-inbox-pattern/): Reliable messaging with transactional outbox +- [Module System](https://fullstackhero.net/dotnet-starter-kit/module-system/): Plug-and-play bounded context modules + +## Modules +- [Identity](https://fullstackhero.net/dotnet-starter-kit/identity-module/): Users, roles, JWT auth, sessions, groups, permissions +- [Multitenancy](https://fullstackhero.net/dotnet-starter-kit/multitenancy/): Tenant resolution, data isolation, provisioning +- [Auditing](https://fullstackhero.net/dotnet-starter-kit/auditing/): Security, activity, entity change, and exception auditing + +## Building Blocks +- [Overview](https://fullstackhero.net/dotnet-starter-kit/building-blocks-overview/): 10 shared framework libraries +- [Core](https://fullstackhero.net/dotnet-starter-kit/core-building-block/): DDD primitives, exceptions, interfaces +- [Persistence](https://fullstackhero.net/dotnet-starter-kit/persistence-building-block/): EF Core, specifications, pagination +- [Web](https://fullstackhero.net/dotnet-starter-kit/web-building-block/): Module system, middleware, API versioning +- [Caching](https://fullstackhero.net/dotnet-starter-kit/caching/): Redis distributed + hybrid cache +- [Eventing](https://fullstackhero.net/dotnet-starter-kit/eventing/): Event bus, outbox/inbox, RabbitMQ + +## Deployment +- [Docker](https://fullstackhero.net/dotnet-starter-kit/docker/): Multi-stage Dockerfile, docker-compose +- [.NET Aspire](https://fullstackhero.net/dotnet-starter-kit/dotnet-aspire/): Local dev orchestration +- [AWS Terraform](https://fullstackhero.net/dotnet-starter-kit/aws-terraform-deployment/): ECS Fargate, RDS, ElastiCache +- [CI/CD](https://fullstackhero.net/dotnet-starter-kit/ci-cd-pipelines/): GitHub Actions pipelines + +## Key Facts +- Framework: .NET 10 / C# latest +- Architecture: Modular Monolith + Vertical Slice Architecture +- Database: PostgreSQL (EF Core 10), also supports SQL Server +- Auth: JWT Bearer + ASP.NET Identity with permission-based authorization +- Multitenancy: Finbuckle.MultiTenant (claim, header, query string strategies) +- CQRS: Mediator 3.x (source-generated, zero-reflection) +- Caching: Redis (StackExchange) with hybrid L1/L2 +- Jobs: Hangfire with tenant-aware job processing +- Observability: Serilog + OpenTelemetry (OTLP) +- Testing: xUnit, Shouldly, NSubstitute, AutoFixture, NetArchTest +- License: MIT +- Created by: Mukesh Murugan (https://codewithmukesh.com) +- GitHub: https://github.com/fullstackhero/dotnet-starter-kit diff --git a/docs/public/robots.txt b/docs/public/robots.txt new file mode 100644 index 0000000000..96bc16663c --- /dev/null +++ b/docs/public/robots.txt @@ -0,0 +1,34 @@ +# fullstackhero.net robots.txt + +User-agent: * +Allow: / + +# AI Search Crawlers - explicitly allowed +User-agent: GPTBot +Allow: / + +User-agent: OAI-SearchBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: PerplexityBot +Allow: / + +User-agent: Google-Extended +Allow: / + +User-agent: Amazonbot +Allow: / + +User-agent: Applebot-Extended +Allow: / + +User-agent: Cohere-ai +Allow: / + +Sitemap: https://fullstackhero.net/sitemap.xml diff --git a/docs/public/site.webmanifest b/docs/public/site.webmanifest new file mode 100644 index 0000000000..4074de6979 --- /dev/null +++ b/docs/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "fullstackhero", + "short_name": "fullstackhero", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/docs/public/web-app-manifest-192x192.png b/docs/public/web-app-manifest-192x192.png new file mode 100644 index 0000000000..04b88b6621 Binary files /dev/null and b/docs/public/web-app-manifest-192x192.png differ diff --git a/docs/public/web-app-manifest-512x512.png b/docs/public/web-app-manifest-512x512.png new file mode 100644 index 0000000000..280e51cbbd Binary files /dev/null and b/docs/public/web-app-manifest-512x512.png differ diff --git a/docs/src/assets/apple-touch-icon.png b/docs/src/assets/apple-touch-icon.png new file mode 100644 index 0000000000..e8bf99c828 Binary files /dev/null and b/docs/src/assets/apple-touch-icon.png differ diff --git a/docs/src/assets/favicon-96x96.png b/docs/src/assets/favicon-96x96.png new file mode 100644 index 0000000000..3170ed8ceb Binary files /dev/null and b/docs/src/assets/favicon-96x96.png differ diff --git a/docs/src/assets/favicon.ico b/docs/src/assets/favicon.ico new file mode 100644 index 0000000000..14140c1b93 Binary files /dev/null and b/docs/src/assets/favicon.ico differ diff --git a/docs/src/assets/favicon.svg b/docs/src/assets/favicon.svg new file mode 100644 index 0000000000..67bbefbcba --- /dev/null +++ b/docs/src/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icon.png b/docs/src/assets/icon.png similarity index 100% rename from icon.png rename to docs/src/assets/icon.png diff --git a/docs/src/assets/mukesh_murugan.png b/docs/src/assets/mukesh_murugan.png new file mode 100644 index 0000000000..e6d036110c Binary files /dev/null and b/docs/src/assets/mukesh_murugan.png differ diff --git a/docs/src/assets/site.webmanifest b/docs/src/assets/site.webmanifest new file mode 100644 index 0000000000..4074de6979 --- /dev/null +++ b/docs/src/assets/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "fullstackhero", + "short_name": "fullstackhero", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/docs/src/assets/web-app-manifest-192x192.png b/docs/src/assets/web-app-manifest-192x192.png new file mode 100644 index 0000000000..04b88b6621 Binary files /dev/null and b/docs/src/assets/web-app-manifest-192x192.png differ diff --git a/docs/src/assets/web-app-manifest-512x512.png b/docs/src/assets/web-app-manifest-512x512.png new file mode 100644 index 0000000000..280e51cbbd Binary files /dev/null and b/docs/src/assets/web-app-manifest-512x512.png differ diff --git a/docs/src/components/Aside.astro b/docs/src/components/Aside.astro new file mode 100644 index 0000000000..e58086cbbb --- /dev/null +++ b/docs/src/components/Aside.astro @@ -0,0 +1,112 @@ +--- +interface Props { + type?: 'note' | 'tip' | 'warning' | 'danger'; + title?: string; +} + +const { type = 'note', title } = Astro.props; + +const config = { + note: { icon: '', defaultTitle: 'Note' }, + tip: { icon: '', defaultTitle: 'Tip' }, + warning: { icon: '', defaultTitle: 'Warning' }, + danger: { icon: '', defaultTitle: 'Danger' }, +}; + +const { icon, defaultTitle } = config[type]; +const displayTitle = title || defaultTitle; +--- + +
+
+ + {displayTitle} +
+
+ +
+
+ + diff --git a/docs/src/components/Badge.astro b/docs/src/components/Badge.astro new file mode 100644 index 0000000000..3a6ba8dff3 --- /dev/null +++ b/docs/src/components/Badge.astro @@ -0,0 +1,38 @@ +--- +interface Props { + variant?: 'default' | 'success' | 'warning' | 'danger' | 'outline'; + size?: 'sm' | 'md'; +} + +const { variant = 'default', size = 'sm' } = Astro.props; +--- + + + + diff --git a/docs/src/components/Breadcrumbs.astro b/docs/src/components/Breadcrumbs.astro new file mode 100644 index 0000000000..aa7521c113 --- /dev/null +++ b/docs/src/components/Breadcrumbs.astro @@ -0,0 +1,84 @@ +--- +import { sidebar, isSubGroup } from '../config/docs.config'; +import type { SidebarLink, SidebarSubGroup } from '../config/docs.config'; + +interface Props { + currentSlug: string; + title: string; +} + +const { currentSlug, title } = Astro.props; + +// Find which group (and optionally sub-group) this page belongs to +let groupLabel = ''; +let subGroupLabel = ''; + +for (const group of sidebar) { + for (const item of group.items) { + if (isSubGroup(item)) { + const sub = item as SidebarSubGroup; + if (sub.items.some((s: SidebarLink) => s.slug === currentSlug)) { + groupLabel = group.label; + subGroupLabel = sub.label; + break; + } + } else { + if ((item as SidebarLink).slug === currentSlug) { + groupLabel = group.label; + break; + } + } + } + if (groupLabel) break; +} +--- + + + + diff --git a/docs/src/components/DocsPagination.astro b/docs/src/components/DocsPagination.astro new file mode 100644 index 0000000000..d5c55ff5cf --- /dev/null +++ b/docs/src/components/DocsPagination.astro @@ -0,0 +1,37 @@ +--- +import { sidebar, isSubGroup } from '../config/docs.config'; +import type { SidebarLink } from '../config/docs.config'; + +interface Props { + currentSlug: string; +} + +const { currentSlug } = Astro.props; + +// Flatten all items (handles nested sub-groups) +const allItems: SidebarLink[] = sidebar.flatMap((group) => + group.items.flatMap((item) => + isSubGroup(item) ? item.items : [item as SidebarLink] + ) +); +const currentIndex = allItems.findIndex((item) => item.slug === currentSlug); +const prev = currentIndex > 0 ? allItems[currentIndex - 1] : null; +const next = currentIndex < allItems.length - 1 ? allItems[currentIndex + 1] : null; +--- + +{(prev || next) && ( + +)} diff --git a/docs/src/components/DocsSidebar.astro b/docs/src/components/DocsSidebar.astro new file mode 100644 index 0000000000..9821b131cd --- /dev/null +++ b/docs/src/components/DocsSidebar.astro @@ -0,0 +1,48 @@ +--- +import { sidebar, isSubGroup } from '../config/docs.config'; +import type { SidebarLink, SidebarSubGroup } from '../config/docs.config'; + +interface Props { + currentSlug: string; +} + +const { currentSlug } = Astro.props; +--- + + diff --git a/docs/src/components/DocsToc.astro b/docs/src/components/DocsToc.astro new file mode 100644 index 0000000000..5443be3707 --- /dev/null +++ b/docs/src/components/DocsToc.astro @@ -0,0 +1,91 @@ +--- +interface Heading { + depth: number; + slug: string; + text: string; +} + +interface Props { + headings: Heading[]; +} + +const { headings } = Astro.props; +const tocHeadings = headings.filter((h) => h.depth >= 2 && h.depth <= 3); +--- + +{tocHeadings.length > 0 && ( + +)} + + diff --git a/docs/src/components/FileTree.astro b/docs/src/components/FileTree.astro new file mode 100644 index 0000000000..a4a8cc3d1d --- /dev/null +++ b/docs/src/components/FileTree.astro @@ -0,0 +1,40 @@ +--- +import FileTreeNode from './FileTreeNode.astro'; + +interface FileItem { + name: string; + type?: 'folder' | 'file'; + desc?: string; + children?: FileItem[]; +} + +interface Props { + items: FileItem[]; +} + +const { items } = Astro.props; +--- + +
+ {items.map((item) => )} +
+ + diff --git a/docs/src/components/FileTreeNode.astro b/docs/src/components/FileTreeNode.astro new file mode 100644 index 0000000000..191a96ee4a --- /dev/null +++ b/docs/src/components/FileTreeNode.astro @@ -0,0 +1,127 @@ +--- +interface FileItem { + name: string; + type?: 'folder' | 'file'; + desc?: string; + children?: FileItem[]; +} + +interface Props { + item: FileItem; + depth: number; + isLast?: boolean; +} + +const { item, depth, isLast = false } = Astro.props; +const isFolder = item.type === 'folder' || !!item.children; +const FileTreeNode = Astro.self; + +const guideLeft = `calc(0.85rem + ${depth} * 1.15rem + 0.35rem + 7px)`; +const connectorLeft = `calc(0.85rem + ${(depth - 1)} * 1.15rem + 0.35rem + 7px)`; +--- + +
0, 'ftn-last': isLast }]}> + {depth > 0 && ( + + )} +
+ {isFolder ? ( + + ) : ( + + )} + {item.name} + {item.desc && {item.desc}} +
+ {item.children && ( +
+ {item.children.map((child, i) => ( + + ))} +
+ )} +
+ + diff --git a/docs/src/components/Footer.astro b/docs/src/components/Footer.astro new file mode 100644 index 0000000000..4245681067 --- /dev/null +++ b/docs/src/components/Footer.astro @@ -0,0 +1,117 @@ +--- +import { Image } from 'astro:assets'; +import logo from '../assets/icon.png'; +--- + + + diff --git a/docs/src/components/Nav.astro b/docs/src/components/Nav.astro new file mode 100644 index 0000000000..0020a14073 --- /dev/null +++ b/docs/src/components/Nav.astro @@ -0,0 +1,284 @@ +--- +import { Image } from 'astro:assets'; +import logo from '../assets/icon.png'; +--- +
+
+ + + fullstackhero + + + + + + +
+
+ + + + + + + + + + +
+ + +
+ + +
+
+ + +
+ + + + diff --git a/docs/src/components/SearchModal.astro b/docs/src/components/SearchModal.astro new file mode 100644 index 0000000000..b2bc5511b2 --- /dev/null +++ b/docs/src/components/SearchModal.astro @@ -0,0 +1,284 @@ + + +
+
+
+ + + Esc +
+
+
+
+ + +
+ +
+
+ + + + + + + diff --git a/docs/src/components/Steps.astro b/docs/src/components/Steps.astro new file mode 100644 index 0000000000..b96f924d8c --- /dev/null +++ b/docs/src/components/Steps.astro @@ -0,0 +1,100 @@ +--- +// Steps component - wraps numbered steps +--- + +
+ + diff --git a/docs/src/components/TabPanel.astro b/docs/src/components/TabPanel.astro new file mode 100644 index 0000000000..9667eab2c9 --- /dev/null +++ b/docs/src/components/TabPanel.astro @@ -0,0 +1,4 @@ +--- +// A single tab panel - used inside +--- +
diff --git a/docs/src/components/Tabs.astro b/docs/src/components/Tabs.astro new file mode 100644 index 0000000000..30f40b1714 --- /dev/null +++ b/docs/src/components/Tabs.astro @@ -0,0 +1,93 @@ +--- +interface Props { + labels: string[]; +} + +const { labels } = Astro.props; +const id = `tabs-${Math.random().toString(36).slice(2, 8)}`; +--- + +
+
+ {labels.map((label, i) => ( + + ))} +
+
+ +
+
+ + + + diff --git a/docs/src/config/docs.config.ts b/docs/src/config/docs.config.ts new file mode 100644 index 0000000000..fe15d969a5 --- /dev/null +++ b/docs/src/config/docs.config.ts @@ -0,0 +1,133 @@ +export interface SidebarLink { + title: string; + slug: string; +} + +export interface SidebarSubGroup { + label: string; + items: SidebarLink[]; +} + +export type SidebarItem = SidebarLink | SidebarSubGroup; + +export interface SidebarGroup { + label: string; + items: SidebarItem[]; +} + +export function isSubGroup(item: SidebarItem): item is SidebarSubGroup { + return 'items' in item && !('slug' in item); +} + +export const sidebar: SidebarGroup[] = [ + { + label: 'Getting Started', + items: [ + { title: 'Introduction', slug: 'introduction' }, + { title: 'Prerequisites', slug: 'prerequisites' }, + { title: 'Quick Start', slug: 'quick-start' }, + { title: 'Project Structure', slug: 'project-structure' }, + ], + }, + { + label: 'Architecture', + items: [ + { title: 'Overview', slug: 'architecture' }, + { title: 'CQRS Pattern', slug: 'cqrs' }, + { title: 'Domain Events', slug: 'domain-events' }, + { title: 'Specification Pattern', slug: 'specification-pattern' }, + { title: 'Outbox & Inbox Pattern', slug: 'outbox-inbox-pattern' }, + { title: 'Module System', slug: 'module-system' }, + ], + }, + { + label: 'Modules', + items: [ + { + label: 'Identity', + items: [ + { title: 'Overview', slug: 'identity-module' }, + { title: 'User Management', slug: 'user-management' }, + { title: 'Roles & Permissions', slug: 'roles-and-permissions' }, + { title: 'Authentication', slug: 'authentication' }, + { title: 'Sessions & Groups', slug: 'sessions-and-groups' }, + ], + }, + { + label: 'Multitenancy', + items: [ + { title: 'Overview', slug: 'multitenancy' }, + { title: 'Tenant Provisioning', slug: 'tenant-provisioning' }, + ], + }, + { + label: 'Auditing', + items: [ + { title: 'Overview', slug: 'auditing' }, + { title: 'Querying Audits', slug: 'querying-audits' }, + ], + }, + { + label: 'Webhooks', + items: [ + { title: 'Overview', slug: 'webhooks' }, + ], + }, + ], + }, + { + label: 'Building Blocks', + items: [ + { title: 'Overview', slug: 'building-blocks-overview' }, + { title: 'Core', slug: 'core-building-block' }, + { title: 'Persistence', slug: 'persistence-building-block' }, + { title: 'Web', slug: 'web-building-block' }, + { title: 'Caching', slug: 'caching' }, + { title: 'Eventing', slug: 'eventing' }, + { title: 'Background Jobs', slug: 'background-jobs' }, + { title: 'Mailing', slug: 'mailing' }, + { title: 'File Storage', slug: 'file-storage' }, + { title: 'Shared Library', slug: 'shared-library' }, + { title: 'HTTP Resilience', slug: 'http-resilience' }, + { title: 'Feature Flags', slug: 'feature-flags' }, + { title: 'Idempotency', slug: 'idempotency' }, + { title: 'Server-Sent Events', slug: 'server-sent-events' }, + ], + }, + { + label: 'Cross-Cutting Concerns', + items: [ + { title: 'Auth & Authorization', slug: 'authentication-and-authorization' }, + { title: 'Multitenancy Deep Dive', slug: 'multitenancy-deep-dive' }, + { title: 'Exception Handling', slug: 'exception-handling' }, + { title: 'Observability', slug: 'observability' }, + { title: 'Rate Limiting', slug: 'rate-limiting' }, + { title: 'Security Headers', slug: 'security-headers' }, + ], + }, + { + label: 'Guides', + items: [ + { title: 'Adding a Feature', slug: 'adding-a-feature' }, + { title: 'Adding a Module', slug: 'adding-a-module' }, + { title: 'Configuration Reference', slug: 'configuration-reference' }, + { title: 'Testing', slug: 'testing' }, + ], + }, + { + label: 'Deployment', + items: [ + { title: 'Overview', slug: 'deployment-overview' }, + { title: 'Docker', slug: 'docker' }, + { title: '.NET Aspire', slug: 'dotnet-aspire' }, + { title: 'AWS Terraform', slug: 'aws-terraform-deployment' }, + { title: 'CI/CD Pipelines', slug: 'ci-cd-pipelines' }, + ], + }, + { + label: 'Contributing', + items: [ + { title: 'Guide', slug: 'contributing' }, + ], + }, +]; diff --git a/docs/src/content.config.ts b/docs/src/content.config.ts new file mode 100644 index 0000000000..db64642ee9 --- /dev/null +++ b/docs/src/content.config.ts @@ -0,0 +1,12 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +export const collections = { + docs: defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs' }), + schema: z.object({ + title: z.string(), + description: z.string().optional(), + }), + }), +}; diff --git a/docs/src/content/docs/adding-a-feature.mdx b/docs/src/content/docs/adding-a-feature.mdx new file mode 100644 index 0000000000..fe3298d283 --- /dev/null +++ b/docs/src/content/docs/adding-a-feature.mdx @@ -0,0 +1,275 @@ +--- +title: "Adding a Feature" +description: "Step-by-step guide to building a new feature in the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; +import FileTree from '../../components/FileTree.astro'; + +Every feature in the fullstackhero .NET Starter Kit follows the **Vertical Slice Architecture** pattern. A single feature lives in one folder and contains everything it needs: a contract (command/query + response), a handler, a validator, and an endpoint. This guide walks through building a complete feature from scratch using a **CreateProduct** example. + + + +### Define the Contract + +Contracts are the public API of your module. They live in the `Modules.{Name}.Contracts` project so other modules can reference them without taking a dependency on your implementation. + +Create two files in `Modules.{Name}.Contracts/v1/{Area}/{Feature}/`: + +**CreateProductCommand.cs** + +```csharp +using Mediator; + +namespace FSH.Modules.Catalog.Contracts.v1.Products.CreateProduct; + +public class CreateProductCommand : ICommand +{ + public string Name { get; set; } = default!; + public decimal Price { get; set; } + public string? Description { get; set; } +} +``` + +**CreateProductResponse.cs** + +```csharp +namespace FSH.Modules.Catalog.Contracts.v1.Products.CreateProduct; + +public record CreateProductResponse(Guid ProductId); +``` + +Commands implement `ICommand` and queries implement `IQuery`. Response types should be records for immutability. + +### Create the Handler + +Handlers contain the business logic for a feature. They live in the runtime module project under `Features/v1/{Area}/{Feature}/`. + +Create `CreateProductCommandHandler.cs` in `Modules.{Name}/Features/v1/Products/CreateProduct/`: + +```csharp +using FSH.Modules.Catalog.Contracts.v1.Products.CreateProduct; +using Mediator; + +namespace FSH.Modules.Catalog.Features.v1.Products.CreateProduct; + +public sealed class CreateProductCommandHandler : ICommandHandler +{ + private readonly IRepository _repository; + + public CreateProductCommandHandler(IRepository repository) + { + _repository = repository; + } + + public async ValueTask Handle( + CreateProductCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var product = Product.Create(command.Name, command.Description, command.Price); + await _repository.AddAsync(product, cancellationToken).ConfigureAwait(false); + + return new CreateProductResponse(product.Id); + } +} +``` + +Key conventions to follow: +- Handler classes must be `sealed`. +- Return `ValueTask`, not `Task`. +- Use `.ConfigureAwait(false)` on every `await`. +- Use `ArgumentNullException.ThrowIfNull()` as a guard clause at the start of the method. + +### Add Validation + +Validators use FluentValidation and are automatically discovered and registered by the `ModuleLoader`. Name validators using the `{Command}Validator` convention. + +Create `CreateProductCommandValidator.cs` in the same feature folder: + +```csharp +using FluentValidation; +using FSH.Modules.Catalog.Contracts.v1.Products.CreateProduct; + +namespace FSH.Modules.Catalog.Features.v1.Products.CreateProduct; + +public sealed class CreateProductCommandValidator : AbstractValidator +{ + public CreateProductCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Product name is required.") + .MaximumLength(200).WithMessage("Product name must not exceed 200 characters."); + + RuleFor(x => x.Price) + .GreaterThan(0).WithMessage("Price must be greater than zero."); + } +} +``` + + + +### Create the Endpoint + +Endpoints are static extension methods on `IEndpointRouteBuilder` that return a `RouteHandlerBuilder`. They use the Minimal API pattern. + +Create `CreateProductEndpoint.cs` in the same feature folder: + +```csharp +using FSH.Modules.Catalog.Contracts.v1.Products.CreateProduct; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FSH.Modules.Catalog.Features.v1.Products.CreateProduct; + +public static class CreateProductEndpoint +{ + internal static RouteHandlerBuilder MapCreateProductEndpoint( + this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/products", (CreateProductCommand command, + IMediator mediator, CancellationToken cancellationToken) => + mediator.Send(command, cancellationToken)) + .WithName("CreateProduct") + .WithSummary("Create a new product") + .RequirePermission("Permissions.Products.Create"); + } +} +``` + +Endpoints follow these conventions: +- One static class per endpoint. +- The `Map*Endpoint` method is `internal static`. +- Use `.WithName()` and `.WithSummary()` for OpenAPI metadata. +- Use `.RequirePermission()` for authorization. + +### Wire the Endpoint + +Register the endpoint in your module's `MapEndpoints()` method. The endpoint is called on the versioned API group: + +```csharp +public void MapEndpoints(IEndpointRouteBuilder endpoints) +{ + ArgumentNullException.ThrowIfNull(endpoints); + + var apiVersionSet = endpoints.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + + var group = endpoints + .MapGroup("api/v{version:apiVersion}/catalog") + .WithTags("Catalog") + .WithApiVersionSet(apiVersionSet); + + group.MapCreateProductEndpoint(); +} +``` + +### Add Tests + +Create a test class in `Tests/{Name}.Tests/Handlers/` following the naming convention `MethodName_Should_ExpectedBehavior_When_Condition`: + +```csharp +using AutoFixture; +using FSH.Modules.Catalog.Contracts.v1.Products.CreateProduct; +using FSH.Modules.Catalog.Features.v1.Products.CreateProduct; +using NSubstitute; +using Shouldly; + +namespace Catalog.Tests.Handlers; + +public sealed class CreateProductCommandHandlerTests +{ + private readonly IRepository _repository; + private readonly CreateProductCommandHandler _sut; + private readonly IFixture _fixture; + + public CreateProductCommandHandlerTests() + { + _repository = Substitute.For>(); + _sut = new CreateProductCommandHandler(_repository); + _fixture = new Fixture(); + } + + #region Handle - Happy Path + + [Fact] + public async Task Handle_Should_ReturnProductId_When_CommandIsValid() + { + // Arrange + var command = new CreateProductCommand + { + Name = "Widget", + Price = 9.99m, + Description = "A useful widget" + }; + + // Act + var result = await _sut.Handle(command, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.ProductId.ShouldNotBe(Guid.Empty); + } + + #endregion + + #region Handle - Exception + + [Fact] + public async Task Handle_Should_ThrowArgumentNullException_When_CommandIsNull() + { + // Act & Assert + await Should.ThrowAsync( + async () => await _sut.Handle(null!, CancellationToken.None)); + } + + #endregion +} +``` + + + +## Resulting File Structure + +After completing all the steps, your feature folder should look like this: + + + + diff --git a/docs/src/content/docs/adding-a-module.mdx b/docs/src/content/docs/adding-a-module.mdx new file mode 100644 index 0000000000..835383e57e --- /dev/null +++ b/docs/src/content/docs/adding-a-module.mdx @@ -0,0 +1,280 @@ +--- +title: "Adding a Module" +description: "How to create a new bounded context module." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; +import FileTree from '../../components/FileTree.astro'; + +Modules are the bounded contexts of the fullstackhero .NET Starter Kit. Each module is a self-contained vertical slice that owns its data, domain logic, and API surface. This guide walks through creating a new **Catalog** module from scratch. + + + + + +### Create the Projects + +Create two projects under `src/Modules/Catalog/`: + +```bash +# Runtime implementation project +dotnet new classlib -n Modules.Catalog -o src/Modules/Catalog/Modules.Catalog + +# Public contracts project (commands, queries, DTOs, service interfaces) +dotnet new classlib -n Modules.Catalog.Contracts -o src/Modules/Catalog/Modules.Catalog.Contracts +``` + +Add both projects to the solution: + +```bash +dotnet sln src/FSH.Starter.slnx add \ + src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj \ + src/Modules/Catalog/Modules.Catalog.Contracts/Modules.Catalog.Contracts.csproj +``` + +Add the required project references. The runtime project references its own contracts and the BuildingBlocks: + +```xml + + + + + + + +``` + +The contracts project should only reference the shared/core building blocks: + +```xml + + + + +``` + +### Implement IModule + +Every module implements the `IModule` interface from `FSH.Framework.Web.Modules`. This is the entry point where you register services and map endpoints. + +Create `CatalogModule.cs` in the runtime project: + +```csharp +using Asp.Versioning; +using FSH.Framework.Eventing; +using FSH.Framework.Persistence; +using FSH.Framework.Web.Modules; +using FSH.Modules.Catalog.Data; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace FSH.Modules.Catalog; + +public class CatalogModule : IModule +{ + public void ConfigureServices(IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var services = builder.Services; + + // Register the module's DbContext + services.AddHeroDbContext(); + + // Register eventing (outbox/inbox) + services.AddEventingCore(builder.Configuration); + services.AddEventingForDbContext(); + services.AddIntegrationEventHandlers(typeof(CatalogModule).Assembly); + + // Health check for the module's database + services.AddHealthChecks() + .AddDbContextCheck( + name: "db:catalog", + failureStatus: HealthStatus.Unhealthy); + + // Register module-specific services + services.AddScoped(); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var apiVersionSet = endpoints.NewApiVersionSet() + .HasApiVersion(new ApiVersion(1)) + .ReportApiVersions() + .Build(); + + var group = endpoints + .MapGroup("api/v{version:apiVersion}/catalog") + .WithTags("Catalog") + .WithApiVersionSet(apiVersionSet); + + // Map feature endpoints here + // group.MapCreateProductEndpoint(); + // group.MapGetProductsEndpoint(); + } +} +``` + +### Register the Module Assembly + +Create an `AssemblyInfo.cs` in the runtime project root to register the module with the framework's module loader. The `FshModuleAttribute` tells the framework which class implements `IModule` and the startup order: + +```csharp +using System.Runtime.CompilerServices; +using FSH.Framework.Web.Modules; + +[assembly: FshModule(typeof(FSH.Modules.Catalog.CatalogModule), 200)] +[assembly: InternalsVisibleTo("Catalog.Tests")] +``` + +The second parameter is the **order** hint. Lower numbers execute first. The built-in modules use 100 (Identity), so use a higher number for your custom modules. + + + +### Create the DbContext + +Create a module-specific DbContext that extends from the framework base. Place it in a `Data/` folder: + +```csharp +using FSH.Framework.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Modules.Catalog.Data; + +public sealed class CatalogDbContext : FshDbContext +{ + public CatalogDbContext(DbContextOptions options) + : base(options) { } + + public DbSet Products => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + base.OnModelCreating(modelBuilder); + modelBuilder.HasDefaultSchema("catalog"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); + } +} +``` + +Each module uses its own database schema (e.g., `catalog`) to maintain data isolation within the shared database. + +### Register in Program.cs + +In the host application's `Program.cs`, add your module assembly to the module discovery. The framework's `ModuleLoader` scans for assemblies with the `[FshModule]` attribute. + +Ensure your module's assembly is referenced by the host project. Add a project reference in the Playground API's `.csproj`: + +```xml + +``` + +The `ModuleLoader` will automatically discover and register your module based on the `[assembly: FshModule(...)]` attribute. + +### Add Architecture Tests + +Add test rules to the `Architecture.Tests` project to enforce that your module respects the boundaries. The existing tests automatically cover new modules if they follow the `Modules.*` naming convention. + +For module-specific tests, create `Tests/Catalog.Tests/`: + +```bash +dotnet new xunit -n Catalog.Tests -o src/Tests/Catalog.Tests +dotnet sln src/FSH.Starter.slnx add src/Tests/Catalog.Tests/Catalog.Tests.csproj +``` + +Add references to the test project: + +```xml + + + + +``` + +The existing architecture tests in `Architecture.Tests/ModuleArchitectureTests.cs` will automatically verify that your new module does not reference other module runtime projects: + +```csharp +// This test scans ALL Modules.*.csproj files automatically +[Fact] +public void Modules_Should_Not_Depend_On_Other_Modules() +{ + // Verifies that runtime projects only reference: + // - Their own contracts + // - BuildingBlocks projects + // - Never another module's runtime project +} +``` + + + +## Resulting Module Structure + + + + diff --git a/docs/src/content/docs/architecture.mdx b/docs/src/content/docs/architecture.mdx new file mode 100644 index 0000000000..a0f87a877e --- /dev/null +++ b/docs/src/content/docs/architecture.mdx @@ -0,0 +1,421 @@ +--- +title: "Architecture Overview" +description: "Understanding the modular monolith and vertical slice architecture of the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; +import FileTree from '../../components/FileTree.astro'; + +The fullstackhero .NET Starter Kit is built on two complementary architectural patterns: **Modular Monolith** for system decomposition and **Vertical Slice Architecture (VSA)** for feature organization. Together, they give you the deployment simplicity of a monolith with the clean boundaries and independent evolvability of microservices. + +## Why Modular Monolith? + +Most applications start as a monolith and eventually need better structure. Jumping straight to microservices introduces distributed system complexity -- network latency, eventual consistency, service discovery, independent deployments -- before you have the team size or traffic patterns to justify it. A modular monolith gives you the best of both worlds. + +| Approach | Deployment | Boundaries | Complexity | Extraction Path | +|----------|-----------|------------|------------|-----------------| +| **Traditional Monolith** | Single unit | None -- everything is tangled | Low initially, grows fast | Painful rewrite | +| **Modular Monolith** | Single unit | Strict module boundaries | Moderate and controlled | Extract modules into services when needed | +| **Microservices** | Many units | Service boundaries | High from day one | Already distributed | + +fullstackhero uses the modular monolith approach because: + +- **Simple deployment** -- a single deployable unit means no container orchestration, service mesh, or distributed tracing required on day one. +- **Clear boundaries** -- each module is an isolated bounded context with its own domain, data access, and API surface. Modules cannot reach into each other's internals. +- **Easy extraction** -- because modules communicate only through lightweight Contracts projects, extracting a module into a standalone microservice later requires changing the transport layer, not the business logic. +- **Shared infrastructure** -- database connections, caching, authentication, and observability are configured once in the host application and shared across all modules. + + + +## Architecture Layers + +The codebase is organized into three layers, each with a clear responsibility: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Playground │ +│ (Host applications that compose modules) │ +│ FSH.Starter.Api · AppHost │ +├─────────────────────────────────────────────────────────────┤ +│ Modules │ +│ (Independent bounded contexts) │ +│ Identity · Multitenancy · Auditing · Webhooks │ +├─────────────────────────────────────────────────────────────┤ +│ BuildingBlocks │ +│ (Shared framework libraries) │ +│ Core · Persistence · Web · Caching · Eventing · Jobs · ... │ +└─────────────────────────────────────────────────────────────┘ +``` + +**BuildingBlocks** form the foundation. These are shared framework libraries that handle cross-cutting concerns -- persistence, caching, eventing, web conventions, and more. Think of them as your internal NuGet packages. Every module builds on top of them. + +**Modules** are the bounded contexts where your business logic lives. Each module owns its domain entities, data access, feature handlers, and API endpoints. Modules are isolated from each other and communicate only through Contracts. + +**Playground** contains the host applications that wire everything together. The API project composes all modules into a running HTTP service, and the AppHost project orchestrates the full stack with .NET Aspire. + + + +## Module Communication Rules + +This is the most important architectural constraint in the entire codebase. Every module is split into two projects: + +| Project | Contains | Visibility | +|---------|----------|------------| +| `Modules.{Name}` | Domain entities, EF Core DbContext, feature handlers, endpoints, services | **Internal** -- only the host application references this | +| `Modules.{Name}.Contracts` | Commands, queries, response DTOs, service interfaces, events | **Public** -- other modules may reference this | + +When Module A needs to interact with Module B, it references **only** `Modules.B.Contracts`. It sends commands or queries through the mediator, or consumes integration events. It never touches Module B's internal types, database context, or service implementations. + +``` +Identity Module ──references──> Multitenancy.Contracts (queries, DTOs) +Identity Module ──NEVER──> Multitenancy Module (runtime internals) +``` + + + +This boundary is enforced automatically by **NetArchTest** architecture tests in `src/Tests/Architecture.Tests/`. The `ContractsPurityTests` class verifies that Contracts assemblies contain no implementation details -- no Entity Framework dependencies, no FluentValidation, no Hangfire, no concrete repository types: + +```csharp +[Fact] +public void Contracts_Should_Not_Depend_On_Module_Implementations() +{ + string[] moduleImplementations = + [ + "FSH.Modules.Auditing.Features", + "FSH.Modules.Auditing.Data", + "FSH.Modules.Identity.Features", + "FSH.Modules.Identity.Data", + "FSH.Modules.Multitenancy.Features", + "FSH.Modules.Multitenancy.Data", + "FSH.Modules.Webhooks.Features", + "FSH.Modules.Webhooks.Data", + ]; + + foreach (var assembly in ContractsAssemblies) + { + var result = Types + .InAssembly(assembly) + .ShouldNot() + .HaveDependencyOnAny(moduleImplementations) + .GetResult(); + + result.IsSuccessful.ShouldBeTrue(...); + } +} +``` + +## Vertical Slice Architecture (VSA) + +Within each module, features are organized as **vertical slices** rather than traditional horizontal layers. Instead of grouping all endpoints in one folder, all handlers in another, and all validators in a third, each feature gets its own folder containing everything it needs. + +### Why Vertical Slices? + +In a layered architecture, adding a single feature requires changes across multiple folders -- a new endpoint in the Controllers folder, a new service in the Services folder, a new model in the Models folder. Over time, these layers grow independently and understanding a feature requires jumping between many files. + +With vertical slices, every file related to a feature lives in one place. When you need to understand or modify the "Register User" feature, you open one folder. When you need to delete a feature, you delete one folder. Changes are localized and self-contained. + +``` +Traditional Layers (scattered) Vertical Slices (co-located) +├── Controllers/ ├── Features/v1/Users/ +│ ├── UsersController.cs │ ├── RegisterUser/ +│ └── RolesController.cs │ │ ├── RegisterUserEndpoint.cs +├── Services/ │ │ ├── RegisterUserCommandHandler.cs +│ ├── UserService.cs │ │ └── RegisterUserCommandValidator.cs +│ └── RoleService.cs │ ├── DeleteUser/ +├── Validators/ │ │ ├── DeleteUserEndpoint.cs +│ ├── RegisterUserValidator.cs │ │ └── DeleteUserCommandHandler.cs +│ └── DeleteUserValidator.cs │ └── GetUsers/ +└── Models/ │ ├── GetUsersEndpoint.cs + ├── RegisterUserRequest.cs │ └── GetUsersQueryHandler.cs + └── ... └── ... +``` + +### Feature Folder Layout + +Features are organized under `Features/v{version}/{Area}/{FeatureName}/`. The version prefix supports API versioning, and the area groups related features together (Users, Roles, Sessions, etc.). + + + +The matching Contracts project mirrors this structure but contains only the command/query definitions and response DTOs: + +)' }, + { name: 'RegisterUserResponse.cs', desc: 'Response DTO' } + ]} + ]} + ]}, + { name: 'Services/', type: 'folder', desc: 'Service interfaces (not implementations)' } + ]} +]} /> + +## Feature Anatomy + +Let us walk through a real feature to see how the pieces fit together. The **RegisterUser** feature in the Identity module is a good example. + +**The command** is defined in the Contracts project so other modules can send it: + +```csharp +// Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs +public class RegisterUserCommand : ICommand +{ + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public string Email { get; set; } = default!; + public string UserName { get; set; } = default!; + public string Password { get; set; } = default!; + public string ConfirmPassword { get; set; } = default!; + public string? PhoneNumber { get; set; } +} +``` + +**The endpoint** maps an HTTP route to the mediator, keeping the HTTP layer razor thin: + +```csharp +// Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs +public static class RegisterUserEndpoint +{ + internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/register", async (RegisterUserCommand command, + HttpContext context, + IMediator mediator, + CancellationToken cancellationToken) => + { + var origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; + command.Origin = origin; + var result = await mediator.Send(command, cancellationToken); + return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result); + }) + .WithName("RegisterUser") + .WithSummary("Register user") + .RequirePermission(IdentityPermissionConstants.Users.Create); + } +} +``` + +**The handler** contains the business logic and lives alongside the endpoint. **The validator** uses FluentValidation to enforce input rules before the handler executes. + + + +## CQRS Flow + +Every request in fullstackhero follows the same pipeline. The mediator sits at the center, dispatching commands and queries to their handlers while running cross-cutting behaviors like validation. + +``` +HTTP Request + │ + ▼ +Endpoint (Minimal API) + │ + ▼ +IMediator.Send(command) + │ + ▼ +ValidationBehavior (FluentValidation) + │ + ▼ +CommandHandler / QueryHandler + │ + ▼ +Response +``` + +Commands represent write operations that change state. Queries represent read operations that return data. Both are defined in the Contracts project and implement `ICommand` or `IQuery` from the Mediator library. + +Handlers implement `ICommandHandler` or `IQueryHandler` and return `ValueTask`. They are the only place business logic lives -- endpoints stay thin, validators stay focused on input shape, and the handler does the real work. + + + +## Module Registration + +Modules are discovered and loaded automatically at startup through the `ModuleLoader` system. Each module declares itself using an assembly-level attribute: + +```csharp +// Modules.Identity/AssemblyInfo.cs +[assembly: FshModule(typeof(IdentityModule), 100)] +``` + +```csharp +// Modules.Multitenancy/AssemblyInfo.cs +[assembly: FshModule(typeof(MultitenancyModule), 200)] +``` + +```csharp +// Modules.Auditing/AssemblyInfo.cs +[assembly: FshModule(typeof(AuditingModule), 300)] +``` + +```csharp +// Modules.Webhooks/AssemblyInfo.cs +[assembly: FshModule(typeof(WebhooksModule), 400)] +``` + +The `Order` parameter controls startup sequencing -- lower numbers load first. This matters when modules have initialization dependencies (for example, Identity must initialize before Auditing can record audit trails). + +Every module implements the `IModule` interface: + +```csharp +public interface IModule +{ + // Register services, options, health checks - no ASP.NET types here + void ConfigureServices(IHostApplicationBuilder builder); + + // Wire up Minimal API endpoints + void MapEndpoints(IEndpointRouteBuilder endpoints); + + // Optional middleware registration - default is no-op + void ConfigureMiddleware(IApplicationBuilder app) { } +} +``` + +The `ModuleLoader` scans assemblies for `[FshModule]` attributes, instantiates each module, and calls `ConfigureServices`, `UseModuleMiddlewares`, and `MapEndpoints` in order: + +```csharp +// In the host's Program.cs +builder.AddModules(typeof(IdentityModule).Assembly, + typeof(MultitenancyModule).Assembly, + typeof(AuditingModule).Assembly, + typeof(WebhooksModule).Assembly); + +// Later in the pipeline +app.UseModuleMiddlewares(); +app.MapModules(); +``` + +FluentValidation validators from all module assemblies are auto-registered during `AddModules`, so you never need to register them manually. + + + +## Dependency Rules + +The BuildingBlocks follow a strict layered dependency hierarchy. Lower layers must not depend on higher layers, and this is enforced by architecture tests in `BuildingBlocksIndependenceTests`. + +``` +Layer 0 (no dependencies) + └── Core + └── Eventing.Abstractions + +Layer 1 (depends on Core) + └── Shared + └── Caching + └── Mailing + +Layer 2 (depends on Core + Shared) + └── Persistence + └── Jobs + └── Storage + └── Eventing (also depends on Eventing.Abstractions) + +Layer 3 (depends on lower layers) + └── Web + + ┌──────────────────┐ + │ Playground │ ← references Modules + BuildingBlocks + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Modules │ ← references BuildingBlocks only + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ BuildingBlocks │ ← references nothing above + └──────────────────┘ +``` + +The key rules: + +- **BuildingBlocks never depend on Modules or Playground.** They are reusable framework libraries. +- **Modules never depend on Playground.** They are self-contained bounded contexts. +- **Modules reference other modules only through Contracts projects.** Never through runtime projects. +- **Core has zero project references.** It depends only on the .NET BCL and Mediator abstractions. + + + +## Cross-Cutting Concerns + +fullstackhero handles cross-cutting concerns at the BuildingBlocks level so that modules stay focused on business logic. Here is a summary of what is provided: + +| Concern | Handled By | Details | +|---------|-----------|---------| +| **Authentication & Authorization** | Identity module + JWT Bearer | User registration, login, role-based and permission-based access control. See [Identity](/dotnet-starter-kit/modules/identity/). | +| **Multitenancy** | Multitenancy module + Finbuckle | Tenant resolution via claims, headers, or query strings. Per-tenant database isolation. See [Multitenancy](/dotnet-starter-kit/modules/multitenancy/). | +| **Auditing** | Auditing module | Automatic audit trail capture for all entity changes. See [Auditing](/dotnet-starter-kit/modules/auditing/). | +| **Webhooks** | Webhooks module | Outbound webhook subscriptions with HMAC signing and resilient delivery. See [Webhooks](/dotnet-starter-kit/webhooks/). | +| **Exception Handling** | Web BuildingBlock | Global exception handler converts exceptions to RFC 9457 ProblemDetails responses. Framework exception types include `CustomException`, `NotFoundException`, `ForbiddenException`, and `UnauthorizedException`. | +| **Validation** | Mediator pipeline + FluentValidation | `ValidationBehavior` runs validators automatically before handlers execute. Validation failures return 400 Bad Request with structured error details. | +| **Caching** | Caching BuildingBlock + Redis | Distributed caching with a clean abstraction layer backed by StackExchange.Redis. | +| **Background Jobs** | Jobs BuildingBlock + Hangfire | Background, scheduled, and recurring task execution. Used for outbox dispatching, session cleanup, and more. | +| **Observability** | Serilog + OpenTelemetry | Structured logging with Serilog and distributed tracing via OpenTelemetry OTLP exporter. | +| **API Documentation** | Web BuildingBlock + Scalar | OpenAPI specification generation with Scalar UI for interactive API exploration. | +| **API Versioning** | Asp.Versioning | URL-based versioning (`v{version:apiVersion}`) with version sets per module. | + + diff --git a/docs/src/content/docs/auditing.mdx b/docs/src/content/docs/auditing.mdx new file mode 100644 index 0000000000..59d1ce226b --- /dev/null +++ b/docs/src/content/docs/auditing.mdx @@ -0,0 +1,151 @@ +--- +title: "Auditing Module" +description: "Comprehensive audit trails for security, activity, entity changes, and exceptions." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; +import FileTree from '../../components/FileTree.astro'; + +The Auditing module provides enterprise-grade audit trails for your application. It captures four types of events -- **EntityChange** (CRUD operations on entities), **Security** (logins, token operations), **Activity** (HTTP requests), and **Exception** (errors with stack traces). All audit events are tenant-aware, ensuring complete isolation in multitenant deployments. + +## Architecture + +The audit pipeline is fully asynchronous, ensuring that audit recording never blocks business logic: + + + +### Source raises audit event + +Application code uses the `Audit` static API, or middleware automatically captures the event (HTTP requests, EF Core changes). + +### Event published to IAuditPublisher + +The `ChannelAuditPublisher` implementation writes the event to a bounded `System.Threading.Channels.Channel`, providing backpressure-aware async delivery. + +### AuditBackgroundWorker dequeues and processes + +A hosted background service continuously reads from the channel and dispatches events to the configured sink. + +### IAuditSink persists to AuditRecord table + +The `SqlAuditSink` implementation writes the final `AuditRecord` entity to PostgreSQL via EF Core. + + + +## AuditRecord Entity + +Every audit event is persisted as an `AuditRecord` with the following properties: + +| Property | Type | Description | +|----------|------|-------------| +| **Id** | `Guid` | Unique identifier for the audit record | +| **OccurredAtUtc** | `DateTimeOffset` | When the audited action happened | +| **ReceivedAtUtc** | `DateTimeOffset` | When the audit system received the event | +| **EventType** | `AuditEventType` | Category of the audit event | +| **Severity** | `AuditSeverity` | Severity level of the event | +| **TenantId** | `string` | Tenant context for the event | +| **UserId** | `string` | User who performed the action | +| **UserName** | `string` | Display name of the user | +| **TraceId** | `string` | OpenTelemetry trace identifier | +| **SpanId** | `string` | OpenTelemetry span identifier | +| **CorrelationId** | `string` | Correlation identifier for request chaining | +| **RequestId** | `string` | ASP.NET Core request identifier | +| **Source** | `string` | Origin of the audit event (e.g., class or endpoint name) | +| **Tags** | `string` | Comma-separated tags for classification | +| **PayloadJson** | `string` | JSON-serialized event payload with masked sensitive fields | + +## Event Types + +The `AuditEventType` enum defines the four categories of audit events: + +- **EntityChange** -- captures create, update, and delete operations on tracked entities, including old and new property values +- **Security** -- captures authentication and authorization events such as logins, token issuance, and revocations +- **Activity** -- captures HTTP request and response metadata (method, path, status code, duration) +- **Exception** -- captures unhandled exceptions with message, stack trace, and request context + +Severity levels follow the standard progression: **Trace**, **Debug**, **Information**, **Warning**, **Error**, **Critical**. + +## Audit API + +The fluent `Audit` static class provides a clean builder API for writing audit events from application code: + +```csharp +await Audit.ForEntityChange(payload) + .WithTenant(tenantId) + .WithUser(userId, userName) + .WithCorrelation(correlationId) + .WriteAsync(publisher); +``` + +Each builder method sets a specific piece of audit metadata, and `WriteAsync` dispatches the completed event to the `IAuditPublisher`. Similar entry points exist for other event types: + +```csharp +await Audit.ForSecurity(payload) + .WithTenant(tenantId) + .WithUser(userId, userName) + .WithSeverity(AuditSeverity.Warning) + .WriteAsync(publisher); + +await Audit.ForActivity(payload) + .WithTenant(tenantId) + .WithTrace(traceId, spanId) + .WriteAsync(publisher); + +await Audit.ForException(payload) + .WithTenant(tenantId) + .WithSeverity(AuditSeverity.Error) + .WriteAsync(publisher); +``` + +## Security Auditing + +The `ISecurityAudit` interface provides dedicated methods for common security events: + +- **LoginSucceededAsync** -- records a successful authentication attempt +- **LoginFailedAsync** -- records a failed authentication attempt with the failure reason +- **TokenIssuedAsync** -- records JWT token generation +- **TokenRevokedAsync** -- records token revocation or refresh token invalidation + +The Identity module's token handler uses `ISecurityAudit` to automatically capture all authentication activity. These events are written with the **Security** event type and are queryable through the dedicated security audit endpoint. + +## Automatic Auditing + +Two mechanisms capture audit events without requiring explicit code: + +**AuditHttpMiddleware** sits in the ASP.NET Core pipeline and automatically captures HTTP request and response metadata -- method, path, query string, status code, duration, and request headers. These events are written with the **Activity** event type. + +**AuditingSaveChangesInterceptor** hooks into EF Core's `SaveChangesAsync` pipeline and automatically captures entity changes. For each tracked entity that was added, modified, or deleted, it records the entity type, primary key, and a diff of old and new property values. These events are written with the **EntityChange** event type. + +## Data Masking + +The `JsonMaskingService` automatically masks sensitive fields in audit payloads before they are persisted. Fields matching patterns such as `password`, `token`, `secret`, `authorization`, and other PII-related names are replaced with `***MASKED***` in the serialized JSON. This ensures that audit records remain useful for investigation without exposing sensitive data. + +## Module Structure + + + + diff --git a/docs/src/content/docs/authentication-and-authorization.mdx b/docs/src/content/docs/authentication-and-authorization.mdx new file mode 100644 index 0000000000..9bee475c76 --- /dev/null +++ b/docs/src/content/docs/authentication-and-authorization.mdx @@ -0,0 +1,80 @@ +--- +title: "Authentication & Authorization" +description: "How JWT authentication and permission-based authorization flow through the stack." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +## Overview + +The fullstackhero .NET Starter Kit uses **JWT Bearer authentication** combined with **permission-based authorization**. Every HTTP request flows through the authentication middleware first, which validates the token and builds a `ClaimsPrincipal`. Then, authorization middleware checks whether the user has the required permissions for the requested endpoint. + +This approach keeps authorization fast - permissions are embedded in the JWT, so no database roundtrip is needed on each request. + +## Authentication Flow + + + +1. **Request arrives** - The client sends an HTTP request with an `Authorization: Bearer ` header. + +2. **JWT extracted** - The authentication middleware extracts the token from the `Authorization` header. + +3. **Token validated** - The token is validated against the configured parameters: signature, issuer, audience, and expiry. + +4. **ClaimsPrincipal created** - On successful validation, a `ClaimsPrincipal` is created from the token claims and attached to `HttpContext.User`. + +5. **CurrentUserMiddleware extracts user context** - The middleware reads the claims and populates `ICurrentUser` with `UserId`, `Email`, `Tenant`, and `Permissions`. + + + +## Authorization Flow + +Authorization is handled at the endpoint level using a custom `RequirePermission` extension method. The flow works as follows: + +1. The endpoint declares a required permission via `.RequirePermission(IdentityPermissionConstants.Users.Create)`. +2. The `RequiredPermissionAuthorizationHandler` reads the permission metadata from the endpoint. +3. It checks the user's permission claims against the required permission. +4. If the user has the permission, the request proceeds. Otherwise, a `403 Forbidden` response is returned. + +```csharp +endpoints.MapPost("/register", handler) + .RequirePermission(IdentityPermissionConstants.Users.Create); +``` + +## CurrentUserMiddleware + +The `CurrentUserMiddleware` runs after authentication and extracts a strongly-typed `ICurrentUser` from the `ClaimsPrincipal`. This provides a clean API for accessing user context throughout the application: + +- **UserId** - The authenticated user's unique identifier. +- **Email** - The user's email address. +- **Tenant** - The tenant ID from the tenant claim. +- **Permissions** - A collection of permission strings assigned to the user. + +Any service can inject `ICurrentUser` to access the current request's user context without touching `HttpContext` directly. + +## Permission Claims + +Permissions are stored directly in the JWT as claims. When a token is issued, the user's role permissions are serialized into the token payload. This means: + +- **No database roundtrip** is needed for authorization checks on each request. +- Permissions are validated purely from the token claims. +- If permissions change, the user must obtain a new token (via refresh) for the changes to take effect. + + + +## PathAwareAuthorizationHandler + +Not all paths require authentication. The `PathAwareAuthorizationHandler` allows unauthenticated access to specific paths that are needed for API documentation and static assets: + +- `/scalar` - API documentation UI +- `/openapi` - OpenAPI specification endpoints +- `/favicon.ico` - Browser favicon requests + +Requests to these paths bypass the authorization requirement, even if a global authorization policy is applied. + +## Next Steps + +For details on configuring JWT settings, token lifetimes, and refresh token behavior, see the **Identity Authentication** documentation. diff --git a/docs/src/content/docs/authentication.mdx b/docs/src/content/docs/authentication.mdx new file mode 100644 index 0000000000..5d78aa6102 --- /dev/null +++ b/docs/src/content/docs/authentication.mdx @@ -0,0 +1,186 @@ +--- +title: "Authentication" +description: "JWT token authentication, refresh tokens, password policies, and security in the Identity module." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +## Overview + +The Identity module implements **JWT Bearer authentication** with an access token + refresh token pattern. Users authenticate with email and password credentials to receive a short-lived access token and a longer-lived refresh token. The access token is included in subsequent API requests as a Bearer token in the `Authorization` header. + +Password security is enforced through configurable policies including password history tracking (preventing reuse of recent passwords) and password expiry with advance warning notifications. + +## Token Generation Flow + + + +### Client sends credentials + +The client sends a `POST` request to `/api/v1/identity/token/issue` with an email and password in the request body. A `tenant` header identifies the tenant context (defaults to `root`). This endpoint allows anonymous access. + +```json +POST /api/v1/identity/token/issue +Headers: { "tenant": "root" } +Body: { "email": "admin@example.com", "password": "YourPassword123!" } +``` + +### Handler validates credentials + +The `GenerateTokenCommandHandler` receives the command and calls `IIdentityService.ValidateCredentialsAsync()` to verify the email and password against the ASP.NET Identity user store. + +### On failure: security audit and 401 + +If credentials are invalid, the handler logs a **LoginFailed** security audit event (capturing the subject, client ID, reason, and IP address) and throws an `UnauthorizedAccessException`, which the global exception handler converts to a `401 Unauthorized` response. + +### On success: security audit + +If credentials are valid, the handler logs a **LoginSucceeded** security audit event with the user ID, username, client ID, IP address, and user agent. + +### TokenService issues the JWT + +The `TokenService.IssueAsync()` method builds the JWT with claims including `sub` (user ID), `email`, `fullName`, `tenant`, `permission` (all user permissions), and `ipAddress`. The token is signed using HMAC-SHA256 with the configured signing key. + +### Session and refresh token are persisted + +The handler stores a hashed refresh token for the user and creates a `UserSession` record for session tracking. Session creation is non-blocking - if it fails (e.g., migrations not yet applied), login still succeeds. + +### Token issuance is audited with fingerprint + +The handler generates a SHA256 fingerprint of the access token (first 8 bytes, hex-encoded) and logs a **TokenIssued** security audit event. The raw token is never stored in audit logs. + +### Integration event is published via outbox + +A `TokenGeneratedIntegrationEvent` is enqueued to the outbox store with the user ID, email, client ID, IP, user agent, token fingerprint, and expiry time. This event is dispatched reliably by the outbox background processor. + +### Response returned to client + +The handler returns a `TokenResponse` containing the access token, refresh token, and their respective expiry timestamps. + +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "base64-encoded-token", + "accessTokenExpiresAt": "2026-03-27T14:30:00Z", + "refreshTokenExpiresAt": "2026-04-03T14:00:00Z" +} +``` + + + +## JWT Configuration + +Token behavior is controlled through the `Jwt` section in your application settings. These options are validated at startup - missing or invalid values will prevent the application from starting. + +```json +{ + "Jwt": { + "Issuer": "your-issuer", + "Audience": "your-audience", + "SigningKey": "your-32-char-minimum-key", + "AccessTokenMinutes": 30, + "RefreshTokenDays": 7 + } +} +``` + +| Property | Default | Description | +|----------|---------|-------------| +| `Issuer` | - | Token issuer identifier (required) | +| `Audience` | - | Intended audience for the token (required) | +| `SigningKey` | - | HMAC-SHA256 signing key (required, minimum 32 characters) | +| `AccessTokenMinutes` | `30` | Access token lifetime in minutes | +| `RefreshTokenDays` | `7` | Refresh token lifetime in days | + + + +## Refresh Tokens + +When an access token expires, the client can obtain a new token pair without re-entering credentials by calling the refresh endpoint. + +**Endpoint:** `POST /api/v1/identity/token/refresh` + +The client sends the current (possibly expired) access token together with a valid refresh token. The handler validates the refresh token, issues a new access token and a rotated refresh token, and returns the updated `TokenResponse`. + +```json +POST /api/v1/identity/token/refresh +Headers: { "tenant": "root" } +Body: { + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "current-refresh-token" +} +``` + +Refresh token rotation means each refresh token can only be used once. After a successful refresh, the previous refresh token is invalidated and a new one is issued. This limits the window of exposure if a refresh token is compromised. + +## Token Claims + +The JWT access token includes the following claims: + +| Claim | Source | Description | +|-------|--------|-------------| +| `sub` | User ID | Unique identifier for the authenticated user | +| `email` | User email | Email address of the user | +| `fullName` | FirstName + LastName | Display name of the user | +| `tenant` | Tenant ID | The tenant context for the request | +| `permission` | All user permissions | One claim per permission (multiple claims) | +| `ipAddress` | Client IP | IP address of the authenticating client | + +## Password Policies + +Password policies are configured through the `PasswordPolicy` options section. These settings control password history, expiry, and warning behavior. + +```json +{ + "PasswordPolicy": { + "PasswordHistoryCount": 5, + "PasswordExpiryDays": 90, + "PasswordExpiryWarningDays": 14, + "EnforcePasswordExpiry": true + } +} +``` + +| Property | Default | Description | +|----------|---------|-------------| +| `PasswordHistoryCount` | `5` | Number of previous passwords to keep in history, preventing reuse of recent passwords | +| `PasswordExpiryDays` | `90` | Number of days before a password expires and must be changed | +| `PasswordExpiryWarningDays` | `14` | Number of days before expiry to begin showing warnings to the user | +| `EnforcePasswordExpiry` | `true` | Set to `false` to disable password expiry enforcement | + +The `PasswordHistoryService` stores hashed previous passwords and checks new passwords against them during change operations. The `PasswordExpiryService` tracks password age and provides expiry status information. + +## Password Operations + +The Identity module exposes endpoints for all standard password workflows under the `/api/v1/identity/users` base path: + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `POST` | `/change-password` | Required | Change the current user's password. Validates against password history. | +| `POST` | `/forgot-password` | Anonymous | Request a password reset email with a verification token. | +| `POST` | `/reset-password` | Anonymous | Reset a password using the token received via email. | +| `GET` | `/confirm-email` | Anonymous | Confirm a user's email address with a verification code. | + +The **change password** endpoint requires the user to be authenticated and enforces password history rules - the new password cannot match any of the last N passwords (configured by `PasswordHistoryCount`). + +The **forgot password** and **reset password** endpoints work together as a two-step flow: the user requests a reset token via email, then submits the token along with their new password to complete the reset. + +## Security Auditing + +All authentication operations are recorded through the `ISecurityAudit` interface from the Auditing module. The following events are captured: + +| Event | Trigger | Data Captured | +|-------|---------|---------------| +| **LoginSucceeded** | Successful credential validation | User ID, username, client ID, IP, user agent | +| **LoginFailed** | Invalid credentials | Subject (email), client ID, reason, IP | +| **TokenIssued** | JWT generated | User ID, username, client ID, token fingerprint, expiry | +| **PasswordChanged** | Password change operation | User ID, timestamp | + +These audit records provide a complete security trail for compliance and incident investigation. See the [Auditing module](/modules/auditing) for details on how audit data is stored and queried. + + diff --git a/docs/src/content/docs/aws-terraform-deployment.mdx b/docs/src/content/docs/aws-terraform-deployment.mdx new file mode 100644 index 0000000000..0f6c87949c --- /dev/null +++ b/docs/src/content/docs/aws-terraform-deployment.mdx @@ -0,0 +1,899 @@ +--- +title: "AWS with Terraform" +description: "Production deployment to AWS using Terraform with ECS Fargate, RDS PostgreSQL, ElastiCache Redis, WAF, and CloudWatch." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; +import FileTree from '../../components/FileTree.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +The starter kit includes a complete **Infrastructure as Code** setup for deploying to AWS using Terraform. The infrastructure covers VPC networking, ECS Fargate for container orchestration, Application Load Balancer, RDS PostgreSQL, ElastiCache Redis, S3 storage with CloudFront CDN, WAF firewall, and CloudWatch monitoring. Three pre-configured environments -- dev, staging, and production -- let you optimize costs during development while maintaining high availability and security in production. + +## Infrastructure Architecture + +``` + ┌─────────────────┐ + │ CloudFront │ + │ (S3 assets) │ + └────────┬────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ + ┌────────▼────────┐ ┌────────▼────────┐ + │ WAF (optional) │ │ Route 53 │ + └────────┬────────┘ └────────┬────────┘ + │ │ + ┌────────▼───────────────────────────────▼────────┐ + │ ALB │ + │ (Public Subnets) │ + │ /api/* → API │ + └────────────────────┬─────────────────────────────┘ + │ + ┌───────────────────────▼───────────────────────┐ + │ ECS Fargate │ + │ (API Service) │ + │ Private Subnet │ + └────────┬────────┘ + │ + ┌────────▼──────────────────────────────────────┐ + │ Private Subnets │ + │ │ + │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ + │ │ RDS │ │ Redis │ │ S3 │ │ + │ │PostgreSQL│ │ElastiCache│ │ Bucket │ │ + │ └──────────┘ └──────────┘ └──────────┘ │ + │ │ + │ ┌─────────────── VPC Endpoints ───────────┐ │ + │ │ ECR · CloudWatch · Secrets Manager · S3 │ │ + │ └─────────────────────────────────────────┘ │ + └────────────────────────────────────────────────┘ +``` + +## Prerequisites + +Before you begin, ensure you have: + +- **Terraform** >= 1.14.0 installed ([tfenv](https://github.com/tfutils/tfenv) recommended -- a `.terraform-version` file is included) +- **AWS CLI** v2 configured with credentials (`aws configure`) +- An **AWS account** with permissions to create VPC, ECS, RDS, ElastiCache, S3, IAM, WAF, and CloudWatch resources +- **Container images** pushed to a container registry (GitHub Container Registry by default) + + + +## Terraform Module Structure + +The infrastructure is organized into **reusable modules**, a **composable app stack**, and **per-environment variable files**: + + + +The architecture follows a **module composition pattern**: reusable modules handle individual AWS services, the `app_stack` module wires them together, `shared/` provides the root Terraform configuration (provider, backend, variables pass-through), and `envs/` directories hold only `backend.hcl` and `terraform.tfvars` -- no duplicated `.tf` files. + +## Reusable Modules + +Each module is self-contained with its own `main.tf`, `variables.tf`, and `outputs.tf`. They are designed to be composed together but can also be used independently. + +### Network + +Creates the foundational VPC infrastructure: + +| Resource | Details | +|----------|---------| +| **VPC** | Configurable CIDR block with DNS support | +| **Public subnets** | Multi-AZ, map-based configuration | +| **Private subnets** | Multi-AZ, for ECS tasks and databases | +| **NAT Gateway** | Single (cost-saving) or per-AZ (HA) | +| **Internet Gateway** | For public subnet internet access | +| **VPC Endpoints** | S3 (gateway, free), ECR API/DKR, CloudWatch Logs, Secrets Manager | +| **Flow Logs** | Optional, to CloudWatch with configurable retention | +| **Default SG** | Deny-all default security group | + + + +### ECS Cluster + +Creates an ECS cluster with Fargate capacity providers: + +- **Capacity providers**: FARGATE and FARGATE_SPOT (configurable default strategy) +- **Container Insights**: Optional CloudWatch Container Insights for metrics and logs +- **Execute Command**: Optional ECS Exec support for debugging running containers + +### ALB (Application Load Balancer) + +Internet-facing load balancer with path-based routing: + +- **HTTP listener** (port 80): Redirects to HTTPS if enabled, or returns 404 as default +- **HTTPS listener** (port 443): Requires ACM certificate, configurable SSL policy (TLS 1.3 default) +- **Security**: Drop invalid headers, desync mitigation, idle timeout configuration +- **Logging**: Optional access logs and connection logs to S3 + +### ECS Service + +Creates a Fargate service with full lifecycle management: + +| Feature | Details | +|---------|---------| +| **Task definition** | Fargate-compatible, awsvpc networking, X86_64 or ARM64 | +| **Container config** | Environment variables, Secrets Manager references, health checks | +| **Deployment** | Circuit breaker with automatic rollback, rolling updates | +| **Auto-scaling** | CPU, memory, and ALB request count target tracking policies | +| **Logging** | CloudWatch awslogs driver with configurable retention | +| **IAM** | Task execution role (image pull, secrets) + optional task role (S3, etc.) | + +### RDS PostgreSQL + +Managed PostgreSQL database with production-grade features: + +| Feature | Details | +|---------|---------| +| **Engine** | PostgreSQL 17 (default), Graviton instances (t4g) | +| **Storage** | gp3 with auto-scaling from `allocated_storage` to `max_allocated_storage` | +| **HA** | Optional Multi-AZ with synchronous replication | +| **Security** | Encryption at rest (KMS optional), restricted security group | +| **Passwords** | AWS Secrets Manager managed (recommended) or manual | +| **Backups** | Configurable retention (0-35 days), automated backup window | +| **Monitoring** | Performance Insights, Enhanced Monitoring, CloudWatch log exports | +| **Parameters** | Optional custom parameter group for tuning | + + + +### ElastiCache Redis + +Managed Redis with replication and encryption: + +| Feature | Details | +|---------|---------| +| **Engine** | Redis 7.2 (default) | +| **Clustering** | 1-6 nodes, automatic failover for multi-node setups | +| **Security** | Transit encryption (TLS), at-rest encryption, auth token support | +| **Logging** | Optional slow-log and engine-log to CloudWatch | +| **Snapshots** | Configurable retention, daily snapshot window, final snapshot on deletion | + +### S3 Bucket + +Object storage with optional CDN: + +| Feature | Details | +|---------|---------| +| **Encryption** | AES256 (default) or KMS | +| **Versioning** | Enabled by default | +| **CloudFront** | Optional distribution with Origin Access Control (OAC) | +| **Lifecycle** | Configurable rules, intelligent tiering | +| **CORS** | Configurable for cross-origin requests | +| **Security** | TLS enforcement, public access block, prefix-scoped public read | + +### WAF (Web Application Firewall) + +AWS WAFv2 with managed rule sets: + +| Rule | Description | +|------|-------------| +| **Rate limiting** | IP-based, configurable limit (default: 2000 req/5 min) | +| **Common Rule Set** | OWASP Top 10 protection (AWS managed) | +| **Known Bad Inputs** | Blocks known malicious patterns | +| **SQL Injection** | AWS managed SQLi protection (optional) | +| **IP Reputation** | Blocks requests from known bad IPs (optional) | +| **Anonymous IP** | Blocks VPN/proxy/Tor traffic (optional) | +| **Linux OS** | Blocks Linux-specific exploit patterns (optional) | + +Logging is sent to CloudWatch with sensitive fields (Authorization, Cookie headers) automatically redacted. + +### CloudWatch Alarms + +Comprehensive monitoring with SNS email notifications: + +| Service | Metrics Monitored | +|---------|-------------------| +| **ECS** | CPU utilization, memory utilization, running task count | +| **RDS** | CPU utilization, free storage space, connections, read latency | +| **ElastiCache** | CPU utilization, memory utilization, evictions | +| **ALB** | 5xx errors, target 5xx errors, response time, unhealthy hosts | + +## Step-by-Step Deployment + +### Step 1: Bootstrap the State Backend + +Before deploying any infrastructure, create the S3 bucket that will store Terraform state: + + + +### Navigate to the bootstrap directory + +```bash +cd deploy/terraform/bootstrap +``` + +### Initialize and apply + +```bash +terraform init +terraform apply +``` + + + +This creates: +- An **S3 bucket** with versioning, AES256 encryption, and public access block +- **Lifecycle rules** to clean up incomplete uploads (7 days) and non-current versions (90 days) +- A **bucket policy** enforcing TLS 1.2+ for all requests +- **S3 native file-based locking** (no DynamoDB table needed with Terraform 1.10+) + + + +### Step 2: Build and Push Container Images + +The Terraform configuration expects container images to be available in a registry. By default, it pulls from GitHub Container Registry: + +```bash +# Build the API image +docker build -t ghcr.io/fullstackhero/fsh-api:v1.0.0 \ + -f src/Playground/FSH.Starter.Api/Dockerfile . + +# Push to registry +docker push ghcr.io/fullstackhero/fsh-api:v1.0.0 +``` + + + +### Step 3: Configure Your Environment + +Each environment has a `terraform.tfvars` file that controls all infrastructure settings. Navigate to the target environment and customize the values: + + + +```hcl +# deploy/terraform/apps/playground/envs/dev/us-east-1/terraform.tfvars + +environment = "dev" +region = "us-east-1" +container_image_tag = "1d2c9f9d" # git SHA or semver + +# Network - cost optimized +vpc_cidr_block = "10.10.0.0/16" +single_nat_gateway = true # single NAT saves ~$30/month + +public_subnets = { + "pub-1" = { cidr_block = "10.10.0.0/24", az = "us-east-1a" } + "pub-2" = { cidr_block = "10.10.1.0/24", az = "us-east-1b" } +} + +private_subnets = { + "priv-1" = { cidr_block = "10.10.10.0/24", az = "us-east-1a" } + "priv-2" = { cidr_block = "10.10.11.0/24", az = "us-east-1b" } +} + +# Database - minimal +db_name = "fshdb" +db_username = "fshadmin" +db_manage_master_user_password = true + +# Services - single instance, Spot pricing +api_desired_count = 1 +api_use_fargate_spot = true + +# Cost savings - disable extras +enable_waf = false +enable_alarms = false + +# S3 +app_s3_bucket_name = "dev-fsh-app-bucket" +app_s3_enable_cloudfront = true +``` + + +```hcl +# deploy/terraform/apps/playground/envs/staging/us-east-1/terraform.tfvars + +environment = "staging" +region = "us-east-1" +container_image_tag = "v0.1.0-rc1" + +# Network - flow logs enabled +vpc_cidr_block = "10.20.0.0/16" +single_nat_gateway = true +enable_flow_logs = true +flow_logs_retention_days = 30 +enable_secretsmanager_endpoint = true + +public_subnets = { + "pub-1" = { cidr_block = "10.20.0.0/24", az = "us-east-1a" } + "pub-2" = { cidr_block = "10.20.1.0/24", az = "us-east-1b" } +} + +private_subnets = { + "priv-1" = { cidr_block = "10.20.10.0/24", az = "us-east-1a" } + "priv-2" = { cidr_block = "10.20.11.0/24", az = "us-east-1b" } +} + +# Database - managed password, deletion protection +db_name = "fshdb" +db_username = "fshadmin" +db_manage_master_user_password = true +db_instance_class = "db.t4g.small" +db_enable_performance_insights = true +db_deletion_protection = true + +# Redis - slightly larger +redis_node_type = "cache.t4g.small" + +# WAF enabled +enable_waf = true +waf_enable_sqli_rule_set = true +waf_enable_ip_reputation_rule_set = true +waf_enable_logging = true + +# Services - 2 instances, auto-scaling +api_desired_count = 2 +api_use_fargate_spot = true +api_enable_autoscaling = true +api_autoscaling_min_capacity = 2 +api_autoscaling_max_capacity = 6 + +enable_container_insights = true +enable_alarms = true + +# S3 +app_s3_bucket_name = "staging-fsh-app-bucket" +app_s3_enable_cloudfront = true +``` + + +```hcl +# deploy/terraform/apps/playground/envs/prod/us-east-1/terraform.tfvars + +environment = "prod" +region = "us-east-1" +container_image_tag = "v1.0.0" + +# Network - full HA, 3 AZs +vpc_cidr_block = "10.30.0.0/16" +single_nat_gateway = false # NAT per AZ for high availability +enable_flow_logs = true +flow_logs_retention_days = 90 +enable_secretsmanager_endpoint = true + +public_subnets = { + "pub-1" = { cidr_block = "10.30.0.0/24", az = "us-east-1a" } + "pub-2" = { cidr_block = "10.30.1.0/24", az = "us-east-1b" } + "pub-3" = { cidr_block = "10.30.2.0/24", az = "us-east-1c" } +} + +private_subnets = { + "priv-1" = { cidr_block = "10.30.10.0/24", az = "us-east-1a" } + "priv-2" = { cidr_block = "10.30.11.0/24", az = "us-east-1b" } + "priv-3" = { cidr_block = "10.30.12.0/24", az = "us-east-1c" } +} + +# Database - production grade +db_name = "fshdb" +db_username = "fshadmin" +db_manage_master_user_password = true +db_instance_class = "db.t4g.medium" +db_allocated_storage = 50 +db_max_allocated_storage = 200 +db_multi_az = true +db_backup_retention_period = 30 +db_deletion_protection = true +db_enable_performance_insights = true +db_enable_enhanced_monitoring = true +db_monitoring_interval = 60 +db_create_parameter_group = true +db_parameters = [ + { name = "log_min_duration_statement", value = "1000", apply_method = "dynamic" }, + { name = "shared_preload_libraries", value = "pg_stat_statements", apply_method = "pending-reboot" }, + { name = "pg_stat_statements.track", value = "all", apply_method = "dynamic" }, + { name = "log_connections", value = "1", apply_method = "dynamic" }, + { name = "log_disconnections", value = "1", apply_method = "dynamic" } +] + +# Redis - multi-node with failover +redis_node_type = "cache.t4g.medium" +redis_num_cache_clusters = 2 +redis_automatic_failover_enabled = true + +# WAF - all rules enabled +enable_waf = true +waf_enable_sqli_rule_set = true +waf_enable_ip_reputation_rule_set = true +waf_enable_linux_rule_set = true +waf_enable_logging = true + +# Services - 3 instances, on-demand, aggressive auto-scaling +api_cpu = "1024" +api_memory = "2048" +api_desired_count = 3 +api_use_fargate_spot = false # on-demand for stability +api_enable_autoscaling = true +api_autoscaling_min_capacity = 3 +api_autoscaling_max_capacity = 20 + +enable_container_insights = true +alb_enable_deletion_protection = true +enable_alarms = true + +# S3 - production optimized +app_s3_bucket_name = "prod-fsh-app-bucket" +app_s3_enable_cloudfront = true +app_s3_cloudfront_price_class = "PriceClass_200" +app_s3_enable_intelligent_tiering = true +``` + + + +### Step 4: Initialize and Deploy + +All deployments run from the `shared/` directory. The environment is selected by pointing to the correct `backend.hcl` and `terraform.tfvars`: + + + +### Navigate to the shared root module + +```bash +cd deploy/terraform/apps/playground/shared +``` + +### Initialize with the target environment's backend + +```bash +terraform init -backend-config=../envs/dev/us-east-1/backend.hcl +``` + +### Review the execution plan + +```bash +terraform plan -var-file=../envs/dev/us-east-1/terraform.tfvars +``` + +### Apply the infrastructure + +```bash +terraform apply -var-file=../envs/dev/us-east-1/terraform.tfvars +``` + +### Retrieve the outputs + +```bash +terraform output alb_dns_name +terraform output api_url +``` + + + + + +### Step 5: Verify the Deployment + +After `terraform apply` completes, verify the infrastructure: + +```bash +# Get the ALB DNS name +terraform output alb_dns_name + +# Test the API health endpoint +curl http://$(terraform output -raw alb_dns_name)/health + +# Check ECS service status +aws ecs describe-services \ + --cluster $(terraform output -raw ecs_cluster_arn) \ + --services $(terraform output -raw api_service_name) \ + --query 'services[0].{status:status,running:runningCount,desired:desiredCount}' +``` + +## How the App Stack Wires Everything Together + +The `app_stack` module is the orchestration layer that composes all reusable modules and passes the right configuration to each. Here is the resource flow: + +``` +app_stack/main.tf +│ +├── module.network → VPC, subnets, NAT, endpoints +├── module.ecs_cluster → ECS cluster + capacity providers +├── module.alb → Load balancer + listeners +├── module.waf → WAF Web ACL → attached to ALB +│ +├── module.rds → PostgreSQL instance +│ └── allowed_security_group_ids = [api_service.security_group_id] +│ +├── module.redis → ElastiCache Redis +│ └── allowed_security_group_ids = [api_service.security_group_id] +│ +├── module.app_s3_bucket → S3 + CloudFront +│ +├── module.api_service → ECS Fargate service +│ ├── environment_variables = { DatabaseOptions__ConnectionString, CachingOptions__Redis, ... } +│ ├── secrets = [rds_secret_arn] (if managed password) +│ ├── task_role_arn → IAM role with S3 + Secrets Manager access +│ └── path_patterns = ["/api/*", "/scalar/*", "/openapi/*", "/health*"] +│ +└── module.alarms → CloudWatch alarms → SNS notifications +``` + +### Environment Variables Injected into Containers + +The app stack automatically injects the following environment variables into the API container: + +| Variable | Source | Example | +|----------|--------|---------| +| `ASPNETCORE_ENVIRONMENT` | `var.environment` | `Production` | +| `DatabaseOptions__ConnectionString` | RDS module output or Secrets Manager | `Host=...;Database=fshdb;...` | +| `CachingOptions__Redis` | Redis module output | `host:6379,ssl=True,abortConnect=False` | +| `Storage__Provider` | Hardcoded | `s3` | +| `Storage__S3__Bucket` | S3 module output | `prod-fsh-app-bucket` | +| `Storage__S3__PublicBaseUrl` | CloudFront domain | `https://d1234.cloudfront.net` | +| `OriginOptions__OriginUrl` | ALB DNS or custom domain | `https://app.example.com` | +| `CorsOptions__AllowedOrigins__0` | ALB DNS or custom domain | `https://app.example.com` | + + + +### IAM Task Role + +The API service gets an IAM task role with least-privilege access: + +``` +S3 Permissions: + - s3:PutObject, s3:DeleteObject, s3:GetObject (on bucket objects) + - s3:ListBucket (on bucket) + +Secrets Manager Permissions (if managed password): + - secretsmanager:GetSecretValue (on RDS secret ARN) +``` + +## Environment Comparison + +### Infrastructure Sizing + +| Resource | Dev | Staging | Production | +|----------|-----|---------|------------| +| **VPC CIDR** | `10.10.0.0/16` | `10.20.0.0/16` | `10.30.0.0/16` | +| **Availability Zones** | 2 | 2 | 3 | +| **NAT Gateways** | 1 (shared) | 1 (shared) | 3 (per-AZ) | +| **VPC Flow Logs** | Disabled | 30-day retention | 90-day retention | + +### Database + +| Setting | Dev | Staging | Production | +|---------|-----|---------|------------| +| **Instance** | db.t4g.micro | db.t4g.small | db.t4g.medium | +| **Storage** | 20 GB (up to 100) | 20 GB (up to 100) | 50 GB (up to 200) | +| **Multi-AZ** | No | No | Yes | +| **Backup retention** | 7 days | 7 days | 30 days | +| **Performance Insights** | No | Yes | Yes | +| **Enhanced Monitoring** | No | No | Yes (60s interval) | +| **Deletion protection** | No | Yes | Yes | +| **Custom parameters** | No | No | Yes (query logging, pg_stat_statements) | + +### Cache + +| Setting | Dev | Staging | Production | +|---------|-----|---------|------------| +| **Node type** | cache.t4g.micro | cache.t4g.small | cache.t4g.medium | +| **Nodes** | 1 | 1 | 2 | +| **Automatic failover** | No | No | Yes | +| **Transit encryption** | Yes | Yes | Yes | + +### Compute + +| Setting | Dev | Staging | Production | +|---------|-----|---------|------------| +| **API replicas** | 1 | 2 | 3 | +| **CPU / Memory** | 256 / 512 | 256 / 512 | 1024 / 2048 | +| **Fargate Spot** | Yes | Yes | No (on-demand) | +| **Auto-scaling** | No | 2-6 | 3-20 | +| **Container Insights** | No | Yes | Yes | + +### Security & Monitoring + +| Feature | Dev | Staging | Production | +|---------|-----|---------|------------| +| **WAF** | Disabled | Enabled (SQLi, IP reputation) | Enabled (all rules) | +| **CloudWatch Alarms** | Disabled | Enabled | Enabled | +| **ALB Deletion Protection** | No | No | Yes | +| **Secrets Manager endpoint** | No | Yes | Yes | +| **CloudFront price class** | PriceClass_100 | PriceClass_100 | PriceClass_200 | +| **S3 Intelligent Tiering** | No | No | Yes | + +## Security Architecture + +### Network Isolation + +- **ECS tasks** run in **private subnets** with no direct internet access +- Outbound traffic routes through **NAT Gateways** +- **VPC Endpoints** eliminate NAT traversal for AWS service calls (ECR, CloudWatch, Secrets Manager, S3) +- The **default VPC security group** is configured to deny all traffic +- Each service gets its own **security group** with least-privilege ingress/egress rules + +### Encryption + +| Layer | Mechanism | +|-------|-----------| +| **Data in transit** | TLS 1.3 on ALB, TLS on Redis, SSL on RDS | +| **Data at rest** | AES256 on S3, KMS-capable encryption on RDS and ElastiCache | +| **Terraform state** | AES256-encrypted S3 bucket | +| **Database password** | AWS Secrets Manager (auto-rotated) | + +### Web Application Firewall + +When enabled, WAF protects the ALB with: + +1. **Rate limiting** -- blocks IPs exceeding 2000 requests per 5 minutes +2. **AWS Common Rule Set** -- OWASP Top 10 coverage +3. **Known Bad Inputs** -- blocks known exploit payloads +4. **SQL Injection** -- AWS-managed SQLi detection +5. **IP Reputation** -- blocks traffic from known malicious IPs +6. **Linux OS rules** -- blocks Linux-specific exploit patterns + +WAF logs are written to CloudWatch with Authorization and Cookie headers **automatically redacted**. + +### IAM Least Privilege + +- **Task execution role**: Pulls container images from ECR and reads secrets +- **Task role**: S3 object access + Secrets Manager for connection strings +- **RDS monitoring role**: Enhanced Monitoring publish only (if enabled) +- No wildcard permissions -- all policies scope to specific resource ARNs + +## Cost Optimization + +### Development Environment + +The dev configuration is optimized to minimize costs while remaining functional: + +| Optimization | Savings | +|-------------|---------| +| **Single NAT Gateway** | ~$30/month vs per-AZ NAT | +| **Fargate Spot** | Up to 70% discount on compute | +| **t4g.micro instances** | Graviton ARM -- lowest cost tier | +| **Single replicas** | Minimum viable compute | +| **No WAF/Alarms** | Eliminates monitoring costs | +| **S3 Gateway Endpoint** | Free -- avoids NAT for S3 traffic | +| **Skipped final snapshots** | Faster teardown for dev | + + + +### Production Environment + +Production uses on-demand capacity with auto-scaling for cost efficiency: + +| Strategy | Benefit | +|----------|---------| +| **Auto-scaling** | Scale down during low traffic (min 3, max 20) | +| **Graviton (t4g) instances** | Best price-performance ratio | +| **S3 Intelligent Tiering** | Automatic storage class optimization | +| **VPC Endpoints** | Reduce NAT data transfer costs | +| **CloudFront CDN** | Cache static assets at edge locations | + +## Adding HTTPS with a Custom Domain + +To enable HTTPS, you need an ACM certificate and a domain: + + + +### Request an ACM certificate + +```bash +aws acm request-certificate \ + --domain-name app.example.com \ + --validation-method DNS \ + --region us-east-1 +``` + +### Validate the certificate via DNS + +Add the CNAME record provided by ACM to your DNS provider. + +### Update terraform.tfvars + +```hcl +enable_https = true +acm_certificate_arn = "arn:aws:acm:us-east-1:123456789:certificate/abc-123" +domain_name = "app.example.com" +``` + +### Create a Route 53 record (or equivalent DNS) + +Point your domain to the ALB DNS name output by Terraform. + + + +## Deploying Your Own Application + +The `playground` app stack is a reference implementation. To deploy your own application: + + + +### Copy the playground directory + +```bash +cp -r deploy/terraform/apps/playground deploy/terraform/apps/myapp +``` + +### Update container image references + +In `app_stack/variables.tf` and your `terraform.tfvars`: + +```hcl +container_registry = "ghcr.io/your-org" +api_image_name = "your-api" +container_image_tag = "v1.0.0" +``` + +### Customize environment variables + +Add application-specific environment variables: + +```hcl +api_extra_environment_variables = { + "MyApp__Setting" = "value" + "FeatureFlags__EnableNewUI" = "true" +} +``` + +### Update the S3 bucket name + +```hcl +app_s3_bucket_name = "myapp-prod-bucket" # must be globally unique +``` + +### Update backend.hcl state paths + +Ensure each app uses a unique state key to avoid conflicts. + + + +## Multi-Region Deployment + +The environment directory structure supports multi-region deployments: + +``` +envs/ +├── prod/ +│ ├── us-east-1/ +│ │ ├── backend.hcl # key = "prod/us-east-1/terraform.tfstate" +│ │ └── terraform.tfvars +│ └── eu-west-1/ +│ ├── backend.hcl # key = "prod/eu-west-1/terraform.tfstate" +│ └── terraform.tfvars +``` + +Each region gets its own state file and variable configuration. Use different VPC CIDR blocks per region if you plan to set up VPC peering. + +## Destroying Infrastructure + + + +For dev and staging environments: + +```bash +cd deploy/terraform/apps/playground/shared +terraform init -backend-config=../envs/dev/us-east-1/backend.hcl +terraform destroy -var-file=../envs/dev/us-east-1/terraform.tfvars +``` + +## Terraform Outputs Reference + +After a successful deployment, these outputs are available: + +| Output | Description | +|--------|-------------| +| `vpc_id` | VPC identifier | +| `alb_dns_name` | ALB public DNS name | +| `api_url` | Full API URL | +| `ecs_cluster_arn` | ECS cluster ARN | +| `api_service_name` | API ECS service name | +| `rds_endpoint` | Database endpoint address | +| `rds_port` | Database port (5432) | +| `rds_secret_arn` | Secrets Manager ARN for DB credentials | +| `redis_endpoint` | Redis primary endpoint | +| `redis_connection_string` | Formatted .NET connection string (sensitive) | +| `s3_bucket_name` | S3 bucket name | +| `s3_cloudfront_domain` | CloudFront distribution domain | +| `waf_web_acl_arn` | WAF Web ACL ARN (if enabled) | +| `alarm_sns_topic_arn` | SNS topic for alarms (if enabled) | + +## Troubleshooting + +### ECS Tasks Failing to Start + +```bash +# Check task stopped reason +aws ecs describe-tasks --cluster --tasks \ + --query 'tasks[0].stoppedReason' + +# Check container logs +aws logs get-log-events --log-group-name /ecs/ \ + --log-stream-name +``` + +Common causes: +- **Image pull failure**: Verify ECR VPC endpoints are enabled and the image exists +- **Secrets access denied**: Check the task execution role has `secretsmanager:GetSecretValue` permission +- **Health check failure**: Increase `health_check_grace_period_seconds` for slow-starting containers + +### Database Connection Issues + +```bash +# Verify security group allows ECS task traffic +aws ec2 describe-security-groups --group-ids \ + --query 'SecurityGroups[0].IpPermissions' + +# Test connectivity from ECS Exec (if enabled) +aws ecs execute-command --cluster --task \ + --container api --interactive --command "/bin/sh" +``` + +### State Lock Conflicts + +With S3 native locking, lock conflicts are rare. If they occur: + +```bash +# Check for existing lock file +aws s3 ls s3://fsh-state-bucket/.tflock + +# Force unlock (use with caution) +terraform force-unlock +``` + + diff --git a/docs/src/content/docs/background-jobs.mdx b/docs/src/content/docs/background-jobs.mdx new file mode 100644 index 0000000000..37ffc10d08 --- /dev/null +++ b/docs/src/content/docs/background-jobs.mdx @@ -0,0 +1,182 @@ +--- +title: "Jobs" +description: "Background job scheduling with Hangfire." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +The Jobs building block provides background job scheduling through [Hangfire](https://www.hangfire.io/), wrapped behind a clean `IJobService` abstraction. It supports fire-and-forget, delayed, and scheduled jobs with built-in multitenancy propagation, OpenTelemetry tracing, and a secured dashboard. + +## IJobService interface + +`IJobService` is the primary abstraction for scheduling background work. Inject it into any handler or service to enqueue jobs without coupling to Hangfire directly. + +```csharp +public interface IJobService +{ + // Fire-and-forget + string Enqueue(Expression methodCall); + string Enqueue(Expression> methodCall); + string Enqueue(string queue, Expression> methodCall); + string Enqueue(Expression> methodCall); + string Enqueue(Expression> methodCall); + + // Delayed / Scheduled + string Schedule(Expression methodCall, TimeSpan delay); + string Schedule(Expression> methodCall, TimeSpan delay); + string Schedule(Expression methodCall, DateTimeOffset enqueueAt); + string Schedule(Expression> methodCall, DateTimeOffset enqueueAt); + string Schedule(Expression> methodCall, TimeSpan delay); + string Schedule(Expression> methodCall, TimeSpan delay); + string Schedule(Expression> methodCall, DateTimeOffset enqueueAt); + string Schedule(Expression> methodCall, DateTimeOffset enqueueAt); + + // Management + bool Delete(string jobId); + bool Delete(string jobId, string fromState); + bool Requeue(string jobId); + bool Requeue(string jobId, string fromState); +} +``` + +All `Enqueue` and `Schedule` methods accept expression trees (`Expression` and `Expression>`) along with their generic counterparts, so the actual method invocation is serialized and executed later by the Hangfire server. + +## HangfireService implementation + +`HangfireService` is the concrete implementation that delegates directly to Hangfire's `BackgroundJob` static API. It is registered as a transient service: + +```csharp +services.AddTransient(); +``` + +## HangfireOptions + +The `HangfireOptions` class controls dashboard authentication and routing: + +```csharp +public class HangfireOptions +{ + public string UserName { get; set; } = "admin"; + public string Password { get; set; } = "Secure1234!Me"; + public string Route { get; set; } = "/jobs"; +} +``` + +| Property | Description | Default | +|----------|-------------|---------| +| `UserName` | Basic auth username for the dashboard | `admin` | +| `Password` | Basic auth password for the dashboard | `Secure1234!Me` | +| `Route` | URL path where the dashboard is mounted | `/jobs` | + + + +## Job filters + +The framework registers three Hangfire filters that run automatically on every job: + +### FshJobFilter (multitenancy + user propagation) + +An `IClientFilter` that captures the current tenant and user ID from `HttpContext` when a job is created, and stores them as Hangfire job parameters. This ensures that background jobs execute in the correct tenant and user context. + +```csharp +context.SetJobParameter(MultitenancyConstants.Identifier, tenantInfo); +context.SetJobParameter(QueryStringKeys.UserId, userId); +``` + +### HangfireTelemetryFilter (OpenTelemetry tracing) + +An `IServerFilter` that creates an `Activity` span around every job execution using the `FSH.Hangfire` activity source. It tags each span with the job ID, type, and method name, and sets the status to `Error` if the job throws an exception. + +### LogJobFilter (structured logging) + +A comprehensive filter implementing `IClientFilter`, `IServerFilter`, `IElectStateFilter`, and `IApplyStateFilter`. It logs the full lifecycle of every job -- creation, execution start, completion, state transitions, and failures -- using Hangfire's built-in `LogProvider`. + +## FshJobActivator + +The custom `FshJobActivator` extends Hangfire's `JobActivator` to create a proper DI scope for each job execution. When a job runs, the activator: + +1. Creates a new `IServiceScope` +2. Restores the **tenant context** from the job parameters (set by `FshJobFilter`) +3. Restores the **current user ID** from the job parameters +4. Resolves the job type from the scoped service provider + +This ensures that services like `IMultiTenantContextAccessor` and `ICurrentUser` work correctly inside background jobs, just as they would in an HTTP request. + +## Dashboard authentication + +The Hangfire dashboard is protected by `HangfireCustomBasicAuthenticationFilter`, which implements `IDashboardAuthorizationFilter`. It validates HTTP Basic Authentication credentials against the values configured in `HangfireOptions`. If credentials are missing or incorrect, it returns a `401` response with a `WWW-Authenticate: Basic` challenge header. + +## Server configuration + +The Hangfire server is configured with sensible defaults: + +```csharp +services.AddHangfireServer(options => +{ + options.HeartbeatInterval = TimeSpan.FromSeconds(30); + options.Queues = ["default", "email"]; + options.WorkerCount = 5; + options.SchedulePollingInterval = TimeSpan.FromSeconds(30); +}); +``` + +| Setting | Value | Purpose | +|---------|-------|---------| +| `HeartbeatInterval` | 30s | How often workers signal they are alive | +| `Queues` | `default`, `email` | Named queues for job prioritization | +| `WorkerCount` | 5 | Number of concurrent job processing threads | +| `SchedulePollingInterval` | 30s | How often the scheduler checks for due jobs | + +## Real-world usage: outbox dispatcher + +A common pattern in the starter kit is scheduling the outbox dispatcher as a Hangfire recurring job. The Identity module registers it like this: + +```csharp +var jobManager = scope.ServiceProvider.GetRequiredService(); +jobManager.AddOrUpdate( + "identity-outbox-dispatcher", + Job.FromExpression(d => d.DispatchAsync(default)), + Cron.Minutely()); +``` + +This ensures that domain events captured in the outbox table are dispatched every minute, with full tenant context propagation handled automatically by the job filters. + +## Configuration + +Add the following to your `appsettings.json`: + +```json +{ + "HangfireOptions": { + "UserName": "admin", + "Password": "YourSecurePassword!", + "Route": "/jobs" + }, + "DatabaseOptions": { + "Provider": "POSTGRESQL", + "ConnectionString": "Host=localhost;Database=myapp;Username=postgres;Password=postgres" + } +} +``` + +Hangfire uses the same `DatabaseOptions.ConnectionString` as the rest of the application. It supports both PostgreSQL (`Hangfire.PostgreSql`) and SQL Server (`Hangfire.SqlServer`) storage providers, selected automatically based on `DatabaseOptions.Provider`. + + + +## Registration + +Jobs are registered in the service pipeline via the `AddHeroJobs()` extension method, and the dashboard is mounted via `UseHeroJobDashboard()`: + +```csharp +// In ConfigureServices +services.AddHeroJobs(); + +// In middleware pipeline +app.UseHeroJobDashboard(configuration); +``` diff --git a/docs/src/content/docs/building-blocks-overview.mdx b/docs/src/content/docs/building-blocks-overview.mdx new file mode 100644 index 0000000000..155999310a --- /dev/null +++ b/docs/src/content/docs/building-blocks-overview.mdx @@ -0,0 +1,132 @@ +--- +title: "Building Blocks Overview" +description: "Shared framework libraries that power every module in the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; +import FileTree from '../../components/FileTree.astro'; + +Building Blocks are the shared framework libraries that live under `src/BuildingBlocks/`. They provide the foundation for every module in the fullstackhero .NET Starter Kit, encapsulating cross-cutting concerns -- persistence, caching, eventing, web infrastructure, and more -- so that modules can focus entirely on business logic. + +Each Building Block is a standalone project with a clear, single responsibility. Modules reference only the Building Blocks they need, and the Building Blocks themselves follow a strict dependency hierarchy where lower layers never depend on higher ones. + +## The Building Blocks + +| Building Block | Project | Purpose | +|----------------|---------|---------| +| **Core** | `Core.csproj` | DDD primitives (`BaseEntity`, `AggregateRoot`, `DomainEvent`), framework exception types, `ICurrentUser` abstraction, CQRS interfaces | +| **Persistence** | `Persistence.csproj` | EF Core abstractions, `Specification` pattern, pagination helpers, audit interceptors, outbox/inbox persistence | +| **Web** | `Web.csproj` | Module system (`IModule`, `ModuleLoader`), global exception handling (`ProblemDetails`), API versioning, rate limiting, security headers, OpenAPI + Scalar, health checks, observability (Serilog + OpenTelemetry) | +| **Caching** | `Caching.csproj` | Distributed and hybrid cache abstraction (`ICacheService`) backed by Redis (StackExchange) or in-memory fallback | +| **Eventing** | `Eventing.csproj` + `Eventing.Abstractions.csproj` | Event bus infrastructure, outbox/inbox pattern implementation, InMemory and RabbitMQ transport providers | +| **Jobs** | `Jobs.csproj` | Background job scheduling via Hangfire, `IJobService` abstraction for fire-and-forget, delayed, and recurring jobs | +| **Mailing** | `Mailing.csproj` | Email abstraction layer with SMTP and SendGrid provider implementations | +| **Storage** | `Storage.csproj` | File storage abstraction with local filesystem and AWS S3 provider implementations | +| **Shared** | `Shared.csproj` | Permission constants, claim helpers, multitenancy constants, shared DTOs used across modules | + + + +## Dependency Hierarchy + +The Building Blocks follow a strict layered dependency structure. Lower layers must never depend on higher layers, and this is enforced by architecture tests in `BuildingBlocksIndependenceTests`. + +``` +Layer 0 -- No dependencies (only .NET BCL + Mediator abstractions) + Core + Eventing.Abstractions + +Layer 1 -- Depends on Core + Shared + Caching + Mailing + +Layer 2 -- Depends on Core + Shared + Persistence + Jobs + Storage + Eventing (also depends on Eventing.Abstractions) + +Layer 3 -- Depends on lower layers + Web (orchestrates module loading, middleware, and API infrastructure) +``` + +In diagram form: + +``` + ┌──────────────────┐ + │ Playground │ references Modules + BuildingBlocks + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Modules │ references BuildingBlocks only + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ BuildingBlocks │ references nothing above + │ │ + │ Web │ Layer 3 + │ ├── Persistence │ Layer 2 + │ ├── Jobs │ + │ ├── Storage │ + │ ├── Eventing │ + │ ├── Shared │ Layer 1 + │ ├── Caching │ + │ ├── Mailing │ + │ ├── Core │ Layer 0 + │ └── Eventing.Abstractions + └──────────────────┘ +``` + +The key rule: **Core has zero project references.** It depends only on the .NET BCL and Mediator abstractions, making it the stable foundation that everything else builds upon. + +## When to Use What + +Use this quick-reference guide to find the right Building Block for your task: + +| You need to... | Use this Building Block | +|-----------------|------------------------| +| Define domain entities, aggregate roots, or value objects | **Core** | +| Throw structured HTTP exceptions (`NotFoundException`, `ForbiddenException`) | **Core** | +| Access the current authenticated user | **Core** (`ICurrentUser`) | +| Configure EF Core DbContext or write specifications | **Persistence** | +| Paginate query results | **Persistence** | +| Register a module or wire up endpoints | **Web** | +| Add middleware, health checks, or OpenAPI docs | **Web** | +| Cache data in Redis or in-memory | **Caching** | +| Publish or subscribe to integration events | **Eventing** | +| Define event contracts and interfaces | **Eventing.Abstractions** | +| Schedule background, delayed, or recurring jobs | **Jobs** | +| Send transactional emails | **Mailing** | +| Upload or retrieve files | **Storage** | +| Reference permission constants or shared DTOs | **Shared** | + + + +## Individual Building Block Pages + +Dive deeper into each Building Block: + +- [Core](/dotnet-starter-kit/core-building-block/) -- DDD primitives, exceptions, and CQRS interfaces +- [Persistence](/dotnet-starter-kit/persistence-building-block/) -- EF Core abstractions and specifications +- [Web](/dotnet-starter-kit/web-building-block/) -- Module system, middleware, and API infrastructure +- [Caching](/dotnet-starter-kit/caching/) -- Distributed and hybrid caching +- [Eventing](/dotnet-starter-kit/eventing/) -- Event bus and outbox/inbox pattern +- [Jobs](/dotnet-starter-kit/background-jobs/) -- Background job scheduling +- [Mailing](/dotnet-starter-kit/mailing/) -- Email abstraction layer +- [Storage](/dotnet-starter-kit/file-storage/) -- File storage abstraction +- [Shared](/dotnet-starter-kit/shared-library/) -- Permission constants and shared DTOs diff --git a/docs/src/content/docs/caching.mdx b/docs/src/content/docs/caching.mdx new file mode 100644 index 0000000000..63fcd8ebf7 --- /dev/null +++ b/docs/src/content/docs/caching.mdx @@ -0,0 +1,155 @@ +--- +title: "Caching" +description: "Distributed and hybrid caching with Redis in the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +fullstackhero provides a **caching abstraction** with two implementations behind a single `ICacheService` interface - a distributed cache backed by Redis, and a hybrid cache that layers in-memory caching on top of Redis. Modules interact only with the interface, so you can switch implementations without changing application code. + +## ICacheService Interface + +All caching operations go through `ICacheService`, defined in the Caching building block: + +```csharp +public interface ICacheService +{ + Task GetItemAsync(string key, CancellationToken ct = default); + Task SetItemAsync(string key, T value, TimeSpan? sliding = default, CancellationToken ct = default); + Task RemoveItemAsync(string key, CancellationToken ct = default); + Task RefreshItemAsync(string key, CancellationToken ct = default); +} +``` + +The API is intentionally minimal - get, set, remove, and refresh. The `sliding` parameter on `SetItemAsync` overrides the default sliding expiration for that specific entry. `RefreshItemAsync` resets the sliding expiration window without retrieving the value, which is useful for keeping hot entries alive. + +## Distributed Cache (DistributedCacheService) + +The `DistributedCacheService` wraps `IDistributedCache` and uses Redis as the backing store. This is the default implementation and works well for single-instance and multi-instance deployments alike. + +### How It Works + +- **JSON serialization** - values are serialized to JSON before storage and deserialized on retrieval, so any serializable type can be cached. +- **Key normalization** - all cache keys are automatically prefixed with the configured `KeyPrefix` (default `fsh_`) and normalized to lowercase. This prevents key collisions across applications sharing the same Redis instance. +- **Graceful error handling** - cache failures are caught and logged rather than propagated. A cache miss or Redis outage will not crash your application - the caller simply receives `null` and can fall back to the primary data source. + +## Hybrid Cache (HybridCacheService) + +The `HybridCacheService` implements a two-level caching strategy that combines the speed of local memory with the consistency of distributed Redis. + +### Cache Layers + +- **L1 - `IMemoryCache` (in-process)** - extremely fast, no network overhead, but local to a single application instance. +- **L2 - `IDistributedCache` (Redis)** - shared across all instances, slightly slower due to network round-trips and serialization. + +### Lookup Flow + +1. **Check L1 (memory)** - if the key exists in the local memory cache, return it immediately. No serialization or network call needed. +2. **Check L2 (Redis)** - if the key is not in memory, query Redis. If found, deserialize the value and populate the local memory cache for future requests. +3. **Cache miss** - if neither layer has the value, return `null`. The caller retrieves the data from the primary source and calls `SetItemAsync` to populate both layers. + +### Expiration Strategy + +The memory cache uses **80% of the distributed cache's expiration time**. This ensures the local cache expires slightly earlier than Redis, causing the next request to refresh from the authoritative distributed store. This approach balances performance with freshness - local reads are fast, but stale entries are naturally evicted before the Redis entry expires. + +For example, with the default configuration: +- Redis sliding expiration: **5 minutes** +- Memory sliding expiration: **4 minutes** (80% of 5 minutes) + + + +## Configuration + +Caching is configured through the `CachingOptions` class: + +```csharp +public sealed class CachingOptions +{ + public string Redis { get; set; } = string.Empty; + public bool? EnableSsl { get; set; } + public TimeSpan? DefaultSlidingExpiration { get; set; } = TimeSpan.FromMinutes(5); + public TimeSpan? DefaultAbsoluteExpiration { get; set; } = TimeSpan.FromMinutes(15); + public string? KeyPrefix { get; set; } = "fsh_"; +} +``` + +Add the corresponding section to your `appsettings.json`: + +```json +{ + "CachingOptions": { + "Redis": "localhost:6379", + "EnableSsl": false, + "DefaultSlidingExpiration": "00:05:00", + "DefaultAbsoluteExpiration": "00:15:00", + "KeyPrefix": "fsh_" + } +} +``` + +| Property | Description | Default | +|----------|-------------|---------| +| `Redis` | Redis connection string | `""` (empty) | +| `EnableSsl` | Enable SSL for the Redis connection | `null` | +| `DefaultSlidingExpiration` | Default sliding expiration for cached entries | 5 minutes | +| `DefaultAbsoluteExpiration` | Absolute expiration regardless of access | 15 minutes | +| `KeyPrefix` | Prefix prepended to all cache keys | `"fsh_"` | + + + +## Usage + +Inject `ICacheService` into your command or query handlers and use it to cache expensive operations: + +```csharp +public sealed class GetProductByIdQueryHandler : IQueryHandler +{ + private readonly ICacheService _cache; + private readonly AppDbContext _dbContext; + + public GetProductByIdQueryHandler(ICacheService cache, AppDbContext dbContext) + { + _cache = cache; + _dbContext = dbContext; + } + + public async ValueTask Handle(GetProductByIdQuery query, CancellationToken ct) + { + string cacheKey = $"product:{query.ProductId}"; + + // Try cache first + ProductDto? cached = await _cache.GetItemAsync(cacheKey, ct) + .ConfigureAwait(false); + if (cached is not null) return cached; + + // Cache miss - load from database + ProductDto product = await _dbContext.Products + .Where(p => p.Id == query.ProductId) + .Select(p => new ProductDto(p.Id, p.Name, p.Price)) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false) + ?? throw new NotFoundException("Product not found."); + + // Populate cache + await _cache.SetItemAsync(cacheKey, product, ct: ct) + .ConfigureAwait(false); + + return product; + } +} +``` + +When data is modified, invalidate the cache entry so subsequent reads fetch fresh data: + +```csharp +await _cache.RemoveItemAsync($"product:{command.ProductId}", ct) + .ConfigureAwait(false); +``` + +The key prefix is applied automatically - you do not need to include `fsh_` in your application-level cache keys. diff --git a/docs/src/content/docs/ci-cd-pipelines.mdx b/docs/src/content/docs/ci-cd-pipelines.mdx new file mode 100644 index 0000000000..9c1abdb7dd --- /dev/null +++ b/docs/src/content/docs/ci-cd-pipelines.mdx @@ -0,0 +1,98 @@ +--- +title: "CI/CD" +description: "Continuous integration and deployment with GitHub Actions." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +## Overview + +The starter kit uses GitHub Actions for continuous integration and deployment. The CI pipeline handles building, testing, vulnerability scanning, container image publishing, and NuGet package releases. CodeQL provides automated security analysis on every pull request. + +## CI Workflow + +The main CI workflow is defined in `.github/workflows/ci.yml` and triggers on: + +- **Push** to `main` or `develop` branches +- **Tags** matching `v*` (e.g., `v1.0.0`) +- **Pull requests** targeting `main` or `develop` + +The workflow only runs when changes are made to files under the `src/` path, skipping docs-only changes. + +## Pipeline Stages + + + 1. **Build** - Sets up the .NET 10 SDK, restores dependencies, builds the solution, and checks for vulnerable NuGet packages. Any known CVE in a dependency fails the pipeline immediately. + + 2. **Test** - Runs five test projects in parallel to minimize pipeline duration: + - Architecture Tests + - Auditing Tests + - Generic Tests + - Identity Tests + - Multitenancy Tests + + 3. **Dev Publish** - Triggered on pushes to the `develop` branch. Builds the container image and pushes it to GitHub Container Registry (GHCR) with two tags: + - `dev-{sha}` (commit-specific) + - `dev-latest` (rolling) + + 4. **Release Publish** - Triggered on pushes to `main` or version tags. Performs two operations: + - Packs 11 NuGet packages and pushes them to NuGet.org + - Builds the container image and pushes to GHCR with version-specific tags + + +## CodeQL + +The CodeQL workflow is defined in `.github/workflows/codeql.yml` and provides automated security scanning: + +- **Triggers** - Runs on pull requests and on a weekly schedule +- **Language** - Analyzes C# code for security vulnerabilities, code quality issues, and common mistakes +- **Results** - Findings appear directly in the pull request Security tab + +## Release Drafter + +The release drafter configuration in `.github/release-drafter.yml` automatically categorizes merged pull requests into release notes: + +- **Breaking Changes** - PRs labeled with `breaking` +- **Features** - PRs labeled with `feature` or `enhancement` +- **Bug Fixes** - PRs labeled with `bug` or `fix` +- **Dependencies** - PRs labeled with `dependencies` +- **Documentation** - PRs labeled with `documentation` + +When you publish a GitHub release, the drafter populates the release body with all changes since the last release, organized by category. + +## NuGet Packages + +The release pipeline publishes 11 NuGet packages covering all BuildingBlocks and module Contracts: + +- `FSH.Framework.Core` +- `FSH.Framework.Persistence` +- `FSH.Framework.Web` +- `FSH.Framework.Caching` +- `FSH.Framework.Eventing` +- `FSH.Framework.Jobs` +- `FSH.Framework.Logging` +- `FSH.Framework.Mailing` +- `FSH.Framework.Storage` +- `FSH.Modules.Identity.Contracts` +- `FSH.Modules.Multitenancy.Contracts` + +These packages allow other projects to consume the framework without cloning the full repository. + +## Container Images + +Container images are published to GitHub Container Registry at `ghcr.io/fullstackhero/dotnet-starter-kit`. Tags follow this convention: + +| Branch/Event | Tag Pattern | Example | +|-------------|------------|---------| +| `develop` push | `dev-{sha}`, `dev-latest` | `dev-a1b2c3d`, `dev-latest` | +| `main` push | `latest` | `latest` | +| Version tag | `{version}` | `1.0.0` | + + + + diff --git a/docs/src/content/docs/configuration-reference.mdx b/docs/src/content/docs/configuration-reference.mdx new file mode 100644 index 0000000000..3dbf77251f --- /dev/null +++ b/docs/src/content/docs/configuration-reference.mdx @@ -0,0 +1,454 @@ +--- +title: "Configuration Reference" +description: "All configuration options for the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +This page documents every configuration section used by the fullstackhero .NET Starter Kit. All settings go in `appsettings.json` (or environment-specific overrides) and are bound to strongly-typed options classes at startup. + +## Database + +Controls the database provider and connection for Entity Framework Core. + +**Options class:** `FSH.Framework.Shared.Persistence.DatabaseOptions` + +```json +{ + "DatabaseOptions": { + "Provider": "PostgreSQL", + "ConnectionString": "Host=localhost;Database=fsh;Username=postgres;Password=yourpassword", + "MigrationsAssembly": "" + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Provider` | `string` | `"PostgreSQL"` | Database provider. Supported: `"PostgreSQL"`, `"MSSQL"`. | +| `ConnectionString` | `string` | `""` | EF Core connection string. **Required.** | +| `MigrationsAssembly` | `string` | `""` | Assembly containing EF Core migrations. | + + + +## JWT Authentication + +Configures JWT bearer token generation and validation for the Identity module. + +**Options class:** `FSH.Modules.Identity.Authorization.Jwt.JwtOptions` + +```json +{ + "JwtOptions": { + "Issuer": "https://localhost:5001", + "Audience": "fsh-api", + "SigningKey": "your-signing-key-at-least-32-characters-long", + "AccessTokenMinutes": 30, + "RefreshTokenDays": 7 + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Issuer` | `string` | `""` | Token issuer URI. **Required.** | +| `Audience` | `string` | `""` | Token audience. **Required.** | +| `SigningKey` | `string` | `""` | HMAC signing key. Must be at least 32 characters. **Required.** | +| `AccessTokenMinutes` | `int` | `30` | Access token lifetime in minutes. | +| `RefreshTokenDays` | `int` | `7` | Refresh token lifetime in days. | + + + +## Caching + +Configures the distributed cache layer. Falls back to in-memory caching when no Redis connection is provided. + +**Options class:** `FSH.Framework.Caching.CachingOptions` + +```json +{ + "CachingOptions": { + "Redis": "localhost:6379", + "EnableSsl": false, + "DefaultSlidingExpiration": "00:05:00", + "DefaultAbsoluteExpiration": "00:15:00", + "KeyPrefix": "fsh_" + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Redis` | `string` | `""` | Redis connection string. If empty, uses in-memory cache. | +| `EnableSsl` | `bool?` | `null` | Enable SSL for Redis. Set to `true` for Aspire or cloud Redis. | +| `DefaultSlidingExpiration` | `TimeSpan?` | `5 minutes` | Default sliding expiration when caller does not specify. | +| `DefaultAbsoluteExpiration` | `TimeSpan?` | `15 minutes` | Default absolute expiration cap. | +| `KeyPrefix` | `string?` | `"fsh_"` | Prefix applied to all cache keys (useful for env/tenant isolation). | + +## Mailing + +Configures email delivery via SMTP or SendGrid. + +**Options class:** `FSH.Framework.Mailing.MailOptions` + + + + +```json +{ + "MailOptions": { + "UseSendGrid": false, + "From": "noreply@yourapp.com", + "DisplayName": "My App", + "Smtp": { + "Host": "smtp.example.com", + "Port": 587, + "UserName": "smtp-user", + "Password": "smtp-password" + } + } +} +``` + + + + +```json +{ + "MailOptions": { + "UseSendGrid": true, + "From": "noreply@yourapp.com", + "DisplayName": "My App", + "SendGrid": { + "ApiKey": "SG.your-sendgrid-api-key", + "From": "noreply@yourapp.com", + "DisplayName": "My App" + } + } +} +``` + + + + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `UseSendGrid` | `bool` | `false` | Use SendGrid instead of SMTP. | +| `From` | `string?` | `null` | Default sender email address. | +| `DisplayName` | `string?` | `null` | Default sender display name. | +| `Smtp.Host` | `string?` | `null` | SMTP server hostname. | +| `Smtp.Port` | `int` | `0` | SMTP server port. | +| `Smtp.UserName` | `string?` | `null` | SMTP authentication username. | +| `Smtp.Password` | `string?` | `null` | SMTP authentication password. | +| `SendGrid.ApiKey` | `string?` | `null` | SendGrid API key. | + + + +## Rate Limiting + +Configures global and per-policy rate limiting using fixed window policies. + +**Options class:** `FSH.Framework.Web.RateLimiting.RateLimitingOptions` + +```json +{ + "RateLimitingOptions": { + "Enabled": true, + "Global": { + "PermitLimit": 100, + "WindowSeconds": 60, + "QueueLimit": 0 + }, + "Auth": { + "PermitLimit": 10, + "WindowSeconds": 60, + "QueueLimit": 0 + } + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Enabled` | `bool` | `true` | Enable or disable rate limiting globally. | +| `Global.PermitLimit` | `int` | `100` | Maximum requests per window (global). | +| `Global.WindowSeconds` | `int` | `60` | Window duration in seconds (global). | +| `Global.QueueLimit` | `int` | `0` | Queue capacity when limit is hit (global). | +| `Auth.PermitLimit` | `int` | `10` | Maximum requests per window (auth endpoints). | +| `Auth.WindowSeconds` | `int` | `60` | Window duration in seconds (auth endpoints). | +| `Auth.QueueLimit` | `int` | `0` | Queue capacity when limit is hit (auth endpoints). | + +## CORS + +Configures Cross-Origin Resource Sharing policies. + +**Options class:** `FSH.Framework.Web.Cors.CorsOptions` + +```json +{ + "CorsOptions": { + "AllowAll": true, + "AllowedOrigins": [], + "AllowedHeaders": ["*"], + "AllowedMethods": ["*"] + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `AllowAll` | `bool` | `true` | Allow all origins. Set to `false` in production. | +| `AllowedOrigins` | `string[]` | `[]` | Explicit list of allowed origins (used when `AllowAll` is `false`). | +| `AllowedHeaders` | `string[]` | `["*"]` | Allowed request headers. | +| `AllowedMethods` | `string[]` | `["*"]` | Allowed HTTP methods. | + + + +## Storage + +Configures file storage. The framework supports local disk and Amazon S3. + + + + +Local storage is the default and requires no additional configuration. Files are stored relative to the application's content root. + +```json +{ + "StorageOptions": { + "Provider": "Local" + } +} +``` + + + + +```json +{ + "StorageOptions": { + "Provider": "S3" + }, + "S3StorageOptions": { + "Bucket": "my-app-uploads", + "Region": "us-east-1", + "Prefix": "uploads/", + "PublicRead": true, + "PublicBaseUrl": "https://cdn.example.com" + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Bucket` | `string?` | `null` | S3 bucket name. | +| `Region` | `string?` | `null` | AWS region. | +| `Prefix` | `string?` | `null` | Key prefix for all uploaded objects. | +| `PublicRead` | `bool` | `true` | Whether uploaded objects are publicly readable. | +| `PublicBaseUrl` | `string?` | `null` | CDN or public base URL for generating download links. | + + + + +## Hangfire (Background Jobs) + +Configures the Hangfire dashboard and background job processing. + +**Options class:** `FSH.Framework.Jobs.HangfireOptions` + +```json +{ + "HangfireOptions": { + "UserName": "admin", + "Password": "Secure1234!Me", + "Route": "/jobs" + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `UserName` | `string` | `"admin"` | Dashboard basic auth username. | +| `Password` | `string` | `"Secure1234!Me"` | Dashboard basic auth password. | +| `Route` | `string` | `"/jobs"` | URL path for the Hangfire dashboard. | + + + +## OpenTelemetry + +Configures distributed tracing, metrics, and OTLP export. + +**Options class:** `FSH.Framework.Web.Observability.OpenTelemetry.OpenTelemetryOptions` + +```json +{ + "OpenTelemetryOptions": { + "Enabled": true, + "Tracing": { + "Enabled": true + }, + "Metrics": { + "Enabled": true, + "MeterNames": null + }, + "Exporter": { + "Otlp": { + "Enabled": true, + "Endpoint": "http://localhost:4317", + "Protocol": "grpc" + } + }, + "Jobs": { + "Enabled": true + }, + "Mediator": { + "Enabled": true + }, + "Http": { + "Histograms": { + "Enabled": true, + "BucketBoundaries": null + } + }, + "Data": { + "FilterEfStatements": true, + "FilterRedisCommands": true + } + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Enabled` | `bool` | `true` | Global switch for all OpenTelemetry features. | +| `Tracing.Enabled` | `bool` | `true` | Enable distributed tracing. | +| `Metrics.Enabled` | `bool` | `true` | Enable metrics collection. | +| `Metrics.MeterNames` | `string[]?` | `null` | Custom meter names to subscribe to. | +| `Exporter.Otlp.Enabled` | `bool` | `true` | Enable OTLP exporter. | +| `Exporter.Otlp.Endpoint` | `string?` | `null` | OTLP collector endpoint URL. | +| `Exporter.Otlp.Protocol` | `string?` | `null` | Transport protocol: `"grpc"` or `"http/protobuf"`. | +| `Jobs.Enabled` | `bool` | `true` | Instrument Hangfire job execution. | +| `Mediator.Enabled` | `bool` | `true` | Add spans around Mediator commands/queries. | +| `Http.Histograms.Enabled` | `bool` | `true` | Enable HTTP request duration histograms. | +| `Http.Histograms.BucketBoundaries` | `double[]?` | `null` | Custom histogram bucket boundaries (seconds). | +| `Data.FilterEfStatements` | `bool` | `true` | Suppress SQL text in EF instrumentation to reduce noise. | +| `Data.FilterRedisCommands` | `bool` | `true` | Suppress Redis command text in instrumentation. | + +## Password Policy + +Configures password history tracking and expiry enforcement for the Identity module. + +**Options class:** `FSH.Modules.Identity.Data.PasswordPolicyOptions` + +```json +{ + "PasswordPolicy": { + "PasswordHistoryCount": 5, + "PasswordExpiryDays": 90, + "PasswordExpiryWarningDays": 14, + "EnforcePasswordExpiry": true + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `PasswordHistoryCount` | `int` | `5` | Number of previous passwords to keep (prevents reuse). | +| `PasswordExpiryDays` | `int` | `90` | Days before a password expires and must be changed. | +| `PasswordExpiryWarningDays` | `int` | `14` | Days before expiry to show a warning to the user. | +| `EnforcePasswordExpiry` | `bool` | `true` | Set to `false` to disable password expiry enforcement. | + +## Eventing + +Configures the outbox/inbox pattern and event bus provider. + +**Options class:** `FSH.Framework.Eventing.EventingOptions` + +```json +{ + "EventingOptions": { + "Provider": "InMemory", + "OutboxBatchSize": 100, + "OutboxMaxRetries": 5, + "EnableInbox": true, + "OutboxDispatchIntervalSeconds": 10, + "UseHostedServiceDispatcher": true + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Provider` | `string` | `"InMemory"` | Event bus provider. Supported: `"InMemory"`, `"RabbitMQ"`. | +| `OutboxBatchSize` | `int` | `100` | Number of outbox messages dispatched per batch. | +| `OutboxMaxRetries` | `int` | `5` | Max retries before an outbox message is marked dead. | +| `EnableInbox` | `bool` | `true` | Enable inbox-based idempotent event handling. | +| `OutboxDispatchIntervalSeconds` | `int` | `10` | Background dispatcher interval. Set to `0` to disable. | +| `UseHostedServiceDispatcher` | `bool` | `true` | Use a hosted service for dispatching. Set to `false` to use Hangfire instead. | + +When using RabbitMQ, add the RabbitMQ-specific configuration: + +```json +{ + "RabbitMqOptions": { + "Host": "localhost", + "Port": 5672, + "UserName": "guest", + "Password": "guest", + "VirtualHost": "/", + "ExchangeName": "fsh.events", + "QueuePrefix": "fsh", + "UseSsl": false, + "PublishRetryCount": 3, + "PublishRetryDelayMs": 1000 + } +} +``` + +## Security Headers + +Configures the security headers middleware (CSP, X-Frame-Options, etc.). + +**Options class:** `FSH.Framework.Web.Security.SecurityHeadersOptions` + +```json +{ + "SecurityHeadersOptions": { + "Enabled": true, + "ExcludedPaths": ["/scalar", "/openapi"], + "AllowInlineStyles": true, + "ScriptSources": [], + "StyleSources": [] + } +} +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Enabled` | `bool` | `true` | Enable or disable the security headers middleware. | +| `ExcludedPaths` | `string[]` | `["/scalar", "/openapi"]` | Paths that bypass security headers. | +| `AllowInlineStyles` | `bool` | `true` | Allow inline styles in CSP (needed for Scalar). | +| `ScriptSources` | `string[]` | `[]` | Additional CSP script-src entries. | +| `StyleSources` | `string[]` | `[]` | Additional CSP style-src entries. | diff --git a/docs/src/content/docs/contributing.mdx b/docs/src/content/docs/contributing.mdx new file mode 100644 index 0000000000..62f0ae1021 --- /dev/null +++ b/docs/src/content/docs/contributing.mdx @@ -0,0 +1,110 @@ +--- +title: "Contributing" +description: "How to contribute to the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +## Overview + +We welcome contributions to the fullstackhero .NET Starter Kit! Whether you are fixing a bug, adding a feature, or improving documentation, your help is appreciated. The project follows a **fork-and-PR workflow** - all changes are submitted as pull requests against the `develop` branch. + +## Getting Started + + + +1. **Fork the repository on GitHub** + + Navigate to the [fullstackhero/dotnet-starter-kit](https://github.com/fullstackhero/dotnet-starter-kit) repository and click the **Fork** button. + +2. **Clone your fork** + + ```bash + git clone https://github.com/YOUR-USERNAME/dotnet-starter-kit.git + cd dotnet-starter-kit + ``` + +3. **Create a feature branch from develop** + + ```bash + git checkout -b feature/your-feature develop + ``` + +4. **Install prerequisites** + + Make sure you have the following installed: + - [.NET 10 SDK](https://dotnet.microsoft.com/download) + - PostgreSQL (local install or via Docker) + - Redis (local install or via Docker) + +5. **Build the solution** + + ```bash + dotnet build src/FSH.Starter.slnx + ``` + +6. **Run the tests** + + ```bash + dotnet test src/FSH.Starter.slnx + ``` + + + +## Coding Standards + +All contributions must follow the established coding conventions: + +- **File-scoped namespaces** - use `namespace X;` (not block-scoped) +- **4-space indentation** - no tabs +- **Prefer explicit types** - use `var` only when the type is obvious from the right-hand side +- **Null checks** - use `is null` / `is not null` (not `== null`) +- **Pattern matching** - preferred over `is`/`as` casts +- **Switch expressions** - preferred over switch statements where applicable +- **Async handlers** - use `ValueTask` for handlers with `.ConfigureAwait(false)` on all awaits +- **Guard clauses** - use `ArgumentNullException.ThrowIfNull(param)` at method entry +- **Records** - use for DTOs, events, and value objects + +## Feature Folder Convention + +New features follow Vertical Slice Architecture inside the module's `Features/` directory: + +``` +Features/v1/{Area}/{FeatureName}/ +├── {FeatureName}Endpoint.cs # Minimal API endpoint +├── {FeatureName}CommandHandler.cs # CQRS handler +└── {FeatureName}CommandValidator.cs # FluentValidation +``` + +Commands and queries are defined in the corresponding `Modules.{Name}.Contracts` project. Handlers, validators, and endpoints live in the module's runtime project. + +## Testing Requirements + +All new features must include tests. Follow these conventions: + +- **Test naming:** `MethodName_Should_ExpectedBehavior_When_Condition` +- **Pattern:** Arrange-Act-Assert with `#region` grouping (Happy Path, Exception, Edge Cases) +- **Assertions:** [Shouldly](https://github.com/shouldly/shouldly) (`result.ShouldBe(...)`, `result.ShouldNotBeNull()`) +- **Mocking:** [NSubstitute](https://nsubstitute.github.io/) (`Substitute.For()`) +- **Test data:** [AutoFixture](https://github.com/AutoFixture/AutoFixture) (`_fixture.Create()`) + +## PR Checklist + +Before submitting your pull request, verify that: + +- [ ] Code builds without warnings +- [ ] All tests pass +- [ ] Architecture tests pass +- [ ] New features have tests +- [ ] No modifications to BuildingBlocks without approval +- [ ] Modules only reference Contracts projects +- [ ] Feature follows the folder convention + + + + diff --git a/docs/src/content/docs/core-building-block.mdx b/docs/src/content/docs/core-building-block.mdx new file mode 100644 index 0000000000..f23bef38fc --- /dev/null +++ b/docs/src/content/docs/core-building-block.mdx @@ -0,0 +1,284 @@ +--- +title: "Core" +description: "DDD primitives, exceptions, and foundational interfaces in the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; + +The Core building block is the foundation of the entire framework. It defines the domain-driven design primitives that all modules build upon -- base entities, aggregate roots, domain events, marker interfaces, a structured exception hierarchy, and context abstractions for accessing the current user and request metadata. + +Everything in Core is **infrastructure-agnostic**. There are no dependencies on EF Core, ASP.NET Core, or any persistence technology. This keeps your domain model clean and portable. + +## Domain Primitives + +### BaseEntity + +Every domain entity in fullstackhero inherits from `BaseEntity`. It provides identity, and built-in domain event support through the `IHasDomainEvents` interface: + +```csharp +public abstract class BaseEntity : IEntity, IHasDomainEvents +{ + private readonly List _domainEvents = []; + public TId Id { get; protected set; } = default!; + public IReadOnlyCollection DomainEvents => _domainEvents; + protected void AddDomainEvent(IDomainEvent @event) => _domainEvents.Add(@event); + public void ClearDomainEvents() => _domainEvents.Clear(); +} +``` + +Key design decisions: + +- **`Id` is generic** -- use `Guid`, `int`, or any type that fits your domain. Most modules use `Guid`. +- **Domain events are encapsulated** -- only the entity itself can add events via the `protected` method `AddDomainEvent`. External code can read them through the `IReadOnlyCollection` but cannot modify the list. +- **`ClearDomainEvents`** is called by the persistence layer after events are dispatched, preventing duplicate publishing. + +### AggregateRoot + +`AggregateRoot` is a semantic marker that extends `BaseEntity`: + +```csharp +public abstract class AggregateRoot : BaseEntity { } +``` + +Use `AggregateRoot` for entities that serve as the root of an aggregate -- the consistency boundary in your domain. All modifications to entities within an aggregate should go through the aggregate root. + + + +### DomainEvent + +All domain events inherit from the `DomainEvent` abstract record: + +```csharp +public abstract record DomainEvent( + Guid EventId, + DateTimeOffset OccurredOnUtc, + string? CorrelationId = null, + string? TenantId = null +) : IDomainEvent +{ + public static T Create(Func factory) + where T : DomainEvent + { + return factory(Guid.NewGuid(), DateTimeOffset.UtcNow); + } +} +``` + +Every domain event carries four pieces of metadata: + +- **`EventId`** -- a unique identifier for this specific event occurrence +- **`OccurredOnUtc`** -- the exact timestamp when the event was created +- **`CorrelationId`** -- optional tracing identifier to correlate related operations across the system +- **`TenantId`** -- optional tenant context for multitenant scenarios + +The `Create` factory method generates the `EventId` and captures the current UTC timestamp automatically: + +```csharp +AddDomainEvent(DomainEvent.Create( + (id, ts) => new UserActivatedEvent(id, ts, UserId))); +``` + + + +## Marker Interfaces + +Core provides four marker interfaces that the persistence layer detects and acts upon automatically. You compose them onto your entities as needed. + +### IAuditableEntity + +Tracks who created and last modified an entity, and when: + +```csharp +public interface IAuditableEntity +{ + DateTimeOffset CreatedOnUtc { get; } + string? CreatedBy { get; } + DateTimeOffset? LastModifiedOnUtc { get; } + string? LastModifiedBy { get; } +} +``` + +The persistence layer populates these properties automatically during `SaveChanges` using the current user context. You never need to set them manually in your domain logic. + +### ISoftDeletable + +Enables soft deletion -- marking an entity as deleted without physically removing it from the database: + +```csharp +public interface ISoftDeletable +{ + bool IsDeleted { get; } + DateTimeOffset? DeletedOnUtc { get; } + string? DeletedBy { get; } +} +``` + +When an entity implementing `ISoftDeletable` is deleted, the persistence layer sets `IsDeleted = true` and records the deletion timestamp and user. Global query filters automatically exclude soft-deleted entities from normal queries. + +### IHasTenant + +Associates an entity with a specific tenant in multitenant scenarios: + +```csharp +public interface IHasTenant +{ + string TenantId { get; } +} +``` + +The persistence layer uses this interface in conjunction with Finbuckle.MultiTenant to apply automatic tenant filtering on all queries, ensuring strict data isolation between tenants. + +### IHasDomainEvents + +Implemented by `BaseEntity`, this interface defines the contract for entities that can raise domain events: + +```csharp +public interface IHasDomainEvents +{ + IReadOnlyCollection DomainEvents { get; } + void ClearDomainEvents(); +} +``` + +The `DomainEventsInterceptor` uses this interface to discover and dispatch pending events after `SaveChanges` succeeds. + + + +## Exceptions + +Core defines a structured exception hierarchy that the global exception handler converts to RFC 9457 `ProblemDetails` responses. This gives you consistent, machine-readable error responses across the entire API. + +### CustomException + +The base exception class for all framework exceptions: + +```csharp +public class CustomException : Exception +{ + public IReadOnlyList ErrorMessages { get; } + public HttpStatusCode StatusCode { get; } + + public CustomException( + string message, + IEnumerable? errors, + HttpStatusCode statusCode = HttpStatusCode.InternalServerError) + : base(message) + { + ErrorMessages = errors?.ToList() ?? new List(); + StatusCode = statusCode; + } +} +``` + +`CustomException` carries two key pieces of information beyond the standard `Message`: + +- **`ErrorMessages`** -- a list of detailed error strings, useful for returning multiple validation failures or business rule violations +- **`StatusCode`** -- the HTTP status code that the global exception handler maps to the response + +### Specialized Exceptions + +Three common exception types are provided out of the box, each pre-configured with the correct HTTP status code: + +| Exception | Status Code | Default Message | Use Case | +|---|---|---|---| +| `NotFoundException` | 404 Not Found | "Resource not found." | Entity lookups that return null | +| `UnauthorizedException` | 401 Unauthorized | "Authentication failed." | Missing or invalid authentication | +| `ForbiddenException` | 403 Forbidden | "Unauthorized access." | Authenticated but lacking permissions | + +All three extend `CustomException` and accept optional custom messages: + +```csharp +// Use default message +throw new NotFoundException(); + +// Use custom message with entity details +throw new NotFoundException($"Tenant with ID '{tenantId}' was not found."); + +// Include multiple error details +throw new CustomException( + "Validation failed.", + ["Email is required.", "Password must be at least 8 characters."], + HttpStatusCode.BadRequest); +``` + + + +## Context Interfaces + +Core defines abstractions for accessing the current user and request metadata without coupling your domain or handler code to ASP.NET Core. + +### ICurrentUser + +Provides read access to the authenticated user's identity and claims: + +```csharp +public interface ICurrentUser +{ + string? Name { get; } + Guid GetUserId(); + string? GetUserEmail(); + string? GetTenant(); + bool IsAuthenticated(); + bool IsInRole(string role); + IEnumerable? GetUserClaims(); +} +``` + +Inject `ICurrentUser` into command handlers, domain services, or any code that needs to know who the current user is: + +```csharp +public sealed class CreateOrderCommandHandler(ICurrentUser currentUser) +{ + public async ValueTask Handle(CreateOrderCommand command, CancellationToken ct) + { + var userId = currentUser.GetUserId(); + var tenantId = currentUser.GetTenant(); + // ... + } +} +``` + +### ICurrentUserInitializer + +Used by the authentication middleware to hydrate the current user context at the start of each request: + +```csharp +public interface ICurrentUserInitializer +{ + void SetCurrentUser(ClaimsPrincipal user); + void SetCurrentUserId(string userId); +} +``` + + + +### IRequestContext + +Provides access to HTTP request metadata without depending on `HttpContext`: + +```csharp +public interface IRequestContext +{ + string? IpAddress { get; } + string? UserAgent { get; } + string ClientId { get; } + string? Origin { get; } +} +``` + +This is primarily used for audit logging, rate limiting, and security checks. The properties map to standard HTTP headers and connection information: + +- **`IpAddress`** -- the remote IP address of the client +- **`UserAgent`** -- the `User-Agent` header value +- **`ClientId`** -- a client identifier from the `X-Client-Id` header, or a default value +- **`Origin`** -- the origin URL (scheme + host + path base) of the current request diff --git a/docs/src/content/docs/cqrs.mdx b/docs/src/content/docs/cqrs.mdx new file mode 100644 index 0000000000..967aa47f29 --- /dev/null +++ b/docs/src/content/docs/cqrs.mdx @@ -0,0 +1,214 @@ +--- +title: "CQRS Pattern" +description: "How the fullstackhero .NET Starter Kit implements Command Query Responsibility Segregation with Mediator." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +fullstackhero uses **CQRS (Command Query Responsibility Segregation)** to cleanly separate write operations (commands) from read operations (queries). Every request flows through a mediator pipeline that handles validation, tracing, and dispatch to the correct handler. + +## What is CQRS? + +CQRS splits your application's operations into two distinct paths: + +- **Commands** - change state (create, update, delete). Return a confirmation or result. +- **Queries** - read state. Return data without side effects. + +This separation makes each side independently optimizable, testable, and scalable. Commands can enforce complex business rules while queries can be tuned for read performance, each without affecting the other. + +## Mediator Library + +The starter kit uses [Mediator](https://github.com/martinothamar/Mediator) by Martin Othamar - a **source-generator-based** mediator implementation for .NET. At compile time, Mediator generates the dispatch code directly, eliminating the runtime reflection overhead of traditional mediator libraries. + + + +## Commands + +Commands represent intentions to change state. They are defined in the **Contracts** project so that endpoints (and other modules) can reference them without depending on the handler implementation. + +Commands implement `ICommand` and live in `Modules.{Name}.Contracts/v1/{Area}/{Feature}/`: + +```csharp +using Mediator; +using System.Text.Json.Serialization; + +namespace FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; + +public class RegisterUserCommand : ICommand +{ + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public string Email { get; set; } = default!; + public string UserName { get; set; } = default!; + public string Password { get; set; } = default!; + public string ConfirmPassword { get; set; } = default!; + public string? PhoneNumber { get; set; } + [JsonIgnore] + public string? Origin { get; set; } +} +``` + +The response type is a simple record defined alongside the command: + +```csharp +public record RegisterUserResponse(string UserId); +``` + +## Queries + +Queries follow the same pattern but implement `IQuery`. They represent read operations that return data without modifying state: + +```csharp +public class GetUserByIdQuery : IQuery +{ + public string UserId { get; set; } = default!; +} +``` + +Query handlers implement `IQueryHandler` and follow the same conventions as command handlers. The separation ensures that read and write paths remain independent. + +## Handlers + +Handlers contain the business logic for a command or query. They are defined inside `Modules.{Name}/Features/v1/{Area}/{Feature}/` and are private to the module - never exposed through Contracts. + +Here is the handler for `RegisterUserCommand`: + +```csharp +public sealed class RegisterUserCommandHandler : ICommandHandler +{ + private readonly IUserService _userService; + + public RegisterUserCommandHandler(IUserService userService) + { + _userService = userService; + } + + public async ValueTask Handle(RegisterUserCommand command, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + string userId = await _userService.RegisterAsync( + command.FirstName, command.LastName, command.Email, + command.UserName, command.Password, command.ConfirmPassword, + command.PhoneNumber ?? string.Empty, command.Origin ?? string.Empty, + cancellationToken).ConfigureAwait(false); + return new RegisterUserResponse(userId); + } +} +``` + +Key conventions for handlers: + +- Implement `ICommandHandler` or `IQueryHandler` +- Return `ValueTask` (not `Task`) for better performance on synchronous completion paths +- Use `.ConfigureAwait(false)` on every `await` call +- Use `ArgumentNullException.ThrowIfNull()` as a guard clause at method entry +- One handler per file, named `{Feature}CommandHandler` or `{Feature}QueryHandler` +- Mark handlers as `sealed` - they are not designed for inheritance + +## Validation Pipeline + +Validation runs automatically before every handler through a pipeline behavior. The `ValidationBehavior` collects all registered `IValidator` instances for the incoming message type and executes them: + +```csharp +public sealed class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior + where TMessage : IMessage +{ + public async ValueTask Handle( + TMessage message, MessageHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var context = new ValidationContext(message); + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList(); + if (failures.Count > 0) throw new ValidationException(failures); + } + return await next(message, cancellationToken); + } +} +``` + +Validators use FluentValidation and are named `{Command}Validator`: + +```csharp +public sealed class RegisterUserCommandValidator : AbstractValidator +{ + public RegisterUserCommandValidator() + { + RuleFor(x => x.FirstName).NotEmpty().WithMessage("First name is required.") + .MaximumLength(100); + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.Password).NotEmpty().MinimumLength(6); + RuleFor(x => x.ConfirmPassword).NotEmpty().Equal(x => x.Password); + } +} +``` + +If any validation rule fails, a `ValidationException` is thrown before the handler ever executes. The global exception handler converts this into a `400 Bad Request` with structured `ProblemDetails`. + + + +## Tracing Pipeline + +The `MediatorTracingBehavior` wraps every request with an OpenTelemetry span. This means every command and query is automatically traced with timing, message type, and success/failure status - no manual instrumentation needed. Traces flow through to your configured OTLP exporter (visible in the Aspire dashboard or any OpenTelemetry-compatible backend). + +## Request Flow + +Here is the complete lifecycle of a CQRS request through the pipeline: + + + +### HTTP request hits the minimal API endpoint + +The client sends an HTTP request. ASP.NET routes it to the matching minimal API endpoint, which deserializes the request body into a command or query object. + +### Endpoint calls `mediator.Send(command)` + +The endpoint delegates to the mediator. No business logic lives in the endpoint - it is purely a thin HTTP adapter. + +### ValidationBehavior runs all registered validators + +The pipeline intercepts the message and runs every `IValidator` registered for that message type. If any rules fail, a `ValidationException` is thrown immediately and the handler is never called. + +### MediatorTracingBehavior creates a trace span + +An OpenTelemetry span is created around the handler execution, capturing timing and diagnostic information for observability. + +### Handler executes business logic + +The appropriate `ICommandHandler` or `IQueryHandler` processes the message, interacts with domain services or repositories, and returns a response. + +### Response flows back through the pipeline + +The response bubbles back through the pipeline behaviors and the endpoint returns it as an HTTP response to the client. + + + +## Conventions + +| Concern | Convention | Location | +|---------|-----------|----------| +| Commands | Implement `ICommand` | `Modules.{Name}.Contracts` project | +| Queries | Implement `IQuery` | `Modules.{Name}.Contracts` project | +| Command handlers | Implement `ICommandHandler` | `Modules.{Name}/Features/` folder | +| Query handlers | Implement `IQueryHandler` | `Modules.{Name}/Features/` folder | +| Validators | Extend `AbstractValidator`, named `{Command}Validator` | Same feature folder as handler | +| Return type | `ValueTask` with `.ConfigureAwait(false)` | All handlers | +| File structure | One handler per file | Feature folder | + +## Next Steps + +- [Adding a Feature](/dotnet-starter-kit/adding-a-feature/) - build a complete vertical slice end to end +- [Domain Events](/dotnet-starter-kit/domain-events/) - raise and handle events from your domain entities +- [Architecture](/dotnet-starter-kit/architecture/) - understand module boundaries and communication diff --git a/docs/src/content/docs/deployment-overview.mdx b/docs/src/content/docs/deployment-overview.mdx new file mode 100644 index 0000000000..6b2bf660fb --- /dev/null +++ b/docs/src/content/docs/deployment-overview.mdx @@ -0,0 +1,56 @@ +--- +title: "Deployment Overview" +description: "Deployment options and environment configuration for the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; + +## Overview + +The fullstackhero .NET Starter Kit supports multiple deployment options depending on your environment, team size, and infrastructure requirements. Whether you are running locally for development, deploying to a single server with Docker, or scaling to production on AWS, the kit provides the tooling and configuration to get you there. + +## Deployment Options + +| Option | Best For | Infrastructure | +|--------|----------|---------------| +| Docker Compose | Local dev, small deployments | Single server | +| .NET Aspire | Local development | Developer machine | +| AWS (Terraform) | Production | ECS Fargate, RDS, ElastiCache | +| Self-hosted | On-premises | IIS / Kestrel | + +Each option is covered in detail on its own page. Choose the one that fits your current stage and scale. + +## Environment Configuration + +Several settings change between environments. These are the key configuration areas you need to manage per deployment target: + +- **Database** - PostgreSQL connection string (host, port, credentials, database name) +- **Redis** - Connection string for distributed caching and session state +- **JWT Keys** - Signing key, issuer, audience, and token lifetimes +- **CORS Origins** - Allowed origins for cross-origin requests from your frontend +- **OTLP Endpoint** - OpenTelemetry collector endpoint for traces, metrics, and logs +- **Mail Provider** - SMTP or transactional email service credentials + +All of these are configured through `appsettings.{Environment}.json` or environment variables. Environment variables always take precedence over file-based configuration. + +## Secrets Management + +Never store secrets in `appsettings.json` or commit them to source control. Use one of the following approaches depending on your environment: + +- **Environment Variables** - The simplest option. Set variables on the host or in your container orchestrator. +- **AWS Secrets Manager** - For AWS deployments. Secrets are injected into ECS task definitions at runtime. +- **Azure Key Vault** - For Azure deployments. Integrates with the .NET configuration system. +- **User Secrets** - For local development only. Stores secrets outside the project directory using `dotnet user-secrets`. + + + +## Detailed Guides + +- [Docker](/deployment/docker) - Containerize and run with Docker Compose +- [.NET Aspire](/deployment/aspire) - Local development orchestration with the Aspire dashboard +- [AWS with Terraform](/deployment/aws-terraform) - Production-grade infrastructure on AWS +- [CI/CD](/deployment/ci-cd) - Automated build, test, and deployment with GitHub Actions diff --git a/docs/src/content/docs/docker.mdx b/docs/src/content/docs/docker.mdx new file mode 100644 index 0000000000..49ae498113 --- /dev/null +++ b/docs/src/content/docs/docker.mdx @@ -0,0 +1,124 @@ +--- +title: "Docker" +description: "Containerizing and running the starter kit with Docker." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +## Overview + +The starter kit includes a multi-stage Dockerfile and a docker-compose configuration to run the full stack locally or on a single server. The multi-stage build keeps the final image small by separating the SDK build environment from the lightweight ASP.NET runtime. + +## Dockerfile Walkthrough + + + 1. **Build stage** - Uses `mcr.microsoft.com/dotnet/sdk:10.0-preview` as the base image. Copies all `.csproj` files first to take advantage of Docker layer caching during `dotnet restore`, then copies the remaining source and runs `dotnet build`. + + 2. **Publish stage** - Runs `dotnet publish` with Release configuration, outputting to `/app/publish`. This produces a self-contained set of assemblies ready for deployment. + + 3. **Runtime stage** - Uses the lightweight `mcr.microsoft.com/dotnet/aspnet:10.0-preview` image. Copies the published output from the previous stage, exposes port 8080, sets `ASPNETCORE_ENVIRONMENT=Production`, and defines the entry point. + + +## Docker Compose + +The `docker-compose.yml` defines four services that make up the full development stack: + +- **api** - The FSH.Starter.Api application, built from the Dockerfile +- **postgres** - PostgreSQL 17 with a persistent volume for data +- **redis** - Redis 7 Alpine for distributed caching +- **otel-collector** - OpenTelemetry Collector for traces, metrics, and logs + +```yaml +services: + api: + build: + context: . + dockerfile: src/Playground/FSH.Starter.Api/Dockerfile + ports: + - "8080:8080" + depends_on: + - postgres + - redis + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ConnectionStrings__DefaultConnection=Host=postgres;Database=fsh;Username=postgres;Password=yourpassword + - ConnectionStrings__Redis=redis:6379 + + postgres: + image: postgres:17 + ports: + - "5432:5432" + environment: + POSTGRES_DB: fsh + POSTGRES_PASSWORD: yourpassword + volumes: + - postgres-data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis-data:/data + + otel-collector: + image: otel/opentelemetry-collector:latest + ports: + - "4317:4317" + +volumes: + postgres-data: + redis-data: +``` + +## Running + +Start all services in detached mode: + +```bash +docker-compose up -d +``` + +The API will be available at `http://localhost:8080`. View logs with: + +```bash +docker-compose logs -f api +``` + +To stop all services: + +```bash +docker-compose down +``` + +## Environment Variables + +Key environment variables to configure in your compose file or `.env`: + +| Variable | Description | +|----------|-------------| +| `ConnectionStrings__DefaultConnection` | PostgreSQL connection string | +| `ConnectionStrings__Redis` | Redis connection string | +| `Jwt__Key` | JWT signing key | +| `Jwt__Issuer` | JWT token issuer | +| `Cors__AllowedOrigins` | Comma-separated allowed origins | +| `ASPNETCORE_ENVIRONMENT` | Runtime environment (Production, Staging) | + +## Building the Image + +To build the Docker image manually: + +```bash +docker build -f src/Playground/FSH.Starter.Api/Dockerfile -t fsh-api . +``` + +Run the image standalone: + +```bash +docker run -p 8080:8080 --env-file .env fsh-api +``` + + diff --git a/docs/src/content/docs/domain-events.mdx b/docs/src/content/docs/domain-events.mdx new file mode 100644 index 0000000000..3b1ffa393a --- /dev/null +++ b/docs/src/content/docs/domain-events.mdx @@ -0,0 +1,207 @@ +--- +title: "Domain Events" +description: "How domain events and integration events work in the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +Domain events are the backbone of reactive, loosely-coupled module design in fullstackhero. They represent **things that have already happened** within your domain - immutable records of facts that other parts of the system can react to. + +## What are Domain Events? + +A domain event captures a meaningful state change in your application. When a user registers, an order is placed, or a tenant is activated, the responsible entity records an event describing what happened. Other components - within the same module or across modules - can subscribe to these events and react accordingly, without the originating code needing to know about them. + +This pattern keeps your modules decoupled: the entity that raises the event has no knowledge of who listens or what they do with it. + +## DomainEvent Base Record + +All domain events inherit from the `DomainEvent` abstract record: + +```csharp +namespace FSH.Framework.Core.Domain; + +public abstract record DomainEvent( + Guid EventId, + DateTimeOffset OccurredOnUtc, + string? CorrelationId = null, + string? TenantId = null +) : IDomainEvent +{ + public static T Create(Func factory) + where T : DomainEvent + { + ArgumentNullException.ThrowIfNull(factory); + return factory(Guid.NewGuid(), DateTimeOffset.UtcNow); + } +} +``` + +Every domain event carries four pieces of metadata: + +- **EventId** - a unique identifier for this specific event occurrence +- **OccurredOnUtc** - the exact timestamp when the event was created +- **CorrelationId** - optional tracing identifier to correlate related operations +- **TenantId** - optional tenant context for multitenant scenarios + +The `Create` factory method generates a new `EventId` and captures the current UTC timestamp automatically, so you never need to supply these values manually. + + + +## Raising Domain Events + +Entities raise domain events through the `BaseEntity` base class, which implements the `IHasDomainEvents` interface: + +```csharp +public interface IHasDomainEvents +{ + IReadOnlyCollection DomainEvents { get; } + void ClearDomainEvents(); +} +``` + +```csharp +public abstract class BaseEntity : IEntity, IHasDomainEvents +{ + private readonly List _domainEvents = []; + public TId Id { get; protected set; } = default!; + public IReadOnlyCollection DomainEvents => _domainEvents; + protected void AddDomainEvent(IDomainEvent @event) => _domainEvents.Add(@event); + public void ClearDomainEvents() => _domainEvents.Clear(); +} +``` + +The pattern is straightforward: an entity performs a state change, then records a domain event describing what happened. The event is queued internally and dispatched later, after the database transaction succeeds. + +```csharp +public void Activate() +{ + IsActive = true; + AddDomainEvent(DomainEvent.Create( + (id, ts) => new UserActivatedEvent(id, ts, UserId))); +} +``` + +In this example, the `Activate()` method changes the entity state and then raises a `UserActivatedEvent`. The event is not published immediately - it is held in the entity's internal list until the persistence layer flushes it. + +## Automatic Dispatch + +Domain events are dispatched automatically by the `DomainEventsInterceptor`, an EF Core `SaveChangesInterceptor` that hooks into the persistence pipeline: + +```csharp +public sealed class DomainEventsInterceptor : SaveChangesInterceptor +{ + private readonly IPublisher _publisher; + + public override async ValueTask SavedChangesAsync(...) + { + var domainEvents = context.ChangeTracker + .Entries() + .SelectMany(e => { + var pending = e.Entity.DomainEvents.ToArray(); + e.Entity.ClearDomainEvents(); + return pending; + }).ToArray(); + + foreach (var domainEvent in domainEvents) + await _publisher.Publish(domainEvent, cancellationToken).ConfigureAwait(false); + } +} +``` + +After `SaveChangesAsync` completes successfully, the interceptor: + +1. Scans all tracked entities that implement `IHasDomainEvents` +2. Collects their pending domain events into an array +3. Clears the events from each entity so they are not published again +4. Publishes each event through Mediator's `IPublisher` + +This design guarantees that **domain events are only published after the database transaction succeeds**. If `SaveChanges` fails or throws an exception, no events are dispatched. + +## Domain Event Lifecycle + + + +### Entity raises the event + +An entity method performs a state change and calls `AddDomainEvent()` to queue a domain event. + +### SaveChangesAsync is called + +The command handler or service calls `DbContext.SaveChangesAsync()` to persist entity changes. + +### EF Core saves to the database + +Entity Framework Core writes all pending entity changes to PostgreSQL within a transaction. + +### SavedChangesAsync interceptor fires + +After the database write succeeds, the `DomainEventsInterceptor.SavedChangesAsync()` hook is triggered. + +### Events are collected and cleared + +The interceptor iterates all tracked entities, collects their pending domain events into an array, and clears the event lists. + +### Events are published via Mediator + +Each collected event is published through `IPublisher.Publish()`, which routes it to all registered handlers. + +### Handlers process the events + +Registered `INotificationHandler` implementations receive and process each domain event - sending emails, updating read models, triggering side effects, or raising integration events. + + + +## Integration Events + +While domain events are scoped to a single module and dispatched in-process, **integration events** are designed for cross-module communication. They represent facts that need to travel beyond the boundary of the module that produced them. + +Integration events use two key interfaces: + +- **`IIntegrationEvent`** - marker interface for events that cross module boundaries +- **`IIntegrationEventHandler`** - interface for handling integration events in consuming modules + +```csharp +public record UserRegisteredIntegrationEvent( + Guid UserId, + string Email, + string TenantId) : IIntegrationEvent; +``` + +A common pattern is for a domain event handler within a module to publish an integration event, bridging the internal domain model with the external contract: + +```csharp +public sealed class UserRegisteredEventHandler + : INotificationHandler +{ + public async ValueTask Handle( + UserRegisteredEvent notification, + CancellationToken cancellationToken) + { + // Publish integration event for other modules + await _eventBus.PublishAsync(new UserRegisteredIntegrationEvent( + notification.UserId, + notification.Email, + notification.TenantId), cancellationToken).ConfigureAwait(false); + } +} +``` + +Integration events use the **Outbox pattern** for reliability, ensuring events are not lost even if the event bus is temporarily unavailable. + +## Domain vs Integration Events + +| Aspect | Domain Events | Integration Events | +|---|---|---| +| **Scope** | Within a module | Across modules | +| **Transport** | In-process (Mediator) | Event bus (InMemory / RabbitMQ) | +| **Reliability** | Same transaction | Outbox pattern | +| **Interface** | `IDomainEvent` | `IIntegrationEvent` | +| **Handler** | `INotificationHandler` | `IIntegrationEventHandler` | +| **Coupling** | Loose (within module) | Completely decoupled | + + diff --git a/docs/src/content/docs/dotnet-aspire.mdx b/docs/src/content/docs/dotnet-aspire.mdx new file mode 100644 index 0000000000..a88d881c99 --- /dev/null +++ b/docs/src/content/docs/dotnet-aspire.mdx @@ -0,0 +1,81 @@ +--- +title: ".NET Aspire" +description: "Local development orchestration with .NET Aspire." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +## Overview + +.NET Aspire orchestrates PostgreSQL, Redis, and all application services with a single command. It provides a built-in dashboard for monitoring logs, traces, and metrics across every service in the stack. This is the recommended approach for local development. + +## AppHost Configuration + +The `FSH.Starter.AppHost` project orchestrates the entire development environment. The `AppHost.cs` file defines: + +- **PostgreSQL** container with a persistent volume (`fsh-postgres-data`) and a database named `fsh` +- **Redis** container with a persistent volume (`fsh-redis-data`) +- **FSH.Starter.Api** with environment variables for OTLP, database, and Redis connections + +```csharp +var postgres = builder.AddPostgres("postgres") + .WithDataVolume("fsh-postgres-data") + .AddDatabase("fsh"); + +var redis = builder.AddRedis("redis") + .WithDataVolume("fsh-redis-data"); + +var api = builder.AddProject("api") + .WithReference(postgres) + .WithReference(redis) + .WaitFor(postgres) + .WaitFor(redis); +``` + +## Running + + + 1. **Install the Aspire workload** if you have not already: + ```bash + dotnet workload install aspire + ``` + + 2. **Run the AppHost** from the repository root: + ```bash + dotnet run --project src/Playground/FSH.Starter.AppHost + ``` + + 3. **Open the Aspire dashboard** at the URL shown in the terminal output. This is typically `https://localhost:17225` or a similar port. + + 4. **Explore traces, logs, and metrics** for all running services directly from the dashboard. + + +## Service Discovery + +Aspire provides automatic service discovery between projects. When the API references PostgreSQL or Redis, connection strings are injected automatically. The `WaitFor` method ensures dependencies are healthy before the dependent service starts, eliminating startup race conditions. + +## Environment Variables + +Aspire configures these automatically, but you can override them if needed: + +| Variable | Default Value | Description | +|----------|--------------|-------------| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | OpenTelemetry collector endpoint | +| `ConnectionStrings__fsh` | Auto-generated | PostgreSQL connection string | +| `ConnectionStrings__redis` | Auto-generated | Redis connection string with SSL | + +## Aspire Dashboard + +The Aspire dashboard provides a unified view of your entire development stack: + +- **Structured Logs** - Serilog output from all services, searchable and filterable +- **Distributed Traces** - OpenTelemetry traces showing request flows across services +- **Metrics** - Runtime and application metrics with real-time charts +- **Resource Health** - Container and project status with restart controls + +The dashboard updates in real time and requires no additional configuration. All services are instrumented automatically through the Aspire integration. + + diff --git a/docs/src/content/docs/eventing.mdx b/docs/src/content/docs/eventing.mdx new file mode 100644 index 0000000000..c6cf83bddd --- /dev/null +++ b/docs/src/content/docs/eventing.mdx @@ -0,0 +1,372 @@ +--- +title: "Eventing" +description: "Integration events, event bus, and the outbox/inbox pattern for reliable messaging." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +fullstackhero provides a complete **integration eventing infrastructure** for cross-module communication. The eventing system is split into two projects - **Eventing.Abstractions** (contracts and interfaces) and **Eventing** (implementations) - so that modules only depend on the abstractions while the concrete transport is configured at the host level. + +Modules publish integration events through `IEventBus`, and handler implementations receive them via `IIntegrationEventHandler`. The framework includes two event bus implementations (InMemory and RabbitMQ), a transactional outbox for reliable publishing, and an inbox for idempotent consumption. + +## Abstractions + +The `Eventing.Abstractions` project sits at Layer 0 of the dependency hierarchy - it has zero project references and defines the contracts that all modules program against. + +### IIntegrationEvent + +The base contract for all integration events that cross module boundaries: + +```csharp +public interface IIntegrationEvent +{ + Guid Id { get; } + DateTime OccurredOnUtc { get; } + string? TenantId { get; } + string CorrelationId { get; } + string Source { get; } +} +``` + +Every integration event carries five pieces of metadata: + +- **Id** - unique identifier for this specific event occurrence +- **OccurredOnUtc** - timestamp when the event was created +- **TenantId** - tenant context for multitenant scenarios (null for global events) +- **CorrelationId** - tracing identifier to correlate related operations across modules +- **Source** - logical origin of the event (module or service name) + +### IEventBus + +The abstraction over the event transport. Modules publish events through this interface without knowing whether the underlying transport is in-memory or RabbitMQ: + +```csharp +public interface IEventBus +{ + Task PublishAsync(IIntegrationEvent @event, CancellationToken ct = default); + Task PublishAsync(IEnumerable events, CancellationToken ct = default); +} +``` + +The single-event overload is a convenience - internally it delegates to the batch overload. Both methods are asynchronous and accept a cancellation token. + +### `IIntegrationEventHandler` + +Handlers implement this interface to react to a specific integration event type: + +```csharp +public interface IIntegrationEventHandler + where TEvent : IIntegrationEvent +{ + Task HandleAsync(TEvent @event, CancellationToken ct = default); +} +``` + +A single event type can have multiple handlers. The framework resolves all registered handlers from DI and invokes them sequentially. + +### IEventSerializer + +Serializes and deserializes integration events for transport and outbox storage: + +```csharp +public interface IEventSerializer +{ + string Serialize(IIntegrationEvent @event); + IIntegrationEvent? Deserialize(string payload, string eventTypeName); +} +``` + +The serializer is used by both the RabbitMQ event bus (for message payloads) and the outbox dispatcher (for persisted event data). + +## Event Bus Implementations + +The framework ships with two `IEventBus` implementations. The provider is selected at startup based on the `EventingOptions.Provider` configuration value. + + + + +### InMemoryEventBus + +The default event bus for single-process deployments. It resolves `IIntegrationEventHandler` implementations from the DI container and invokes them directly in-process. + +**Key characteristics:** + +- **DI-based handler resolution** - creates a new scope per event and resolves all registered handlers for the event type +- **Inbox integration** - if an `IInboxStore` is registered, the bus checks whether each event has already been processed by a given handler before invoking it, providing idempotent delivery +- **Sequential execution** - handlers for the same event are invoked one at a time in registration order +- **Structured logging** - uses source-generated `LoggerMessage` methods for high-performance logging of publish, skip, and no-handler scenarios + +```csharp +// Registration happens automatically based on configuration +// In appsettings.json: +{ + "EventingOptions": { + "Provider": "InMemory" + } +} +``` + +The InMemory bus is ideal for development and for production systems where all modules run in a single process. Since events never leave the process boundary, there is no serialization overhead and no external infrastructure dependency. + + + + +### RabbitMqEventBus + +The distributed event bus for multi-process and multi-service deployments. It publishes integration events to a durable **topic exchange** on RabbitMQ. + +**Key characteristics:** + +- **Topic exchange** - events are published with the event type's full name as the routing key, enabling consumers to subscribe to specific event types using wildcard bindings +- **Persistent delivery** - messages use `DeliveryModes.Persistent` so they survive broker restarts +- **Connection pooling** - a single connection and channel are reused across publishes, with thread-safe lazy initialization via `SemaphoreSlim` +- **Automatic reconnection** - if a publish fails, the bus disposes the current connection, creates a new one, and retries +- **Retry logic** - configurable retry count and delay for transient publish failures +- **SSL support** - optional TLS encryption for the broker connection +- **Rich message metadata** - each message carries the event type, tenant ID, source, correlation ID, and timestamp in headers and properties + +```json +{ + "EventingOptions": { + "Provider": "RabbitMQ", + "RabbitMQ": { + "Host": "localhost", + "Port": 5672, + "UserName": "guest", + "Password": "guest", + "VirtualHost": "/", + "ExchangeName": "fsh.events", + "QueuePrefix": "fsh", + "UseSsl": false, + "PublishRetryCount": 3, + "PublishRetryDelayMs": 1000 + } + } +} +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `Host` | RabbitMQ host name | `localhost` | +| `Port` | RabbitMQ port | `5672` | +| `UserName` | Authentication username | `guest` | +| `Password` | Authentication password | `guest` | +| `VirtualHost` | RabbitMQ virtual host | `/` | +| `ExchangeName` | Name of the topic exchange | `fsh.events` | +| `QueuePrefix` | Prefix for consumer queue names | `fsh` | +| `UseSsl` | Enable SSL/TLS for the connection | `false` | +| `PublishRetryCount` | Number of retry attempts on publish failure | `3` | +| `PublishRetryDelayMs` | Delay between retries in milliseconds | `1000` | + + + + + + +## Outbox Pattern + +The outbox pattern ensures that integration events are never lost, even if the event bus is temporarily unavailable. Instead of publishing events directly, the framework writes them to an `OutboxMessages` table within the same database transaction as your domain changes. A background dispatcher then reads pending messages and publishes them through the event bus. + +``` +Entity Change + OutboxMessage → Same DB Transaction + ↓ + OutboxDispatcher (background) + ↓ + IEventBus.PublishAsync() +``` + +The `OutboxMessage` entity tracks each event's lifecycle: + +- **Id** - the integration event's unique identifier +- **Type** - assembly-qualified type name for deserialization +- **Payload** - JSON-serialized event data +- **TenantId / CorrelationId** - metadata carried through from the event +- **ProcessedOnUtc** - set when the dispatcher successfully publishes the event +- **RetryCount / LastError / IsDead** - retry tracking and dead-letter support + +The `OutboxDispatcher` processes pending messages in configurable batches. Messages that fail repeatedly are marked as dead-lettered after exceeding the maximum retry count. + + + +## Inbox Pattern + +The inbox pattern provides idempotent event consumption. Before a handler processes an integration event, the framework checks the `InboxMessages` table to see if that specific event has already been handled by that specific handler. If it has, the handler is skipped. + +``` +Event arrives → Check InboxMessages (EventId + HandlerName) + ↓ + Already processed? → Skip + ↓ (No) + Execute handler + ↓ + Mark as processed in InboxMessages +``` + +The `InboxMessage` entity uses a composite key of `Id` (event ID) and `HandlerName`, so the same event can be processed by multiple different handlers while still preventing duplicate processing by any single handler. + + + +## Serialization + +The `JsonEventSerializer` is the default `IEventSerializer` implementation, using `System.Text.Json` with camelCase naming: + +```csharp +public sealed class JsonEventSerializer : IEventSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public string Serialize(IIntegrationEvent @event) + { + return JsonSerializer.Serialize(@event, @event.GetType(), Options); + } + + public IIntegrationEvent? Deserialize(string payload, string eventTypeName) + { + var type = Type.GetType(eventTypeName, throwOnError: false); + if (type is null) return null; + + var result = JsonSerializer.Deserialize(payload, type, Options); + return result as IIntegrationEvent; + } +} +``` + +The serializer preserves the concrete event type during serialization (not just the `IIntegrationEvent` interface), so all custom properties are included in the JSON payload. Deserialization uses `Type.GetType` with the assembly-qualified name stored in the outbox or message headers, returning `null` if the type cannot be resolved. + +## Creating Integration Events + +Define integration events as sealed records that implement `IIntegrationEvent`. Place them in the module's **Contracts** project so other modules can reference them without taking a dependency on the runtime module: + +```csharp +// In Modules.Identity.Contracts/v1/Users/ +public sealed record UserRegisteredIntegrationEvent( + Guid Id, + DateTime OccurredOnUtc, + string? TenantId, + string CorrelationId, + string Source, + string UserId, + string Email, + string FirstName, + string LastName +) : IIntegrationEvent; +``` + +The first five properties satisfy the `IIntegrationEvent` contract. Add any additional domain-specific data as extra properties - these are serialized into the event payload and available to all handlers. + +A common pattern is to publish integration events from within a domain event handler, bridging internal domain events with the external contract: + +```csharp +public sealed class UserRegisteredDomainHandler : INotificationHandler +{ + private readonly IEventBus _eventBus; + + public async ValueTask Handle(UserRegisteredEvent notification, CancellationToken ct) + { + await _eventBus.PublishAsync(new UserRegisteredIntegrationEvent( + Guid.NewGuid(), + DateTime.UtcNow, + notification.TenantId, + notification.CorrelationId ?? Guid.NewGuid().ToString(), + "Identity", + notification.UserId, + notification.Email, + notification.FirstName, + notification.LastName + ), ct).ConfigureAwait(false); + } +} +``` + +## Handling Events + +Create a handler class that implements `IIntegrationEventHandler` for the event type you want to react to. Handlers are automatically discovered and registered when you call `AddIntegrationEventHandlers` with the module's assembly: + +```csharp +public sealed class UserRegisteredEmailHandler + : IIntegrationEventHandler +{ + private readonly IMailService _mailService; + + public UserRegisteredEmailHandler(IMailService mailService) + { + _mailService = mailService; + } + + public async Task HandleAsync( + UserRegisteredIntegrationEvent @event, + CancellationToken ct) + { + await _mailService.SendAsync(new MailRequest( + To: @event.Email, + Subject: "Welcome!", + Body: $"Hello {@event.FirstName}, your account has been created." + ), ct).ConfigureAwait(false); + } +} +``` + +Multiple handlers can subscribe to the same event type. Each handler runs independently - if one handler fails, the others still execute (in the InMemory bus, an exception from one handler will propagate after all handlers have been attempted). + +## Configuration + +The eventing system is configured through `EventingOptions`: + +```json +{ + "EventingOptions": { + "Provider": "InMemory", + "OutboxBatchSize": 100, + "OutboxMaxRetries": 5, + "EnableInbox": true, + "OutboxDispatchIntervalSeconds": 10, + "UseHostedServiceDispatcher": true + } +} +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `Provider` | Event bus implementation (`InMemory` or `RabbitMQ`) | `InMemory` | +| `OutboxBatchSize` | Number of outbox messages processed per dispatch cycle | `100` | +| `OutboxMaxRetries` | Maximum retries before a message is dead-lettered | `5` | +| `EnableInbox` | Enable inbox-based idempotent handler execution | `true` | +| `OutboxDispatchIntervalSeconds` | Background dispatcher polling interval in seconds | `10` | +| `UseHostedServiceDispatcher` | Use the built-in hosted service for outbox dispatching (set to `false` if using Hangfire) | `true` | + +## Registration + +The eventing services are registered through extension methods in `ServiceCollectionExtensions`: + +```csharp +// Register core eventing (serializer, event bus, options) +services.AddEventingCore(configuration); + +// Register EF Core-based outbox and inbox stores for a specific DbContext +services.AddEventingForDbContext(); + +// Register integration event handlers from module assemblies +services.AddIntegrationEventHandlers(typeof(IdentityModule).Assembly); +``` + +`AddEventingCore` reads the `EventingOptions` section from configuration to determine the provider. It registers the `JsonEventSerializer` as a singleton, selects the appropriate `IEventBus` implementation, and optionally registers the `OutboxDispatcherHostedService` background service. + +`AddEventingForDbContext` registers the EF Core-backed `IOutboxStore` and `IInboxStore` implementations scoped to the specified `DbContext`, along with the `OutboxDispatcher`. + +`AddIntegrationEventHandlers` scans the provided assemblies for classes implementing `IIntegrationEventHandler` and registers them as scoped services in the DI container. + + diff --git a/docs/src/content/docs/exception-handling.mdx b/docs/src/content/docs/exception-handling.mdx new file mode 100644 index 0000000000..d64816e0d3 --- /dev/null +++ b/docs/src/content/docs/exception-handling.mdx @@ -0,0 +1,115 @@ +--- +title: "Exception Handling" +description: "Global exception handling with ProblemDetails (RFC 9457)." +--- + +import Aside from '../../components/Aside.astro'; + +## Overview + +All exceptions in the fullstackhero .NET Starter Kit are caught by the `GlobalExceptionHandler` and converted to **ProblemDetails** responses following [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457). This provides a consistent, machine-readable error format across all API endpoints. + +## Exception Mapping + +The following exception types are mapped to their corresponding HTTP status codes: + +| Exception | Status Code | ProblemDetails Type | +|-----------|-------------|---------------------| +| `CustomException` | Varies (default 500) | Custom | +| `ValidationException` | 400 | Validation | +| `NotFoundException` | 404 | NotFound | +| `UnauthorizedException` | 401 | Unauthorized | +| `ForbiddenException` | 403 | Forbidden | +| Unhandled exceptions | 500 | InternalServerError | + +## ProblemDetails Response Format + +Every error response follows a consistent JSON structure: + +```json +{ + "type": "https://httpstatuses.com/400", + "title": "Bad Request", + "status": 400, + "detail": "Validation failed", + "errors": ["First name is required.", "Email is required."] +} +``` + +The `errors` array is included when there are multiple validation or business rule errors. For single-error responses, the `detail` field contains the error message. + +## Custom Exceptions + +The `CustomException` class is the base for all domain-specific exceptions. It accepts an `HttpStatusCode` and an optional list of error messages: + +```csharp +public class CustomException : Exception +{ + public HttpStatusCode StatusCode { get; } + public List? ErrorMessages { get; } + + public CustomException(string message, + HttpStatusCode statusCode = HttpStatusCode.InternalServerError, + List? errors = null) + : base(message) + { + StatusCode = statusCode; + ErrorMessages = errors; + } +} +``` + +The framework provides several built-in exception types that inherit from `CustomException`: + +- **NotFoundException** - Returns 404. Use when an entity or resource cannot be found. +- **ForbiddenException** - Returns 403. Use when the user lacks permission. +- **UnauthorizedException** - Returns 401. Use when authentication is required but missing or invalid. + +## Using Exceptions in Handlers + +Throw framework exception types in your command/query handlers. The `GlobalExceptionHandler` will catch them and return the appropriate ProblemDetails response: + +```csharp +public async ValueTask Handle( + GetUserQuery query, CancellationToken cancellationToken) +{ + var user = await repository.GetByIdAsync(query.UserId, cancellationToken) + .ConfigureAwait(false); + + if (user is null) + { + throw new NotFoundException("User not found"); + } + + return user.Adapt(); +} +``` + +```csharp +// Forbidden access +throw new ForbiddenException("Insufficient permissions"); + +// Custom exception with multiple errors +throw new CustomException( + "Operation failed", + HttpStatusCode.BadRequest, + new List { "Error 1", "Error 2" }); +``` + + + + + +## Validation Exceptions + +FluentValidation failures are automatically caught by the validation pipeline behavior in the mediator. When validation fails, a `ValidationException` is thrown with all validation errors, resulting in a 400 response with the errors array populated. + +You do not need to manually validate commands - the pipeline handles it before your handler is invoked. diff --git a/docs/src/content/docs/feature-flags.mdx b/docs/src/content/docs/feature-flags.mdx new file mode 100644 index 0000000000..9165512067 --- /dev/null +++ b/docs/src/content/docs/feature-flags.mdx @@ -0,0 +1,199 @@ +--- +title: "Feature Flags" +description: "Per-tenant feature management with Microsoft.FeatureManagement and endpoint gating." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +fullstackhero integrates **Microsoft.FeatureManagement** to provide feature flags with per-tenant targeting. Feature flags let you toggle functionality without redeploying code - useful for gradual rollouts, A/B testing, and tenant-specific features in multitenant applications. + +## Registration + +Feature flags are registered through the `AddHeroFeatureFlags()` extension method. Enable it via `FshPlatformOptions` when configuring the platform: + +```csharp +builder.AddHeroPlatform(options => +{ + options.EnableFeatureFlags = true; +}); +``` + +This calls `AddHeroFeatureFlags()` internally, which registers Microsoft Feature Management and the custom `TenantFeatureFilter`: + +```csharp +public static IServiceCollection AddHeroFeatureFlags( + this IServiceCollection services, IConfiguration configuration) +{ + services.AddFeatureManagement(configuration.GetSection("FeatureManagement")) + .AddFeatureFilter(); + + return services; +} +``` + + + +## Configuration + +Feature flags are defined in the `FeatureManagement` section of `appsettings.json`. Each flag can be a simple boolean or use filters for conditional evaluation: + +```json +{ + "FeatureManagement": { + "BetaDashboard": true, + "AdvancedReporting": false, + "NewCheckoutFlow": { + "EnabledFor": [ + { + "Name": "Tenant", + "Parameters": { + "AllowedTenants": ["tenant-alpha", "tenant-beta"] + } + } + ] + } + } +} +``` + +In this example: +- **BetaDashboard** is enabled globally for all tenants +- **AdvancedReporting** is disabled globally +- **NewCheckoutFlow** is enabled only for `tenant-alpha` and `tenant-beta` + +## TenantFeatureFilter + +The `TenantFeatureFilter` is a custom `IFeatureFilter` that evaluates feature flags based on the current tenant. It is registered with the filter alias `"Tenant"` so you reference it by that name in configuration. + +The filter resolves the current tenant ID from: +1. The `IMultiTenantContextAccessor` (Finbuckle multitenancy context) +2. Falling back to the tenant identifier header if the context is not yet resolved + +If no tenant ID is available, the filter returns `false` (feature disabled). Otherwise, it checks whether the tenant ID appears in the `AllowedTenants` array configured for that feature flag. The comparison is case-insensitive. + +```csharp +[FilterAlias("Tenant")] +public sealed class TenantFeatureFilter( + IHttpContextAccessor httpContextAccessor, + IMultiTenantContextAccessor? tenantContextAccessor = null) + : IFeatureFilter +{ + public Task EvaluateAsync(FeatureFilterEvaluationContext context) + { + var tenantId = tenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; + if (string.IsNullOrWhiteSpace(tenantId)) + { + tenantId = httpContextAccessor.HttpContext?.Request + .Headers[MultitenancyConstants.Identifier].ToString(); + } + + if (string.IsNullOrWhiteSpace(tenantId)) + return Task.FromResult(false); + + var allowedTenants = context.Parameters + .GetSection("AllowedTenants").Get() ?? []; + return Task.FromResult( + allowedTenants.Contains(tenantId, StringComparer.OrdinalIgnoreCase)); + } +} +``` + +## Gating Endpoints with .RequireFeature() + +The `FeatureGateEndpointFilter` is an `IEndpointFilter` that checks whether a feature flag is enabled before allowing the request to proceed. If the flag is disabled, it returns **404 Not Found** - the endpoint behaves as if it does not exist. + +Apply it to any endpoint using the `.RequireFeature()` extension method: + +```csharp +public static class GetBetaDashboardEndpoint +{ + internal static RouteHandlerBuilder MapGetBetaDashboardEndpoint( + this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/dashboard/beta", (IMediator mediator, CancellationToken ct) => + mediator.Send(new GetBetaDashboardQuery(), ct)) + .WithName("GetBetaDashboard") + .WithSummary("Beta dashboard") + .RequireFeature("BetaDashboard"); + } +} +``` + +When a request arrives at this endpoint: +1. The `FeatureGateEndpointFilter` resolves `IFeatureManager` from DI +2. It calls `IsEnabledAsync("BetaDashboard")` +3. If the feature is enabled, the request proceeds to the handler +4. If the feature is disabled, the filter short-circuits with a 404 response + + + +## Checking Flags in Handlers + +You can also inject `IFeatureManager` directly into command or query handlers for more granular conditional logic: + +```csharp +public sealed class GetDashboardQueryHandler : IQueryHandler +{ + private readonly IFeatureManager _featureManager; + private readonly AppDbContext _dbContext; + + public GetDashboardQueryHandler(IFeatureManager featureManager, AppDbContext dbContext) + { + _featureManager = featureManager; + _dbContext = dbContext; + } + + public async ValueTask Handle(GetDashboardQuery query, CancellationToken ct) + { + var dashboard = await LoadBaseDashboard(ct).ConfigureAwait(false); + + if (await _featureManager.IsEnabledAsync("AdvancedReporting").ConfigureAwait(false)) + { + dashboard.AdvancedMetrics = await LoadAdvancedMetrics(ct).ConfigureAwait(false); + } + + return dashboard; + } +} +``` + +## Per-Tenant Feature Rollout Example + +A common pattern is to roll out a feature to specific tenants before enabling it globally: + +```json +{ + "FeatureManagement": { + "NewCheckoutFlow": { + "EnabledFor": [ + { + "Name": "Tenant", + "Parameters": { + "AllowedTenants": ["tenant-pilot"] + } + } + ] + } + } +} +``` + +As confidence grows, add more tenants to the `AllowedTenants` array. When the feature is ready for general availability, replace the filter with a simple `true`: + +```json +{ + "FeatureManagement": { + "NewCheckoutFlow": true + } +} +``` + + diff --git a/docs/src/content/docs/file-storage.mdx b/docs/src/content/docs/file-storage.mdx new file mode 100644 index 0000000000..a5e84798b9 --- /dev/null +++ b/docs/src/content/docs/file-storage.mdx @@ -0,0 +1,244 @@ +--- +title: "Storage" +description: "File storage abstraction with local filesystem and AWS S3 support." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +The Storage building block provides a unified file storage abstraction with two provider implementations -- local filesystem and AWS S3. Both providers share the same `IStorageService` interface, so your application code works identically regardless of where files are stored. + +## IStorageService + +The `IStorageService` interface defines four operations for file management: + +```csharp +public interface IStorageService +{ + Task UploadAsync( + FileUploadRequest request, + FileType fileType, + CancellationToken cancellationToken = default) where T : class; + + Task DownloadAsync( + string path, + CancellationToken cancellationToken = default); + + Task ExistsAsync( + string path, + CancellationToken cancellationToken = default); + + Task RemoveAsync( + string path, + CancellationToken cancellationToken = default); +} +``` + +| Method | Returns | Description | +|--------|---------|-------------| +| `UploadAsync` | `string` (path/URL) | Uploads a file, organizing it under a folder derived from the type parameter `T` | +| `DownloadAsync` | `FileDownloadResponse?` | Downloads a file by path, returning the stream and metadata. Returns `null` if not found | +| `ExistsAsync` | `bool` | Checks whether a file exists at the given path | +| `RemoveAsync` | `void` | Deletes a file at the given path | + +The generic type parameter `T` on `UploadAsync` is used to derive the storage folder. For example, `UploadAsync(...)` stores files under `uploads/userprofile/`. + +## FileUploadRequest + +Defined in the Shared building block, `FileUploadRequest` carries the upload payload: + +```csharp +public class FileUploadRequest +{ + public string FileName { get; set; } = default!; + public string ContentType { get; set; } = default!; + public List Data { get; set; } = []; +} +``` + +## FileDownloadResponse + +Returned by `DownloadAsync`, this class wraps the file stream and metadata: + +```csharp +public sealed class FileDownloadResponse +{ + public required Stream Stream { get; init; } + public required string ContentType { get; init; } + public required string FileName { get; init; } + public long? ContentLength { get; init; } +} +``` + +## FileType enum and validation + +The `FileType` enum controls which file extensions and size limits are enforced during upload: + +```csharp +public enum FileType +{ + Image, + Document, + Pdf +} +``` + +Each file type has associated validation rules: + +| FileType | Allowed extensions | Max size | +|----------|--------------------|----------| +| `Image` | `.jpg`, `.jpeg`, `.png`, `.ico` | 5 MB | +| `Pdf` | `.pdf` | 10 MB | + +Both providers validate the file extension and size before uploading, throwing `InvalidOperationException` if the file does not meet the rules. + +## Providers + + + + +The `LocalStorageService` stores files on the local filesystem under the application's `wwwroot` directory. + +**Storage layout:** +``` +wwwroot/ + uploads/ + userprofile/ + a1b2c3d4_avatar.png + product/ + e5f6g7h8_photo.jpg +``` + +**Key behaviors:** + +- Files are stored under `wwwroot/uploads/{entity_folder}/{guid}_{sanitized_filename}` +- The entity folder name is derived from the generic type parameter, lowercased and sanitized +- File names are prefixed with a GUID to prevent collisions +- Returns a relative URL path (e.g., `uploads/userprofile/a1b2c3d4_avatar.png`) +- Content types are resolved automatically using `FileExtensionContentTypeProvider` + +```csharp +// Upload a profile image +var request = new FileUploadRequest +{ + FileName = "avatar.png", + ContentType = "image/png", + Data = fileBytes.ToList() +}; + +string relativePath = await storageService.UploadAsync( + request, FileType.Image, cancellationToken); +// Returns: "uploads/userprofile/a1b2c3d4e5f6_avatar.png" +``` + + + + +The `S3StorageService` stores files in an AWS S3 bucket using the AWS SDK. + +**Key behaviors:** + +- Files are stored under `{prefix}/uploads/{entity_folder}/{guid}_{sanitized_filename}` +- The optional `Prefix` setting allows organizing files under a common key prefix +- Returns a public URL if `PublicBaseUrl` is set, otherwise constructs an S3 URL from the bucket and region +- Relies on bucket policy for public access (does not set ACLs, compatible with ACL-disabled buckets) +- Download, exists, and remove operations accept either S3 keys or full URLs (URLs are automatically normalized) + +```csharp +// Upload a product image to S3 +var request = new FileUploadRequest +{ + FileName = "product-photo.jpg", + ContentType = "image/jpeg", + Data = fileBytes.ToList() +}; + +string url = await storageService.UploadAsync( + request, FileType.Image, cancellationToken); +// Returns: "https://my-bucket.s3.us-east-1.amazonaws.com/uploads/product/a1b2c3d4_product_photo.jpg" +``` + + + + +## S3StorageOptions + +When using the S3 provider, configure these options: + +```csharp +public sealed class S3StorageOptions +{ + public string? Bucket { get; set; } + public string? Region { get; set; } + public string? Prefix { get; set; } + public bool PublicRead { get; set; } = true; + public string? PublicBaseUrl { get; set; } +} +``` + +| Property | Description | Default | +|----------|-------------|---------| +| `Bucket` | S3 bucket name (required) | -- | +| `Region` | AWS region (e.g., `us-east-1`) | -- | +| `Prefix` | Key prefix for all uploaded files | -- | +| `PublicRead` | Whether to construct public S3 URLs | `true` | +| `PublicBaseUrl` | Custom base URL (e.g., CloudFront distribution) | -- | + + + +## Configuration + + + + +No additional configuration is needed for local storage. Files are stored under `wwwroot/uploads/` by default. + +```json +{ + "Storage": { + "Provider": "local" + } +} +``` + +Or simply omit the `Storage` section entirely -- local is the default provider. + + + + +```json +{ + "Storage": { + "Provider": "s3", + "S3": { + "Bucket": "my-app-uploads", + "Region": "us-east-1", + "Prefix": "production", + "PublicRead": true, + "PublicBaseUrl": "https://cdn.yourapp.com" + } + } +} +``` + +AWS credentials are resolved by the AWS SDK's default credential chain (environment variables, IAM role, `~/.aws/credentials`, etc.). You do not configure credentials in the application settings. + + + + +## Registration + +Storage is registered via one of two extension methods: + +```csharp +// Auto-detect provider from configuration +services.AddHeroStorage(configuration); + +// Or explicitly use local storage +services.AddHeroLocalFileStorage(); +``` + +The `AddHeroStorage` method reads `Storage:Provider` from configuration. If set to `"s3"`, it configures the AWS S3 client and registers `S3StorageService`. Otherwise, it falls back to `LocalStorageService`. diff --git a/docs/src/content/docs/http-resilience.mdx b/docs/src/content/docs/http-resilience.mdx new file mode 100644 index 0000000000..c2353ce3fa --- /dev/null +++ b/docs/src/content/docs/http-resilience.mdx @@ -0,0 +1,152 @@ +--- +title: "HTTP Resilience" +description: "Adding retry, circuit breaker, and timeout policies to HTTP clients with Polly." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +fullstackhero provides a one-line extension method to add production-grade resilience to any `HttpClient` registration. Under the hood it uses the **Microsoft.Extensions.Http.Resilience** library (built on Polly) to apply a standard resilience pipeline - retry with exponential backoff, circuit breaker, and per-attempt and total request timeouts. + +## AddHeroResilience + +The `AddHeroResilience()` extension method is available on `IHttpClientBuilder`. Call it when registering an HTTP client in your module's `ConfigureServices`: + +```csharp +builder.Services.AddHttpClient("Webhooks") + .AddHeroResilience(builder.Configuration); +``` + +This wires up the standard resilience handler with settings read from the `HttpResilienceOptions` configuration section. If `Enabled` is `false`, the method returns immediately without adding any handlers - useful for disabling resilience in integration tests. + +## Resilience Pipeline + +The standard resilience handler applies three layers of protection, evaluated from outermost to innermost: + +### 1. Total Request Timeout + +A hard ceiling on the entire operation including all retry attempts. If the total timeout is reached, the request is cancelled regardless of retry state. Default: **30 seconds**. + +### 2. Retry with Exponential Backoff + +Failed requests are retried with jittered exponential backoff. The delay between retries grows exponentially from the configured median first retry delay. Transient HTTP errors (5xx responses, timeouts, `HttpRequestException`) trigger retries automatically. + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `MaxRetryAttempts` | Maximum number of retry attempts | `3` | +| `MedianFirstRetryDelay` | Median delay before the first retry | 1 second | + +### 3. Circuit Breaker + +Prevents cascading failures by short-circuiting requests when the downstream service is unhealthy. When the failure ratio exceeds the threshold within the sampling window, the circuit opens and all requests fail immediately for the configured break duration. + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `CircuitBreakerBreakDuration` | How long the circuit stays open | 5 seconds | +| `CircuitBreakerFailureRatio` | Failure ratio that trips the breaker | 0.5 (50%) | +| `CircuitBreakerMinimumThroughput` | Minimum requests before the breaker evaluates | 10 | + +### 4. Per-Attempt Timeout + +Each individual attempt (including the initial request and each retry) is capped at the attempt timeout. Default: **10 seconds**. + +## HttpResilienceOptions + +All resilience settings are configured through the `HttpResilienceOptions` class: + +```csharp +public sealed class HttpResilienceOptions +{ + public bool Enabled { get; set; } = true; + public int MaxRetryAttempts { get; set; } = 3; + public TimeSpan MedianFirstRetryDelay { get; set; } = TimeSpan.FromSeconds(1); + public TimeSpan TotalTimeout { get; set; } = TimeSpan.FromSeconds(30); + public TimeSpan AttemptTimeout { get; set; } = TimeSpan.FromSeconds(10); + public TimeSpan CircuitBreakerBreakDuration { get; set; } = TimeSpan.FromSeconds(5); + public double CircuitBreakerFailureRatio { get; set; } = 0.5; + public int CircuitBreakerMinimumThroughput { get; set; } = 10; +} +``` + +| Property | Description | Default | +|----------|-------------|---------| +| `Enabled` | Whether resilience handlers are applied | `true` | +| `MaxRetryAttempts` | Maximum retry attempts | `3` | +| `MedianFirstRetryDelay` | Median delay for the first retry (exponential backoff) | 1 second | +| `TotalTimeout` | Total timeout for the entire request including retries | 30 seconds | +| `AttemptTimeout` | Timeout for each individual attempt | 10 seconds | +| `CircuitBreakerBreakDuration` | Duration the circuit breaker stays open | 5 seconds | +| `CircuitBreakerFailureRatio` | Failure ratio that trips the circuit breaker | 0.5 | +| `CircuitBreakerMinimumThroughput` | Minimum throughput before the breaker evaluates | 10 | + +## Configuration + +Add the `HttpResilienceOptions` section to your `appsettings.json`: + +```json +{ + "HttpResilienceOptions": { + "Enabled": true, + "MaxRetryAttempts": 3, + "MedianFirstRetryDelay": "00:00:01", + "TotalTimeout": "00:00:30", + "AttemptTimeout": "00:00:10", + "CircuitBreakerBreakDuration": "00:00:05", + "CircuitBreakerFailureRatio": 0.5, + "CircuitBreakerMinimumThroughput": 10 + } +} +``` + + + +## Usage Example + +A typical module registers a named HTTP client with resilience in its `ConfigureServices` method: + +```csharp +public sealed class WebhooksModule : IModule +{ + public void ConfigureServices(IHostApplicationBuilder builder) + { + // Register a named HttpClient with resilience policies + builder.Services.AddHttpClient("Webhooks") + .AddHeroResilience(builder.Configuration); + + // Inject via IHttpClientFactory in your services + builder.Services.AddScoped(); + } +} +``` + +Then consume it via `IHttpClientFactory`: + +```csharp +public sealed class WebhookDeliveryService( + IHttpClientFactory httpClientFactory) : IWebhookDeliveryService +{ + public async Task DeliverAsync(string url, string payloadJson, CancellationToken ct) + { + var client = httpClientFactory.CreateClient("Webhooks"); + + using var content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); + var response = await client.PostAsync(new Uri(url), content, ct) + .ConfigureAwait(false); + + // If the downstream returns 5xx, the resilience pipeline retries automatically. + // If retries are exhausted, the exception propagates to the caller. + response.EnsureSuccessStatusCode(); + } +} +``` + +## OpenTelemetry Integration + +The resilience pipeline automatically integrates with OpenTelemetry when it is enabled in your application. Polly emits telemetry for each resilience event (retries, circuit breaker state changes, timeouts), and the `Microsoft.Extensions.Http.Resilience` library enriches HTTP client traces with resilience metadata. No additional configuration is required - if you have called `AddHeroOpenTelemetry()` via the platform builder, resilience telemetry is captured automatically. + + diff --git a/docs/src/content/docs/idempotency.mdx b/docs/src/content/docs/idempotency.mdx new file mode 100644 index 0000000000..012a5552ba --- /dev/null +++ b/docs/src/content/docs/idempotency.mdx @@ -0,0 +1,160 @@ +--- +title: "Idempotency" +description: "Preventing duplicate side effects with idempotency keys on POST/PUT/PATCH endpoints." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +fullstackhero provides an **idempotency filter** that prevents duplicate side effects from retried or duplicated HTTP requests. When a client sends an `Idempotency-Key` header, the framework caches the response and replays it for subsequent requests with the same key - ensuring that operations like payment processing, order creation, or webhook delivery are safe to retry. + +## How It Works + +The idempotency mechanism follows this flow: + +``` +Client sends POST with Idempotency-Key header + | + v + Is Idempotency-Key present? + | + No --+--> Execute handler normally (no idempotency) + | + Yes -+--> Check cache for key + | + Hit -+--> Return cached response + Idempotency-Replayed: true + | + Miss -+--> Execute handler + | Cache response with TTL + | Return response to client +``` + +1. The client includes an `Idempotency-Key` header with a unique value (typically a UUID) +2. The filter checks the distributed cache for a previously stored response under that key +3. **Cache hit** - the cached response is replayed with the `Idempotency-Replayed: true` header, and the handler is never invoked +4. **Cache miss** - the handler executes, and the response (status code, content type, body) is cached for the configured TTL +5. If no `Idempotency-Key` header is present, the request passes through without any idempotency behavior + +## IdempotencyOptions + +All settings are configured through the `IdempotencyOptions` class: + +```csharp +public sealed class IdempotencyOptions +{ + public string HeaderName { get; set; } = "Idempotency-Key"; + public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromHours(24); + public int MaxKeyLength { get; set; } = 128; +} +``` + +| Property | Description | Default | +|----------|-------------|---------| +| `HeaderName` | The request header name to read the idempotency key from | `"Idempotency-Key"` | +| `DefaultTtl` | How long cached responses are retained | 24 hours | +| `MaxKeyLength` | Maximum allowed length for the idempotency key | 128 characters | + +If the key exceeds `MaxKeyLength`, the filter returns a **400 Bad Request** response immediately. + +## Registration + +Enable idempotency via `FshPlatformOptions`: + +```csharp +builder.AddHeroPlatform(options => +{ + options.EnableCaching = true, // Required - idempotency uses ICacheService + options.EnableIdempotency = true +}); +``` + +This binds `IdempotencyOptions` from the `IdempotencyOptions` configuration section. Override defaults in `appsettings.json`: + +```json +{ + "IdempotencyOptions": { + "HeaderName": "Idempotency-Key", + "DefaultTtl": "1.00:00:00", + "MaxKeyLength": 128 + } +} +``` + + + +## Applying to Endpoints + +Add idempotency to specific endpoints using the `.WithIdempotency()` extension method: + +```csharp +public static class CreateOrderEndpoint +{ + internal static RouteHandlerBuilder MapCreateOrderEndpoint( + this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/orders", async ( + CreateOrderCommand command, + IMediator mediator, + CancellationToken ct) => + { + var id = await mediator.Send(command, ct); + return TypedResults.Created($"/api/v1/orders/{id}", id); + }) + .WithName("CreateOrder") + .WithSummary("Create an order") + .WithIdempotency(); + } +} +``` + +Only endpoints with `.WithIdempotency()` participate in idempotency. The filter is opt-in per endpoint - it does not apply globally. + +## Tenant-Scoped Cache Keys + +Cache keys are automatically scoped to the current tenant to ensure isolation in multitenant deployments. The cache key format is: + +``` +idempotency:{tenantId}:{idempotencyKey} +``` + +If no tenant context is available, `"global"` is used as the tenant segment. This prevents one tenant's idempotency keys from colliding with another's, even if both tenants send the same key value. + +## Idempotency-Replayed Header + +When a cached response is returned, the framework sets the `Idempotency-Replayed: true` response header. Clients can use this header to distinguish between fresh executions and replayed responses - useful for logging, metrics, or conditional client-side behavior. + +## Client Usage Example + +A client that needs safe retries includes the `Idempotency-Key` header on mutating requests: + +```bash +# First request - handler executes, response is cached +curl -X POST https://api.example.com/api/v1/orders \ + -H "Authorization: Bearer {token}" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ + -d '{"productId": "abc", "quantity": 1}' + +# Retry (network timeout, etc.) - cached response is replayed +curl -X POST https://api.example.com/api/v1/orders \ + -H "Authorization: Bearer {token}" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ + -d '{"productId": "abc", "quantity": 1}' +# Response includes: Idempotency-Replayed: true +``` + + + +## Error Handling + +The idempotency filter handles errors gracefully: + +- **Cache failures** - if the cache is unavailable when storing a response, the failure is logged as a warning but the original response is still returned to the client. The next retry will execute the handler again. +- **Missing header** - requests without the `Idempotency-Key` header pass through to the handler without any idempotency behavior. The header is fully opt-in. +- **Key too long** - if the key exceeds `MaxKeyLength`, a 400 Bad Request is returned immediately before the handler executes. diff --git a/docs/src/content/docs/identity-module.mdx b/docs/src/content/docs/identity-module.mdx new file mode 100644 index 0000000000..07fd294b9b --- /dev/null +++ b/docs/src/content/docs/identity-module.mdx @@ -0,0 +1,203 @@ +--- +title: "Identity Module" +description: "User management, authentication, authorization, roles, groups, and sessions." +--- + +import Aside from '../../components/Aside.astro'; +import FileTree from '../../components/FileTree.astro'; + +The Identity module handles all aspects of user identity in the fullstackhero .NET Starter Kit: registration, authentication (JWT Bearer tokens), authorization (permission-based), roles, groups, sessions, and password management. It is the largest module in the framework and is built on top of ASP.NET Identity with a rich domain model that supports multi-tenancy out of the box. + +All Identity endpoints are grouped under `api/v{version:apiVersion}/identity` and use API versioning. The module implements `IModule` and is loaded automatically by the framework's `ModuleLoader`. + + + +## Domain Entities + +The Identity module defines the following domain entities: + +| Entity | Base Type | Purpose | +|--------|-----------|---------| +| **FshUser** | `IdentityUser` | Core user entity -- `FirstName`, `LastName`, `ImageUrl`, `IsActive`, `RefreshToken`, `LastPasswordChangeDate`. Implements `IHasDomainEvents`. | +| **FshRole** | `IdentityRole` | Named role with `Description`. Used for role-based access control. | +| **FshRoleClaim** | `IdentityRoleClaim` | Maps a role to a permission claim. Tracks `CreatedBy` and `CreatedOn`. | +| **Group** | -- | User group for permission aggregation -- `Name`, `Description`, `IsDefault`, `IsSystemGroup`. Implements `IAuditableEntity` and `ISoftDeletable`. | +| **GroupRole** | -- | Join entity linking a `Group` to an `FshRole`. | +| **UserGroup** | -- | Join entity linking an `FshUser` to a `Group`. Tracks `AddedAt` and `AddedBy`. | +| **UserSession** | -- | Session tracking -- `UserId`, `IpAddress`, `DeviceType`, `Browser`, `OperatingSystem`, expiration, and revocation support. Implements `IHasDomainEvents`. | +| **PasswordHistory** | -- | Historical password hash tracking for policy enforcement (prevent password reuse). | + +### Domain Events + +The domain model raises the following events through `IHasDomainEvents`: + +- `UserRegisteredEvent` -- raised when a new user is created +- `UserActivatedEvent` / `UserDeactivatedEvent` -- raised when user status changes +- `PasswordChangedEvent` -- raised on password change or reset +- `UserRoleAssignedEvent` -- raised when roles are assigned to a user +- `SessionRevokedEvent` -- raised when a session is revoked + +Integration event handlers (`UserRegisteredEmailHandler`, `TokenGeneratedLogHandler`) subscribe to cross-module events defined in `Modules.Identity.Contracts`. + +## Module Structure + + + +## Permission System + +Permissions follow the `Permissions.{Resource}.{Action}` naming convention. They are defined as constants in `IdentityPermissionConstants` (in the Shared Building Block) and enforced on endpoints via the `.RequirePermission()` extension method, which is backed by the `RequiredPermissionAuthorizationHandler`. + +| Resource | Permissions | +|----------|-------------| +| **Users** | `View`, `Create`, `Update`, `Delete`, `ManageRoles` | +| **Roles** | `View`, `Create`, `Update`, `Delete` | +| **Sessions** | `View`, `Revoke`, `ViewAll`, `RevokeAll` | +| **Groups** | `View`, `Create`, `Update`, `Delete`, `ManageMembers` | + + + +Usage in an endpoint: + +```csharp +group.MapGetUsersListEndpoint() + .RequirePermission(IdentityPermissionConstants.Users.View); +``` + +## Database + +`IdentityDbContext` extends Finbuckle's `MultiTenantIdentityDbContext`, making all Identity data tenant-isolated by default. It includes the following `DbSet` properties beyond the standard ASP.NET Identity tables: + +| DbSet | Table | Purpose | +|-------|-------|---------| +| `PasswordHistories` | Password history | Tracks previous password hashes to enforce reuse policies | +| `UserSessions` | User sessions | Active and revoked sessions with device/browser metadata | +| `Groups` | Groups | User groups for permission aggregation | +| `GroupRoles` | Group-role mapping | Links groups to roles | +| `UserGroups` | User-group mapping | Links users to groups | +| `OutboxMessages` | Outbox | Transactional outbox for reliable integration event publishing | +| `InboxMessages` | Inbox | Idempotent inbox for integration event consumption | + + + +## Configuration + +### JWT Options + +Configured under the `Jwt` section in `appsettings.json`: + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Issuer` | `string` | -- | Token issuer (required) | +| `Audience` | `string` | -- | Token audience (required) | +| `SigningKey` | `string` | -- | HMAC signing key, minimum 32 characters (required) | +| `AccessTokenMinutes` | `int` | `30` | Access token lifetime in minutes | +| `RefreshTokenDays` | `int` | `7` | Refresh token lifetime in days | + + + +### Password Policy Options + +Configured under the `PasswordPolicy` section in `appsettings.json`: + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `PasswordHistoryCount` | `int` | `5` | Number of previous passwords to retain (prevents reuse) | +| `PasswordExpiryDays` | `int` | `90` | Days before a password expires | +| `PasswordExpiryWarningDays` | `int` | `14` | Days before expiry to start warning the user | +| `EnforcePasswordExpiry` | `bool` | `true` | Whether to enforce password expiry | + +### ASP.NET Identity Options + +The module configures ASP.NET Identity with the following defaults: + +- Minimum password length: `6` characters +- Require digit: `true` +- Require lowercase: `true` +- Require uppercase: `true` +- Require non-alphanumeric: `false` +- Require unique email: `true` + +## Sub-pages + +Explore specific areas of the Identity module in detail: + +- [Users](/dotnet-starter-kit/user-management/) -- Registration, profiles, status management, and role assignment +- [Roles & Permissions](/dotnet-starter-kit/roles-and-permissions/) -- Role management and permission configuration +- [Authentication](/dotnet-starter-kit/authentication/) -- JWT token generation, refresh flow, and security +- [Sessions & Groups](/dotnet-starter-kit/sessions-and-groups/) -- Session tracking, revocation, and group-based permissions diff --git a/docs/src/content/docs/introduction.mdx b/docs/src/content/docs/introduction.mdx new file mode 100644 index 0000000000..0474f4c714 --- /dev/null +++ b/docs/src/content/docs/introduction.mdx @@ -0,0 +1,142 @@ +--- +title: "Introduction" +description: "What is fullstackhero .NET Starter Kit and why should you use it?" +--- + +import Aside from '../../components/Aside.astro'; +import Badge from '../../components/Badge.astro'; +import FileTree from '../../components/FileTree.astro'; + +**fullstackhero .NET Starter Kit** is a production-ready, modular .NET framework for building enterprise SaaS applications. It gives you a battle-tested foundation - multitenancy, identity management, auditing, webhooks, CQRS, background jobs, and observability - so you can skip months of boilerplate and focus on what matters: your business logic. + +Whether you are launching a new SaaS product or modernizing an internal line-of-business system, this starter kit provides the architecture, patterns, and infrastructure you need from day one. + + + +## Key Highlights + +
+Modular Monolith + Vertical Slice Architecture +Built-in Multitenancy (Finbuckle) +Identity & Access Management (ASP.NET Identity + JWT) +CQRS with Source-Generated Mediator +Entity Framework Core with PostgreSQL +Domain-Driven Design Primitives +Outbox/Inbox Pattern for Reliable Messaging +Webhooks with HMAC Signing +Background Jobs (Hangfire) +Observability (Serilog + OpenTelemetry) +API Documentation (OpenAPI + Scalar) +.NET Aspire Orchestration +Comprehensive Architecture Tests (NetArchTest) +
+ +## What You Get Out of the Box + +The starter kit ships with four fully implemented **modules** and ten reusable **building blocks** that you can extend or replace as your application grows. + +### Modules + +Modules are bounded contexts - each one owns its domain, data, and API surface. + +| Module | Purpose | +|--------|---------| +| **Identity** | User registration, login, role management, JWT token issuance, and permission-based authorization | +| **Multitenancy** | Tenant provisioning, activation/deactivation, per-tenant configuration, and tenant resolution via claims, headers, or query strings | +| **Auditing** | Automatic audit trail capture for all entity changes with queryable audit log endpoints | +| **Webhooks** | Tenant-scoped outbound webhook subscriptions with HMAC-SHA256 payload signing and resilient delivery | + +### Building Blocks + +Building blocks are the shared framework libraries that every module can leverage. They live in `src/BuildingBlocks/` and handle cross-cutting concerns so that modules stay focused on business logic. + +| Building Block | Responsibility | +|----------------|---------------| +| **Core** | Base entities, domain events, CQRS contracts, exception types, and shared abstractions | +| **Persistence** | EF Core configuration, specifications, repository patterns, outbox/inbox, and migration infrastructure | +| **Web** | Minimal API conventions, global exception handling, OpenAPI/Scalar configuration, and API versioning | +| **Caching** | Redis-backed distributed caching with a clean abstraction layer | +| **Eventing** | Domain event dispatching, integration events, and the outbox/inbox pattern for reliable messaging | +| **Jobs** | Hangfire integration for background, scheduled, and recurring tasks | +| **Mailing** | Email sending abstractions and provider integrations | +| **Storage** | File storage abstractions for local and cloud-based providers | +| **Shared** | Cross-module constants, permission definitions, and shared DTOs | + +## Architecture at a Glance + +fullstackhero follows a **Modular Monolith** pattern combined with **Vertical Slice Architecture (VSA)**. You get the deployment simplicity of a monolith with the clean boundaries and independent evolvability of microservices. + +The codebase is organized into three layers: + +1. **BuildingBlocks** - Shared framework libraries that provide cross-cutting infrastructure. These are the foundation that all modules build upon. Think of them as your internal NuGet packages: persistence, caching, eventing, web conventions, and more. + +2. **Modules** - Independent bounded contexts, each with its own domain, data access, features, and API surface. Modules communicate exclusively through **Contracts** projects - lightweight assemblies containing only commands, queries, events, DTOs, and service interfaces. A module never references another module's runtime project, and this boundary is enforced by architecture tests. + +3. **Playground** - Reference host applications that wire everything together. The API project composes all modules into a running application, and the AppHost project orchestrates the full stack with .NET Aspire. + +``` +src/ +├── BuildingBlocks/ # Shared framework (Core, Persistence, Web, Caching, ...) +├── Modules/ +│ ├── Identity/ # User management, auth, permissions +│ ├── Identity.Contracts/ # Public API for Identity module +│ ├── Multitenancy/ # Tenant management, resolution +│ ├── Multitenancy.Contracts/ +│ ├── Auditing/ # Audit trail capture and querying +│ ├── Auditing.Contracts/ +│ ├── Webhooks/ # Outbound webhook subscriptions and delivery +│ └── Webhooks.Contracts/ +├── Playground/ +│ ├── FSH.Starter.Api/ # Reference API host +│ ├── FSH.Starter.AppHost/ # .NET Aspire orchestrator +│ └── FSH.Starter.Migrations.PostgreSQL/ +├── Tools/ +│ └── CLI/ # FSH CLI tool (fsh new, fsh doctor) +└── Tests/ # Per-module tests + architecture tests +``` + +Each feature within a module is a complete vertical slice - endpoint, handler, validator - all co-located in a single folder. No hunting across layers to understand a feature. + +## Who Is This For? + +### Developers Building SaaS or Line-of-Business Applications + +If you are building a multi-tenant SaaS product, an internal enterprise tool, or any data-driven application, this starter kit gives you the scaffolding to move fast without cutting corners on architecture. + +### Teams Wanting a Production-Ready Starting Point + +Rather than spending weeks setting up authentication, multitenancy, CQRS pipelines, validation, caching, and observability, start with a framework that has best practices baked in. Every pattern has been chosen for real-world production use. + +### Organizations Needing Multitenancy, Role-Based Access, and Audit Trails + +Compliance-sensitive industries - finance, healthcare, government - often require tenant isolation, fine-grained permissions, and complete audit logs. This starter kit provides all three as first-class features, not afterthoughts. + +## Tech Stack + +| Concern | Technology | +|---------|-----------| +| Framework | .NET 10 / C# latest | +| Solution format | `.slnx` (XML-based) | +| Package management | Central (`Directory.Packages.props`) | +| CQRS / Mediator | Mediator 3.x (source generator) | +| Validation | FluentValidation 12.x | +| ORM | Entity Framework Core 10.x | +| Database | PostgreSQL (Npgsql) | +| Auth | JWT Bearer + ASP.NET Identity | +| Multitenancy | Finbuckle.MultiTenant 10.x | +| Caching | Redis (StackExchange) | +| Jobs | Hangfire | +| Logging | Serilog + OpenTelemetry (OTLP) | +| Object mapping | Mapster | +| API docs | OpenAPI + Scalar | +| API versioning | Asp.Versioning | +| Webhooks | HMAC-SHA256 signed, resilient HTTP delivery | +| Hosting | .NET Aspire | +| Testing | xUnit, Shouldly, NSubstitute, AutoFixture, NetArchTest | +| IaC | Terraform (AWS ECS, RDS, ElastiCache) | + + diff --git a/docs/src/content/docs/mailing.mdx b/docs/src/content/docs/mailing.mdx new file mode 100644 index 0000000000..f645bd339d --- /dev/null +++ b/docs/src/content/docs/mailing.mdx @@ -0,0 +1,212 @@ +--- +title: "Mailing" +description: "Email abstraction with SMTP and SendGrid providers." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +The Mailing building block provides a clean email abstraction with two swappable providers -- SMTP (via MailKit) and SendGrid. Switch between them with a single configuration flag, no code changes required. + +## IMailService + +The `IMailService` interface defines a single method for sending emails: + +```csharp +public interface IMailService +{ + Task SendAsync(MailRequest request, CancellationToken ct); +} +``` + +Inject `IMailService` into any handler or service to send transactional emails without coupling to a specific provider. + +## MailRequest + +The `MailRequest` class carries all the data needed to compose an email: + +```csharp +public class MailRequest( + Collection to, + string subject, + string? body = null, + string? from = null, + string? displayName = null, + string? replyTo = null, + string? replyToName = null, + Collection? bcc = null, + Collection? cc = null, + IDictionary? attachmentData = null, + IDictionary? headers = null) +``` + +| Property | Type | Description | +|----------|------|-------------| +| `To` | `Collection` | Recipient email addresses (required) | +| `Subject` | `string` | Email subject line (required) | +| `Body` | `string?` | HTML body content | +| `From` | `string?` | Sender address (overrides config default) | +| `DisplayName` | `string?` | Sender display name (overrides config default) | +| `ReplyTo` | `string?` | Reply-to email address | +| `ReplyToName` | `string?` | Reply-to display name | +| `Bcc` | `Collection` | Blind carbon copy recipients | +| `Cc` | `Collection` | Carbon copy recipients | +| `AttachmentData` | `IDictionary` | File attachments (filename to bytes) | +| `Headers` | `IDictionary` | Custom email headers | + +## Providers + + + + +The `SmtpMailService` uses [MailKit](https://github.com/jstedfast/MailKit) to send emails over SMTP with StartTLS encryption. + +**How it works:** + +1. Validates that `SmtpOptions.Host` is configured +2. Builds a `MimeMessage` with sender, recipients, CC, BCC, reply-to, and custom headers +3. Attaches files from `AttachmentData` using MailKit's `BodyBuilder` +4. Connects to the SMTP server with `SecureSocketOptions.StartTls` +5. Authenticates with the configured username and password +6. Sends the email and disconnects + +```csharp +// Sending an email via SMTP +var request = new MailRequest( + to: new Collection { "user@example.com" }, + subject: "Welcome!", + body: "

Welcome to our platform

"); + +await mailService.SendAsync(request, cancellationToken); +``` + +
+ + +The `SendGridMailService` uses the [SendGrid](https://sendgrid.com/) API client to send emails through SendGrid's cloud service. + +**How it works:** + +1. Validates that `SendGridOptions.ApiKey` is configured +2. Creates a `SendGridClient` with the API key +3. Builds a `SendGridMessage` with sender, recipient, subject, and body +4. Adds CC, BCC, reply-to, and attachments (base64-encoded) +5. Sends the email via the SendGrid API + +```csharp +// Sending an email via SendGrid (same code -- provider is transparent) +var request = new MailRequest( + to: new Collection { "user@example.com" }, + subject: "Welcome!", + body: "

Welcome to our platform

"); + +await mailService.SendAsync(request, cancellationToken); +``` + +
+
+ +## MailOptions + +The `MailOptions` class controls which provider is used and how it is configured: + +```csharp +public class MailOptions +{ + public bool UseSendGrid { get; set; } + public string? From { get; set; } + public string? DisplayName { get; set; } + public SmtpOptions? Smtp { get; set; } + public SendGridOptions? SendGrid { get; set; } +} + +public class SmtpOptions +{ + public string? Host { get; set; } + public int Port { get; set; } + public string? UserName { get; set; } + public string? Password { get; set; } +} + +public class SendGridOptions +{ + public string? ApiKey { get; set; } + public string? From { get; set; } + public string? DisplayName { get; set; } +} +``` + + + +## Configuration + + + + +```json +{ + "MailOptions": { + "UseSendGrid": false, + "From": "noreply@yourapp.com", + "DisplayName": "Your App", + "Smtp": { + "Host": "smtp.gmail.com", + "Port": 587, + "UserName": "your-email@gmail.com", + "Password": "your-app-password" + } + } +} +``` + + + + +```json +{ + "MailOptions": { + "UseSendGrid": true, + "From": "noreply@yourapp.com", + "DisplayName": "Your App", + "SendGrid": { + "ApiKey": "SG.your-api-key-here", + "From": "noreply@yourapp.com", + "DisplayName": "Your App" + } + } +} +``` + + + + +## Provider selection logic + +The framework uses a factory pattern to resolve the correct provider at runtime: + +```csharp +services.AddTransient(sp => +{ + var options = sp.GetRequiredService>().Value; + if (options.UseSendGrid) + { + return new SendGridMailService(/* ... */); + } + return new SmtpMailService(/* ... */); +}); +``` + +The `From` and `DisplayName` properties on `MailRequest` override the defaults from `MailOptions`. If not provided on the request, the configured defaults are used. SendGrid has its own `From` and `DisplayName` overrides that take precedence over the top-level values when the SendGrid provider is active. + +## Registration + +Mailing is registered in the service pipeline via the `AddHeroMailing()` extension method: + +```csharp +services.AddHeroMailing(); +``` + +This binds `MailOptions` from configuration, validates it on startup, and registers the appropriate `IMailService` implementation. diff --git a/docs/src/content/docs/module-system.mdx b/docs/src/content/docs/module-system.mdx new file mode 100644 index 0000000000..f0caafb547 --- /dev/null +++ b/docs/src/content/docs/module-system.mdx @@ -0,0 +1,203 @@ +--- +title: "Module System" +description: "How the fullstackhero .NET Starter Kit discovers, loads, and orchestrates modules at startup." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; +import FileTree from '../../components/FileTree.astro'; + +The module system is the foundation of fullstackhero's modular monolith architecture. It enables **plug-and-play bounded contexts** - each module is a self-contained unit with its own dependency injection, endpoints, middleware, and database context. The framework discovers and loads modules automatically at startup, so adding a new capability is as simple as dropping in a new module project. + +## IModule Interface + +Every module implements the `IModule` interface, which defines three lifecycle hooks: + +```csharp +namespace FSH.Framework.Web.Modules; + +public interface IModule +{ + void ConfigureServices(IHostApplicationBuilder builder); + void MapEndpoints(IEndpointRouteBuilder endpoints); + void ConfigureMiddleware(IApplicationBuilder app) { } +} +``` + +- **`ConfigureServices`** - Called during host startup. Register DI services, configuration options, health checks, DbContext, and anything else the module needs. This is where you wire up EF Core, add Hangfire jobs, or register module-specific services. +- **`MapEndpoints`** - Called during endpoint mapping. Wire minimal API endpoints under versioned API groups (`api/v1/{module}/...`). Each module owns its own route namespace. +- **`ConfigureMiddleware`** - Called during the middleware pipeline configuration. This method has a default no-op implementation, so modules only override it when they need to inject custom middleware into the pipeline. + +## Module Registration + +Modules are registered using an **assembly-level attribute** - there is no manual wiring in `Program.cs`. Each module project declares itself with the `[FshModule]` attribute: + +```csharp +[assembly: FshModule(typeof(IdentityModule), Order = 1)] +``` + +The attribute definition: + +```csharp +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class FshModuleAttribute : Attribute +{ + public Type ModuleType { get; } + public int Order { get; } + + public FshModuleAttribute(Type moduleType, int order = 0) + { + ModuleType = moduleType ?? throw new ArgumentNullException(nameof(moduleType)); + Order = order; + } +} +``` + + + +## Module Discovery and Loading + +The `ModuleLoader` is the engine that drives module discovery. It scans assemblies, instantiates modules in the correct order, and invokes their lifecycle methods. + +```csharp +public static class ModuleLoader +{ + private static readonly List _modules = new(); + private static readonly object _lock = new(); + private static bool _modulesLoaded; + + public static IHostApplicationBuilder AddModules(this IHostApplicationBuilder builder, params Assembly[] assemblies) + { + lock (_lock) + { + if (_modulesLoaded) return builder; + builder.Services.AddValidatorsFromAssemblies(assemblies); + var moduleRegistrations = source + .SelectMany(a => a.GetCustomAttributes()) + .Where(r => typeof(IModule).IsAssignableFrom(r.ModuleType)) + .DistinctBy(r => r.ModuleType) + .OrderBy(r => r.Order) + .ThenBy(r => r.ModuleType.Name) + .Select(r => r.ModuleType); + foreach (var moduleType in moduleRegistrations) + { + var module = (IModule)Activator.CreateInstance(moduleType)!; + module.ConfigureServices(builder); + _modules.Add(module); + } + _modulesLoaded = true; + } + return builder; + } + + public static IApplicationBuilder UseModuleMiddlewares(this IApplicationBuilder app) { ... } + public static IEndpointRouteBuilder MapModules(this IEndpointRouteBuilder endpoints) { ... } +} +``` + +Key design decisions in the loader: + +- **Thread-safe initialization** - The `lock` and `_modulesLoaded` flag ensure modules are loaded exactly once, even if `AddModules` is called multiple times. +- **Deterministic ordering** - Modules are sorted by `Order` first, then alphabetically by type name as a tiebreaker. This makes startup behavior predictable and reproducible. +- **Automatic validator registration** - FluentValidation validators from all provided assemblies are discovered and registered in a single pass, so individual modules do not need to register their own validators. + +Here is how the full startup sequence flows: + + + +### Host calls AddModules + +In `Program.cs`, the host builder calls `builder.AddModules(assemblies)` with the module assemblies to scan. + +### ModuleLoader scans for attributes + +The loader iterates each assembly and collects all `[FshModule]` attributes, filtering to types that implement `IModule`. + +### Modules are ordered + +Found modules are sorted by `Order` (ascending), then by type name as a tiebreaker, ensuring deterministic startup order. + +### ConfigureServices is called + +Each module is instantiated via `Activator.CreateInstance` and its `ConfigureServices()` method is invoked, registering all DI services. + +### Validators are auto-registered + +FluentValidation validators from all provided assemblies are discovered and registered automatically - no per-module registration needed. + +### Middleware is configured + +During pipeline setup, `app.UseModuleMiddlewares()` iterates the loaded modules and calls each one's `ConfigureMiddleware()` method. + +### Endpoints are mapped + +During endpoint mapping, `endpoints.MapModules()` iterates the loaded modules and calls each one's `MapEndpoints()` method, wiring all API routes. + + + +## Module Structure + +Each module follows a canonical folder layout that separates the **runtime implementation** from the **public contracts**: + + + +The **runtime project** (`Modules.Identity`) contains all internal implementation details: the EF Core DbContext, domain entities, feature handlers, and services. Nothing outside the module should reference this project. + +The **contracts project** (`Modules.Identity.Contracts`) is the module's public API. It contains commands, queries, DTOs, events, and service interfaces that other modules can depend on. This is the only project that crosses module boundaries. + +## Module Boundaries + +This is the most critical architectural rule in fullstackhero: **modules only reference each other's Contracts projects, never the runtime project**. This constraint ensures loose coupling between bounded contexts and keeps each module's internals truly private. + +``` +Identity Module ──references──> Multitenancy.Contracts (queries, DTOs) +Identity Module ──NEVER──> Multitenancy (runtime implementation) +``` + +When Module A needs to communicate with Module B, it sends a command or query defined in Module B's Contracts project through Mediator. Module A never instantiates Module B's services directly, never accesses its DbContext, and never touches its domain entities. + + + +## Existing Modules + +fullstackhero ships with four core modules: + +| Module | Order | Purpose | +|---|---|---| +| **Identity** | 1 | Users, roles, JWT authentication, sessions, user groups | +| **Multitenancy** | 2 | Tenant management, provisioning, theme configuration | +| **Auditing** | 3 | Audit trails, security auditing, exception tracking | +| **Webhooks** | 400 | Outbound webhook subscriptions with HMAC signing and resilient delivery | + +These modules demonstrate the patterns and conventions you should follow when building your own modules. Identity loads first because other modules depend on authentication and user services. Multitenancy loads second to establish tenant context. Auditing loads third since it observes operations from other modules. Webhooks loads last as it is an independent module that pushes event notifications to external systems. + +## Adding a New Module + +Creating a new module involves setting up the project structure, implementing `IModule`, creating a DbContext, and wiring everything together. For detailed step-by-step instructions, see the [Adding a Module](/dotnet-starter-kit/adding-a-module) guide. diff --git a/docs/src/content/docs/multitenancy-deep-dive.mdx b/docs/src/content/docs/multitenancy-deep-dive.mdx new file mode 100644 index 0000000000..4f3fd9df05 --- /dev/null +++ b/docs/src/content/docs/multitenancy-deep-dive.mdx @@ -0,0 +1,97 @@ +--- +title: "Multitenancy Deep Dive" +description: "How tenant context flows through every layer of the application." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +## Overview + +Tenant context is resolved at the HTTP layer and flows through every layer of the application - middleware, DbContext, audit context, and event publishing. The framework uses **Finbuckle.MultiTenant** with multiple resolution strategies, ensuring that tenant isolation is enforced consistently across the entire stack. + +## Tenant Resolution Flow + + + +### HTTP request arrives + +An incoming request hits the ASP.NET Core pipeline. + +### Finbuckle strategies attempt resolution + +Multiple strategies are tried in order: Claim strategy, Header strategy (`X-Tenant-ID`), QueryString strategy, and Delegate strategy. The first successful match wins. + +### TenantInfo loaded + +The resolved tenant identifier is used to load full `TenantInfo` from the EF Core tenant store. Results are cached in the distributed cache (Redis) to avoid repeated database lookups. + +### Tenant context available + +`HttpContext.GetMultiTenantContext()` is now available to all downstream middleware and services. + + + +## Middleware Layer + +The `CurrentUserMiddleware` extracts the tenant from the authenticated user's claims and makes it available via `IRequestContext`. This provides the tenant ID to all services within the request scope. + +For unauthenticated requests, the tenant is resolved from the `X-Tenant-ID` header or query string parameter. + +## Database Layer + +The `BaseDbContext` integrates with Finbuckle to route queries to the correct tenant-specific connection string. Two key mechanisms ensure data isolation: + +- **Connection string routing** - Each tenant can have its own database connection string, configured in the tenant store. +- **Global query filters** - Entities implementing `IHasTenant` are automatically filtered by the current tenant ID. This acts as a safety net, ensuring that even if connection strings are shared, tenants cannot see each other's data. + +```csharp +// Automatic global filter applied by BaseDbContext +modelBuilder.Entity().HasQueryFilter(e => e.TenantId == currentTenantId); +``` + +## Audit Layer + +The `AuditScope` captures tenant context automatically. Every audit record is tagged with the tenant ID of the request that generated it. This ensures audit trails are tenant-isolated and can be queried per tenant. + +## Event Layer + +Domain events carry `TenantId` as part of the `DomainEvent` base record. When events are raised from an entity, the tenant context is captured at the point of creation. + +Integration events also include `TenantId`, enabling cross-module event routing that respects tenant boundaries. + +```csharp +public abstract record DomainEvent : IDomainEvent +{ + public Guid EventId { get; init; } + public DateTimeOffset OccurredOnUtc { get; init; } + public string? CorrelationId { get; init; } + public string? TenantId { get; init; } +} +``` + +## Caching + +Cache keys are prefixed with the tenant context to ensure complete isolation. When a service caches a query result, the cache key includes the tenant identifier, preventing cross-tenant cache pollution. + + + +## Tenant Store + +Tenants are stored in the database and managed through the Multitenancy module. Each tenant record includes: + +- **Identifier** - Unique tenant key used for resolution. +- **Name** - Human-readable tenant name. +- **Connection String** - Optional per-tenant database connection string. +- **Admin Email** - The tenant administrator's email. +- **Validity** - Active/inactive status and validity period. + + diff --git a/docs/src/content/docs/multitenancy.mdx b/docs/src/content/docs/multitenancy.mdx new file mode 100644 index 0000000000..b4816de9e1 --- /dev/null +++ b/docs/src/content/docs/multitenancy.mdx @@ -0,0 +1,171 @@ +--- +title: "Multitenancy Module" +description: "Tenant management, resolution strategies, and data isolation." +--- + +import Aside from '../../components/Aside.astro'; +import FileTree from '../../components/FileTree.astro'; + +The Multitenancy module provides SaaS multitenancy built on [Finbuckle.MultiTenant](https://www.finbuckle.com/multitenant/). It supports tenant resolution via claims, headers, and query strings, with per-tenant database isolation and automatic query filtering. Every request is scoped to a tenant, and the module handles the full tenant lifecycle - creation, activation, subscription upgrades, and provisioning. + +## AppTenantInfo Entity + +`AppTenantInfo` extends Finbuckle's `TenantInfo` base class and serves as the central tenant record. It is defined in the Shared building block so all modules can reference it. + +| Property | Type | Description | +|----------|------|-------------| +| `Id` | `string` | Unique tenant identifier (e.g., `"root"`) | +| `Identifier` | `string` | Tenant lookup key used by resolution strategies | +| `Name` | `string?` | Display name | +| `ConnectionString` | `string` | Per-tenant database connection string | +| `AdminEmail` | `string` | Tenant administrator email | +| `IsActive` | `bool` | Whether the tenant is currently active | +| `ValidUpto` | `DateTime` | Subscription expiration date | +| `Issuer` | `string?` | JWT issuer for the tenant | + +Key methods on `AppTenantInfo`: + +- **`AddValidity(int months)`** - extends the subscription by the given number of months +- **`SetValidity(DateTime validTill)`** - sets an explicit expiration date (cannot backdate) +- **`Activate()`** - enables the tenant (blocked for root tenant) +- **`Deactivate()`** - disables the tenant (blocked for root tenant) + +## Tenant Resolution Strategies + +When a request arrives, Finbuckle tries multiple resolution strategies in order until one succeeds: + +1. **Claim** - extracts the tenant identifier from the authenticated user's JWT claims. This is the primary strategy for authenticated API requests. +2. **Header** - reads the `tenant` HTTP header. Useful for service-to-service calls and initial authentication requests. +3. **Delegate** - a custom strategy that reads the `?tenant=value` query string parameter. Convenient for testing and one-off requests. +4. **Distributed Cache Store** - resolved tenants are cached in Redis with a 60-minute TTL, backed by the EF Core store as the source of truth. + + + +## Data Isolation + +Entities that implement `IHasTenant` get automatic EF Core global query filters - every query is scoped to the current tenant without any manual `WHERE` clauses. The framework supports two isolation models: + +- **Database-per-tenant** - each tenant has its own `ConnectionString` pointing to a separate database. Full physical isolation. +- **Shared database** - tenants share a database with row-level filtering via the `TenantId` column. The global query filter ensures tenants never see each other's data. + +The `BaseDbContext` reads the current tenant's connection string from `IMultiTenantContextAccessor` and configures the database provider accordingly on each request. + +## Endpoints + +All endpoints are grouped under `api/v1/tenants` with API versioning. + +| Method | Path | Description | Permission | +|--------|------|-------------|------------| +| `GET` | `/tenants` | List tenants (paginated) | `Tenants.View` | +| `GET` | `/tenants/{id}/status` | Get tenant status | `Tenants.View` | +| `POST` | `/tenants` | Create tenant | `Tenants.Create` | +| `POST` | `/tenants/{id}/activate` | Activate tenant | `Tenants.Update` | +| `POST` | `/tenants/{id}/deactivate` | Deactivate tenant | `Tenants.Update` | +| `POST` | `/tenants/{id}/upgrade` | Upgrade subscription | `Tenants.Update` | + +## Module Structure + + + +## Configuration + +The module is configured via `MultitenancyOptions` in `appsettings.json`: + +```json +{ + "MultitenancyOptions": { + "RunTenantMigrationsOnStartup": false, + "AutoProvisionOnStartup": true + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `RunTenantMigrationsOnStartup` | `false` | Runs per-tenant migrations and seeding for all tenants during startup. Recommended for development only. | +| `AutoProvisionOnStartup` | `true` | Enqueues Hangfire provisioning jobs on startup for tenants that have not completed provisioning. | + +The tenant store uses a **distributed cache** (Redis) with a 60-minute TTL for fast lookups. When a tenant is resolved from the EF Core store, it is automatically promoted into the distributed cache for subsequent requests. + +## Root Tenant + +The framework ships with a pre-seeded root tenant: + +- **Id:** `root` +- **Admin email:** `admin@root.com` +- **Default validity:** 1 month from creation + +The root tenant is the system-level tenant used for administrative operations. It is always active and cannot be toggled. + + diff --git a/docs/src/content/docs/observability.mdx b/docs/src/content/docs/observability.mdx new file mode 100644 index 0000000000..287a078b4a --- /dev/null +++ b/docs/src/content/docs/observability.mdx @@ -0,0 +1,142 @@ +--- +title: "Observability" +description: "Structured logging with Serilog and distributed tracing with OpenTelemetry." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +## Overview + +The fullstackhero .NET Starter Kit implements all three pillars of observability: + +- **Logs** - Structured logging with Serilog +- **Traces** - Distributed tracing with OpenTelemetry +- **Metrics** - Application metrics with OpenTelemetry + +Together, these provide full visibility into application behavior across all modules and tenants. + +## Serilog - Structured Logging + +Serilog provides structured logging with contextual enrichment. Every log entry is enriched with request context automatically. + +### HttpRequestContextEnricher + +The `HttpRequestContextEnricher` adds the following properties to every log entry within a request scope: + +- **CorrelationId** - Unique identifier for tracing a request across services. +- **UserId** - The authenticated user's ID. +- **TenantId** - The current tenant's identifier. + +This enrichment ensures that logs can be filtered and correlated by user, tenant, or request. + +### StaticLogger + +The `StaticLogger` is available for startup logging before the DI container is built. Use it for logging during `Program.cs` or module registration: + +```csharp +StaticLogger.EnsureInitialized(); +Log.Information("Starting application..."); +``` + + + + +```json +{ + "Timestamp": "2026-03-27T10:15:30.123Z", + "Level": "Information", + "Message": "User registered successfully", + "Properties": { + "CorrelationId": "abc-123", + "UserId": "user-456", + "TenantId": "tenant-789", + "SourceContext": "RegisterUserCommandHandler" + } +} +``` + + + + +```json +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { "Name": "OpenTelemetry" } + ] + } +} +``` + + + + +## OpenTelemetry Traces + +Distributed tracing captures the full lifecycle of requests across the application. The following instrumentations are configured: + +- **ASP.NET Core** - HTTP request spans with route, status code, and duration. +- **Entity Framework Core** - Database query spans with command text and duration. +- **Mediator** - Command/query spans via `MediatorTracingBehavior`. +- **RabbitMQ** - Message publish/consume spans for integration events. + +All trace data is exported via OTLP to a configurable endpoint. + +### MediatorTracingBehavior + +The `MediatorTracingBehavior` creates spans around every mediator request. Each span includes: + +- The request type name as the span name. +- Tags for the request type and whether it is a command or query. +- Error status and exception details on failure. + +This gives you visibility into every CQRS operation flowing through the system. + +## OpenTelemetry Metrics + +Built-in .NET metrics are collected automatically, including: + +- ASP.NET Core request metrics (duration, count, status codes) +- Runtime metrics (GC, thread pool, memory) +- Custom application metrics (e.g., `IdentityMetrics` for login/register counters) + +## Aspire Dashboard + +When running the application with .NET Aspire (via `FSH.Starter.AppHost`), traces, logs, and metrics are automatically routed to the Aspire dashboard. This provides a unified view of all telemetry without additional configuration. + + + +## Configuration + +Enable OpenTelemetry and configure the OTLP exporter endpoint in your application settings: + +```json +{ + "OpenTelemetry": { + "Enabled": true, + "Endpoint": "http://localhost:4317" + } +} +``` + +When `Enabled` is `false`, OpenTelemetry instrumentation is skipped entirely, so there is no performance overhead in environments where tracing is not needed. + + diff --git a/docs/src/content/docs/outbox-inbox-pattern.mdx b/docs/src/content/docs/outbox-inbox-pattern.mdx new file mode 100644 index 0000000000..8dca8316f7 --- /dev/null +++ b/docs/src/content/docs/outbox-inbox-pattern.mdx @@ -0,0 +1,187 @@ +--- +title: "Outbox & Inbox Pattern" +description: "Reliable messaging with the transactional outbox and idempotent inbox patterns in the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +Saving domain changes and publishing integration events are two separate operations. If the event bus is down when you try to publish, the event is lost. If the event publishes successfully but the database transaction rolls back, downstream consumers act on data that never persisted. Either way, your system ends up in an inconsistent state. + +## The Problem + +Consider a handler that registers a user and publishes a `UserRegisteredIntegrationEvent`: + +1. The handler saves the new user to the database. +2. The handler publishes an integration event to notify other modules. + +What happens when step 2 fails? The user exists in the database, but no other module knows about it. What happens when step 2 succeeds but step 1 rolls back? Other modules react to a user that does not exist. + +These are the classic dual-write problems. The **outbox pattern** eliminates them by turning two operations into one atomic database transaction. + +## How the Outbox Pattern Works + + + +### Domain handler performs business logic + +The command handler executes business logic and raises integration events as part of the same unit of work. + +### Events are saved to the outbox table + +Integration events are serialized and inserted into the `OutboxMessages` table **in the same database transaction** as the domain changes. If the transaction commits, both the domain state and the outbox messages are persisted atomically. If it rolls back, neither is persisted. + +### Background dispatcher reads pending messages + +A background job periodically queries the `OutboxMessages` table for unprocessed messages, fetching them in configurable batches. + +### Messages are published to the event bus + +Each message is deserialized back into its original event type and published to the event bus - either InMemory or RabbitMQ depending on your configuration. + +### Successful messages are marked as processed + +Once the event bus confirms receipt, the outbox message is stamped with a `ProcessedOnUtc` timestamp so it is not picked up again. + +### Failed messages are retried or dead-lettered + +If publishing fails, the `RetryCount` is incremented and the error is recorded. After exceeding the maximum retry count, the message is flagged as dead-lettered (`IsDead = true`) to prevent infinite retry loops. + + + +## OutboxMessage Entity + +The `OutboxMessage` entity represents a single event waiting to be dispatched: + +```csharp +public class OutboxMessage +{ + public Guid Id { get; set; } + public DateTime CreatedOnUtc { get; set; } + public string Type { get; set; } = default!; + public string Payload { get; set; } = default!; + public string? TenantId { get; set; } + public string? CorrelationId { get; set; } + public DateTime? ProcessedOnUtc { get; set; } + public int RetryCount { get; set; } + public string? LastError { get; set; } + public bool IsDead { get; set; } +} +``` + +### Field Reference + +| Field | Description | +|-------|-------------| +| `Id` | Unique identifier for the outbox message. | +| `CreatedOnUtc` | When the event was originally raised. | +| `Type` | Fully qualified .NET type name of the event, used for deserialization. | +| `Payload` | The event serialized as JSON. | +| `TenantId` | Tenant context so the dispatcher can restore tenant scope. | +| `CorrelationId` | Trace correlation ID for distributed tracing. | +| `ProcessedOnUtc` | Timestamp when the message was successfully published. `null` means the message is still pending. | +| `RetryCount` | Number of failed publish attempts so far. | +| `LastError` | Error message from the most recent failed attempt. | +| `IsDead` | Dead-letter flag. When `true`, the dispatcher will no longer attempt to process this message. | + +## OutboxDispatcher + +The `OutboxDispatcher` runs as a background job and processes pending outbox messages in batches: + +```csharp +public async Task DispatchAsync(CancellationToken ct = default) +{ + var messages = await _outbox.GetPendingBatchAsync(batchSize, ct).ConfigureAwait(false); + foreach (var message in messages) + { + try + { + var @event = _serializer.Deserialize(message.Payload, message.Type); + await _bus.PublishAsync(@event, ct).ConfigureAwait(false); + await _outbox.MarkAsProcessedAsync(message, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + var isDead = message.RetryCount + 1 >= maxRetries; + await _outbox.MarkAsFailedAsync(message, ex.Message, isDead, ct).ConfigureAwait(false); + } + } +} +``` + +The dispatcher follows a straightforward flow: + +- **Batch processing** - `GetPendingBatchAsync` fetches a configurable number of unprocessed messages, controlled by `EventingOptions.OutboxBatchSize`. +- **Deserialization** - The `Type` field is used to resolve the .NET type, and `Payload` is deserialized back into the original event object. +- **Publishing** - The event is published to the configured event bus (`InMemoryEventBus` or `RabbitMqEventBus`). +- **Error handling** - On failure, the retry count is incremented and the error message is stored. When `RetryCount` reaches `EventingOptions.OutboxMaxRetries`, the message is dead-lettered to prevent infinite retries. + +## Inbox Pattern + +The outbox guarantees that events are published. The **inbox pattern** guarantees that events are processed only once on the consumer side. + +Because the outbox provides at-least-once delivery, the same event may arrive more than once - for example, if the dispatcher crashes after publishing but before marking the message as processed. The inbox prevents duplicate handling. + +### How It Works + +Before processing an incoming integration event, the handler checks the `InboxMessages` table: + +1. **If the event ID already exists** - the event was already processed. Skip it. +2. **If the event ID does not exist** - process the event and record its ID in the inbox. + +This makes every handler idempotent by default, regardless of how many times the same event is delivered. + +### Implementation + +The framework provides `IInboxStore` as the abstraction and `EfCoreInboxStore` as the default Entity Framework Core implementation. The inbox check and insert happen within the same transaction as the handler's business logic, ensuring atomicity. + +## Event Bus Options + +The outbox dispatcher publishes events to an event bus. The framework ships with two implementations: + +| Implementation | Use Case | +|---------------|----------| +| `InMemoryEventBus` | Single-process deployments, local development. Events are dispatched in-process with no external dependencies. This is the default. | +| `RabbitMqEventBus` | Distributed deployments where modules run as separate services. Events are published to RabbitMQ exchanges for cross-process delivery. | + + + +## Configuration + +Configure eventing behavior in your `appsettings.json`: + +```json +{ + "Eventing": { + "OutboxBatchSize": 100, + "OutboxMaxRetries": 5, + "UseRabbitMq": false, + "RabbitMq": { + "Host": "localhost", + "Port": 5672 + } + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `OutboxBatchSize` | `100` | Number of pending messages fetched per dispatch cycle. | +| `OutboxMaxRetries` | `5` | Maximum publish attempts before a message is dead-lettered. | +| `UseRabbitMq` | `false` | When `true`, uses `RabbitMqEventBus` instead of `InMemoryEventBus`. | +| `RabbitMq:Host` | `localhost` | RabbitMQ server hostname. | +| `RabbitMq:Port` | `5672` | RabbitMQ server port. | + +## Reliability Guarantees + +The outbox and inbox patterns together provide strong reliability guarantees for event-driven communication: + +- **At-least-once delivery** - The outbox persists events atomically with domain changes and retries failed publishes. No event is silently lost. +- **Idempotent processing** - The inbox deduplicates incoming events by ID. Handlers can safely assume they will not process the same event twice. +- **Dead-letter handling** - Poison messages that repeatedly fail are flagged as dead-lettered after exceeding the maximum retry count. This prevents a single bad message from blocking the entire outbox queue. + + diff --git a/docs/src/content/docs/persistence-building-block.mdx b/docs/src/content/docs/persistence-building-block.mdx new file mode 100644 index 0000000000..ca92cd14d1 --- /dev/null +++ b/docs/src/content/docs/persistence-building-block.mdx @@ -0,0 +1,283 @@ +--- +title: "Persistence" +description: "Entity Framework Core abstractions, specifications, pagination, and interceptors." +--- + +import Aside from '../../components/Aside.astro'; + +The Persistence building block provides the data access foundation for every module in the starter kit. It wraps Entity Framework Core with opinionated defaults for multitenancy, auditing, soft deletes, domain event dispatch, and pagination - so modules get production-ready data access by extending a single base class. + +Key components: + +- **BaseDbContext** - multitenant-aware EF Core context with soft delete query filters +- **Specifications** - composable, reusable query objects +- **Pagination** - `ToPagedResponseAsync()` extension for consistent paged results +- **Interceptors** - automatic domain event publishing and audit field population +- **Model Builder Extensions** - convention-based global query filters +- **Database Provider Configuration** - switchable PostgreSQL / SQL Server support + +## BaseDbContext + +`BaseDbContext` is the foundation that every module's DbContext inherits from. It extends Finbuckle's `MultiTenantDbContext` to provide tenant-isolated data access out of the box. + +```csharp +public class BaseDbContext( + IMultiTenantContextAccessor multiTenantContextAccessor, + DbContextOptions options, + IOptions settings, + IHostEnvironment environment) + : MultiTenantDbContext(multiTenantContextAccessor, options) +``` + +What it does automatically: + +- **Soft delete filtering** - applies a global query filter (`WHERE IsDeleted = false`) to every entity implementing `ISoftDeletable`, so deleted records are excluded from all queries by default. +- **Tenant connection routing** - reads the current tenant's connection string from `IMultiTenantContextAccessor` and configures the database provider accordingly. Each tenant can have its own database. +- **SaveChanges override** - sets `TenantNotSetMode.Overwrite` to allow saving entities when a tenant context is being established (e.g., during seeding or migration). + +A module creates its own context by inheriting from `BaseDbContext` and registering its entity configurations: + +```csharp +public class IdentityDbContext : BaseDbContext +{ + public IdentityDbContext( + IMultiTenantContextAccessor multiTenantContextAccessor, + DbContextOptions options, + IOptions settings, + IHostEnvironment environment) + : base(multiTenantContextAccessor, options, settings, environment) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); // applies soft delete filter + modelBuilder.ApplyConfigurationsFromAssembly(typeof(IdentityDbContext).Assembly); + } +} +``` + + + +## Specifications + +The Persistence building block includes a full Specification Pattern implementation for building composable, reusable query objects. Specifications encapsulate filtering, ordering, includes, and query options into a single class. + +```csharp +public sealed class ActiveUsersByRoleSpec : Specification +{ + public ActiveUsersByRoleSpec(string role) + { + Where(u => u.IsActive); + Where(u => u.Roles.Any(r => r.Name == role)); + OrderBy(u => u.LastName); + Include(u => u.Roles); + } +} +``` + +Key features of `Specification`: + +- **Where()** - add filter criteria (combined with logical AND) +- **Include()** - eager loading via expressions or string paths +- **OrderBy() / OrderByDescending()** - primary ordering +- **ThenBy() / ThenByDescending()** - secondary ordering +- **ApplySortingOverride()** - accept client-provided sort expressions with a whitelisted key mapping +- **AsNoTracking** - enabled by default for read-optimized queries + + + +## Pagination + +The `ToPagedResponseAsync()` extension method converts any `IQueryable` into a standardized paged result. It works alongside specifications or any other query-building approach. + +### IPagedQuery + +Queries that support pagination implement `IPagedQuery`: + +```csharp +public interface IPagedQuery +{ + int? PageNumber { get; set; } // 1-based, defaults to 1 + int? PageSize { get; set; } // defaults to 20, max 100 + string? Sort { get; set; } // e.g., "Name,-CreatedOn" +} +``` + +The `Sort` property supports multi-column sorting. Prefix a column name with `-` for descending order. For example, `"Name,-CreatedOn"` sorts by Name ascending, then by CreatedOn descending. + +### PagedResponse + +The result is wrapped in a `PagedResponse`: + +```csharp +public sealed class PagedResponse +{ + public IReadOnlyCollection Items { get; init; } + public int PageNumber { get; init; } + public int PageSize { get; init; } + public long TotalCount { get; init; } + public int TotalPages { get; init; } + public bool HasNext => PageNumber < TotalPages; + public bool HasPrevious => PageNumber > 1; +} +``` + +### Usage + +```csharp +public async ValueTask> Handle( + GetUsersQuery query, CancellationToken cancellationToken) +{ + var spec = new SearchUsersSpec(query); + + return await _dbContext.Users + .WithSpecification(spec) + .Select(u => new UserDto(u.Id, u.Email, u.FirstName, u.LastName)) + .ToPagedResponseAsync(query, cancellationToken) + .ConfigureAwait(false); +} +``` + + + +## Interceptors + +The Persistence building block registers two EF Core `SaveChangesInterceptor` implementations that run automatically on every `SaveChangesAsync` call. Modules do not need to configure these - they are added globally by `AddHeroDatabaseOptions()`. + +### DomainEventsInterceptor + +Publishes domain events **after** changes have been successfully saved to the database. This ensures events are only dispatched when the underlying data change is committed. + +How it works: + +1. After `SaveChangesAsync` completes, the interceptor scans the change tracker for entities implementing `IHasDomainEvents`. +2. It collects all pending domain events from those entities and clears them. +3. Each event is published sequentially through the Mediator `IPublisher`. + +```csharp +// Events are raised by domain entities: +public void Activate() +{ + IsActive = true; + RaiseDomainEvent(new UserActivatedEvent(Id)); +} +// The interceptor publishes UserActivatedEvent after SaveChangesAsync succeeds. +``` + + + +### AuditableEntitySaveChangesInterceptor + +Automatically populates audit metadata on entities implementing `IAuditableEntity` and handles soft delete for `ISoftDeletable` entities. + +For **added** entities: +- Sets `CreatedOnUtc` to the current UTC timestamp +- Sets `CreatedBy` to the current authenticated user's ID + +For **modified** entities (including changes to owned entities): +- Sets `LastModifiedOnUtc` to the current UTC timestamp +- Sets `LastModifiedBy` to the current authenticated user's ID + +For **deleted** entities that implement `ISoftDeletable`: +- Converts the hard delete to a soft delete (`EntityState.Deleted` becomes `EntityState.Modified`) +- Sets `IsDeleted = true`, `DeletedOnUtc`, and `DeletedBy` + +```csharp +// You never need to set these manually: +entity.CreatedOnUtc = ...; // handled by interceptor +entity.LastModifiedBy = ...; // handled by interceptor +entity.IsDeleted = ...; // handled by interceptor on Delete() +``` + + + +## Model Builder Extensions + +The `ModelBuilderExtensions` class provides `AppendGlobalQueryFilter()`, which applies a query filter to every entity implementing a given interface. This is how `BaseDbContext` applies soft delete filtering without requiring each entity to configure it individually. + +```csharp +// Inside BaseDbContext.OnModelCreating: +modelBuilder.AppendGlobalQueryFilter(s => !s.IsDeleted); +``` + +The method intelligently combines with any existing query filters on an entity using logical AND, so module-specific filters and the global soft delete filter work together without conflict. + +## Database Provider Configuration + +The starter kit supports multiple database providers through the `DatabaseOptions` configuration class. The provider is selected at startup and applies to all module DbContexts. + +### Configuration + +Add the following to your `appsettings.json`: + +```json +{ + "DatabaseOptions": { + "Provider": "POSTGRESQL", + "ConnectionString": "Host=localhost;Database=fsh;Username=postgres;Password=secret", + "MigrationsAssembly": "FSH.Starter.Api" + } +} +``` + +### Supported Providers + +| Provider | Value | NuGet Package | +|----------|-------|---------------| +| PostgreSQL (default) | `POSTGRESQL` | Npgsql.EntityFrameworkCore.PostgreSQL | +| SQL Server | `MSSQL` | Microsoft.EntityFrameworkCore.SqlServer | + + + +### Development Mode + +When the application runs in the `Development` environment, the database configuration automatically enables: + +- **Sensitive data logging** - connection strings and parameter values appear in logs +- **Detailed errors** - EF Core provides verbose error messages with query details + +These are disabled in all other environments to protect sensitive information. + +### Registration + +Database options and interceptors are registered in a module's `ConfigureServices` method: + +```csharp +public void ConfigureServices(IHostApplicationBuilder builder) +{ + builder.Services.AddHeroDatabaseOptions(builder.Configuration); + builder.Services.AddHeroDbContext(); +} +``` + +`AddHeroDatabaseOptions()` registers the `DatabaseOptions` configuration, validates it on startup, and adds both the `AuditableEntitySaveChangesInterceptor` and `DomainEventsInterceptor` to the service collection. `AddHeroDbContext()` then wires up the DbContext with the correct provider, connection string, and interceptors. + +## Next Steps + +- [Specifications Pattern](/dotnet-starter-kit/specification-pattern/) - deep dive into composable query objects +- [Domain Events](/dotnet-starter-kit/domain-events/) - how events flow from entities through interceptors to handlers +- [CQRS Pattern](/dotnet-starter-kit/cqrs/) - the command/query pipeline that sits above persistence +- [Adding a Feature](/dotnet-starter-kit/adding-a-feature/) - build a complete vertical slice with data access diff --git a/docs/src/content/docs/prerequisites.mdx b/docs/src/content/docs/prerequisites.mdx new file mode 100644 index 0000000000..2c08b9cd8f --- /dev/null +++ b/docs/src/content/docs/prerequisites.mdx @@ -0,0 +1,242 @@ +--- +title: "Prerequisites" +description: "Everything you need to set up before working with the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; +import Steps from '../../components/Steps.astro'; + +The fullstackhero .NET Starter Kit requires a few tools to be installed on your machine before you can build and run the project. This guide walks you through each dependency and how to verify your setup. + + + +## Required + + + +### Install .NET 10 SDK + +The starter kit targets .NET 10. Download and install the latest SDK for your operating system. + + + +Install via **winget** from a terminal: + +```bash +winget install Microsoft.DotNet.SDK.10 +``` + +Alternatively, download the installer directly from [dot.net/download](https://dot.net/download). + + +Install via **Homebrew**: + +```bash +brew install dotnet-sdk +``` + +Alternatively, download the installer from [dot.net/download](https://dot.net/download). + + +On **Ubuntu/Debian**: + +```bash +sudo apt-get update +sudo apt-get install -y dotnet-sdk-10.0 +``` + +For other distributions, refer to the [official Microsoft documentation](https://learn.microsoft.com/en-us/dotnet/core/install/linux). + + + +Verify the installation: + +```bash +dotnet --version +``` + +You should see a version starting with `10.`. + +### Install PostgreSQL 16+ + +PostgreSQL is the primary database used by the starter kit. Version 16 or higher is recommended. + + + +Install via **winget**: + +```bash +winget install PostgreSQL.PostgreSQL.16 +``` + +Alternatively, download the installer from [postgresql.org/download](https://www.postgresql.org/download/). + + +Install via **Homebrew**: + +```bash +brew install postgresql@16 +``` + +Start the service: + +```bash +brew services start postgresql@16 +``` + + +On **Ubuntu/Debian**: + +```bash +sudo apt-get update +sudo apt-get install -y postgresql-16 +``` + + + + + +### Install Redis + +Redis is used for distributed caching and is required at runtime. + + + +Redis does not officially support Windows. Use one of these alternatives: + +- **Docker** (recommended): `docker run -d -p 6379:6379 redis:latest` +- **Memurai**: A Redis-compatible server for Windows - download from [memurai.com](https://www.memurai.com/) + + +Install via **Homebrew**: + +```bash +brew install redis +``` + +Start the service: + +```bash +brew services start redis +``` + + +On **Ubuntu/Debian**: + +```bash +sudo apt-get update +sudo apt-get install -y redis-server +``` + +Start the service: + +```bash +sudo systemctl enable redis-server +sudo systemctl start redis-server +``` + + + + + + + +## Recommended + +### .NET Aspire Workload + +Install the Aspire workload to enable the AppHost orchestrator for local development. Aspire manages service dependencies (PostgreSQL, Redis, and more) as containers, provides a developer dashboard, and simplifies the local development experience. + +```bash +dotnet workload install aspire +``` + +### Docker Desktop + +Docker is required if you want to run PostgreSQL and Redis as containers instead of installing them natively. It is also required for the Aspire AppHost. + +Download from [docker.com](https://www.docker.com/products/docker-desktop/). + +### IDE + +Choose the editor or IDE that fits your workflow. + + + +Download [Visual Studio 2022](https://visualstudio.microsoft.com/) (Community edition is free). During installation, select the **ASP.NET and web development** workload. + + +Download [Visual Studio Code](https://code.visualstudio.com/) and install the [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) extension for a full .NET development experience including solution explorer, test runner, and debugging support. + + +Download [JetBrains Rider](https://www.jetbrains.com/rider/). Rider provides built-in support for .NET, Entity Framework, and database tooling out of the box. + + + +## Verification + +Run through these checks to confirm everything is ready. + + + +### Verify .NET SDK + +```bash +dotnet --version +``` + +The output should show a version starting with `10.`. + +### Verify Aspire Workload + +```bash +dotnet workload list +``` + +You should see `aspire` in the list of installed workloads. + +### Verify PostgreSQL + +If installed natively: + +```bash +psql --version +``` + +If running via Docker: + +```bash +docker ps +``` + +Look for a running PostgreSQL container. + +### Verify Redis + +If installed natively: + +```bash +redis-cli ping +``` + +You should see `PONG` in response. If running via Docker: + +```bash +docker ps +``` + +Look for a running Redis container. + + + +## Next Steps + +Once your environment is ready, head over to the [Quick Start](/dotnet-starter-kit/quick-start/) guide to clone, build, and run the starter kit. diff --git a/docs/src/content/docs/project-structure.mdx b/docs/src/content/docs/project-structure.mdx new file mode 100644 index 0000000000..7bf5df0b7b --- /dev/null +++ b/docs/src/content/docs/project-structure.mdx @@ -0,0 +1,204 @@ +--- +title: "Project Structure" +description: "A complete guide to the fullstackhero .NET Starter Kit directory layout and project organization." +--- + +import Aside from '../../components/Aside.astro'; +import FileTree from '../../components/FileTree.astro'; + +The fullstackhero .NET Starter Kit is organized as a **modular monolith**. The codebase is split into shared framework libraries (BuildingBlocks), isolated bounded-context modules, sample host applications, and a comprehensive test suite. Every module enforces strict boundaries through a dual-project pattern, and every feature follows a consistent vertical slice layout. + +## Solution Overview + +The solution file `FSH.Starter.slnx` uses the modern XML-based `.slnx` format introduced in .NET 9. It contains **29 projects** organized into five logical groups: + +| Group | Projects | Purpose | +|-------|----------|---------| +| **BuildingBlocks** | 11 | Shared framework libraries consumed by all modules | +| **Modules** | 8 | Bounded context modules (runtime + contracts pairs) | +| **Playground** | 2 | Sample host applications (API, Aspire) | +| **Tests** | 5 | Unit tests, integration tests, and architecture tests | +| **Tools** | 2 | CLI tooling and EF Core migrations | + + + +## Top-Level Directory Layout + + + +## Module Dual-Project Structure + +Every module in the starter kit is split into exactly two projects: a **runtime** project and a **contracts** project. This separation is the foundation of module isolation. + + + +**Runtime project** (`Modules.{Name}/`) contains everything internal to the module: command and query handlers, EF Core data access, domain entities, services, and event handlers. No other module should ever reference this project directly. + +**Contracts project** (`Modules.{Name}.Contracts/`) is the public API surface. It defines the commands, queries, response DTOs, integration events, and service interfaces that other modules are allowed to consume. + + + +## Feature Folder Layout + +Inside every runtime module, features follow the **Vertical Slice Architecture** pattern. Each feature is a self-contained folder that groups its endpoint, handler, and validator together - no scattering code across layers. + +The convention is: + + + +Here is a real example from the Identity module: + + + +The corresponding command and response types live in the Contracts project: + + + +This pattern keeps every file related to a feature co-located. When you need to understand or modify user registration, everything is in one place. + +## Dependency Hierarchy + +The projects follow a strict dependency hierarchy that flows in one direction - from foundational libraries up through modules to host applications. + + + +- **Core** sits at the bottom with zero external project dependencies. It defines the DDD primitives, exception types, and CQRS interfaces that everything else builds on. +- **Shared** adds cross-cutting constants, DTOs, and permission contracts. +- **Infrastructure libraries** (Persistence, Caching, Eventing, Jobs, Mailing, Storage) each depend on Core and/or Shared but not on each other. +- **Web** ties the infrastructure together and provides the module loading system. +- **Modules** consume BuildingBlocks and other modules' Contracts projects - never their runtime projects. +- **Playground** applications sit at the top, composing modules into runnable hosts. + + + +## Configuration Files + +The `src/` directory contains several configuration files that govern how the entire solution is built and styled. + +| File | Purpose | +|------|---------| +| `Directory.Build.props` | Shared MSBuild properties applied to every project - target framework (`net10.0`), nullable reference types, implicit usings, analyzers, and common metadata. | +| `Directory.Packages.props` | Central package management. Every NuGet package version is declared here in a single place, so individual `.csproj` files reference packages without specifying versions. | +| `.editorconfig` | Coding style rules enforced by the IDE and analyzers - indentation, namespace style, naming conventions, and severity levels. | +| `FSH.Starter.slnx` | The solution file that ties all 29 projects together into the logical groups shown above. | + + diff --git a/docs/src/content/docs/querying-audits.mdx b/docs/src/content/docs/querying-audits.mdx new file mode 100644 index 0000000000..1e8b34b4e4 --- /dev/null +++ b/docs/src/content/docs/querying-audits.mdx @@ -0,0 +1,123 @@ +--- +title: "Querying Audits" +description: "Query and filter audit records by correlation, trace, security events, and exceptions." +--- + +import Aside from '../../components/Aside.astro'; + +The Auditing module exposes rich query endpoints for searching and filtering audit records. These endpoints support pagination, date ranges, identity filtering, and distributed tracing lookups. All audit query endpoints require the **AuditTrails.View** permission. + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/auditing/audits` | Paginated audit list with filtering | +| GET | `/api/v1/auditing/audits/{id}` | Audit detail by ID | +| GET | `/api/v1/auditing/audits/correlation/{correlationId}` | Audit chain by correlation ID | +| GET | `/api/v1/auditing/audits/trace/{traceId}` | Audit chain by OpenTelemetry trace ID | +| GET | `/api/v1/auditing/audits/security` | Security audit events | +| GET | `/api/v1/auditing/audits/exceptions` | Exception audit events | +| GET | `/api/v1/auditing/audits/summary` | Aggregated audit statistics | + +## Filtering + +The `GetAuditsQuery` supports a comprehensive set of filters that can be combined to narrow down audit records: + +**Date range:** +- `FromUtc` -- start of the time window (inclusive) +- `ToUtc` -- end of the time window (inclusive) + +**Identity:** +- `TenantId` -- filter by tenant context +- `UserId` -- filter by the user who performed the action + +**Classification:** +- `EventType` -- filter by audit event type (EntityChange, Security, Activity, Exception) +- `Severity` -- filter by severity level (Trace through Critical) +- `Tags` -- filter by comma-separated tags +- `Source` -- filter by the originating source (e.g., class name, endpoint) + +**Tracing:** +- `CorrelationId` -- filter by correlation identifier +- `TraceId` -- filter by OpenTelemetry trace identifier + +**Search:** +- `Search` -- free-text search across user name, source, tags, and payload + +**Pagination:** +- `PageNumber` -- page index (1-based) +- `PageSize` -- number of records per page +- `Sort` -- sort expression (e.g., `OccurredAtUtc desc`) + +## Correlation Queries + +The correlation endpoint lets you follow a request's complete journey across the system. When a single user action triggers multiple audit events -- an HTTP request, entity changes, and integration events -- they all share the same `CorrelationId`. + +Querying by correlation returns all related audit events in chronological order, giving you a full picture of what happened during a single logical operation: + +``` +GET /api/v1/auditing/audits/correlation/abc-123-def +``` + +This returns every audit record tagged with that correlation ID, ordered by `OccurredAtUtc`, so you can trace the complete chain from the initial HTTP request through entity modifications and any downstream events. + +## Trace Queries + +The trace endpoint uses OpenTelemetry's `TraceId` to retrieve all audit events within a distributed trace. This is particularly useful when you have observability tooling (Jaeger, Zipkin, Aspire Dashboard) and want to see the audit perspective of a trace: + +``` +GET /api/v1/auditing/audits/trace/4bf92f3577b34da6a3ce929d0e0e4736 +``` + +Unlike correlation queries which follow application-level grouping, trace queries follow the infrastructure-level distributed trace, capturing events that may span multiple services or background workers within the same trace context. + +## Security Audits + +The security endpoint provides a filtered view of authentication and authorization events: + +``` +GET /api/v1/auditing/audits/security +``` + +This returns audit records with `EventType = Security`, including login attempts (successful and failed), token issuance, token revocations, and permission changes. The same date range, identity, and pagination filters apply. + +Security audits are essential for compliance reporting and investigating unauthorized access attempts. + +## Exception Audits + +The exception endpoint surfaces error events with their full context: + +``` +GET /api/v1/auditing/audits/exceptions +``` + +This returns audit records with `EventType = Exception`, including the exception message, stack trace, severity level, and the request context in which the error occurred. Use severity filtering to focus on critical errors or widen the view to include warnings. + +## Summary Endpoint + +The summary endpoint returns aggregated statistics about your audit data: + +``` +GET /api/v1/auditing/audits/summary +``` + +The response includes: + +- **Event counts by type** -- how many EntityChange, Security, Activity, and Exception events occurred +- **Severity distribution** -- breakdown of events across severity levels +- **Top users** -- users with the most audit activity +- **Top sources** -- most active sources of audit events + +The summary accepts the same date range filters (`FromUtc`, `ToUtc`) to scope the aggregation to a specific time window. + +## Response DTOs + +The module returns different response shapes depending on the endpoint: + +- **AuditDetailDto** -- full audit record including the complete `PayloadJson`, used by the detail and list endpoints +- **AuditSummaryDto** -- lightweight representation without the payload, used in correlation and trace chain responses for faster transfer +- **AuditSummaryAggregateDto** -- statistical aggregation with counts, distributions, and rankings, used by the summary endpoint + + diff --git a/docs/src/content/docs/quick-start.mdx b/docs/src/content/docs/quick-start.mdx new file mode 100644 index 0000000000..3cb2c81ffd --- /dev/null +++ b/docs/src/content/docs/quick-start.mdx @@ -0,0 +1,173 @@ +--- +title: "Quick Start" +description: "Get the fullstackhero .NET Starter Kit up and running in minutes." +--- + +import Aside from '../../components/Aside.astro'; +import Badge from '../../components/Badge.astro'; +import Steps from '../../components/Steps.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +Get the starter kit running locally in under 5 minutes. + + + +## Installation Pick one + + + + +Install the FSH CLI tool and create a new project with the interactive wizard: + +```bash +dotnet tool install -g FullStackHero.CLI +fsh doctor # verify your environment +fsh new MyApp +cd MyApp +``` + +The wizard lets you choose your database provider (PostgreSQL/SQL Server) and whether to include the Aspire AppHost. + +Other useful commands: + +```bash +fsh info # show versions and available updates +fsh update # update CLI and template to latest +``` + + + + +Install the template and scaffold a new project: + +```bash +dotnet new install FullStackHero.NET.StarterKit +dotnet new fsh -n MyApp +cd MyApp +``` + +Available template options: + +```bash +dotnet new fsh -n MyApp --db sqlserver # use SQL Server instead of PostgreSQL +dotnet new fsh -n MyApp --aspire false # skip Aspire AppHost +``` + + + + +Clone the repository directly: + +```bash +git clone https://github.com/fullstackhero/dotnet-starter-kit.git MyApp +cd MyApp +``` + + + + + + +## Run the Application 2 min + + + +### Start the Application + + + + +```bash +dotnet run --project src/Playground/MyApp.AppHost +``` + +Aspire orchestrates PostgreSQL, Redis, and the API automatically - no manual setup needed. Once it starts, open the Aspire dashboard at the URL shown in your terminal output to monitor all running services, view logs, and inspect traces. + + + + + + +```bash +dotnet run --project src/Playground/MyApp.Api +``` + + + + + + +```bash +docker-compose up -d +``` + +This builds and runs the entire stack including PostgreSQL, Redis, and the API in containers. No local dependencies needed beyond Docker itself. + + + + +### Explore the API + +Once the application is running: + +- **API base URL** - `https://localhost:7030` (or the port shown in your terminal) +- **Scalar API docs** - `https://localhost:7030/scalar/v1` +- **Health check** - `https://localhost:7030/health` + +Open the Scalar UI in your browser to browse all available endpoints, inspect request/response schemas, and execute API calls directly from the documentation interface. It is the fastest way to explore what the starter kit offers out of the box. + + + +## Your First API Call + +Start by generating an authentication token against the root tenant: + +```bash +curl -X POST https://localhost:7030/api/v1/identity/tokens \ + -H "Content-Type: application/json" \ + -H "X-Tenant-ID: root" \ + -d '{"email": "admin@root.com", "password": "123Pa$$word!"}' +``` + +Copy the `token` value from the response and use it to call a protected endpoint: + +```bash +curl https://localhost:7030/api/v1/identity/users \ + -H "Authorization: Bearer {token}" \ + -H "X-Tenant-ID: root" +``` + + + +## Run Tests + +```bash +dotnet test src/MyApp.slnx +``` + +This runs the full test suite including architecture tests (module boundary enforcement via NetArchTest), unit tests, and module-level integration tests. + + + + + +## Next Steps + +- [Project Structure](/dotnet-starter-kit/project-structure/) - understand the codebase layout +- [Architecture](/dotnet-starter-kit/architecture/) - learn about the modular monolith design +- [Adding a Feature](/dotnet-starter-kit/adding-a-feature/) - build your first feature diff --git a/docs/src/content/docs/rate-limiting.mdx b/docs/src/content/docs/rate-limiting.mdx new file mode 100644 index 0000000000..bfdf98d0d7 --- /dev/null +++ b/docs/src/content/docs/rate-limiting.mdx @@ -0,0 +1,121 @@ +--- +title: "Rate Limiting" +description: "Protecting API endpoints with fixed window rate limiting." +--- + +import Aside from '../../components/Aside.astro'; + +## Overview + +The fullstackhero .NET Starter Kit includes built-in rate limiting using the ASP.NET Core rate limiting middleware. Two policies are configured out of the box: a **global** policy for general API traffic and an **auth** policy for authentication-related endpoints. + +## Policies + +### Global Policy + +The `global` policy applies a fixed window rate limit across all API endpoints: + +- **Permit Limit:** 100 requests +- **Window:** 60 seconds +- **Partition Key:** Client IP address + +This protects the API from excessive traffic while allowing normal usage patterns. + +### Auth Policy + +The `auth` policy applies a stricter limit to authentication endpoints (token, register, refresh): + +- **Permit Limit:** 10 requests +- **Window:** 60 seconds +- **Partition Key:** Client IP address + + + +## Configuration + +Rate limiting is configured through `RateLimitingOptions` and `FixedWindowPolicyOptions`: + +```csharp +public class RateLimitingOptions +{ + public FixedWindowPolicyOptions Global { get; set; } = new() + { + PermitLimit = 100, + WindowInSeconds = 60 + }; + + public FixedWindowPolicyOptions Auth { get; set; } = new() + { + PermitLimit = 10, + WindowInSeconds = 60 + }; +} +``` + +Override these values in your `appsettings.json`: + +```json +{ + "RateLimiting": { + "Global": { + "PermitLimit": 200, + "WindowInSeconds": 60 + }, + "Auth": { + "PermitLimit": 5, + "WindowInSeconds": 60 + } + } +} +``` + +## Applying to Endpoints + +The `auth` rate limiting policy is applied to authentication endpoints using `.RequireRateLimiting()`: + +```csharp +endpoints.MapPost("/token", handler) + .RequireRateLimiting("auth"); + +endpoints.MapPost("/register", handler) + .RequireRateLimiting("auth"); + +endpoints.MapPost("/refresh", handler) + .RequireRateLimiting("auth"); +``` + +All other endpoints fall under the `global` policy automatically. + +## Health Endpoint Exclusion + +Health check endpoints (`/health`, `/alive`) are excluded from rate limiting. This ensures that load balancers and orchestrators can always reach health probes, even under heavy traffic. + +## Tenant-Aware Rate Limiting + +Partition keys can include the tenant ID for per-tenant limits. This prevents a single noisy tenant from consuming the rate limit quota of other tenants sharing the same infrastructure. + +## Response + +When a client exceeds the rate limit, the API returns: + +- **Status Code:** `429 Too Many Requests` +- **Header:** `Retry-After` indicating how many seconds the client should wait before retrying. + +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 45 +Content-Type: application/problem+json + +{ + "type": "https://httpstatuses.com/429", + "title": "Too Many Requests", + "status": 429, + "detail": "Rate limit exceeded. Try again in 45 seconds." +} +``` + + diff --git a/docs/src/content/docs/roles-and-permissions.mdx b/docs/src/content/docs/roles-and-permissions.mdx new file mode 100644 index 0000000000..54a54a9798 --- /dev/null +++ b/docs/src/content/docs/roles-and-permissions.mdx @@ -0,0 +1,216 @@ +--- +title: "Roles & Permissions" +description: "Role management and permission-based authorization in the Identity module." +--- + +import Aside from '../../components/Aside.astro'; + +## Overview + +The Identity module implements **role-based access control (RBAC)** with fine-grained permissions. The authorization model is composed of three layers: + +- **Permissions** - atomic actions on specific resources (e.g., `Permissions.Users.Create`) +- **Roles** - named collections of permissions assigned to users +- **Groups** - organizational units that aggregate roles, making it easy to assign common permission sets to many users at once + +A user's effective permissions are the **union** of all permissions from their directly assigned roles and the roles inherited through group membership. + +## Permission Model + +Permissions follow the `Permissions.{Resource}.{Action}` naming convention and are represented by the `FshPermission` record: + +```csharp +public record FshPermission(string Description, string Action, string Resource, bool IsBasic = false, bool IsRoot = false) +{ + public string Name => NameFor(Action, Resource); + public static string NameFor(string action, string resource) + { + return $"Permissions.{resource}.{action}"; + } +} +``` + +Each permission has two flags that control which default roles receive it: + +- **IsBasic** - included in the `Basic` role (and by extension, `Admin`) +- **IsRoot** - reserved for root-level operations (e.g., tenant management), excluded from both default roles + +Actions and resources are defined as constants in `ActionConstants` and `ResourceConstants` for type-safe, refactor-friendly references. + +## Built-in Permissions + +The framework ships with the following permission areas, registered in `PermissionConstants`: + +| Resource | Actions | IsBasic | IsRoot | +|----------|---------|---------|--------| +| **Users** | View, Search, Create, Update, Delete, Export | View | | +| **UserRoles** | View, Update | View | | +| **Roles** | View, Create, Update, Delete | View | | +| **RoleClaims** | View, Update | View | | +| **Sessions** | View, Revoke, ViewAll, RevokeAll | | | +| **Groups** | View, Create, Update, Delete, ManageMembers | | | +| **Tenants** | View, Create, Update, UpgradeSubscription | | Yes | +| **AuditTrails** | View | View | | +| **Dashboard** | View | View | | +| **Hangfire** | View | View | | + +Modules can register additional permissions at startup using `PermissionConstants.Register()`: + +```csharp +PermissionConstants.Register(new[] +{ + new FshPermission("View Products", ActionConstants.View, "Products", IsBasic: true), + new FshPermission("Create Products", ActionConstants.Create, "Products"), +}); +``` + +## Default Roles + +Two roles are seeded automatically for every tenant during database initialization: + +| Role | Permissions | Deletable | +|------|-------------|-----------| +| **Admin** | All permissions except `IsRoot` | No | +| **Basic** | Only permissions marked `IsBasic = true` | No | + + + +During tenant provisioning, the framework also creates an **Administrators** system group with the Admin role assigned, and adds the tenant's admin user to it. + +## Role Endpoints + +| Method | Path | Description | Permission | +|--------|------|-------------|------------| +| GET | `/roles` | List all roles | `Permissions.Roles.View` | +| GET | `/roles/{id}` | Get role by ID | `Permissions.Roles.View` | +| GET | `/{id}/permissions` | Get role with permissions | `Permissions.Roles.View` | +| POST | `/roles` | Create or update role | `Permissions.Roles.Create` | +| PUT | `/{id}/permissions` | Update role permissions | `Permissions.Roles.Update` | +| DELETE | `/roles/{id}` | Delete role | `Permissions.Roles.Delete` | + +All paths are relative to `api/v1/identity`. + +## Applying Permissions to Endpoints + +Permissions are applied to endpoints using the `.RequirePermission()` extension method, which attaches `RequiredPermissionAttribute` metadata to the endpoint: + +```csharp +group.MapGetRolesEndpoint(); // internally calls .RequirePermission(IdentityPermissionConstants.Roles.View) +``` + +The underlying endpoint implementation looks like this: + +```csharp +public static RouteHandlerBuilder MapGetRolesEndpoint(this IEndpointRouteBuilder endpoints) +{ + return endpoints.MapGet("/roles", async (IMediator mediator, CancellationToken cancellationToken) => + TypedResults.Ok(await mediator.Send(new GetRolesQuery(), cancellationToken))) + .WithName("ListRoles") + .WithSummary("List all roles") + .RequirePermission(IdentityPermissionConstants.Roles.View); +} +``` + +The `RequirePermission` extension method is defined in `EndpointExtensions` and works with any `IEndpointConventionBuilder`: + +```csharp +public static TBuilder RequirePermission( + this TBuilder endpointConventionBuilder, + string requiredPermission, + params string[] additionalRequiredPermissions) + where TBuilder : IEndpointConventionBuilder +{ + return endpointConventionBuilder.WithMetadata( + new RequiredPermissionAttribute(requiredPermission, additionalRequiredPermissions)); +} +``` + +## Permission Resolution + +When a request reaches a protected endpoint, the `RequiredPermissionAuthorizationHandler` performs the authorization check: + +1. The handler reads the `IRequiredPermissionMetadata` from the endpoint metadata to determine which permission is required. +2. It extracts the user ID from the authenticated `ClaimsPrincipal`. +3. It calls `IUserService.HasPermissionAsync()` to check whether the user holds the required permission. +4. If the permission is present, the request is authorized; otherwise, it receives a `403 Forbidden` response. + +```csharp +public sealed class RequiredPermissionAuthorizationHandler(IUserService userService) + : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionAuthorizationRequirement requirement) + { + var endpoint = context.Resource switch + { + HttpContext ctx => ctx.GetEndpoint(), + Endpoint ep => ep, + _ => null, + }; + + var requiredPermissions = endpoint?.Metadata + .GetMetadata()?.RequiredPermissions; + + if (requiredPermissions == null) + { + context.Succeed(requirement); + return; + } + + if (context.User?.GetUserId() is { } userId + && await userService.HasPermissionAsync(userId, requiredPermissions.First(), cancellationToken)) + { + context.Succeed(requirement); + } + } +} +``` + +A user's effective permissions are the **union** of: + +- Permissions from **directly assigned roles** (user-role mapping) +- Permissions from **group roles** (roles assigned to groups the user belongs to) + +These permissions are aggregated when the user authenticates and stored as claims in the JWT token. + + + +## Permission Constants + +For type-safe permission checks in endpoint definitions, the framework provides `IdentityPermissionConstants`: + +```csharp +public static class IdentityPermissionConstants +{ + public static class Users + { + public const string View = "Permissions.Users.View"; + public const string Create = "Permissions.Users.Create"; + public const string Update = "Permissions.Users.Update"; + public const string Delete = "Permissions.Users.Delete"; + public const string ManageRoles = "Permissions.Users.ManageRoles"; + } + + public static class Roles + { + public const string View = "Permissions.Roles.View"; + public const string Create = "Permissions.Roles.Create"; + public const string Update = "Permissions.Roles.Update"; + public const string Delete = "Permissions.Roles.Delete"; + } + + public static class Sessions { ... } + public static class Groups { ... } +} +``` + +Always reference these constants rather than hard-coding permission strings to keep endpoint definitions refactor-safe. diff --git a/docs/src/content/docs/security-headers.mdx b/docs/src/content/docs/security-headers.mdx new file mode 100644 index 0000000000..36a4f6a0ba --- /dev/null +++ b/docs/src/content/docs/security-headers.mdx @@ -0,0 +1,81 @@ +--- +title: "Security Headers" +description: "HTTP security headers for defense-in-depth." +--- + +import Aside from '../../components/Aside.astro'; + +## Overview + +The `SecurityHeadersMiddleware` adds a comprehensive set of HTTP security headers to every response. These headers provide defense-in-depth against common web attacks including XSS, clickjacking, MIME sniffing, and information leakage. + +## Default Headers + +The following headers are applied to every HTTP response: + +| Header | Value | Purpose | +|--------|-------|---------| +| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing | +| `X-Frame-Options` | `DENY` | Prevent clickjacking via iframes | +| `X-XSS-Protection` | `0` | Disable legacy XSS filter (CSP handles it) | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer information leakage | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Restrict browser feature access | +| `Content-Security-Policy` | `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'` | XSS prevention via content restrictions | +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Enforce HTTPS for one year | + +## Configuration + +Security headers can be customized through `SecurityHeadersOptions`: + +```csharp +builder.Services.Configure(options => +{ + options.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'nonce-{random}'"; + options.PermissionsPolicy = "camera=(), microphone=(), geolocation=(), payment=()"; + options.ReferrerPolicy = "no-referrer"; +}); +``` + +You can configure these values in `appsettings.json` as well: + +```json +{ + "SecurityHeaders": { + "ContentSecurityPolicy": "default-src 'self'", + "ReferrerPolicy": "strict-origin-when-cross-origin", + "PermissionsPolicy": "camera=(), microphone=(), geolocation=()" + } +} +``` + + + + + +## Header Details + +### Content-Security-Policy + +CSP is the primary defense against XSS attacks. The default policy restricts all content sources to the same origin (`'self'`), with an exception for inline styles (`'unsafe-inline'`) which is needed by many CSS frameworks. + +For stricter CSP, consider using nonce-based script loading: + +``` +script-src 'self' 'nonce-{random}' +``` + +### Strict-Transport-Security + +HSTS tells browsers to only connect via HTTPS for the specified duration. The `includeSubDomains` directive extends this to all subdomains. The default `max-age` of 31536000 seconds equals one year. + +### X-Frame-Options + +Set to `DENY` to prevent any site from embedding your application in an iframe. If you need to allow embedding from your own domain, change this to `SAMEORIGIN`. + +### Permissions-Policy + +Restricts access to browser features. The default configuration disables camera, microphone, and geolocation access. Add additional restrictions as needed for your security requirements. diff --git a/docs/src/content/docs/server-sent-events.mdx b/docs/src/content/docs/server-sent-events.mdx new file mode 100644 index 0000000000..dfeca35862 --- /dev/null +++ b/docs/src/content/docs/server-sent-events.mdx @@ -0,0 +1,226 @@ +--- +title: "Server-Sent Events" +description: "Real-time push notifications to clients using Server-Sent Events (SSE)." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +fullstackhero includes a built-in **Server-Sent Events (SSE)** infrastructure for pushing real-time notifications to connected clients. The implementation uses `System.Threading.Channels` for backpressure-aware event buffering and supports targeted sends (per user), tenant-scoped broadcasts, and global broadcasts. + +## Registration + +Enable SSE via `FshPlatformOptions` and `FshPipelineOptions`: + +```csharp +builder.AddHeroPlatform(options => +{ + options.EnableSse = true; +}); + +// ... + +app.UseHeroPlatform(options => +{ + options.MapSseEndpoints = true; +}); +``` + +`EnableSse` registers the `SseConnectionManager` as a singleton in DI. `MapSseEndpoints` maps the SSE streaming endpoint. Both default to `false` and must be explicitly enabled. + +## SSE Endpoint + +The SSE endpoint is mapped at: + +``` +GET /api/v1/sse/stream +``` + +It requires authentication. The user ID is extracted from the `ClaimTypes.NameIdentifier` claim, and the tenant ID from the `"tenant"` claim. + +The endpoint sets the following response headers: + +| Header | Value | Purpose | +|--------|-------|---------| +| `Content-Type` | `text/event-stream` | SSE content type | +| `Cache-Control` | `no-cache` | Prevent response caching | +| `Connection` | `keep-alive` | Keep the connection open | +| `X-Accel-Buffering` | `no` | Disable Nginx proxy buffering | + +The connection stays open until the client disconnects. Events are streamed as they arrive following the [SSE specification](https://html.spec.whatwg.org/multipage/server-sent-events.html). + +## SseEvent + +The `SseEvent` record represents a single event to push to a connected client: + +```csharp +public sealed record SseEvent(string EventType, string Data, string? Id = null); +``` + +| Property | Description | +|----------|-------------| +| `EventType` | Maps to the SSE `event:` field. Clients use this to listen for specific event types. | +| `Data` | The event payload, typically JSON. Maps to the SSE `data:` field. Multi-line data is handled automatically. | +| `Id` | Optional event ID for client reconnection tracking. Maps to the SSE `id:` field. | + +## SseConnectionManager + +The `SseConnectionManager` is a singleton that manages all active SSE connections. It is thread-safe via `ConcurrentDictionary` and uses bounded channels (capacity 100, drop-oldest policy) to prevent slow clients from causing memory issues. + +### Connect + +```csharp +ChannelReader Connect(string userId, string? tenantId = null) +``` + +Creates a bounded channel for the user and returns the reader. The SSE endpoint reads from this channel and writes events to the HTTP response. If a user reconnects, the previous channel is replaced. + +### Disconnect + +```csharp +void Disconnect(string userId) +``` + +Removes the user's channel and completes the writer, causing the SSE endpoint to finish gracefully. + +### TrySend + +```csharp +bool TrySend(string userId, SseEvent sseEvent) +``` + +Sends an event to a specific user. Returns `false` if the user is not connected or the channel is full (after dropping the oldest event). + +### Broadcast + +```csharp +int Broadcast(string tenantId, SseEvent sseEvent) +``` + +Sends an event to all connected users in a specific tenant. Returns the number of users who received the event. + +### BroadcastAll + +```csharp +int BroadcastAll(SseEvent sseEvent) +``` + +Sends an event to every connected user across all tenants. Returns the count of users reached. + +### ActiveConnections + +```csharp +int ActiveConnections { get; } +``` + +Returns the current number of active SSE connections. + +## Client-Side Consumption + +Connect to the SSE endpoint using the browser's `EventSource` API: + +```javascript +const token = "your-jwt-token"; + +// EventSource doesn't support custom headers natively, +// so use a polyfill or fetch-based approach for auth +const eventSource = new EventSource("/api/v1/sse/stream", { + headers: { + "Authorization": `Bearer ${token}` + } +}); + +// Listen for specific event types +eventSource.addEventListener("order.created", (event) => { + const data = JSON.parse(event.data); + console.log("New order:", data); +}); + +eventSource.addEventListener("notification", (event) => { + const data = JSON.parse(event.data); + showToast(data.message); +}); + +// Handle connection lifecycle +eventSource.onopen = () => console.log("SSE connected"); +eventSource.onerror = (err) => console.error("SSE error:", err); +``` + + + +## Pushing Events from Handlers + +Inject `SseConnectionManager` into your command handlers, domain event handlers, or integration event handlers to push real-time updates: + +```csharp +public sealed class OrderCreatedHandler : INotificationHandler +{ + private readonly SseConnectionManager _sse; + + public OrderCreatedHandler(SseConnectionManager sse) + { + _sse = sse; + } + + public async ValueTask Handle(OrderCreatedEvent notification, CancellationToken ct) + { + var payload = JsonSerializer.Serialize(new + { + orderId = notification.OrderId, + total = notification.Total + }); + + // Send to the specific user who placed the order + _sse.TrySend(notification.UserId, new SseEvent("order.created", payload)); + + // Or broadcast to all users in the tenant + if (notification.TenantId is not null) + { + _sse.Broadcast(notification.TenantId, new SseEvent("order.created", payload)); + } + + await ValueTask.CompletedTask.ConfigureAwait(false); + } +} +``` + +## Reverse Proxy Configuration + +SSE connections are long-lived HTTP responses that stream data incrementally. Many reverse proxies buffer responses by default, which breaks SSE. The endpoint already sets `X-Accel-Buffering: no` for Nginx, but you may need additional configuration depending on your proxy: + +### Nginx + +```nginx +location /api/v1/sse/ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; # Keep connection alive for 1 hour +} +``` + +### Azure App Service / Application Gateway + +Azure Application Gateway and App Service support SSE out of the box. Ensure your timeout settings accommodate long-lived connections - the default idle timeout is 4 minutes, which you may want to increase. + + + +## Channel Behavior + +Each user connection uses a bounded channel with these settings: + +| Setting | Value | Description | +|---------|-------|-------------| +| Capacity | 100 | Maximum buffered events per connection | +| Full mode | Drop oldest | When the buffer is full, the oldest event is discarded | +| Single reader | true | Optimized for a single SSE endpoint reader | +| Single writer | false | Multiple producers can write events concurrently | + +This ensures that a slow client consuming events cannot block event production for other users, and memory usage remains bounded regardless of client behavior. diff --git a/docs/src/content/docs/sessions-and-groups.mdx b/docs/src/content/docs/sessions-and-groups.mdx new file mode 100644 index 0000000000..d89fdf9705 --- /dev/null +++ b/docs/src/content/docs/sessions-and-groups.mdx @@ -0,0 +1,117 @@ +--- +title: "Sessions & Groups" +description: "User session tracking with device detection and group-based permission management." +--- + +import Aside from '../../components/Aside.astro'; + +## Sessions + +The Identity module provides full **user session tracking** with device detection, IP logging, and revocation support. Every time a user authenticates, a `UserSession` record is created that captures the device, browser, operating system, and network details of the request. Sessions can be revoked individually or in bulk, both by the owning user and by administrators. + +### UserSession Entity + +The `UserSession` entity captures a comprehensive snapshot of each authentication session: + +**Identity & Ownership** + +- **UserId** - the authenticated user this session belongs to +- **RefreshTokenHash** - hashed refresh token tied to this session + +**Network & Client** + +- **IpAddress** - originating IP address of the session +- **UserAgent** - raw User-Agent header from the client request + +**Device Information** + +- **DeviceType** - classified device category (Desktop, Mobile, Tablet, etc.) +- **Browser** - browser name (Chrome, Firefox, Safari, etc.) +- **BrowserVersion** - browser version string +- **OperatingSystem** - OS name (Windows, macOS, Linux, Android, iOS, etc.) +- **OsVersion** - OS version string + +**Timestamps** + +- **CreatedAt** - when the session was first created +- **LastActivityAt** - updated on each token refresh to track session liveness +- **ExpiresAt** - absolute expiration time for the session + +**Revocation** + +- **IsRevoked** - whether the session has been revoked +- **RevokedAt** - timestamp of revocation +- **RevokedBy** - user ID of who revoked the session (self or admin) +- **RevokedReason** - optional reason for revocation + +### Endpoints + +| Method | Path | Description | Permission | +|--------|------|-------------|------------| +| GET | `/sessions/me` | Get my sessions | `Sessions.View` | +| DELETE | `/sessions/{id}` | Revoke my session | `Sessions.Revoke` | +| DELETE | `/sessions` | Revoke all my sessions | `Sessions.Revoke` | +| GET | `/sessions/users/{userId}` | Get user sessions (admin) | `Sessions.View` | +| DELETE | `/sessions/admin/{id}` | Revoke session (admin) | `Sessions.Revoke` | +| DELETE | `/sessions/admin/users/{userId}` | Revoke all user sessions (admin) | `Sessions.Revoke` | + +### Session Lifecycle + +Sessions follow a predictable lifecycle: + +1. **Created** - a new `UserSession` is created when a token pair is generated during authentication +2. **Updated** - the `LastActivityAt` timestamp is refreshed each time the user performs a token refresh, keeping the session alive +3. **Expired** - sessions that pass their `ExpiresAt` time are no longer valid +4. **Revoked** - sessions can be explicitly revoked by the user or an administrator +5. **Cleaned up** - the `SessionCleanupHostedService` runs as a background service and periodically removes expired and revoked sessions from the database + +### Device Detection + +The `DeviceTypeClassifier` parses the incoming `User-Agent` header to extract structured device information. This enables the UI to show users a human-readable list of their active sessions (e.g., "Chrome 125 on Windows 11 - Desktop") and helps administrators identify suspicious login patterns across devices and locations. + +## Groups + +Groups provide a layer of indirection between users and roles, making permission management significantly easier at scale. Instead of assigning five individual roles to each new user, you create a group containing those roles and add users to the group. + +### Group Entity + +The `Group` entity supports the following properties: + +- **Name** - display name for the group +- **Description** - optional description of the group's purpose +- **IsDefault** - when true, new users are automatically added to this group during registration +- **IsSystemGroup** - marks framework-managed groups that cannot be deleted +- **CreatedOnUtc** - audit timestamp for creation +- **LastModifiedOnUtc** - audit timestamp for last modification + +Groups also support **soft delete**, so removing a group does not permanently destroy the record. + +### Endpoints + +| Method | Path | Description | Permission | +|--------|------|-------------|------------| +| GET | `/groups` | List groups | `Groups.View` | +| GET | `/groups/{id}` | Get group detail | `Groups.View` | +| POST | `/groups` | Create group | `Groups.Create` | +| PUT | `/groups/{id}` | Update group | `Groups.Update` | +| DELETE | `/groups/{id}` | Delete group | `Groups.Delete` | +| GET | `/groups/{id}/members` | Get members | `Groups.View` | +| POST | `/groups/{id}/members` | Add users to group | `Groups.Update` | +| DELETE | `/groups/{id}/members/{userId}` | Remove member | `Groups.Update` | + +### Permission Aggregation + +A user's effective permissions are the union of: + +1. **Direct role assignments** - roles assigned explicitly to the user +2. **Group-derived roles** - roles inherited from all groups the user belongs to + +The `GroupRoleService` resolves group-derived permissions at login time, merging them with direct assignments to produce the final set of claims written into the JWT. This means permission changes to a group take effect the next time affected users authenticate. + + + + diff --git a/docs/src/content/docs/shared-library.mdx b/docs/src/content/docs/shared-library.mdx new file mode 100644 index 0000000000..2d77a5af1c --- /dev/null +++ b/docs/src/content/docs/shared-library.mdx @@ -0,0 +1,357 @@ +--- +title: "Shared" +description: "Constants, DTOs, and cross-cutting contracts shared across all modules." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +The Shared building block contains constants, DTOs, and cross-cutting contracts that are referenced by multiple modules. It has no business logic -- only data structures, constants, and lightweight extension methods that need to be shared across module boundaries. + + + +## Identity constants + +The identity-related constants define the permission system, roles, claims, and resources used throughout the framework. + +### Permission structure + +Permissions follow the pattern `Permissions.{Resource}.{Action}`. For example: + +- `Permissions.Users.Create` +- `Permissions.Roles.View` +- `Permissions.Tenants.Update` + +### ActionConstants + +Defines the standard actions available in the permission system: + +```csharp +public static class ActionConstants +{ + public const string View = nameof(View); + public const string Search = nameof(Search); + public const string Create = nameof(Create); + public const string Update = nameof(Update); + public const string Delete = nameof(Delete); + public const string Export = nameof(Export); + public const string Generate = nameof(Generate); + public const string Clean = nameof(Clean); + public const string UpgradeSubscription = nameof(UpgradeSubscription); +} +``` + +### ResourceConstants + +Defines the resources that permissions can be applied to: + +```csharp +public static class ResourceConstants +{ + public const string Tenants = nameof(Tenants); + public const string Dashboard = nameof(Dashboard); + public const string Hangfire = nameof(Hangfire); + public const string Users = nameof(Users); + public const string UserRoles = nameof(UserRoles); + public const string Roles = nameof(Roles); + public const string RoleClaims = nameof(RoleClaims); + public const string AuditTrails = nameof(AuditTrails); +} +``` + +### PermissionConstants + +The `PermissionConstants` class maintains a registry of all `FshPermission` records in the system. Each permission is a combination of an action, a resource, and flags indicating whether it is a root-only or basic-tier permission. + +```csharp +public record FshPermission( + string Description, + string Action, + string Resource, + bool IsBasic = false, + bool IsRoot = false) +{ + public string Name => NameFor(Action, Resource); + public static string NameFor(string action, string resource) + => $"Permissions.{resource}.{action}"; +} +``` + +Permissions are grouped into tiers: + +| Tier | Description | Example | +|------|-------------|---------| +| **Root** | Only available to the root tenant | `Permissions.Tenants.View`, `Permissions.Tenants.Create` | +| **Admin** | Available to all admin users (non-root) | `Permissions.Users.Create`, `Permissions.Roles.Delete` | +| **Basic** | Available to all authenticated users | `Permissions.Users.View`, `Permissions.Roles.View` | + +Modules can register additional permissions dynamically: + +```csharp +PermissionConstants.Register(new[] +{ + new FshPermission("View Products", ActionConstants.View, "Products", IsBasic: true), + new FshPermission("Create Products", ActionConstants.Create, "Products"), +}); +``` + +### IdentityPermissionConstants + +Provides strongly-typed permission string constants for the Identity module, organized by entity: + +```csharp +public static class IdentityPermissionConstants +{ + public static class Users + { + public const string View = "Permissions.Users.View"; + public const string Create = "Permissions.Users.Create"; + public const string Update = "Permissions.Users.Update"; + public const string Delete = "Permissions.Users.Delete"; + public const string ManageRoles = "Permissions.Users.ManageRoles"; + } + + public static class Roles { /* View, Create, Update, Delete */ } + public static class Sessions { /* View, Revoke, ViewAll, RevokeAll */ } + public static class Groups { /* View, Create, Update, Delete, ManageMembers */ } +} +``` + +### RoleConstants + +Defines the built-in roles: + +```csharp +public static class RoleConstants +{ + public const string Admin = nameof(Admin); + public const string Basic = nameof(Basic); + + public static IReadOnlyList DefaultRoles { get; } + public static bool IsDefault(string roleName); +} +``` + +### ClaimConstants and CustomClaims + +Define the custom JWT claim types used throughout the framework: + +```csharp +public static class ClaimConstants +{ + public const string Tenant = "tenant"; + public const string Fullname = "fullName"; + public const string Permission = "permission"; + public const string ImageUrl = "image_url"; + public const string IpAddress = "ipAddress"; + public const string Expiration = "exp"; +} +``` + +## RequiredPermissionAttribute + +The `RequiredPermissionAttribute` is applied to endpoints to enforce permission checks. It implements `IRequiredPermissionMetadata` and supports one or more required permissions: + +```csharp +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequiredPermissionAttribute : Attribute, IRequiredPermissionMetadata +{ + public HashSet RequiredPermissions { get; } + + public RequiredPermissionAttribute( + string? requiredPermission, + params string[]? additionalRequiredPermissions); +} +``` + +In practice, endpoints use the `RequirePermission` extension method which applies this attribute as endpoint metadata: + +```csharp +endpoints.MapPost("/register", handler) + .RequirePermission(IdentityPermissionConstants.Users.Create); +``` + +## ClaimsPrincipalExtensions + +A set of extension methods on `ClaimsPrincipal` for extracting common claim values: + +| Method | Returns | Claim type | +|--------|---------|------------| +| `GetEmail()` | `string?` | `ClaimTypes.Email` | +| `GetTenant()` | `string?` | `tenant` | +| `GetFullName()` | `string?` | `fullName` | +| `GetFirstName()` | `string?` | `ClaimTypes.Name` | +| `GetSurname()` | `string?` | `ClaimTypes.Surname` | +| `GetPhoneNumber()` | `string?` | `ClaimTypes.MobilePhone` | +| `GetUserId()` | `string?` | `ClaimTypes.NameIdentifier` | +| `GetImageUrl()` | `Uri?` | `image_url` | +| `GetExpiration()` | `DateTimeOffset` | `exp` | + +```csharp +// Example usage in a handler +var userId = httpContext.User.GetUserId(); +var tenantId = httpContext.User.GetTenant(); +``` + +## Multitenancy + +### MultitenancyConstants + +Defines the root tenant seed data and configuration keys: + +```csharp +public static class MultitenancyConstants +{ + public static class Root + { + public const string Id = "root"; + public const string Name = "Root"; + public const string EmailAddress = "admin@root.com"; + } + + public const string DefaultPassword = "123Pa$$word!"; + public const string Identifier = "tenant"; + public const string Schema = "tenant"; + + public static class Permissions + { + public const string View = "Permissions.Tenants.View"; + public const string Create = "Permissions.Tenants.Create"; + public const string Update = "Permissions.Tenants.Update"; + public const string ViewTheme = "Permissions.Tenants.ViewTheme"; + public const string UpdateTheme = "Permissions.Tenants.UpdateTheme"; + } +} +``` + +### AppTenantInfo and IAppTenantInfo + +`AppTenantInfo` extends Finbuckle's `TenantInfo` with application-specific properties: + +```csharp +public class AppTenantInfo : TenantInfo, IAppTenantInfo +{ + public string ConnectionString { get; set; } + public string AdminEmail { get; set; } + public bool IsActive { get; set; } + public DateTime ValidUpto { get; set; } + public string? Issuer { get; set; } + + public void AddValidity(int months); + public void SetValidity(in DateTime validTill); + public void Activate(); + public void Deactivate(); +} +``` + +New tenants are created with a default validity of 1 month. The root tenant cannot be activated or deactivated. + +## Persistence + +### DatabaseOptions + +Configuration for database provider selection and connection: + +```csharp +public class DatabaseOptions : IValidatableObject +{ + public string Provider { get; set; } = DbProviders.PostgreSQL; + public string ConnectionString { get; set; } = string.Empty; + public string MigrationsAssembly { get; set; } = string.Empty; +} +``` + +### DbProviders + +Supported database provider constants: + +```csharp +public static class DbProviders +{ + public const string PostgreSQL = "POSTGRESQL"; + public const string MSSQL = "MSSQL"; +} +``` + +### IPagedQuery + +A shared pagination and sorting contract for query requests: + +```csharp +public interface IPagedQuery +{ + int? PageNumber { get; set; } // 1-based page number + int? PageSize { get; set; } // Requested page size + string? Sort { get; set; } // e.g., "Name,-CreatedOn" (- prefix = descending) +} +``` + +### `PagedResponse` + +A generic wrapper for paginated query results: + +```csharp +public sealed class PagedResponse +{ + public IReadOnlyCollection Items { get; init; } + public int PageNumber { get; init; } + public int PageSize { get; init; } + public long TotalCount { get; init; } + public int TotalPages { get; init; } + public bool HasNext { get; } + public bool HasPrevious { get; } +} +``` + +## Storage + +### FileUploadRequest + +A shared DTO for file uploads, consumed by the Storage building block: + +```csharp +public class FileUploadRequest +{ + public string FileName { get; set; } = default!; + public string ContentType { get; set; } = default!; + public List Data { get; set; } = []; +} +``` + +## Project structure + +``` +Shared/ + Identity/ + ActionConstants.cs + ClaimConstants.cs + CustomClaims.cs + IdentityPermissionConstants.cs + PermissionConstants.cs + ResourceConstants.cs + RoleConstants.cs + AuditingPermissionConstants.cs + Authorization/ + EndpointExtensions.cs + RequiredPermissionAttribute.cs + Claims/ + ClaimsPrincipalExtensions.cs + Multitenancy/ + AppTenantInfo.cs + IAppTenantInfo.cs + MultitenancyConstants.cs + Persistence/ + DatabaseOptions.cs + DbProviders.cs + IPagedQuery.cs + PagedResponse.cs + Storage/ + FileUploadRequest.cs + Auditing/ + AuditAttributes.cs +``` diff --git a/docs/src/content/docs/specification-pattern.mdx b/docs/src/content/docs/specification-pattern.mdx new file mode 100644 index 0000000000..352f596215 --- /dev/null +++ b/docs/src/content/docs/specification-pattern.mdx @@ -0,0 +1,207 @@ +--- +title: "Specifications Pattern" +description: "Composable, reusable query specifications for Entity Framework Core in the fullstackhero .NET Starter Kit." +--- + +import Aside from '../../components/Aside.astro'; + +fullstackhero uses the **Specification Pattern** to encapsulate query logic into reusable, composable objects. Instead of scattering LINQ expressions across command and query handlers, you define each query shape once in a specification class and apply it through a consistent evaluation pipeline. + +## What is the Specification Pattern? + +In a typical EF Core application, query logic tends to accumulate directly inside handlers - filtering, includes, sorting, and projections are all inline. This leads to duplication and makes queries difficult to test in isolation. + +The Specification Pattern solves this by moving query concerns into dedicated classes that describe **what** data is needed rather than **how** to retrieve it. Each specification declares its criteria, includes, ordering, and projections, and the framework evaluates them against an `IQueryable` at runtime. + +## Specification Base Class + +The `Specification` base class provides the full API surface for building query specifications: + +```csharp +public abstract class Specification : ISpecification where T : class +{ + private readonly List>> _criteria = []; + private readonly List>> _includes = []; + private readonly List _includeStrings = []; + private readonly List> _orderExpressions = []; + + protected Specification() { AsNoTracking = true; } // Read-only by default + + public Expression>? Criteria => /* combines all criteria with AND */; + public IReadOnlyList>> Includes => ...; + public IReadOnlyList> OrderExpressions => ...; + public bool AsNoTracking { get; private set; } + public bool AsSplitQuery { get; } + public bool IgnoreQueryFilters { get; } + + protected void Where(Expression> expression); + protected void Include(Expression> includeExpression); + protected void Include(string includeString); + protected void OrderBy(Expression> keySelector); + protected void OrderByDescending(Expression> keySelector); + protected void ThenBy(Expression> keySelector); + protected void ThenByDescending(Expression> keySelector); + protected void ClearOrderExpressions(); + protected void AsNoTrackingQuery(); + protected void ApplySortingOverride( + string? sortExpression, + Action applyDefaultOrdering, + IReadOnlyDictionary>> sortMappings); +} +``` + +### Key Design Decisions + +- **`AsNoTracking` is `true` by default.** Specifications are optimized for reads. EF Core skips change tracking overhead, which significantly improves performance for list and detail queries. +- **Multiple `Where()` calls are AND-combined.** Each call adds a criterion. At evaluation time, all criteria are combined into a single expression with logical AND, so you can build up filters incrementally. +- **Both expression-based and string-based includes are supported.** Use strongly-typed expressions for compile-time safety, or string-based includes for deeply nested navigation paths. + + + +## Creating a Specification + +Define a specification by inheriting from `Specification` and configuring criteria, ordering, and projections in the constructor: + +```csharp +public sealed class GetTenantsSpecification : Specification +{ + public GetTenantsSpecification(GetTenantsQuery query) + { + // Filtering + if (!string.IsNullOrEmpty(query.Search)) + Where(t => t.Name!.Contains(query.Search) || t.Identifier!.Contains(query.Search)); + + if (query.IsActive.HasValue) + Where(t => t.IsActive == query.IsActive.Value); + + // Projection + Select(t => new TenantDto(t.Id, t.Identifier!, t.Name!, t.IsActive)); + + // Sorting with client override + ApplySortingOverride(query.Sort, + () => OrderByDescending(t => t.Name!), + new Dictionary>> + { + ["name"] = t => t.Name!, + ["identifier"] = t => t.Identifier!, + }); + } +} +``` + +The constructor receives the query object and uses it to conditionally build up criteria. Each `Where()` call adds an independent filter - they are all AND-combined at evaluation time. The `Select()` call defines the projection to a DTO, and `ApplySortingOverride()` handles client-controlled sorting with a safe fallback. + +## Client-Controlled Sorting + +The `ApplySortingOverride` method allows API clients to control sort order through a string expression while keeping the server in control of what is sortable. + +Clients pass sort strings in the format `"name,-identifier"` where: +- A bare key (e.g., `name`) sorts ascending +- A `-` prefix (e.g., `-identifier`) sorts descending +- Multiple keys are comma-separated and applied in order + +The method accepts three parameters: +1. **`sortExpression`** - the raw sort string from the client (nullable) +2. **`applyDefaultOrdering`** - a callback that sets the default sort when no valid client keys are provided +3. **`sortMappings`** - a whitelist dictionary mapping allowed sort keys to their corresponding expressions + +```csharp +ApplySortingOverride(query.Sort, + () => OrderByDescending(t => t.CreatedAt), + new Dictionary>> + { + ["name"] = t => t.Name!, + ["identifier"] = t => t.Identifier!, + ["createdAt"] = t => t.CreatedAt, + }); +``` + +If the client sends `"name,-createdAt"`, the specification applies `OrderBy(Name)` then `ThenByDescending(CreatedAt)`. If the client sends an empty string or only invalid keys, the default ordering callback runs instead. + + + +## Using Specifications in Handlers + +Apply a specification in your query handler using the `WithSpecification` extension method: + +```csharp +public async ValueTask> Handle(GetTenantsQuery query, CancellationToken ct) +{ + var spec = new GetTenantsSpecification(query); + return await _dbContext.Set() + .WithSpecification(spec) + .ToPagedResultAsync(query.PageNumber, query.PageSize, ct); +} +``` + +The handler remains focused on orchestration - constructing the specification, applying it to the query, and returning the result. All query details live in the specification class. + +### How Evaluation Works + +Under the hood, `SpecificationEvaluator` applies the specification to an `IQueryable` in a defined order: + +```csharp +internal static class SpecificationEvaluator +{ + public static IQueryable GetQuery(IQueryable inputQuery, ISpecification specification) where T : class + { + IQueryable query = inputQuery; + query = ApplyQueryBehaviors(query, specification); // NoTracking, SplitQuery, IgnoreFilters + query = ApplyCriteria(query, specification); // Where clauses + query = ApplyIncludes(query, specification); // Eager loading + query = ApplyOrdering(query, specification); // OrderBy/ThenBy + return query; + } +} +``` + +The evaluation pipeline ensures a consistent order: query behaviors first, then criteria, includes, and finally ordering. This is handled automatically - you never call the evaluator directly. + +## Specification with Projection + +For queries that return DTOs instead of entities, use the two-type-parameter variant `Specification`. This allows you to define a `Select()` projection as part of the specification: + +```csharp +public sealed class GetUserDetailsSpecification : Specification +{ + public GetUserDetailsSpecification(Guid userId) + { + Where(u => u.Id == userId); + Select(u => new UserDetailDto(u.Id, u.Email!, u.FirstName, u.LastName)); + } +} +``` + +Projections are translated to SQL by EF Core, so only the selected columns are fetched from the database - not the entire entity. + +## Available Methods + +Quick reference for all protected methods available in `Specification`: + +| Method | Description | +|--------|-------------| +| `Where(expression)` | Add a filter criterion. Multiple calls are AND-combined. | +| `Include(expression)` | Add a strongly-typed eager-load include. | +| `Include(string)` | Add a string-based include for nested navigation paths. | +| `Select(expression)` | Define a projection (only on `Specification`). | +| `OrderBy(expression)` | Set primary ascending sort. | +| `OrderByDescending(expression)` | Set primary descending sort. | +| `ThenBy(expression)` | Add secondary ascending sort. | +| `ThenByDescending(expression)` | Add secondary descending sort. | +| `ClearOrderExpressions()` | Remove all previously added sort expressions. | +| `AsNoTrackingQuery()` | Explicitly enable `AsNoTracking` (already the default). | +| `ApplySortingOverride(sort, default, mappings)` | Enable client-controlled sorting with a whitelist and fallback. | + + diff --git a/docs/src/content/docs/tenant-provisioning.mdx b/docs/src/content/docs/tenant-provisioning.mdx new file mode 100644 index 0000000000..cae82500b4 --- /dev/null +++ b/docs/src/content/docs/tenant-provisioning.mdx @@ -0,0 +1,134 @@ +--- +title: "Tenant Provisioning" +description: "Tenant lifecycle management, auto-provisioning, themes, and health checks." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +When a new tenant is created, it needs database migration, seeding, and configuration before it can serve requests. The provisioning system handles this lifecycle end-to-end, tracking each step and supporting retries on failure. + +## Provisioning Workflow + + +1. **Validate and create tenant** - The `CreateTenantCommand` validates that the tenant identifier and admin email are unique, then persists the `AppTenantInfo` record. + +2. **Create provisioning record** - A `TenantProvisioning` entity is created with `Status = Pending` and a unique `CorrelationId` for tracing. + +3. **Enqueue Hangfire job** - A background `TenantProvisioningJob` is enqueued via Hangfire. The job ID is stored on the provisioning record. + +4. **Database step** - The job sets the tenant context and verifies database connectivity. The provisioning record transitions to `Status = Running`. + +5. **Migrations step** - EF Core migrations run against the tenant's connection string, applying all pending schema changes. + +6. **Seeding step** - Default data is seeded: roles, the tenant admin user, and any module-specific seed data. + +7. **Cache warm step** - The tenant store cache is warmed so the tenant is immediately resolvable. + +8. **Mark completed** - On success, the provisioning record transitions to `Status = Completed`. On failure at any step, it transitions to `Status = Failed` with the error recorded. + + +## TenantProvisioning Entity + +The `TenantProvisioning` entity tracks the state of a single provisioning run: + +| Property | Type | Description | +|----------|------|-------------| +| `Id` | `Guid` | Unique provisioning record ID | +| `TenantId` | `string` | The tenant being provisioned | +| `CorrelationId` | `string` | Trace correlation ID | +| `Status` | `TenantProvisioningStatus` | `Pending`, `Running`, `Completed`, or `Failed` | +| `CurrentStep` | `string?` | The step currently executing (or the step that failed) | +| `Error` | `string?` | Error message if provisioning failed | +| `JobId` | `string?` | Hangfire background job ID | +| `CreatedUtc` | `DateTime` | When the provisioning was requested | +| `StartedUtc` | `DateTime?` | When execution began | +| `CompletedUtc` | `DateTime?` | When execution finished (success or failure) | + +Each provisioning run also tracks individual **TenantProvisioningStep** records for granular progress: + +| Step Name | Order | Description | +|-----------|-------|-------------| +| `Database` | 1 | Verify database connectivity | +| `Migrations` | 2 | Apply EF Core migrations | +| `Seeding` | 3 | Seed default roles and admin user | +| `CacheWarm` | 4 | Warm the distributed tenant cache | + +## Auto-Provisioning + +The `TenantAutoProvisioningHostedService` runs on application startup and ensures all tenants are provisioned: + +- When `AutoProvisionOnStartup` is `true` (the default), it checks each tenant for a completed provisioning record and enqueues jobs for any that are missing or failed. +- When `RunTenantMigrationsOnStartup` is `true`, it unconditionally enqueues provisioning for all tenants - useful in development to keep databases up to date. +- The service checks that Hangfire storage is initialized before attempting to enqueue. If Hangfire is not ready, it logs a warning and skips. + +## Provisioning Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/tenants/{id}/provisioning` | Get the latest provisioning status for a tenant | +| `POST` | `/tenants/{id}/provisioning/retry` | Retry provisioning for a tenant that failed | + +## Tenant Themes + +Each tenant can have a custom visual theme applied to the UI. The `TenantTheme` entity stores the full palette and brand configuration: + +**Light palette** (9 colors): Primary, Secondary, Tertiary, Background, Surface, Error, Warning, Success, Info + +**Dark palette** (9 colors): matching dark-mode variants for each color above + +**Brand assets:** + +| Property | Description | +|----------|-------------| +| `LogoUrl` | Light-mode logo URL | +| `LogoDarkUrl` | Dark-mode logo URL | +| `FaviconUrl` | Browser favicon URL | + +**Typography:** + +| Property | Default | Description | +|----------|---------|-------------| +| `FontFamily` | `Inter, sans-serif` | Body text font | +| `HeadingFontFamily` | `Inter, sans-serif` | Heading font | +| `FontSizeBase` | `14` | Base font size in pixels | +| `LineHeightBase` | `1.5` | Base line height multiplier | + +**Layout:** + +| Property | Default | Description | +|----------|---------|-------------| +| `BorderRadius` | `4px` | Default border radius | +| `DefaultElevation` | `1` | Default elevation level | + +### Theme Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/tenants/{id}/theme` | Get the tenant's current theme | +| `PUT` | `/tenants/{id}/theme` | Update the tenant's theme | +| `POST` | `/tenants/{id}/theme/reset` | Reset the theme to framework defaults | + +The root tenant can mark a theme as `IsDefault`, which serves as the template for newly created tenants. + +## Health Checks + +The `TenantMigrationsHealthCheck` iterates over all registered tenants and checks each database for pending EF Core migrations. It reports per-tenant details including: + +- Tenant name and active status +- Subscription validity (`ValidUpto`) +- Whether pending migrations exist +- List of pending migration names +- Any connection errors + +The health check is registered under the name `db:tenants-migrations` and always returns `Healthy` status with detailed data in the response body - it is informational rather than a failure trigger. + +## Subscription Management + +New tenants are created with a **1-month trial** (`ValidUpto` is set to one month from creation). The `UpgradeTenant` command extends the subscription by a specified number of months using `AppTenantInfo.AddValidity()`. + +Subscription cannot be backdated - calling `SetValidity()` with a date earlier than the current `ValidUpto` throws an `InvalidOperationException`. + + diff --git a/docs/src/content/docs/testing.mdx b/docs/src/content/docs/testing.mdx new file mode 100644 index 0000000000..7dff87c332 --- /dev/null +++ b/docs/src/content/docs/testing.mdx @@ -0,0 +1,448 @@ +--- +title: "Testing" +description: "Testing strategy, architecture tests, and unit test patterns." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +The fullstackhero .NET Starter Kit uses a layered testing strategy that catches architectural violations, validates business logic, and ensures correctness across module boundaries. + +## Testing Pyramid + +The test suite is organized into three tiers: + +1. **Architecture tests** -- structural guardrails that enforce module boundaries, naming conventions, and dependency rules. These prevent accidental coupling before it reaches code review. +2. **Unit tests** -- fast, isolated tests for handlers, validators, domain entities, and services. These validate business logic without databases or HTTP. +3. **Integration tests** -- end-to-end tests that verify API behavior against a real database and HTTP pipeline. + +## Test Stack + +| Library | Purpose | +|---------|---------| +| **xUnit** | Test framework and runner | +| **Shouldly** | Fluent assertion library (`result.ShouldBe(...)`, `result.ShouldNotBeNull()`) | +| **NSubstitute** | Mocking framework (`Substitute.For()`) | +| **AutoFixture** | Test data generation (`_fixture.Create()`) | +| **NetArchTest** | Architecture rule enforcement via reflection | + +## Naming Convention + +All test methods follow the pattern: + +``` +MethodName_Should_ExpectedBehavior_When_Condition +``` + +Examples: + +```csharp +Handle_Should_ReturnRegisteredUserId_When_RegistrationIsSuccessful() +Handle_Should_ThrowArgumentNullException_When_CommandIsNull() +Handle_Should_HandleNullPhoneNumber_When_NotProvided() +Validate_Should_HaveError_When_NameIsEmpty() +``` + +## Test Structure + +Every test class uses the **Arrange-Act-Assert** pattern, organized with `#region` blocks to group tests by category: + +```csharp +public sealed class CreateProductCommandHandlerTests +{ + // Fields and constructor (System Under Test + dependencies) + + #region Handle - Happy Path + + [Fact] + public async Task Handle_Should_ReturnProductId_When_CommandIsValid() + { + // Arrange + // Act + // Assert + } + + #endregion + + #region Handle - Exception + + [Fact] + public async Task Handle_Should_ThrowException_When_ServiceFails() + { + // Arrange + // Act & Assert + } + + #endregion + + #region Handle - Edge Cases + + [Fact] + public async Task Handle_Should_HandleEmptyStrings_When_ProvidedInCommand() + { + // Arrange + // Act + // Assert + } + + #endregion +} +``` + +The standard region groupings are: +- **Happy Path** -- normal successful execution +- **Exception** -- error conditions and expected failures +- **Edge Cases** -- boundary values, nulls, empty strings + +## Architecture Tests + +Architecture tests are the first line of defense against structural drift. They run as regular xUnit tests and use NetArchTest and file-system scanning to enforce rules. + +All architecture tests live in `src/Tests/Architecture.Tests/`. + +### Module Boundary Enforcement + +Prevents runtime modules from referencing each other's implementation projects. Modules must communicate through Contracts projects only. + +```csharp +[Fact] +public void Modules_Should_Not_Depend_On_Other_Modules() +{ + string modulesRoot = Path.Combine(solutionRoot, "src", "Modules"); + + var runtimeProjects = Directory + .GetFiles(modulesRoot, "Modules.*.csproj", SearchOption.AllDirectories) + .Where(path => !path.Contains(".Contracts", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + foreach (string projectPath in runtimeProjects) + { + string currentName = Path.GetFileNameWithoutExtension(projectPath); + var document = XDocument.Load(projectPath); + var references = document + .Descendants("ProjectReference") + .Select(x => (string?)x.Attribute("Include") ?? string.Empty) + .Where(include => include.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)); + + foreach (string include in references) + { + string referencedName = Path.GetFileNameWithoutExtension(include); + bool isModuleRuntime = referencedName.StartsWith("Modules.", StringComparison.OrdinalIgnoreCase) + && !referencedName.EndsWith(".Contracts", StringComparison.OrdinalIgnoreCase); + + if (!isModuleRuntime) continue; + + bool isSelfReference = string.Equals(referencedName, currentName, StringComparison.OrdinalIgnoreCase); + isSelfReference.ShouldBeTrue( + $"Module '{currentName}' must not reference '{referencedName}'. " + + "Only contracts or building block projects are allowed."); + } + } +} +``` + +### Contracts Purity Tests + +Ensures Contracts projects remain pure data transfer objects and interfaces, free from implementation concerns: + +```csharp +[Fact] +public void Contracts_Should_Not_Depend_On_EntityFramework() +{ + foreach (var assembly in ContractsAssemblies) + { + var result = Types + .InAssembly(assembly) + .ShouldNot() + .HaveDependencyOn("Microsoft.EntityFrameworkCore") + .GetResult(); + + result.IsSuccessful.ShouldBeTrue( + $"Contracts assembly '{assembly.GetName().Name}' should not depend on Entity Framework."); + } +} + +[Fact] +public void Contracts_Should_Not_Depend_On_FluentValidation() +{ + // Validators belong in the module implementation, not contracts + foreach (var assembly in ContractsAssemblies) + { + var result = Types + .InAssembly(assembly) + .ShouldNot() + .HaveDependencyOn("FluentValidation") + .GetResult(); + + result.IsSuccessful.ShouldBeTrue( + $"Contracts assembly '{assembly.GetName().Name}' should not depend on FluentValidation."); + } +} +``` + +### Feature Version Isolation + +Prevents v1 features from depending on v2+ namespaces, ensuring clean API versioning: + +```csharp +[Fact] +public void Features_Versions_Should_Not_Depend_On_Newer_Versions() +{ + foreach (var module in ModuleAssemblies) + { + var result = Types + .InAssembly(module) + .That() + .ResideInNamespaceEndingWith(".Features.v1") + .Should() + .NotHaveDependencyOnAny(".Features.v2", ".Features.v3") + .GetResult(); + + result.IsSuccessful.ShouldBeTrue( + $"v1 features must not depend on newer feature versions."); + } +} +``` + +### Handler-Validator Pairing + +Identifies command handlers that are missing corresponding validators, helping maintain validation coverage: + +```csharp +[Fact] +public void CommandHandlers_Should_Have_Corresponding_Validators() +{ + // Scans all ICommandHandler<,> implementations + // Checks for a matching {CommandName}Validator in the same assembly + // Reports missing validators as coverage gaps +} + +[Fact] +public void QueryHandlers_With_Pagination_Should_Have_Validators() +{ + // Queries with PageNumber/PageSize properties MUST have validators + // to prevent unbounded queries +} +``` + +## Unit Test Patterns + +### Handler Test with NSubstitute and Shouldly + +This is the standard pattern for testing CQRS handlers. Dependencies are mocked with NSubstitute, test data is generated with AutoFixture, and assertions use Shouldly. + +```csharp +using AutoFixture; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using FSH.Modules.Identity.Features.v1.Users.RegisterUser; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Shouldly; + +namespace Identity.Tests.Handlers; + +public sealed class RegisterUserCommandHandlerTests +{ + private readonly IUserService _userService; + private readonly RegisterUserCommandHandler _sut; + private readonly IFixture _fixture; + + public RegisterUserCommandHandlerTests() + { + _userService = Substitute.For(); + _sut = new RegisterUserCommandHandler(_userService); + _fixture = new Fixture(); + } + + #region Handle - Happy Path + + [Fact] + public async Task Handle_Should_ReturnRegisteredUserId_When_RegistrationIsSuccessful() + { + // Arrange + var command = new RegisterUserCommand + { + FirstName = "John", + LastName = "Doe", + Email = "john.doe@example.com", + UserName = "johndoe", + Password = "Password123!", + ConfirmPassword = "Password123!", + PhoneNumber = "+1234567890", + Origin = "web" + }; + + var expectedUserId = _fixture.Create(); + + _userService.RegisterAsync( + command.FirstName, command.LastName, command.Email, + command.UserName, command.Password, command.ConfirmPassword, + command.PhoneNumber, command.Origin, + Arg.Any()) + .Returns(expectedUserId); + + // Act + var result = await _sut.Handle(command, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.UserId.ShouldBe(expectedUserId); + } + + #endregion + + #region Handle - Exception + + [Fact] + public async Task Handle_Should_ThrowException_When_UserServiceThrows() + { + // Arrange + var command = new RegisterUserCommand + { + FirstName = "John", LastName = "Doe", + Email = "john.doe@example.com", UserName = "johndoe", + Password = "Password123!", ConfirmPassword = "Password123!", + PhoneNumber = "+1234567890", Origin = "web" + }; + + _userService.RegisterAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Email already exists")); + + // Act & Assert + var exception = await Should.ThrowAsync( + async () => await _sut.Handle(command, CancellationToken.None)); + + exception.Message.ShouldBe("Email already exists"); + } + + #endregion + + #region Handle - Null Command + + [Fact] + public async Task Handle_Should_ThrowArgumentNullException_When_CommandIsNull() + { + // Act & Assert + await Should.ThrowAsync( + async () => await _sut.Handle(null!, CancellationToken.None)); + } + + #endregion +} +``` + +### Validator Test Pattern + +```csharp +using FluentValidation.TestHelper; +using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using FSH.Modules.Identity.Features.v1.Users.RegisterUser; + +namespace Identity.Tests.Validators; + +public sealed class RegisterUserCommandValidatorTests +{ + private readonly RegisterUserCommandValidator _validator = new(); + + [Fact] + public void Validate_Should_HaveNoErrors_When_CommandIsValid() + { + // Arrange + var command = new RegisterUserCommand + { + FirstName = "John", + LastName = "Doe", + Email = "john@example.com", + UserName = "johndoe", + Password = "Password123!", + ConfirmPassword = "Password123!" + }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_Should_HaveError_When_EmailIsEmpty() + { + // Arrange + var command = new RegisterUserCommand + { + FirstName = "John", + LastName = "Doe", + Email = "", + UserName = "johndoe", + Password = "Password123!", + ConfirmPassword = "Password123!" + }; + + // Act + var result = _validator.TestValidate(command); + + // Assert + result.ShouldHaveValidationErrorFor(x => x.Email); + } +} +``` + +## Test Project Structure + +Each module has a corresponding test project under `src/Tests/`: + +``` +src/Tests/ + Architecture.Tests/ # Cross-cutting architecture rules + Identity.Tests/ # Identity module unit tests + Handlers/ + Validators/ + Services/ + Multitenancy.Tests/ # Multitenancy module unit tests + Handlers/ + Domain/ + Provisioning/ + Auditing.Tests/ # Auditing module unit tests + Contracts/ + Http/ + Serialization/ + Generic.Tests/ # Shared/cross-cutting tests + Architecture/ + Validators/ +``` + +## Running Tests + +Run the entire test suite: + +```bash +dotnet test src/FSH.Starter.slnx +``` + +Run only architecture tests: + +```bash +dotnet test src/FSH.Starter.slnx --filter "FullyQualifiedName~Architecture.Tests" +``` + +Run tests for a specific module: + +```bash +dotnet test src/FSH.Starter.slnx --filter "FullyQualifiedName~Identity.Tests" +``` + +Run with verbose output: + +```bash +dotnet test src/FSH.Starter.slnx --verbosity normal +``` + + diff --git a/docs/src/content/docs/user-management.mdx b/docs/src/content/docs/user-management.mdx new file mode 100644 index 0000000000..61003a9ba9 --- /dev/null +++ b/docs/src/content/docs/user-management.mdx @@ -0,0 +1,137 @@ +--- +title: "Users" +description: "User registration, management, search, and profile operations in the Identity module." +--- + +import Aside from '../../components/Aside.astro'; +import Steps from '../../components/Steps.astro'; + +User management covers registration, profile management, status toggle, search, and deletion. All endpoints are under `api/v1/identity/` and require authentication unless noted otherwise. + +## Endpoints + +| Method | Path | Description | Permission | +|--------|------|-------------|------------| +| `POST` | `/register` | Register a new user | `Users.Create` | +| `POST` | `/self-register` | Self-registration (public) | `AllowAnonymous` | +| `GET` | `/users` | List users (paginated) | `Users.View` | +| `GET` | `/users/search` | Search users with filters | `Users.View` | +| `GET` | `/users/{id}` | Get user by ID | `Users.View` | +| `GET` | `/profile` | Get current user profile | Authenticated | +| `PUT` | `/profile` | Update current user profile | `Users.Update` | +| `PATCH` | `/users/{id}` | Activate/deactivate user | `Users.Update` | +| `DELETE` | `/users/{id}` | Delete user | `Users.Delete` | +| `GET` | `/permissions` | Get current user permissions | `Users.View` | +| `GET` | `/users/{id}/roles` | Get user roles | `Users.View` | +| `POST` | `/users/{id}/roles` | Assign roles to user | `Users.ManageRoles` | +| `GET` | `/users/{userId}/groups` | Get user groups | `Groups.View` | + +## Registration Flow + + + +### Client sends registration request + +Send a `POST` request to `/register` with a `RegisterUserCommand` payload: + +```csharp +public class RegisterUserCommand : ICommand +{ + public string FirstName { get; set; } + public string LastName { get; set; } + public string Email { get; set; } + public string UserName { get; set; } + public string Password { get; set; } + public string ConfirmPassword { get; set; } + public string? PhoneNumber { get; set; } +} +``` + +### Validation runs automatically + +The `RegisterUserCommandValidator` is invoked by the `ValidationBehavior` pipeline. It enforces: + +- `FirstName` and `LastName` are required (max 100 characters) +- `Email` must be a valid email address +- `UserName` is required (3--50 characters) +- `Password` is required (min 6 characters) +- `ConfirmPassword` must match `Password` +- `PhoneNumber` is optional (max 20 characters) + +### Handler delegates to IUserService + +`RegisterUserCommandHandler` calls `IUserService.RegisterAsync()`, which uses ASP.NET Identity's `UserManager` to create the user account. + +### Domain event is raised + +A `UserRegisteredEvent` domain event is raised when the user is successfully created. + +### Integration event published via outbox + +The domain event triggers an integration event that is published through the outbox pattern, enabling downstream actions such as sending a welcome email. + +### Response returned + +The endpoint returns a `201 Created` response with the new user's ID: + +```csharp +public record RegisterUserResponse(string UserId); +``` + + + +## Search and Filtering + +The `/users/search` endpoint accepts a `SearchUsersQuery` with the following parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `Search` | `string?` | Filters across `FirstName`, `LastName`, `Email`, and `UserName` (case-insensitive contains) | +| `IsActive` | `bool?` | Filter by active/inactive status | +| `EmailConfirmed` | `bool?` | Filter by email confirmation status | +| `RoleId` | `string?` | Filter by role membership | +| `Sort` | `string?` | Sort field(s), comma-separated. Prefix with `-` for descending | +| `PageNumber` | `int?` | Page number (1-based) | +| `PageSize` | `int?` | Number of items per page | + +**Sortable fields:** `firstname`, `lastname`, `email`, `username`, `isactive` + +**Example request:** + +``` +GET /api/v1/identity/users/search?Search=john&IsActive=true&Sort=-lastname&PageNumber=1&PageSize=20 +``` + +The response is a `PagedResponse` containing `Items`, `PageNumber`, `PageSize`, `TotalCount`, and `TotalPages`. + + + +## User Status + +The `PATCH /users/{id}` endpoint toggles a user's active status. Send a `ToggleUserStatusCommand` with the `ActivateUser` flag: + +```csharp +public class ToggleUserStatusCommand : ICommand +{ + public bool ActivateUser { get; set; } + public string? UserId { get; set; } +} +``` + +Setting `ActivateUser` to `true` activates the user; `false` deactivates them. The handler raises a `UserActivatedEvent` or `UserDeactivatedEvent` domain event accordingly. + + + +## Self-Registration + +The `POST /self-register` endpoint allows unauthenticated users to create their own accounts. It uses the same `RegisterUserCommand` payload as the admin registration endpoint but differs in two ways: + +- **No authentication required** -- the endpoint is marked with `AllowAnonymous` +- **Tenant header required** -- the `tenant` header must be provided since there is no authenticated user to resolve the tenant from +- **Rate limited** -- the endpoint uses the `auth` rate limiting policy to prevent abuse + +After self-registration, a confirmation email is sent to the user's email address. The account remains inactive until the email is confirmed via the `/confirm-email` endpoint. diff --git a/docs/src/content/docs/web-building-block.mdx b/docs/src/content/docs/web-building-block.mdx new file mode 100644 index 0000000000..8d55be8593 --- /dev/null +++ b/docs/src/content/docs/web-building-block.mdx @@ -0,0 +1,515 @@ +--- +title: "Web" +description: "Host infrastructure, middleware, module system, and API configuration." +--- + +import Aside from '../../components/Aside.astro'; + +The Web building block is the **host infrastructure layer** for the fullstackhero .NET Starter Kit. It provides the complete pipeline that turns a bare ASP.NET Core application into a production-ready API host -- module loading, middleware orchestration, exception handling, API versioning, rate limiting, security headers, OpenAPI documentation, health checks, and observability. + +Everything is wired through two entry points: + +- **`AddHeroPlatform()`** -- registers all services during host building. +- **`UseHeroPlatform()`** -- configures the middleware pipeline at startup. + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.AddHeroPlatform(options => +{ + options.EnableCors = true; + options.EnableOpenApi = true; + options.EnableCaching = true; + options.EnableJobs = true; + options.EnableOpenTelemetry = true; +}); + +var app = builder.Build(); + +app.UseHeroPlatform(options => +{ + options.UseCors = true; + options.UseOpenApi = true; + options.MapModules = true; +}); +``` + + + +## Module System + +The Web building block contains the module loading infrastructure that discovers and orchestrates all modules at startup. Each module implements `IModule` and is registered via the assembly-level `FshModuleAttribute`. The `ModuleLoader` class scans assemblies, orders modules by their `Order` property, and invokes their lifecycle hooks. + +```csharp +builder.AddModules(typeof(IdentityModule).Assembly, typeof(AuditingModule).Assembly); +``` + +This auto-registers FluentValidation validators from the provided assemblies and calls `ConfigureServices` on each module in order. + + + +## Middleware Stack + +The `UseHeroPlatform()` method configures the middleware pipeline in a specific order. The sequence matters -- middleware runs top-to-bottom on requests and bottom-to-top on responses. + +``` +Request + │ + ├── ExceptionHandler (GlobalExceptionHandler) + ├── HTTPS Redirection + ├── SecurityHeadersMiddleware + ├── Static Files (optional) + ├── Job Dashboard (Hangfire) + ├── Routing + ├── CORS + ├── OpenAPI + Scalar + ├── Authentication + ├── Module Middlewares (e.g., AuditHttpMiddleware) + ├── Rate Limiting + ├── Authorization + ├── Module Endpoints + ├── Health Endpoints + └── CurrentUserMiddleware + │ +Response +``` + + + +## Exception Handling + +The `GlobalExceptionHandler` implements `IExceptionHandler` and converts all exceptions into [RFC 9457 ProblemDetails](https://www.rfc-editor.org/rfc/rfc9457) responses. This ensures every error the API returns has a consistent, machine-readable structure. + +### Exception Mapping + +| Exception Type | HTTP Status | Title | +|----------------|-------------|-------| +| `FluentValidation.ValidationException` | 400 Bad Request | Validation error | +| `CustomException` | Uses `StatusCode` property | Exception type name | +| `NotFoundException` | 404 Not Found | NotFoundException | +| `UnauthorizedException` | 401 Unauthorized | UnauthorizedException | +| `ForbiddenException` | 403 Forbidden | ForbiddenException | +| Any other exception | 500 Internal Server Error | An unexpected error occurred | + +Validation exceptions include grouped error details in the `errors` extension property: + +```json +{ + "status": 400, + "title": "Validation error", + "detail": "One or more validation errors occurred.", + "instance": "/api/v1/identity/users/register", + "errors": { + "Email": ["Email is required.", "Email must be a valid email address."], + "Password": ["Password must be at least 8 characters."] + } +} +``` + + + +## API Versioning + +fullstackhero uses [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning) with URL segment-based versioning. The version is embedded directly in the route path. + +### Configuration + +```csharp +services.AddApiVersioning(options => +{ + options.ReportApiVersions = true; + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ApiVersionReader = new UrlSegmentApiVersionReader(); +}) +.AddApiExplorer(options => +{ + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; +}) +.EnableApiVersionBinding(); +``` + +### URL Pattern + +All module endpoints are grouped under versioned paths: + +``` +api/v{version:apiVersion}/{module}/{resource} +``` + +For example: +- `api/v1/identity/users/register` +- `api/v1/identity/tokens` +- `api/v1/auditing/trails` + +Modules define their version sets using `NewApiVersionSet()` when mapping endpoint groups. The version is substituted automatically by the API explorer, so OpenAPI documents reflect the correct versioned URLs. + + + +## Rate Limiting + +The framework uses ASP.NET Core's built-in rate limiting with **fixed window** policies. Rate limiting is tenant-aware -- partition keys are resolved from the authenticated user's tenant claim, user ID, or IP address. + +### Policies + +| Policy | Default Limit | Window | Queue | Purpose | +|--------|--------------|--------|-------|---------| +| `global` | 100 requests | 60 seconds | 0 | Applied to all endpoints via the global limiter | +| `auth` | 10 requests | 60 seconds | 0 | Protects authentication endpoints (login, register) | + +### Partition Key Resolution + +The rate limiter partitions requests using the following priority: + +1. **Tenant claim** -- if the user has a tenant claim, the key is `tenant:{tenantId}`. +2. **User ID** -- if authenticated but no tenant, the key is `user:{userId}`. +3. **IP address** -- for anonymous requests, the key is `ip:{address}`. + +Health check endpoints (`/health`, `/healthz`, `/ready`, `/live`) are always excluded from rate limiting. + +### Configuration + +```json +{ + "RateLimitingOptions": { + "Enabled": true, + "Global": { + "PermitLimit": 100, + "WindowSeconds": 60, + "QueueLimit": 0 + }, + "Auth": { + "PermitLimit": 10, + "WindowSeconds": 60, + "QueueLimit": 0 + } + } +} +``` + + + +## Security Headers + +The `SecurityHeadersMiddleware` adds hardened HTTP response headers to every request. It runs early in the pipeline to ensure all responses -- including error responses -- carry security headers. + +### Headers Applied + +| Header | Value | Purpose | +|--------|-------|---------| +| `X-Content-Type-Options` | `nosniff` | Prevents MIME type sniffing | +| `X-Frame-Options` | `DENY` | Prevents clickjacking via iframes | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Controls referrer information sent with requests | +| `X-XSS-Protection` | `0` | Disables legacy XSS filter (modern CSP is preferred) | +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Enforces HTTPS (only on HTTPS connections) | +| `Content-Security-Policy` | See below | Controls resource loading | + +### Content Security Policy + +The default CSP is: + +``` +default-src 'self'; +img-src 'self' data: https:; +script-src 'self' https: {custom sources}; +style-src 'self' {inline if allowed} {custom sources}; +object-src 'none'; +frame-ancestors 'none'; +base-uri 'self'; +``` + +### Configuration + +```json +{ + "SecurityHeadersOptions": { + "Enabled": true, + "ExcludedPaths": ["/scalar", "/openapi"], + "AllowInlineStyles": true, + "ScriptSources": [], + "StyleSources": [] + } +} +``` + +| Property | Description | Default | +|----------|-------------|---------| +| `Enabled` | Enables or disables the middleware entirely | `true` | +| `ExcludedPaths` | Paths that bypass security headers (for UI tools) | `["/scalar", "/openapi"]` | +| `AllowInlineStyles` | Adds `'unsafe-inline'` to the style-src CSP directive | `true` | +| `ScriptSources` | Additional script sources appended to CSP | `[]` | +| `StyleSources` | Additional style sources appended to CSP | `[]` | + + + +## OpenAPI and Scalar + +The framework integrates ASP.NET Core's built-in OpenAPI document generation with the [Scalar](https://scalar.com) API explorer for a modern API documentation experience. + +### What You Get + +- **OpenAPI document** at `/openapi/v1.json` -- auto-generated from your Minimal API endpoints. +- **Scalar UI** at `/scalar` -- interactive API explorer with dark mode, authentication support, and request builder. +- **Bearer security scheme** -- the `BearerSecuritySchemeTransformer` automatically adds JWT Bearer authentication to all operations in the OpenAPI document. + +### Configuration + +```json +{ + "OpenApiOptions": { + "Enabled": true, + "Title": "fullstackhero .NET Starter Kit API", + "Version": "v1", + "Description": "Production-ready modular .NET API.", + "Contact": { + "Name": "fullstackhero", + "Url": "https://fullstackhero.net", + "Email": "hello@fullstackhero.net" + }, + "License": { + "Name": "MIT", + "Url": "https://opensource.org/licenses/MIT" + } + } +} +``` + + + +## Health Checks + +The Web building block exposes two health check endpoints under the `/health` group. Both are anonymous and exempt from rate limiting. + +### Endpoints + +| Endpoint | Purpose | Checks | +|----------|---------|--------| +| `GET /health/live` | **Liveness probe** | Only the `self` check -- confirms the process is running. Does not check external dependencies. | +| `GET /health/ready` | **Readiness probe** | All registered health checks, including database connectivity. Returns 503 if any check is unhealthy. | + +### Response Format + +```json +{ + "status": "Healthy", + "results": [ + { + "name": "self", + "status": "Healthy", + "description": null, + "durationMs": 0.12 + }, + { + "name": "npgsql", + "status": "Healthy", + "description": null, + "durationMs": 3.45 + } + ] +} +``` + + + +## Observability + +The Web building block integrates Serilog for structured logging and OpenTelemetry for distributed traces and metrics. + +### Serilog + +The `AddHeroLogging()` method configures Serilog with: + +- **Configuration-driven setup** -- reads from the `Serilog` section in `appsettings.json`. +- **HTTP request enrichment** -- the `HttpRequestContextEnricher` adds `RequestMethod`, `RequestPath`, `UserAgent`, `UserId`, `Tenant`, and `UserEmail` to every log entry for authenticated requests. +- **Noise reduction** -- verbose logging from `Microsoft.EntityFrameworkCore`, `Hangfire`, and `Finbuckle.MultiTenant` is suppressed to Warning or Error level. + +### OpenTelemetry + +When `EnableOpenTelemetry` is `true`, the framework configures: + +**Tracing** -- distributed traces across: +- ASP.NET Core requests (with health check endpoints filtered out) +- HTTP client calls +- PostgreSQL (Npgsql) queries +- Entity Framework Core operations +- Redis commands +- Mediator commands and queries (via `MediatorTracingBehavior`) +- Hangfire jobs + +**Metrics** -- runtime and request metrics including: +- ASP.NET Core request metrics +- HTTP client metrics +- Npgsql metrics +- .NET runtime metrics +- Custom HTTP request duration histograms + +**Exporting** -- traces and metrics are exported via OTLP (gRPC or HTTP/protobuf) to any compatible backend (Jaeger, Grafana Tempo, Azure Monitor, etc.). + +### MediatorTracingBehavior + +The `MediatorTracingBehavior` is a pipeline behavior that wraps every Mediator command and query in an OpenTelemetry span. Each span is tagged with the request type name, and errors are recorded with exception details. + +``` +Mediator RegisterUserCommand + ├── mediator.request_type: FSH.Modules.Identity.Contracts.v1.Users.RegisterUser.RegisterUserCommand + ├── status: Ok + └── duration: 45ms +``` + +### Configuration + +```json +{ + "OpenTelemetryOptions": { + "Enabled": true, + "Tracing": { "Enabled": true }, + "Metrics": { + "Enabled": true, + "MeterNames": ["MyCustomMeter"] + }, + "Exporter": { + "Otlp": { + "Enabled": true, + "Endpoint": "http://localhost:4317", + "Protocol": "grpc" + } + }, + "Mediator": { "Enabled": true }, + "Http": { + "Histograms": { + "Enabled": true, + "BucketBoundaries": [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5] + } + }, + "Data": { + "FilterEfStatements": true, + "FilterRedisCommands": true + } + } +} +``` + + + +## CORS + +Cross-Origin Resource Sharing is configured through the `CorsOptions` class. When enabled, the framework registers a named policy (`FSHCorsPolicy`) and applies it between Routing and Authentication in the middleware pipeline. + +### Configuration + +```json +{ + "CorsOptions": { + "AllowAll": false, + "AllowedOrigins": ["https://app.example.com", "http://localhost:3000"], + "AllowedHeaders": ["*"], + "AllowedMethods": ["*"] + } +} +``` + +| Property | Description | Default | +|----------|-------------|---------| +| `AllowAll` | Allows any origin, header, and method | `true` | +| `AllowedOrigins` | Specific origins to allow (required when `AllowAll` is `false`) | `[]` | +| `AllowedHeaders` | Allowed request headers | `["*"]` | +| `AllowedMethods` | Allowed HTTP methods | `["*"]` | + + + +## Mediator Registration + +The `EnableMediator()` extension method registers the `ValidationBehavior` as a pipeline behavior. This behavior runs all registered `IValidator` instances before the handler executes, throwing a `ValidationException` if any rules fail. + +```csharp +services.EnableMediator(typeof(IdentityModule).Assembly); +``` + +The validation pipeline: + +1. Collect all `IValidator` instances from DI. +2. Run all validators in parallel using `Task.WhenAll`. +3. If any validation failures exist, throw a `FluentValidation.ValidationException`. +4. If all validators pass, invoke the next handler in the pipeline. + +The `GlobalExceptionHandler` then converts the `ValidationException` into a 400 ProblemDetails response with grouped error details, as described in the [Exception Handling](#exception-handling) section. + + + +## Source Structure + +``` +src/BuildingBlocks/Web/ +├── Auth/ +│ └── CurrentUserMiddleware.cs +├── Cors/ +│ ├── CorsOptions.cs +│ └── Extensions.cs +├── Exceptions/ +│ └── GlobalExceptionHandler.cs +├── Health/ +│ └── HealthEndpoints.cs +├── Mediator/ +│ ├── Behaviors/ +│ │ └── ValidationBehavior.cs +│ └── Extensions.cs +├── Modules/ +│ ├── FshModuleAttribute.cs +│ ├── IModule.cs +│ ├── IModuleConstants.cs +│ └── ModuleLoader.cs +├── Observability/ +│ ├── Logging/Serilog/ +│ │ ├── Extensions.cs +│ │ ├── HttpRequestContextEnricher.cs +│ │ └── StaticLogger.cs +│ └── OpenTelemetry/ +│ ├── Extensions.cs +│ ├── MediatorTracingBehavior.cs +│ └── OpenTelemetryOptions.cs +├── OpenApi/ +│ ├── BearerSecuritySchemeTransformer.cs +│ ├── Extensions.cs +│ └── OpenApiOptions.cs +├── Origin/ +│ └── OriginOptions.cs +├── RateLimiting/ +│ ├── Extensions.cs +│ ├── FixedWindowPolicyOptions.cs +│ └── RateLimitingOptions.cs +├── Security/ +│ ├── SecurityExtensions.cs +│ ├── SecurityHeadersMiddleware.cs +│ └── SecurityHeadersOptions.cs +├── Validation/ +│ └── PagedQueryValidator.cs +├── Versioning/ +│ └── Extensions.cs +├── Extensions.cs +└── Web.csproj +``` diff --git a/docs/src/content/docs/webhooks.mdx b/docs/src/content/docs/webhooks.mdx new file mode 100644 index 0000000000..c24c312125 --- /dev/null +++ b/docs/src/content/docs/webhooks.mdx @@ -0,0 +1,224 @@ +--- +title: "Webhooks" +description: "Outbound webhook subscriptions with HMAC signing and resilient delivery." +--- + +import Aside from '../../components/Aside.astro'; +import Tabs from '../../components/Tabs.astro'; +import TabPanel from '../../components/TabPanel.astro'; + +The **Webhooks module** enables your application to push event notifications to external systems over HTTP. Consumers register webhook subscriptions with a target URL and a set of event types they are interested in. When a matching event occurs, the module delivers a signed JSON payload to the subscriber's URL with automatic retries via the HTTP resilience pipeline. + +## Module Overview + +The Webhooks module follows the standard fullstackhero module structure: + +- **Module order:** 400 +- **API group:** `api/v{version}/webhooks` +- **Authentication:** All endpoints require authorization +- **Database:** Dedicated `WebhookDbContext` with health check + +``` +Modules/Webhooks/ +├── Modules.Webhooks/ +│ ├── Domain/ +│ │ ├── WebhookSubscription.cs +│ │ └── WebhookDelivery.cs +│ ├── Data/ +│ │ ├── WebhookDbContext.cs +│ │ └── WebhookDbInitializer.cs +│ ├── Features/v1/ +│ │ ├── CreateWebhookSubscription/ +│ │ ├── DeleteWebhookSubscription/ +│ │ ├── GetWebhookSubscriptions/ +│ │ ├── GetWebhookDeliveries/ +│ │ └── TestWebhookSubscription/ +│ ├── Services/ +│ │ ├── IWebhookDeliveryService.cs +│ │ ├── WebhookDeliveryService.cs +│ │ └── WebhookPayloadSigner.cs +│ └── WebhooksModule.cs +└── Modules.Webhooks.Contracts/ + ├── Dtos/ + │ ├── WebhookSubscriptionDto.cs + │ └── WebhookDeliveryDto.cs + └── v1/ + ├── CreateWebhookSubscription/ + ├── DeleteWebhookSubscription/ + ├── GetWebhookSubscriptions/ + ├── GetWebhookDeliveries/ + └── TestWebhookSubscription/ +``` + +## Domain Model + +### WebhookSubscription + +Represents a registered webhook endpoint: + +| Property | Type | Description | +|----------|------|-------------| +| `Id` | `Guid` | Unique identifier (v7 UUID) | +| `Url` | `string` | The target URL to deliver payloads to | +| `EventsCsv` | `string` | Comma-separated list of event types to subscribe to | +| `SecretHash` | `string?` | Shared secret for HMAC-SHA256 payload signing | +| `IsActive` | `bool` | Whether the subscription is active | +| `CreatedAtUtc` | `DateTime` | When the subscription was created | + +The `MatchesEvent(string eventType)` method checks whether a subscription should receive a given event. It supports exact event type matching and the wildcard `*` pattern to receive all events. + +### WebhookDelivery + +Records each delivery attempt for audit and debugging: + +| Property | Type | Description | +|----------|------|-------------| +| `Id` | `Guid` | Unique delivery identifier (v7 UUID) | +| `SubscriptionId` | `Guid` | The subscription this delivery belongs to | +| `EventType` | `string` | The event type that triggered the delivery | +| `PayloadJson` | `string` | The JSON payload that was sent | +| `HttpStatusCode` | `int` | The HTTP status code returned by the subscriber | +| `Success` | `bool` | Whether the delivery was successful | +| `AttemptCount` | `int` | Number of delivery attempts | +| `AttemptedAtUtc` | `DateTime` | When the delivery was attempted | +| `ErrorMessage` | `string?` | Error message if the delivery failed | + +## API Endpoints + +All endpoints are under `api/v1/webhooks` and require authentication. + +### Create Subscription + +``` +POST /api/v1/webhooks/subscriptions +``` + +Creates a new webhook subscription. + +**Request body:** + +```json +{ + "url": "https://example.com/hooks/receiver", + "events": ["user.registered", "order.created"], + "secret": "my-shared-secret" +} +``` + +**Response:** `201 Created` with the subscription ID. + +### Delete Subscription + +``` +DELETE /api/v1/webhooks/subscriptions/{id} +``` + +Deactivates and removes a webhook subscription. + +**Response:** `204 No Content` + +### List Subscriptions + +``` +GET /api/v1/webhooks/subscriptions?pageNumber=1&pageSize=10 +``` + +Returns a paginated list of webhook subscriptions. + +### List Deliveries + +``` +GET /api/v1/webhooks/subscriptions/{subscriptionId}/deliveries?pageNumber=1&pageSize=10 +``` + +Returns a paginated list of delivery attempts for a specific subscription. Useful for debugging failed deliveries. + +### Test Subscription + +``` +POST /api/v1/webhooks/subscriptions/{id}/test +``` + +Sends a test event to the subscription's URL. Returns whether the delivery was successful. + +**Response:** + +```json +{ + "success": true +} +``` + +## HMAC-SHA256 Payload Signing + +When a subscription has a `Secret` configured, every delivery includes an `X-Webhook-Signature` header containing an HMAC-SHA256 signature of the payload body: + +``` +X-Webhook-Signature: sha256=a1b2c3d4e5f6... +``` + +The signature is computed using the shared secret as the HMAC key and the raw JSON payload as the message: + +```csharp +public static string Sign(string payload, string secret) +{ + var keyBytes = Encoding.UTF8.GetBytes(secret); + var payloadBytes = Encoding.UTF8.GetBytes(payload); + var hash = HMACSHA256.HashData(keyBytes, payloadBytes); + return $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}"; +} +``` + +Subscribers should verify the signature by computing the same HMAC-SHA256 hash and comparing it to the header value. This ensures the payload was not tampered with in transit and originated from your application. + +### Delivery Headers + +Each webhook delivery includes these custom headers: + +| Header | Description | +|--------|-------------| +| `X-Webhook-Signature` | HMAC-SHA256 signature (only when a secret is configured) | +| `X-Webhook-Event` | The event type that triggered the delivery | +| `X-Webhook-Delivery-Id` | Unique identifier for this delivery attempt | + +## Resilient Delivery + +The Webhooks module registers a named HTTP client (`"Webhooks"`) with the fullstackhero HTTP resilience pipeline: + +```csharp +builder.Services.AddHttpClient("Webhooks") + .AddHeroResilience(builder.Configuration); +``` + +This applies automatic **retry with exponential backoff**, **circuit breaker**, and **timeout** policies to all webhook deliveries. If a subscriber's endpoint is temporarily unavailable, the resilience pipeline retries the delivery before recording a failure. + +Failed deliveries are recorded in the `WebhookDelivery` table with the error message and a `Success = false` status. Use the deliveries endpoint to inspect failures and diagnose issues. + + + +## Event Matching + +Subscriptions specify which events they want to receive via the `Events` array in the create command. Events are stored as a comma-separated string internally. + +- **Exact match** - `["user.registered", "order.created"]` receives only those two event types +- **Wildcard** - `["*"]` receives all events + +The `MatchesEvent` method performs case-insensitive comparison and checks for the wildcard pattern. + +## Setup + +The Webhooks module is automatically discovered and loaded by the module system. Ensure it is included in your solution and referenced by the host application. + +In `Program.cs`, the module is registered automatically via the `[FshModule]` assembly attribute: + +```csharp +[assembly: FshModule(typeof(WebhooksModule), 400)] +``` + +The module registers its own `WebhookDbContext`, database initializer, delivery service, and health check during `ConfigureServices`. No additional manual wiring is required. + + diff --git a/docs/src/layouts/Docs.astro b/docs/src/layouts/Docs.astro new file mode 100644 index 0000000000..550323c2b5 --- /dev/null +++ b/docs/src/layouts/Docs.astro @@ -0,0 +1,333 @@ +--- +import Landing from './Landing.astro'; +import Nav from '../components/Nav.astro'; +import Footer from '../components/Footer.astro'; +import DocsSidebar from '../components/DocsSidebar.astro'; +import DocsToc from '../components/DocsToc.astro'; +import DocsPagination from '../components/DocsPagination.astro'; +import Breadcrumbs from '../components/Breadcrumbs.astro'; +import SearchModal from '../components/SearchModal.astro'; +import { sidebar, isSubGroup } from '../config/docs.config'; +import type { SidebarLink, SidebarSubGroup } from '../config/docs.config'; +import '../styles/docs.css'; + +interface Heading { + depth: number; + slug: string; + text: string; +} + +interface Props { + title: string; + description?: string; + slug: string; + headings: Heading[]; +} + +const { title, description, slug, headings } = Astro.props; +const editUrl = `https://github.com/fullstackhero/dotnet-starter-kit/edit/develop/docs/src/content/docs/${slug}.mdx`; +const tocHeadings = headings.filter((h) => h.depth >= 2 && h.depth <= 3); + +// Build breadcrumb trail from sidebar config +const siteUrl = 'https://fullstackhero.net'; +const pageUrl = `${siteUrl}/dotnet-starter-kit/${slug}/`; + +// Find the section this page belongs to +const currentGroup = sidebar.find(g => g.items.some(i => i.slug === slug)); +const sectionName = currentGroup?.label || 'Docs'; + +// TechArticle structured data for this documentation page +const techArticleSchema = { + "@context": "https://schema.org", + "@type": "TechArticle", + "headline": title, + "description": description || `${title} - fullstackhero documentation`, + "url": pageUrl, + "dateModified": new Date().toISOString().split('T')[0], + "author": { + "@type": "Person", + "name": "Mukesh Murugan", + "url": "https://codewithmukesh.com", + "jobTitle": "Software Engineer", + "sameAs": [ + "https://twitter.com/iammukeshm", + "https://www.linkedin.com/in/iammukeshm/", + "https://github.com/iammukeshm" + ] + }, + "publisher": { + "@type": "Organization", + "name": "fullstackhero", + "url": siteUrl, + "logo": { + "@type": "ImageObject", + "url": `${siteUrl}/favicon.svg` + } + }, + "mainEntityOfPage": { + "@type": "WebPage", + "@id": pageUrl + }, + "inLanguage": "en", + "isPartOf": { + "@type": "WebSite", + "name": "fullstackhero", + "url": siteUrl + }, + "speakable": { + "@type": "SpeakableSpecification", + "cssSelector": [".prose > h1", ".prose > p:first-of-type"] + } +}; + +// BreadcrumbList structured data +const breadcrumbSchema = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": [ + { + "@type": "ListItem", + "position": 1, + "name": "Home", + "item": siteUrl + }, + { + "@type": "ListItem", + "position": 2, + "name": "Docs", + "item": `${siteUrl}/dotnet-starter-kit/introduction/` + }, + { + "@type": "ListItem", + "position": 3, + "name": sectionName, + "item": pageUrl + }, + { + "@type": "ListItem", + "position": 4, + "name": title, + "item": pageUrl + } + ] +}; +--- + + +