Asyncz Dashboard¶
Asyncz includes an optional Lilya dashboard for inspecting tasks and running common management actions from a browser.
What it provides¶
- an overview page with scheduler, task state, and recent run summaries
- a runtime page with scheduler state, timing, store, and executor metadata
- an instances page with scheduler identity and heartbeat freshness for the current process
- a timeline page that previews upcoming run times across all tasks
- an events page for scheduler and task events observed by the dashboard process
- an audit page for dashboard create/update/run/pause/resume/remove actions
- grouped navigation for operation and review pages
- a task list with search, state/executor/trigger filters, sortable views, and last run status
- task detail pages with scheduler metadata, upcoming run preview, recent runs, and recent logs
- actions on each task row for Run now, Logs, Edit, pause, resume, remove, and history inspection
- bulk task actions for Run now, pause, resume, and remove
- a run history page populated from scheduler execution events
- run detail pages with correlated log records
- an optional login flow
- a log viewer powered by the dashboard logging subsystem, including run id filtering
Install¶
Mounting with Lilya¶
from lilya.apps import Lilya
from asyncz.contrib.dashboard.admin import AsynczAdmin
from asyncz.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
app = Lilya()
admin = AsynczAdmin(scheduler=scheduler)
admin.include_in(app)
Start and stop the scheduler with your application lifecycle:
Remote worker scheduler¶
Use the remote dashboard option when the production app serves the admin UI but does not own the running scheduler. The worker can be another process, host, or Docker container; it only needs to expose the Asyncz remote control app on a private service URL.
This is the intended shape for deployments where the same codebase is started in two modes:
| Process | Scheduler | Purpose |
|---|---|---|
| App | Disabled | Serves the product app and AsynczAdmin. |
| Worker | Enabled | Starts AsyncIOScheduler and executes tasks. |
Keep the scheduler construction in shared code so both processes agree on stores, executors, task defaults, and task imports:
# myapp/scheduler.py
from asyncz.schedulers.asyncio import AsyncIOScheduler
def build_scheduler() -> AsyncIOScheduler:
return AsyncIOScheduler(
identity="asyncz-worker",
stores={"default": {"type": "memory"}},
)
Mount the control app in the worker that starts the scheduler:
# myapp/worker.py
import os
from lilya.apps import Lilya
from lilya.routing import Include
from asyncz.contrib.dashboard import create_remote_scheduler_app
from .scheduler import build_scheduler
scheduler = build_scheduler()
worker_app = Lilya(
routes=[
Include(
"/asyncz-remote",
app=create_remote_scheduler_app(
scheduler,
token=os.environ["ASYNCZ_REMOTE_TOKEN"],
),
)
],
on_startup=[scheduler.start],
on_shutdown=[scheduler.shutdown],
)
Pass a RemoteSchedulerClient to AsynczAdmin in the app that serves the
dashboard:
# myapp/app.py
import os
from lilya.apps import Lilya
from asyncz.contrib.dashboard import RemoteSchedulerClient
from asyncz.contrib.dashboard.admin import AsynczAdmin
app = Lilya()
remote_scheduler = RemoteSchedulerClient(
"http://asyncz-worker:8000/asyncz-remote",
token=os.environ["ASYNCZ_REMOTE_TOKEN"],
)
admin = AsynczAdmin(scheduler=remote_scheduler)
admin.include_in(app)
In Docker Compose, the app can point at the worker service name:
services:
app:
image: myapp
command: uvicorn myapp.app:app --host 0.0.0.0 --port 8000
environment:
ASYNCZ_REMOTE_URL: http://worker:8001/asyncz-remote
ASYNCZ_REMOTE_TOKEN: ${ASYNCZ_REMOTE_TOKEN}
worker:
image: myapp
command: uvicorn myapp.worker:worker_app --host 0.0.0.0 --port 8001
environment:
ASYNCZ_REMOTE_TOKEN: ${ASYNCZ_REMOTE_TOKEN}
With this setup, task inspection, create, edit, run, pause, resume, remove, and timeline preview requests are forwarded to the worker. The app process does not need to start or hold a scheduler instance.
The dashboard task creation form sends the callable path as text, for example
myapp.tasks:send_invoice. The worker imports that callable from its own
runtime. That means the worker image must contain the same application code and
dependencies as the app image.
Keep the remote control endpoint private. The token is a small shared secret for Asyncz-level protection, not a replacement for network isolation, service auth, or proxy access controls. Dashboard task creation sends callable references and JSON trigger arguments to the worker, so the worker image must be able to import the referenced callables. If custom code sends a trigger object directly through the client, Asyncz serializes that trusted object for the worker; do not expose that path to untrusted callers.
Run history, event history, and logs are recorded where their storage and listeners run. In a split deployment, use shared dashboard storage or forward worker logs if the app-side dashboard needs to display worker-local execution history.
The remote client is not a distributed scheduler and does not elect workers. It is a dashboard control plane for the scheduler process that owns task state. If you run more than one scheduler worker, point the dashboard at the worker or internal service that is responsible for the task store you want to manage.
Mounting in other ASGI frameworks¶
Use admin.get_asgi_app() and mount it according to your framework's ASGI mounting API.
from starlette.applications import Starlette
from starlette.routing import Mount
from asyncz.contrib.dashboard.admin import AsynczAdmin
from asyncz.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
admin = AsynczAdmin(scheduler=scheduler)
app = Starlette(routes=[Mount("/dashboard", app=admin.get_asgi_app())])
Login support¶
Enable login by passing enable_login=True and a backend implementation.
from asyncz.contrib.dashboard.admin import (
AsynczAdmin,
SimpleUsernamePasswordBackend,
User,
)
def verify(username: str, password: str) -> User | None:
if username == "admin" and password == "secret":
return User(id="admin", name="Admin")
return None
admin = AsynczAdmin(
scheduler=scheduler,
enable_login=True,
backend=SimpleUsernamePasswordBackend(verify),
)
Admin surfaces¶
The dashboard is organized around operational tasks:
| Area | Purpose |
|---|---|
| Overview | Scheduler state, task counts, recent tasks, and recent runs. |
| Tasks | Filter tasks, inspect task detail, trigger immediate runs, open logs for a task, edit supported metadata, pause, resume, remove, and inspect last run state. |
| Runtime | Inspect scheduler timing, timezone, state code, stores, executors, and task distribution. |
| Instances | Inspect scheduler identity, active/stale status, and heartbeat freshness for the current process. |
| Timeline | Preview upcoming run times across tasks without mutating scheduler state. |
| Events | Inspect scheduler and task events observed by the dashboard process. |
| History | Inspect manual and scheduled task runs captured from scheduler events. |
| Audit | Inspect dashboard management actions separately from execution history. |
| Logs | Filter captured log records by task id, run id, level, and message text. |
The dashboard uses packaged Alpine.js for transient browser state such as navigation, table density, filter visibility, selection, and modal controls. The scheduler remains the source of truth: task rows, history records, and logs are rendered from scheduler APIs and the dashboard storage backends.
Configuration¶
Dashboard defaults live in asyncz.contrib.dashboard.config.DashboardConfig.
The most commonly customized fields are:
dashboard_url_prefixtitleheader_titledescriptionsession_cookiesame_sitehttps_only
You can return a custom DashboardConfig from settings.dashboard_config.
Reverse proxies¶
When the dashboard is served behind a proxy prefix, enable the forwarded prefix middleware and configure the proxy to strip the external prefix before forwarding requests to the ASGI app.
from asyncz.contrib.dashboard.admin import AsynczAdmin
admin = AsynczAdmin(
scheduler=scheduler,
url_prefix="/dashboard",
enable_forward_middleware=True,
trusted_forwarded_hosts=("127.0.0.1", "::1"),
)
The repository tests cover the Asyncz side of this contract with
X-Forwarded-Prefix: generated links, static asset URLs, and HTMX endpoints
must include the external prefix while route matching continues to use the
upstream path.
For Nginx, use the same contract in your location block:
location /ops/dashboard/ {
proxy_set_header X-Forwarded-Prefix /ops;
proxy_set_header Host $http_host;
proxy_pass http://127.0.0.1:8000/dashboard/;
}
With that shape, the upstream app receives /dashboard/... requests while
rendered links, static assets, and HTMX endpoints point at /ops/dashboard/....
Use $http_host so nonstandard ports survive in generated absolute asset URLs
during local or internal deployments.
trusted_forwarded_hosts accepts exact client host names, IP addresses, and IP
network ranges such as "10.0.0.0/8". Keep it limited to the proxy addresses
that can reach the application. Use "*" only when another network layer
already prevents direct client access to the dashboard process.
Static assets¶
The dashboard serves its browser assets from package resources. It does not load Tailwind CSS, Alpine.js, HTMX, Toastify, or the default favicon from public CDNs at runtime.
Packaged assets live under asyncz.contrib.dashboard/statics and include:
- Tailwind CSS compiled for the bundled Jinja templates
- Alpine.js CSP build
- HTMX
- Toastify JavaScript and CSS
- the dashboard favicon
The asset manifest at statics/vendor/manifest.json records upstream package
versions, npm integrity values, SHA-256 checksums, and license files. When
updating these assets, regenerate the compiled Tailwind file from
statics/css/asyncz-tailwind.input.css, update the manifest, and run the
dashboard static asset tests plus a wheel/sdist build.
Security headers¶
Dashboard responses include browser hardening headers by default:
Content-Security-PolicyX-Content-Type-OptionsX-Frame-OptionsReferrer-PolicyPermissions-Policy
The default CSP allows scripts only from the dashboard origin. Styles allow
'unsafe-inline' because current templates and third-party browser libraries
still require inline style support, but inline script handlers are not used.
Task list behavior¶
The task dashboard now reads from the scheduler's immutable task inspection snapshots rather than serializing live tasks ad hoc inside each controller.
That means the UI can consistently show:
- task id and display name
- callable name/reference
- trigger alias and readable trigger description
- next run time
- store and executor aliases
- derived task state (
pending,paused, orscheduled)
The task table supports:
- text search
- state filtering
- executor filtering
- trigger filtering
- sorting by name, trigger, state, or next run time
- bounded pagination with 25, 50, 100, or 200 rows per page
- edit links for supported task fields
- log links that open the log viewer filtered to the selected task id
- comfortable and compact density modes
- a resizable table region with sticky row actions on wide viewports
Active filters are preserved across:
- automatic HTMX refreshes
- pagination
- row actions
- bulk actions
Task edits¶
The task edit page delegates validation and persistence to
scheduler.update_task(...). It supports the same operational metadata exposed
by the asyncz update command:
- task name
- callable reference
- positional arguments as JSON
- keyword arguments as JSON
- executor alias
- coalesce behavior
- maximum concurrent instances
- misfire grace time, including clearing the value
Posting the edit form with intent=preview validates the proposed update and
renders a diff without mutating the task. Posting with
intent=apply applies the update through the scheduler and records a
task.update audit event with the changed fields.
Dashboard "run now" semantics¶
When you use the dashboard's Run action, the dashboard calls scheduler.run_task(..., remove_finished=False).
This is intentional:
- recurring tasks advance to their next scheduled run
- date tasks remain visible in the dashboard as paused instead of disappearing immediately
That behavior is useful for operators because a manually triggered date task can still be inspected, resumed, or removed explicitly after the forced run.
Manual dashboard runs are recorded in run history with source="manual".
Scheduled executions are recorded with source="scheduled" when they are
submitted through normal scheduler processing.
Run history¶
When the dashboard is mounted, it installs a scheduler listener for task submission and execution events. The default run history backend is memory storage in the current process.
Each run record captures:
- run id
- task id and task name
- callable reference
- store and executor alias
- scheduler identity from the task event that recorded the run
- number of due runs omitted by coalescing
- source (
manual,scheduled, orunknown) - status (
running,succeeded,failed,missed, ormax_instances) - submitted and finished timestamps
- duration
- return value or exception type, message, and traceback when available
Run history handles synchronous debug executors as well as asynchronous executors. If a debug executor emits the execution event before the submission event, the record is still merged into one run and the later submission event fills in the source.
Run Logs¶
Run history lifecycle events write structured log records with a run_id.
Lifecycle log records also include the scheduler identity stored on the run
record, so a run detail page can show both the logs and the scheduler process
that recorded them.
The run detail page shows:
- direct lifecycle logs for that run id
- task logs emitted during the run window
For application task logs, prefer Python logging with a task id:
from asyncz.contrib.dashboard.logs.handler import get_task_logger
logger = get_task_logger("cleanup-task")
logger.info("cleanup started")
Or pass task metadata directly:
import logging
logging.getLogger("asyncz.jobs").info(
"cleanup started",
extra={"task_id": "cleanup-task"},
)
Overview page¶
The overview page summarizes:
- scheduler running/stopped state
- configured timezone
- total task count
- scheduled / paused / pending counts
- recent tasks with their callable and trigger metadata
- recent runs with status, source, duration, and log inspection links
Runtime page¶
The runtime page shows the active scheduler's read-only operational metadata:
- running/stopped state and scheduler state code
- scheduler identity, start time, and uptime for the active process
- task inventory totals, including tasks accepted into the scheduler lifecycle
- configured timezone, store retry interval, and startup delay
- registered stores with alias, implementation class, logger namespace, and task count
- registered executors with alias, implementation class, logger namespace, and task count
Use this page when you need to confirm which persistence and execution backends the running process is actually using.
Instances page¶
The instances page renders scheduler.get_scheduler_instance_infos() and shows:
- scheduler identity and current process scope
- active/stale state
- last seen timestamp and heartbeat age
- start time and uptime
- configured store and executor aliases
- task inventory totals
This page reports the scheduler instance reachable through the current runtime object. It does not invent distributed scheduler membership from task stores.
Timeline page¶
The timeline page previews upcoming run times across all tasks by calling the
scheduler's preview_task_runs() API for each task. It does not advance
triggers, update next_run_time, or submit work to executors.
Use the controls to choose how many run times to preview per task and the total number of rows to show. The result is sorted by due time so operators can quickly see what is expected to run next.
Events page¶
The events page installs a scheduler listener when the dashboard is mounted and records events observed in the current process. It can filter by category, event name, task id, search text, and limit.
This page is intentionally process local. It does not claim distributed event membership across machines or processes.
Captured task event details include:
- scheduler identity when present on the event
- submitted run times and coalesced run count for submission events
- scheduled run time, return value, exception type, and exception message for execution events
Audit page¶
The audit page records dashboard management actions separately from run history. It captures task create, update, run, pause, resume, and remove attempts with:
- action name
- target task id
- status (
succeeded,warning, orfailed) - timestamp
- message for the operator
The default audit backend is memory storage in the current process. Pass
audit_storage= to AsynczAdmin when you want to share or replace that storage
inside a larger application.
Custom log storage¶
Pass log_storage= to AsynczAdmin if you want the dashboard log viewer to use something other than the default memory storage.
from asyncz.contrib.dashboard.admin import AsynczAdmin
from asyncz.contrib.dashboard.logs.storage import MemoryLogStorage
admin = AsynczAdmin(
scheduler=scheduler,
log_storage=MemoryLogStorage(maxlen=20_000),
)
Custom Run History Storage¶
Pass run_history_storage= to AsynczAdmin to control the run
history backend. The built-in storage is MemoryRunHistoryStorage.
from asyncz.contrib.dashboard.admin import AsynczAdmin
from asyncz.contrib.dashboard.history import MemoryRunHistoryStorage
admin = AsynczAdmin(
scheduler=scheduler,
run_history_storage=MemoryRunHistoryStorage(maxlen=10_000),
)
The built-in backend belongs to the current process. Use a custom backend if you need run history to survive process restarts or be shared across multiple worker processes.