GrootUI 0.1.2
dotnet add package GrootUI --version 0.1.2
NuGet\Install-Package GrootUI -Version 0.1.2
<PackageReference Include="GrootUI" Version="0.1.2" />
<PackageVersion Include="GrootUI" Version="0.1.2" />
<PackageReference Include="GrootUI" />
paket add GrootUI --version 0.1.2
#r "nuget: GrootUI, 0.1.2"
#:package GrootUI@0.1.2
#addin nuget:?package=GrootUI&version=0.1.2
#tool nuget:?package=GrootUI&version=0.1.2
Groot
A dev-time admin panel for ASP.NET Core — automatic CRUD UI over any EF Core
DbContext.
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
DbSetmarked 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.Appframework 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
DbContextper scope).
Install
dotnet add package GrootUI
The package is named
GrootUI(theGrootid is taken on NuGet), but the namespace is unchanged — you still writeusing 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/entitiesschema 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,nullmeans "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.
Search
When GrootTableQuery.Search is used, Groot generates a case-insensitive PostgreSQL
ILIKE '%term%' query. The searched properties are resolved like this:
- If the entity has at least one property marked
[GrootSearchable], only those are searched. - Otherwise every
stringproperty 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 < 1is clamped to1.PageSize <= 0falls back to10.PageSize > 100is capped at100(abuse protection).SortByis 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:
- DataAnnotations validation passes.
- Mass-assignment sanitization (Create) or property patch (Update).
BeforeCreateAsync/BeforeUpdateAsync/BeforeDeleteAsync.SaveChangesAsync/ExecuteDeleteAsync.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.
Deleteissues a realDELETE(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 | Versions 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. |
-
net10.0
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.1)
-
net8.0
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 8.0.11)
-
net9.0
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 9.0.4)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.