Skip to main content
zerotal

Dashboard widgets

Register widgets with Panel.widgets(...); they render on the dashboard.

import { Panel, statsWidget, stat, chartWidget, tableWidget } from "@zerotal/admin";

Panel.widgets(
  statsWidget(async () => [
    stat("Users", await User.count())
      .description("+12% this week")
      .color("success"),
    stat("Revenue", "$48k"),
  ]),
  chartWidget("Signups", async () => ({
    type: "line",
    labels: ["Mon", "Tue", "Wed"],
    datasets: [{ label: "Signups", data: [4, 9, 7] }],
  })),
  tableWidget(
    "Latest orders",
    [
      { key: "id", label: "#" },
      { key: "total", label: "Total", align: "end" },
    ],
    async () => await Order.latest().limit(5).get(),
  ),
);

Charts use Chart.js via CDN (the dashboard renders once, so there's no morph re-init).

Widgets on a resource

The dashboard answers "how is the business doing". A resource's own widgets answer "what is going on in this list" — a pending count above the orders table, stock value above products:

export class OrderResource extends Resource {
  static override widgets() {
    return [
      statsWidget(async () => [
        stat("Awaiting payment", await Order.query().where("status", "pending").count()),
      ]).poll("30s"),
    ];
  }
}

They render above the table and use the same builders as the dashboard, polling included — and a polling widget there refreshes the table with it, which is what someone watching a queue actually wants.

Polling

A dashboard on a second screen is stale the moment it renders. .poll() gives a widget an interval:

statsWidget(async () => [
  stat("Awaiting payment", await Order.query().where("status", "pending").count()),
]).poll("30s"),

The dashboard refreshes as one unit, at the shortest interval any of its widgets asked for. That costs a query per tick per viewer, so it is worth it for the numbers someone actually watches and wasteful on everything else — leave the rest to render once per navigation.

Global search & the command palette

Any resource with .searchable() columns and a recordTitleAttribute joins global search — a top-bar box, a /search results page, and a client-side ⌘K / Ctrl-K command palette that filters resources and escalates a free query to the search page.

Notifications

The admin owns the bell + notifications page UI; your app supplies the data through a provider (the same split as relations — admin UI, app data):

import { Panel } from "@zerotal/admin";

Panel.notifications({
  async resolve() {
    return (await Auth.user().notifications().latest().limit(20).get()).map((n) => ({
      id: String(n.id),
      title: n.data.title,
      body: n.data.body,
      href: n.data.url,
      read: n.read_at != null,
      time: n.created_at,
    }));
  },
  async markRead(id) {
    await Notification.find(id)?.markAsRead();
  },
  async markAllRead() {
    await Auth.user().unreadNotifications().markAsRead();
  },
  async unreadCount() {
    return Auth.user().unreadNotifications().count();
  },
});

The header bell shows a live unread badge (refreshed each navigation). The notifications page polls and listens for broadcasts: broadcast a notification from your app on the NOTIFICATION_CHANNEL / NOTIFICATION_EVENT (admin-notifications / .notification.sent) broadcast channel and the open page updates live. When no provider is configured, the bell and page stay hidden.

Database-backed notifications

Panel.notifications() takes a provider because "the current user's notifications" depends on your auth and your schema. When both are the ordinary ones — @zerotal/auth for the user, @zerotal/notifications' DatabaseChannel for storage — there is a ready-made one:

import { databaseNotifications } from "@zerotal/admin";

Panel.notifications(databaseNotifications());

Everything is adjustable: notifiable says whose notifications to show, present turns a stored row into a title and a link, table names the table, limit caps how many the bell holds.

It fails soft throughout. A missing table, an unconfigured database or a signed-out user yields an empty bell rather than a broken panel — a notification centre is never worth taking the page down for.

The sidebar is derived from each resource's navigationGroup, navigationSort, navigationIcon, and navigationParentItem. Groups are collapsible (<details>), parent items nest their children, and navigationBadge() renders a colored count pill. The top-bar user menu comes from Panel.configure({ userMenu }).

Custom pages and anything contributed by a package sit in the same sidebar, sorted and grouped by the same rules — a page's group heading is just a string, so a page and a resource that name the same group land together.

Next steps

  • Admin overview — the guide's front page and the rest of the sections.
  • Reference — the full API surface in one table.