Skip to content

Powered by Grav CMS + Helios Theme

Dashboard alerts

Above every screen in the KahunaCart admin sits a strip saying what needs your attention right now: orders waiting to be paid, a job queue that is not draining, products running low, and a report rollup that is out of date. This page is for store administrators reading that strip, and for add-on authors who want to put their own line in it.

What ships

The heading of the strip is a button. Click it and the strip folds down to a single line: the heading and a count of the alerts still open. The browser remembers which way you left it. Under the strip, Show hidden appears only when you have hidden something, and it says how many.

Nine checks come with KahunaCart, and two more, exchange_rates_stale and exchange_rates_drift, are added on a store that fetches exchange rates. Each runs on its own schedule. Anyone can hide an alert without hiding it from everyone else, and it comes back on its own if the problem is still there.

Alert Severity Re-runs after Says
unpaid_orders warning every read How many completed orders are still waiting to be paid.
jobs_stale error, or warning for failures 60 seconds The queue is not draining, or jobs are failing.
low_stock error when something is out, warning otherwise 5 minutes How many products are at or below the low-stock threshold.
refund_requests warning every read How many customers have asked for a refund and have not been answered.
reports_rollup_stale info 1 hour The report rollup predates cost of goods, so margin is scanned for rather than read. See Analytics.
site_url_missing error 60 seconds Neither security.site_urls nor system.custom_base_url names the store's address, so sign-in, set-password and cart links are not being sent. Its button opens the Security settings. See Hardening → Site address for emailed links.
worker_environment error for email settings, warning otherwise 5 minutes Settings for one of the store's addresses were saved into a user/env/<host>/ folder the background worker never reads. Its button opens Health. See Going live → Worker environment.
root_addons_outdated error 5 minutes The shop is at the site root (route: '/') and an installed add-on is older than the first version that builds its links for it, so its pages and links break. Names each add-on with the version it needs. Its button opens Health. See Storefront → Mounting the shop at the site root.
owner_messages_nowhere info 5 minutes The store has a notification channel, has never chosen where new store-owner messages go, and some switched-on store-owner messages reach no channel. Its button opens the Channels page. Choosing a default, even none, ends it for good, and hiding it keeps it away for 30 days.
exchange_rates_drift info 5 minutes A rate you typed is further than currencies.rates.manual_drift_percent from the market rate the last refresh got for it. It names each currency and how far. This is advice only. It never sends an owner notification, and hiding it keeps it away for a week. It is only there on a store that fetches its exchange rates.
exchange_rates_stale warning 5 minutes A fetched rate was held because it moved more than currencies.rates.hold_move_percent (said first, with the percent), a rate your store fetches is older than currencies.rates.max_age_days working days, or a currency has none. Its button opens the Currencies screen. It is only there on a store that fetches its exchange rates. A rate you typed yourself never counts as old. The same facts go to the owner notification Exchange rates out of date. See A big move is held for you to check.

site_url_missing, worker_environment and root_addons_outdated are also pushed to your phone, as Urgent store-owner messages.

The store status isn't one of these alerts. While the store is coming soon or paused, the top of the dashboard says so in the warning colour, with what shoppers are told and the switch to open it, and bin/plugin kahunacart check warns about it. See Configuration → Store status.

The three rules

A check does not run again before its interval is up. Six screens asking within a minute cost one check.

Hiding an alert only hides it for you. The check still runs. The alert is left out of your list and nobody else's. Hiding the low-stock alert hides it until tomorrow, so a reorder you have already placed stops nagging this afternoon but reminds you tomorrow if it has not turned up. The others have no window.

One alert failing costs one alert. If an add-on's check errors, that alert disappears and the dashboard still draws.

Turning it off, and slowing it down

Key Default Effect
alerts.enabled true Off runs no checks at all, rather than running them and drawing nothing
alerts.min_recheck_seconds 0 A floor under every alert's own interval, an add-on's included. Zero leaves each alert to its own timing

Raise alerts.min_recheck_seconds on a store whose database is slow and every check gets cheaper at once, without editing anybody's code.

What an alert carries

An alert comes back as its id, its severity, a headline and a sentence under it, its actions, the raw data the check produced, and whether this user has hidden it. The headline says what is wrong and the sentence says what to do about it.

The headline and sentence are language keys plus the arguments to fill in, so an add-on's alert reads like one of KahunaCart's own.

Severity is one of info, warning or error, the same three the order notices system uses.

API reference

Route Permission What it does
GET /kahunacart/alerts kahunacart.orders.view Every alert this user should see, loudest first. ?dismissed=1 includes the hidden ones
POST /kahunacart/alerts/{id}/dismiss kahunacart.orders.view Hide one for this user, for as long as its definition says
POST /kahunacart/alerts/rebuild kahunacart.settings Throw away every cached check so the next read re-runs all of them

Rebuilding needs the settings permission because clearing the cache affects the whole store rather than one person's view.

Each route has an MCP tool: list_dashboard_alerts, dismiss_dashboard_alert and rebuild_dashboard_alerts.

Registering your own

An add-on registers an alert on onKahunaCartRegisterDashboardAlerts, which carries the registry.

PHP
public static function getSubscribedEvents(): array
{
    return [
        'onKahunaCartRegisterDashboardAlerts' => ['onRegisterAlerts', 0],
    ];
}

public function onRegisterAlerts(Event $event): void
{
    $event['registry']->register(new CallableAlert(
        'licenses_expiring',
        // What the check finds. Return an array. Every other callback here is
        // handed this, and it is what the payload carries.
        fn (): array => ['count' => $this->expiringSoon()],
        // Seconds before it runs again. This one is a counting query over an
        // indexed column, so five minutes is generous.
        recheckInterval: 300,
        // Whether there is anything to say. A check that finds nothing wrong
        // should return data and answer false here.
        shouldShow: fn (array $d): bool => $d['count'] > 0,
        // The headline, as a key and its arguments.
        title: fn (array $d): array => [
            'key' => 'PLUGIN_LICENSES.ALERT_EXPIRING',
            'args' => [(string)$d['count']],
        ],
        // The sentence under it, saying what to do. Optional.
        detail: fn (): ?array => ['key' => 'PLUGIN_LICENSES.ALERT_EXPIRING_DETAIL', 'args' => []],
        severity: fn (): string => 'warning',
        // Where the button goes, as an admin route inside the KahunaCart
        // section. The shell navigates to it without reloading the page.
        actions: fn (): array => [[
            'code' => 'open',
            'label_key' => 'PLUGIN_LICENSES.ALERT_EXPIRING_ACTION',
            'route' => '/section/licenses?filter=expiring',
        ]],
        // How long a dismissal lasts, in seconds. Null means until the user
        // puts it back.
        dismissWindow: 86400,
    ));
}

Three things to get right.

Return data from check(), decide in shouldShow(). The result of check() is cached for the recheck interval and handed to every other callback.

Make check() cheap, or give it an interval. It runs on every admin screen that draws the strip. A counting query over an indexed column with a five-minute interval is fine. A full table scan on every read is not, whatever interval you give it.

Publish keys, not sentences. title, detail and every action's label_key are language keys. Hardcode a sentence there and no translation can reach it. Make sure the arguments match the placeholders: a string with two %1$s-style holes needs two arguments, or the merchant reads a raw placeholder at the top of their admin.

Pushing an alert to your phone

An alert can also reach the store owner, through the same channels as every other store-owner message. Add notify: with a push priority, and label: with a language key that names it without arguments:

PHP
use Grav\Plugin\KahunaCart\Notifications\Priority;

$event['registry']->register(new CallableAlert(
    'licenses_expiring',
    fn (): array => ['count' => $this->expiringSoon()],
    recheckInterval: 300,
    shouldShow: fn (array $d): bool => $d['count'] > 0,
    title: fn (array $d): array => ['key' => 'PLUGIN_LICENSES.ALERT_EXPIRING', 'args' => [(string)$d['count']]],
    detail: fn (): ?array => ['key' => 'PLUGIN_LICENSES.ALERT_EXPIRING_DETAIL', 'args' => []],
    actions: fn (): array => [['code' => 'open', 'label_key' => 'PLUGIN_LICENSES.ALERT_EXPIRING_ACTION', 'route' => '/section/licenses']],
    // Also tell the owner, at this priority. Null (the default) keeps it on the dashboard.
    notify: Priority::NORMAL,
    // What its row on Notifications → Store owner is called. The headline
    // carries a count, and a row label has nothing to fill it with.
    label: 'PLUGIN_LICENSES.ALERT_EXPIRING_LABEL',
));

That is all the add-on writes. The store then has a row on Notifications → Store owner coded alert.licenses_expiring, at that priority, going to the store's default channels like any new row. Without label: the row is named from the id: "Licenses expiring".

What happens next:

  • The worker checks it, not the dashboard. On its housekeeping tick the worker runs each pushed alert on the alert's own recheck interval, through the same cache the strip reads, so a five-minute alert is checked every five minutes however often the worker runs. It skips the check entirely while nothing receives the row, while alerts.enabled is off, and on a store with no channels.
  • One message per episode. The headline, the sentence and the severity go out when the alert starts showing, with a link to its first action's route (the dashboard when it has none). Nothing more while it keeps showing. When it stops, the next time is news again, and if the first message went out the same channels get "Cleared: Licenses expiring".
  • Hiding it on the dashboard changes nothing. That is per person. Switching the row off is how the owner stops it.
  • An alert that errors costs only itself, as on the dashboard.

A class that implements AlertDefinition opts in by implementing NotifyingAlert instead, which adds notify(): ?string and label(): ?string. A class that stays on AlertDefinition keeps working and stays on the dashboard. The headline and sentence are translated when the worker checks the alert, so keys and arguments follow the same rules as above.

Several add-ons push theirs: Affiliates, Bookings, Newsletters, Polar, Print on demand and Subscriptions. Each add-on's chapter says which of its alerts do.

Where the queue warning went

The two boxes that once sat above every screen when jobs were not running are now the jobs_stale alert, drawn in the alert strip with the same sentences.

The provider-coverage warning, the one that says no payment method can sell your shippable products, is still a box of its own. It is a fact about how the store is configured, not a check, and there is nothing about it to dismiss.

  • Notifications - the channels a pushed alert goes to, and where new messages go
  • Orders and the admin - the admin the strip sits above, and the notice queue it shares its severities with
  • Analytics - what the reports_rollup_stale alert is telling you
  • CLI - the worker tick the jobs_stale alert is watching
  • Configuration - the two keys above
  • Troubleshooting - what to do when the queue really has stopped