GrootUI 0.1.2

dotnet add package GrootUI --version 0.1.2
                    
NuGet\Install-Package GrootUI -Version 0.1.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="GrootUI" Version="0.1.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="GrootUI" Version="0.1.2" />
                    
Directory.Packages.props
<PackageReference Include="GrootUI" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add GrootUI --version 0.1.2
                    
#r "nuget: GrootUI, 0.1.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package GrootUI@0.1.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=GrootUI&version=0.1.2
                    
Install as a Cake Addin
#tool nuget:?package=GrootUI&version=0.1.2
                    
Install as a Cake Tool

Groot

A dev-time admin panel for ASP.NET Core — automatic CRUD UI over any EF Core DbContext.

NuGet License .NET

v0.1.2 — preview release. The public API is not stable yet and may change in upcoming versions.

What is Groot?

Groot is a Hangfire dashboard / Swagger UI style panel for your DbContext. Install it from NuGet, put [GrootTable] on a DbSet property, and you get a full CRUD interface at /groot-ui: tables, paging, search, sort, forms, validation, relationships and lifecycle hooks.

Groot is a dev-time tool, not a production CRUD framework. It exists so you can inspect and edit your data faster in development and internal staging environments.

Features

  • Automatic CRUD UI for every DbSet marked with [GrootTable].
  • Realtime wire protocol over SignalR (/groot/hub).
  • Relationships: many-to-one select, searchable multi-select for many-to-many.
  • Mass-assignment protection: [GrootIgnore], [GrootReadOnly].
  • Automatic DataAnnotations validation ([Required], [StringLength], etc.).
  • Lifecycle hooks via IGrootInterceptor<TEntity>: Before/After Create/Update/Delete.
  • Paging (capped at 100), case-insensitive ILIKE search, sorting.
  • Optional login: a UI page plus an environment guard.
  • Schema endpoint built from the EF Core model: GET /groot/entities.

Requirements

  • .NET 8, 9 or 10
  • EF Core matching your target framework (8.x / 9.x / 10.x — the right Npgsql provider is selected automatically)
  • PostgreSQL
  • ASP.NET Core (Microsoft.AspNetCore.App framework reference)

.NET 6 and 7 are not supported: both are out of Microsoft support, and EF Core 6 lacks ExecuteDeleteAsync, which Groot uses for deletes.

Groot supports PostgreSQL only. SQL Server, SQLite and MySQL are not supported. Multi-tenant DI scenarios are not supported either (one DbContext per scope).

Install

dotnet add package GrootUI

The package is named GrootUI (the Groot id is taken on NuGet), but the namespace is unchanged — you still write using Groot; in code.

Quickstart

1. Mark the entities on your DbContext

using Groot;
using Microsoft.EntityFrameworkCore;

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    [GrootTable]
    public DbSet<Product> Products => Set<Product>();

    [GrootTable]
    public DbSet<Category> Categories => Set<Category>();

    // DbSets without [GrootTable] never show up in the panel.
    public DbSet<InternalLog> Logs => Set<InternalLog>();
}

2. Write your entities

using System.ComponentModel.DataAnnotations;
using Groot;

public class Product
{
    [GrootReadOnly]
    public int Id { get; set; }

    [Required, StringLength(200)]
    [GrootSearchable]
    public string Name { get; set; } = string.Empty;

    public decimal Price { get; set; }

    [GrootReadOnly]
    public DateTime CreatedAt { get; set; }

    [GrootIgnore]
    public string? InternalNotes { get; set; }
}

3. Wire it up in Program.cs

using Groot;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddGroot<AppDbContext, int>();

var app = builder.Build();

app.UseGroot<AppDbContext, int>();

app.Run();

<AppDbContext, int> is the DbContext type and the primary key type (int, Guid, long, string, etc.).

4. Run it and sign in

dotnet run

Open in your browser: http://localhost:5000/groot-ui

When UseGroot is called, Groot:

  • Adds the environment and auth guard middleware.
  • Serves the bundled UI as static files under /groot-ui.
  • Maps the auth endpoints (/groot/auth, /groot/login, /groot/logout).
  • Maps the GET /groot/entities schema endpoint.
  • Maps the SignalR hub at /groot/hub.

Migrations: Groot does not run migrations — your schema stays your application's responsibility. If a table is missing, EF Core raises a clear error on its own.

Mark entities for the panel

Only DbSet<> properties marked with [GrootTable] appear in the panel. This gives you precise control: internal audit tables, identity tables and the like stay hidden.

[GrootTable]
public DbSet<Product> Products => Set<Product>();

The attribute targets properties (AttributeTargets.Property), so it sits fine on the Set<>() shorthand.

Securing your fields

Mass-assignment is the most important risk vector in Groot, because any client can send JSON and try to set a property. Two attributes close that hole:

[GrootIgnore] — dropped entirely

public class User
{
    public int Id { get; set; }
    public string Email { get; set; } = "";

    [GrootIgnore]
    public bool IsAdmin { get; set; }          // Never read on Create/Update.

    [GrootIgnore]
    public string PasswordHash { get; set; } = ""; // Add [JsonIgnore] to hide it on Read too.
}

[GrootIgnore] is rejected on Create and Update. The property is still returned in Read responses — add [JsonIgnore] to hide it there as well.

[GrootReadOnly] — server-managed

public class Order
{
    [GrootReadOnly] public int Id { get; set; }
    [GrootReadOnly] public DateTime CreatedAt { get; set; }
    [GrootReadOnly] public DateTime UpdatedAt { get; set; }

    public string CustomerName { get; set; } = "";
    public decimal Total { get; set; }
}

Returned on Read, rejected on Create/Update. Use it for server-managed fields: primary keys, timestamps and so on.

Under the hood: on Create, Groot builds a fresh instance and copies only writable properties. On Update it loads the existing row, resets it to the database state, and applies only writable properties on top. Your IDs and timestamps are never influenced by the client payload.

Relationships

Many-to-one

When you have an FK property (CategoryId) and a navigation (Category), the UI renders a select automatically and shows the related record's name in both the table and the details page.

public class Product
{
    public int Id { get; set; }
    public int? CategoryId { get; set; }
    public Category? Category { get; set; }
}

Many-to-many and [GrootDominant]

A many-to-many relationship is visible from both sides, but editing it from one side is what makes sense. [GrootDominant] decides which side owns it:

public class Product
{
    [GrootDominant]
    public List<Tag>? Tags { get; set; }        // Product's form gets the multi-select
}

public class Tag
{
    public List<Product>? Products { get; set; } // Tag's form gets no select
}

The rule matches [GrootSearchable]: if neither side is marked, both stay editable. Once one side is marked, the non-dominant side is rejected on the server too — a hand-crafted payload cannot write through it either.

Important: declare the collection as nullable (List<Tag>?). To Groot, null means "the client did not send this field, leave it alone", while an empty array means "clear all relations". With an = [] initializer, every Update could silently wipe the relationships.

Validation

Standard System.ComponentModel.DataAnnotations attributes are checked automatically. On failure a GrootValidationException is thrown and the hub relays it to the client as a HubException.

public class Customer
{
    [GrootReadOnly] public int Id { get; set; }

    [Required(ErrorMessage = "Name is required")]
    [StringLength(100, MinimumLength = 2)]
    public string Name { get; set; } = "";

    [EmailAddress, Required]
    public string Email { get; set; } = "";

    [Range(0, 150)]
    public int Age { get; set; }
}

HubException.Message carries structured JSON:

{
  "type": "validation",
  "errors": [{ "fields": ["Email"], "message": "The Email field is not a valid e-mail address." }]
}

Validation runs via Validator.TryValidateObject(..., validateAllProperties: true), before the Before* hooks.

When GrootTableQuery.Search is used, Groot generates a case-insensitive PostgreSQL ILIKE '%term%' query. The searched properties are resolved like this:

  1. If the entity has at least one property marked [GrootSearchable], only those are searched.
  2. Otherwise every string property is searched.
public class Article
{
    [GrootReadOnly] public int Id { get; set; }

    [GrootSearchable] public string Title { get; set; } = "";
    [GrootSearchable] public string Body { get; set; } = "";

    public string AuthorIp { get; set; } = ""; // Not searched
}

PostgreSQL hint: pg_trgm GIN index

Leading-wildcard %term% ILIKE queries cannot use a default B-tree index. For large tables, add a pg_trgm GIN index:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_articles_title_trgm
    ON "Articles" USING gin ("Title" gin_trgm_ops);

Sorting & paging

public class GrootTableQuery
{
    public int Page { get; set; } = 1;
    public int PageSize { get; set; } = 10;
    public string? Search { get; set; }
    public string? SortBy { get; set; }
    public bool SortDescending { get; set; }
}

public class GrootTableResult<T>
{
    public int Total { get; set; }
    public List<T> Items { get; set; } = [];
}

Rules:

  • Page < 1 is clamped to 1.
  • PageSize <= 0 falls back to 10.
  • PageSize > 100 is capped at 100 (abuse protection).
  • SortBy is a property name and is case-insensitive (both "name" and "Name" work).
  • Only scalar properties are sortable; a navigation is skipped silently.
  • With no SortBy, rows are ordered by primary key so paging stays stable.

Lifecycle hooks

Implement IGrootInterceptor<TEntity> and register it in DI:

public class AuditTimestampsInterceptor : IGrootInterceptor<Order>
{
    public Task BeforeCreateAsync(Order entity, CancellationToken ct)
    {
        entity.CreatedAt = DateTime.UtcNow;
        entity.UpdatedAt = DateTime.UtcNow;
        return Task.CompletedTask;
    }

    public Task BeforeUpdateAsync(Order entity, CancellationToken ct)
    {
        entity.UpdatedAt = DateTime.UtcNow;
        return Task.CompletedTask;
    }
}
builder.Services.AddScoped<IGrootInterceptor<Order>, AuditTimestampsInterceptor>();

Call order:

  1. DataAnnotations validation passes.
  2. Mass-assignment sanitization (Create) or property patch (Update).
  3. BeforeCreateAsync / BeforeUpdateAsync / BeforeDeleteAsync.
  4. SaveChangesAsync / ExecuteDeleteAsync.
  5. AfterCreateAsync / AfterUpdateAsync / AfterDeleteAsync (only if the operation actually affected rows).

Throwing inside a hook aborts the operation and the error propagates to the hub. You can register several interceptors for one entity — all of them run in turn.

Before/AfterDeleteAsync(object id, ...) receives the id as object.

Hub API

SignalR hub: /groot/hub.

Method Arguments Returns
GetTable entityName: string, query: GrootTableQuery GrootTableResult<T>
Get entityName: string, id: any T?
Create entityName: string, data: T T (sanitized + saved)
Update entityName: string, data: T T (updated)
Delete entityName: string, id: any bool
BatchDelete entityName: string, ids: TKey[] bool

entityName is the case-insensitive CLR type name (for example "Product").

ID conversion from JSON is automatic: strings, numbers and GUIDs are all accepted and converted to TKey.

Errors from the service are mapped onto HubException:

  • GrootValidationException → the JSON payload shown above.
  • InvalidOperationException (missing PK, missing row) → the message.
  • Any other exception propagates as-is.

TypeScript client (@microsoft/signalr)

import { HubConnectionBuilder } from "@microsoft/signalr";

const connection = new HubConnectionBuilder()
  .withUrl("/groot/hub")
  .withAutomaticReconnect()
  .build();

await connection.start();

const page = await connection.invoke("GetTable", "Product", {
  page: 1,
  pageSize: 25,
  search: "phone",
  sortBy: "Price",
  sortDescending: true,
});
console.log(page.total, page.items);

const created = await connection.invoke("Create", "Product", { name: "Pixel 9", price: 799 });
await connection.invoke("Update", "Product", { id: created.id, name: "Pixel 9 Pro", price: 999 });
await connection.invoke("Delete", "Product", created.id);
await connection.invoke("BatchDelete", "Product", [1, 2, 3]);

Schema endpoint

GET /groot/entities

Returns metadata for every entity marked with [GrootTable]. The UI builds its columns and relation editors from this endpoint.

public class GrootEntityMetadata
{
    public string EntityName { get; set; }
    public string PrimaryKey { get; set; }
    public List<GrootFieldMetadata> Fields { get; set; }
}

public class GrootFieldMetadata
{
    public string Name { get; set; }
    public string ClrType { get; set; }   // CLR type name (Nullable unwrapped)
    public bool IsNullable { get; set; }
    public bool IsPrimaryKey { get; set; }
    public bool IsForeignKey { get; set; }
    public bool IsCollection { get; set; }
    public bool IsNavigation { get; set; }
    public string? RelationKind { get; set; }       // "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"
    public string? TargetEntity { get; set; }
    public string? ForeignKeyProperty { get; set; }
    public string? TargetKeyProperty { get; set; }
    public bool IsDominant { get; set; }            // many-to-many: editable from this side?
}

Metadata is read from the EF Core model via context.Model.FindEntityType(...). Shadow properties are skipped. Skip navigations (many-to-many) are supported.

Cancellation

Every IGrootService method accepts a CancellationToken. The hub forwards Context.ConnectionAborted on each invocation — if the client disconnects, the EF Core query is cancelled.

Task<GrootTableResult<TEntity>> GetTableAsync<TEntity>(
    GrootTableQuery query,
    CancellationToken cancellationToken = default) where TEntity : class;

Configuration

builder.Services.AddGroot<AppDbContext, int>(options =>
{
    options.Username = "admin";
    options.Password = builder.Configuration["Groot:Password"]!;
    options.EnabledEnvironments = new List<string> { "Development", "Staging" };
    options.SessionLifetime = TimeSpan.FromHours(8);
});
Option Default Description
Username "" Login user name.
Password "" Login password. Compared in constant time.
EnabledEnvironments ["Development"] Outside these environments /groot/* and /groot-ui/* return 404.
SessionLifetime 8 hours Lifetime of the groot.session cookie set after login.

Auth is optional

If Username and Password are both empty, auth is disabled entirely and the panel opens directly. Fill in either one and the login page is shown.

Endpoint Description
GET /groot/auth { authRequired, authenticated } — how the UI knows whether to show the login page.
POST /groot/login { username, password }. On success the groot.session cookie is set.
POST /groot/logout Clears the cookie.

The static UI files (/groot-ui/*) and those three endpoints are exempt from auth — otherwise the login page itself could never load. Everything that carries data (/groot/hub, /groot/entities) stays behind the gate.

Why "dev mode only"

Groot is universal CRUD over your DbContext. That means a lot of things you deliberately would not want to expose in production:

  • No fine-grained authorization. Anyone who logs in can manage every [GrootTable] table.
  • No audit trail. Who changed what is not recorded automatically (though you can add it yourself with IGrootInterceptor).
  • Soft-delete is not the default. Delete issues a real DELETE (ExecuteDeleteAsync).
  • No rate limiting. Login attempts are not throttled.
  • No CSRF protection (SignalR hub).

Recommendation: run it behind a VPN or on internal staging only.

Development

# UI (React + Vite). Proxies /groot to the backend.
cd vue-app && npm ci && npm run build

# Backend
dotnet build Groot.slnx -c Release

# Tests — Docker required (Testcontainers spins up a PostgreSQL container).
dotnet test tests/Groot.Tests/Groot.Tests.csproj

vue-app/dist ships inside the NuGet package as the bundled UI, so build it before packing.

License

MIT — see LICENSE.txt.

Status

  • Current version: v0.1.2
  • Targets: net8.0 / net9.0 / net10.0
  • NuGet: GrootUI (namespace: Groot)

Issues and PRs: https://github.com/Nodirbek-Abdulaxadov/Groot

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.2 95 7/31/2026
0.1.1 87 7/31/2026
0.1.0 92 7/31/2026