vizier 0.2.0
vizier: ^0.2.0 copied to clipboard
Resource-driven server-rendered admin panels for Maat applications.
Vizier #

Resource-driven, server-rendered admin panels for
Maat applications. Describe a model once as
an AdminResource and Vizier renders the list, create, edit and show pages,
enforces your authorization hooks on every request, and serves the whole panel
behind Anubis web sessions. No JavaScript build step: the assets ship inside
the package and vizier:install publishes them into your application.
class SlotAdminResource extends AdminResource<Slot> {
@override
ModelDefinition<Slot> get model => Slot.def;
@override
String get slug => 'slots';
@override
String get singularLabel => 'Slot';
@override
String get pluralLabel => 'Slots';
@override
String get titleAttribute => 'starts_at';
@override
AdminForm form(AdminForm form) => form.fields([
NumberField('resource_id', label: 'Resource id').integer().required(),
DateTimeField('starts_at').required(),
DateTimeField('ends_at').required(),
]);
@override
AdminTable<Slot> table(AdminTable<Slot> table) => table
.columns([
DateTimeColumn<Slot>('starts_at').sortable(),
DateTimeColumn<Slot>('ends_at'),
])
.defaultSort('starts_at');
@override
bool canCreate(Request request) => request.user<User>()?.isAdmin ?? false;
}
Installation #
dependencies:
vizier: ^0.1.0
Vizier needs the packages it builds on: anubis for web sessions and CSRF,
khnum_maat for views, and seshat_maat for models.
Register the service provider and the console commands, then publish the views and assets once:
providers: [VizierServiceProvider.new, AdminServiceProvider.new],
commands: [...vizierCommands()],
dart run bin/maat.dart vizier:install
A normal install never overwrites files your application already published;
pass --force after reviewing the differences to take newer defaults.
Vizier stores nothing of its own, but it needs Anubis's session table, so run the migrations before the first request:
final migrations = <Migration>[...anubisMigrations, CreateApplicationTables()];
dart run bin/maat.dart migrate
Scaffold a resource and register it in one step:
dart run bin/maat.dart make:admin-resource User
The generator writes lib/app/vizier/resources/user_resource.dart and adds
the import and list entry to the registry vizier:install published. Pass
--model= for a differently named model and --force to overwrite.
Defining a panel #
A panel is registered from a service provider that boots after
VizierServiceProvider, because the panel resolves the anubis.session
middleware by alias at registration time.
class AdminServiceProvider extends ServiceProvider {
AdminServiceProvider(super.app);
@override
void boot() {
Anubis.provider('users', (id) => User.query().find(int.parse(id)));
Vizier.panel(
Panel(
title: 'Booking Admin',
userProvider: 'users',
authenticate: (email, password) async {
final user = await User.query().where('email', email).first();
final valid = await Hash.checkAsync(password, user?.authPassword ?? '');
return user != null && valid && user.isStaff ? user : null;
},
resources: [BookingAdminResource(), SlotAdminResource()],
),
);
}
}
authenticate returns the user to sign in, or null to reject. The panel
mounts at /admin by default; pass path to move it.
Resources #
Every AdminResource<T> describes one model:
| Member | Purpose |
|---|---|
model, slug, singularLabel, pluralLabel, titleAttribute |
Identity and how a record is named. |
form(AdminForm) |
Fields for create and edit. |
table(AdminTable<T>) |
Columns, sorting, search and filters for the index page. |
navigationIcon, navigationOrder |
Placement in the sidebar. |
query() |
The base query; override to scope what the panel can see. |
beforeCreate, afterCreate, beforeUpdate, afterUpdate |
Hooks around writes. |
Fields: TextField, EmailField, PasswordField, TextareaField,
NumberField, BooleanField, SelectField, DateField, DateTimeField,
HiddenField.
Columns: TextColumn, BooleanColumn, DateColumn, DateTimeColumn, and
ComputedColumn for values derived from the record.
Authorization #
Nothing is writable by default. Each hook receives the current Request, so
the same rule that guards your API guards the panel:
@override
bool canViewAny(Request request) => true;
@override
bool canCreate(Request request) => _isAdmin(request);
@override
bool canUpdate(Request request, Slot record) => _isAdmin(request);
@override
bool canDelete(Request request, Slot record) => _isAdmin(request);
Actions #
Row and bulk actions run your own code and report back to the page:
@override
List<AdminAction<Booking>> actions() => [
AdminAction(
name: 'mark-no-show',
label: 'Mark no-show',
bulk: true,
icon: VizierIcon.check,
requiresConfirmation: true,
handler: (request, records, values) async {
for (final record in records) {
await bookings.markNoShow(record);
}
return ActionResult(message: '${records.length} bookings updated.');
},
),
];
@override
bool canRunAction(Request request, AdminAction<Booking> action, Booking record) =>
_isStaff(request);
An action may take a form to collect input before it runs; the values arrive
in values.
Relations #
A resource lists the relations that may be managed from its pages. Every relation mutation is denied until the subclass opens it:
class RolesRelation extends AdminRelation<User, Role> {
RolesRelation(AdminResource<Role> roles)
: super(
name: 'roles',
relatedResource: roles,
pivotForm: (form) => form.fields([TextField('granted_by').required()]),
);
@override
bool canAttach(Request request, User owner, Role related) => _isAdmin(request);
@override
bool canDetach(Request request, User owner, Role related) => _isAdmin(request);
}
@override
List<AdminRelation<User, dynamic>> relations() => [RolesRelation(roles)];
belongsTo, hasOne, hasMany and belongsToMany are managed directly;
morphOne, morphMany, morphTo, morphToMany and morphedByMany are
managed polymorphically. A morphTo relation declares its own morphTargets
allowlist, and a submitted type is checked against it rather than against the
global MorphMap.
Every relation page has a full-page selector at
…/relations/{name}/select: searchable, paginated, one attach form per row,
and — for many-to-many — a checkbox column with a sync form that replaces
the set. It all works with JavaScript disabled.
Pages, widgets and extensions #
VizierPagerenders a custom view at its ownslugand appears in the sidebar;dashboardreplaces the defaultDashboardPage.VizierWidgetrenders a view inside the dashboard.VizierExtensionbundles resources, pages, dashboard widgets and translations under a package name, so a third-party package can plug a whole section into a panel with one entry inextensions.
Localization #
Panels ship English and Arabic catalogs, and Arabic renders right-to-left.
defaultLocale, supportedLocales, rtlLocales, localeResolver and
translations on Panel control the rest; resources and actions accept
per-locale labels through singularLabelFor, pluralLabelFor and
localizedLabels.
Appearance #
The panel wears Maat's identity: a Nile navigation rail in both themes, Solar
Gold marking the active section and the primary action in dark mode, Faience
for success and Oxide for anything destructive. Dark mode is written before
first paint, remembered in localStorage, and defaulted from
prefers-color-scheme.
Every painted value resolves through one layer of custom properties, so an application restyles the whole panel without touching a component rule:
/* public/vendor/vizier/vizier.css, after vizier:install */
:root {
--vizier-rail-bg: #101828; /* the navigation column */
--vizier-accent: #2563eb; /* brand tile, active navigation marker */
--vizier-solid-bg: #2563eb; /* primary buttons */
--vizier-solid-fg: #ffffff;
}
The layout is written in logical properties, so Arabic mirrors from dir="rtl"
alone. Below 48rem the rail becomes a drawer and every control grows to a
2.75rem touch target. See
Appearance
for the full token list.
Customizing the templates #
vizier:install publishes the Khnum templates to
resources/views/vendor/vizier/ and the CSS and JavaScript to
public/vendor/vizier/. Edit them freely; your application's copies win over
the package defaults.
Assets and deployment #
The CSS and JavaScript are compiled into a generated Dart constant, so an
AOT-compiled binary can publish them without reading the package directory.
After editing resources/css/vizier.css or resources/js/vizier.js:
dart run tool/build_vizier_assets.dart # regenerate
dart run tool/build_vizier_assets.dart --check # CI: fail if stale
Alpine is vendored at a pinned version with its license; nothing is fetched from a CDN at runtime.
dart compile exe bin/server.dart -o build/admin
APP_BASE_PATH=/srv/admin ./build/admin
Use dart build cli --target bin/server.dart -o build/aot instead when a
dependency ships native build hooks (the SQLite driver does); it emits the same
executable plus its native libraries.
The binary reads the published resources/views/ and public/ directories at
APP_BASE_PATH; serve public/ and keep APP_DEBUG false in production.
Scope #
This alpha registers one panel per application, mounted at /admin by
default; there is no multi-panel infrastructure. It uses no reflection or
model-source parsing — every slug, field, column, relation, morph target and
action comes from a registered definition — and every operation keeps a plain
page route that works without JavaScript. The field list is fixed to text,
email, password, textarea, number, boolean, select, date, datetime and hidden.
Out of scope: file uploads, charts, global search, arbitrary filters, soft-delete management, multi-tenancy, MFA, OAuth, runtime themes, and marketplace or plugin discovery.
Documentation #
The full guide lives in the Maat documentation.