Skip to content

Commit 27f0dcc

Browse files
authored
Merge pull request #37 from cardmagic/agent/web-dashboard
feat: add operator dashboard
2 parents daf0050 + cd32e68 commit 27f0dcc

59 files changed

Lines changed: 5010 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Add `SolidObjects::Web`, a mountable Rack dashboard for the actor runtime.
6+
It covers instances and their committed state, the ready and claimed
7+
mailbox, reminders, effects, broadcasts, dead letters, and processes, with
8+
actor-type and actor-id filtering, status filters, paging, and a polled
9+
`GET /stats` endpoint. Mount it with
10+
`mount SolidObjects::Web => "/solid_objects/dashboard"` after
11+
`require "solid_objects/web"`; requiring the gem does not load it, so a
12+
worker process carries no web stack.
13+
- Authorize every dashboard route through `authorize_administration`. Each
14+
route declares its own `action` and `resource`, and a route declared without
15+
a policy raises at load time. The policy receives a context that answers
16+
`request`, `session`, and `env`.
17+
- Add two dashboard actions: an idempotent dead letter retry through
18+
`SolidObjects.dead_letters.retry`, and instance pause/resume, which sets and
19+
clears `paused_at` so the activation manager stops claiming that identity. A
20+
retry the mailbox refuses, such as an actor class that no longer exists,
21+
renders the reason with a 422 rather than failing the request.
22+
- Draw instances per actor type, mailbox depth, and outbox and reminder status
23+
with Chart.js, loaded from a CDN with a subresource integrity hash. The CDN
24+
host is the only external origin the content security policy names. Point
25+
`SolidObjects::Web.chart_library_url` at a vendored copy for a deployment
26+
with no outbound network access, or set it to nil to render without charts.
27+
- Add `SolidObjects::Web.register` for extension tabs, routes, and view
28+
directories, and `SolidObjects::Web.use` for Rack middleware in front of the
29+
dashboard.
30+
- Add `rack` as an explicit dependency at `>= 3.1`, and package the `web/`
31+
directory in the gem.
32+
333
## 0.13.0 - 2026-08-13
434

535
- **Breaking:** make observables invalidation-only by default. An ordinary

Gemfile.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ PATH
77
actionview (>= 8.0)
88
activerecord (>= 8.0)
99
activesupport (>= 8.0)
10+
rack (>= 3.1)
1011
railties (>= 8.0)
1112
thor (>= 1.3)
1213

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ tested, but the project does not yet claim production readiness. See
8686
- [State migrations](#state-migrations)
8787
- [Configuration](#configuration)
8888
- [Workers and operations](#workers-and-operations)
89+
- [Dashboard](#dashboard)
8990
- [Database support](#database-support)
9091
- [Guarantees](#guarantees)
9192
- [When to use it](#when-to-use-it)
@@ -1084,6 +1085,50 @@ an initializer.
10841085
See the [operations guide](docs/operations.md) for monitoring, reconciliation,
10851086
shutdown, retention, and backup guidance.
10861087

1088+
## Dashboard
1089+
1090+
`SolidObjects::Web` is a Rack application that shows instances and their state,
1091+
the mailbox, reminders, effects, broadcasts, dead letters, and the registered
1092+
processes. Mount it inside the application routes, so the Rails session
1093+
middleware runs first:
1094+
1095+
```ruby
1096+
# config/routes.rb
1097+
require "solid_objects/web"
1098+
1099+
Rails.application.routes.draw do
1100+
mount SolidObjects::Web => "/solid_objects/dashboard"
1101+
end
1102+
```
1103+
1104+
It is not loaded by `require "solid_objects"`: a worker process must not carry
1105+
a web stack. The dashboard and the engine are separate mounts, so an
1106+
application that uses reactive ERB mounts both on different paths.
1107+
1108+
Every page asks `authorize_administration` before its handler runs, and that
1109+
policy denies by default, so a mount alone exposes nothing. The block receives
1110+
the route's own `action:` and `resource:`, and an `authorization_context:` that
1111+
answers `request`, `session`, and `env`.
1112+
1113+
The dashboard changes only two things. Retrying a dead letter goes through
1114+
`SolidObjects.dead_letters.retry`, which is idempotent. Pausing an instance
1115+
sets `paused_at` so the activation manager stops claiming that identity; a pass
1116+
already in flight finishes its turn, and a synchronous caller waiting on a
1117+
paused instance times out rather than receiving a result.
1118+
1119+
The dashboard draws instances per actor type, mailbox depth, and outbox status
1120+
with Chart.js, loaded from a CDN with a subresource integrity hash. A
1121+
deployment with no outbound network access can vendor the file, or turn the
1122+
charts off:
1123+
1124+
```ruby
1125+
SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
1126+
SolidObjects::Web.chart_library_integrity = nil
1127+
```
1128+
1129+
Read the [dashboard guide](docs/dashboard.md) for the full policy table,
1130+
extension registration, and query cost.
1131+
10871132
## Database support
10881133

10891134
Solid Objects supports:

docs/authorization.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ intentionally inert until the host application defines its trust boundary.
1313
| `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants |
1414
| `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
1515
| `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
16-
| `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
16+
| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
1717

1818
Waiting again through `MessageReference#wait` reauthorizes the stored
1919
invocation as a message or query. Internal reminder, effect-callback, and
@@ -154,6 +154,13 @@ configuration.authorize_administration = lambda do |authorization_context:, **|
154154
end
155155
```
156156

157+
That policy also denies every `SolidObjects::Web` page, which is the correct
158+
result for a host whose only administration boundary is shell access. A policy
159+
that opens the dashboard should separate reading from writing, because
160+
`action` distinguishes them: `index` and `show` read, while `pause`, `resume`,
161+
and `retry` change the runtime. The [dashboard guide](dashboard.md) lists the
162+
action and resource of every page.
163+
157164
Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
158165
probe is deliberately conservative: a context-aware policy may correctly warn
159166
because it denies a `nil` context.

docs/dashboard.md

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Operator dashboard
2+
3+
`SolidObjects::Web` is a Rack application that shows what the actor runtime is
4+
doing: instances and their state, the mailbox, reminders, effects, broadcasts,
5+
dead letters, and the registered processes. It reads the same tables the
6+
runtime writes, so it needs no separate store and no agent.
7+
8+
It is deliberately not loaded by `require "solid_objects"`. A worker process
9+
must not carry a web stack, and an application that never mounts the dashboard
10+
must not pay for it.
11+
12+
## Mounting
13+
14+
```ruby
15+
# config/routes.rb
16+
require "solid_objects/web"
17+
18+
Rails.application.routes.draw do
19+
mount SolidObjects::Web => "/solid_objects/dashboard"
20+
end
21+
```
22+
23+
Mount it inside the application routes so the Rails session middleware runs
24+
first. The dashboard needs a Rack session for CSRF protection and refuses a
25+
state changing request without one.
26+
27+
The dashboard and the engine are separate mounts. Mount the engine as well if
28+
the application uses reactive ERB, and give each one its own path:
29+
30+
```ruby
31+
mount SolidObjects::Engine => "/solid_objects"
32+
mount SolidObjects::Web => "/solid_objects/dashboard"
33+
```
34+
35+
In a bare Rack application, supply the session middleware yourself:
36+
37+
```ruby
38+
use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
39+
run SolidObjects::Web
40+
```
41+
42+
## Authorization
43+
44+
Every page asks `configuration.authorize_administration` before its handler
45+
runs. That policy denies by default, so a mount alone exposes nothing. A route
46+
declared without a policy raises at load time, which is why a new page cannot
47+
reach the actor tables before an application has said who may read it.
48+
49+
The block receives the route's own action and resource:
50+
51+
| Page | `action` | `resource` | `resource_id` |
52+
| --- | --- | --- | --- |
53+
| Dashboard, `GET /stats`, `HEAD /` | `index` | `dashboard` | none |
54+
| Instance list | `index` | `instances` | none |
55+
| Instance detail | `show` | `instances` | instance id |
56+
| Pause an instance | `pause` | `instances` | instance id |
57+
| Resume an instance | `resume` | `instances` | instance id |
58+
| Mailbox | `index` | `messages` | none |
59+
| Message detail | `show` | `messages` | message id |
60+
| Reminders | `index` | `reminders` | none |
61+
| Effects | `index` | `effects` | none |
62+
| Broadcasts | `index` | `broadcasts` | none |
63+
| Dead letter list | `index` | `dead_letters` | none |
64+
| Dead letter detail | `show` | `dead_letters` | dead letter id |
65+
| Retry a dead letter | `retry` | `dead_letters` | dead letter id |
66+
| Processes | `index` | `processes` | none |
67+
68+
`authorization_context:` is the request object. It answers `request`,
69+
`session`, and `env`, so a policy can read the signed-in operator the same way
70+
a controller does:
71+
72+
```ruby
73+
SolidObjects.configure do |configuration|
74+
configuration.authorize_administration = lambda do |action:, authorization_context:, **|
75+
return false unless authorization_context.respond_to?(:session)
76+
77+
operator = Operator.find_by(id: authorization_context.session[:operator_id])
78+
return false unless operator&.administrator?
79+
80+
action == "index" || action == "show" || operator.may_write_runtime?
81+
end
82+
end
83+
```
84+
85+
The command line reaches the same policy with `{ source: "cli" }` rather than
86+
a request, which is why the example checks what the context answers before
87+
reading a session from it.
88+
89+
## Pages
90+
91+
**Dashboard.** Totals per subsystem, the registered processes, and the most
92+
recent dead letters. The summary bar appears on every page and can poll
93+
`GET /stats` for the same numbers; nothing else on the page refreshes, because
94+
a table that reloads under an operator who is reading it is worse than a stale
95+
one.
96+
97+
**Instances.** Filter by actor type and by an actor id substring. Each row
98+
shows the lease state: `idle`, `activated`, `expired`, or `paused`. The detail
99+
page shows committed state, the ready and claimed mailbox, message history,
100+
reminders, effects, broadcasts, and dead letters for that identity.
101+
102+
**Mailbox.** The ready and claimed messages across every identity, oldest
103+
first. Mailbox lag on the summary bar is the age of the oldest message that is
104+
already due, which is how far behind the workers are.
105+
106+
**Reminders, effects, broadcasts, processes.** Status filtered lists.
107+
108+
## Charts
109+
110+
The dashboard draws three charts: instances per actor type, mailbox depth, and
111+
a stacked view of effects, broadcasts, and reminders by status. Each canvas
112+
carries its own numbers in a `data-chart-values` attribute, so the page needs
113+
no inline script and no request to draw. Mailbox depth and the status chart
114+
redraw when the Live poller reports new totals, because `/stats` already
115+
carries those numbers. The instance chart does not: `/stats` does not group by
116+
actor type, and adding that would put a `GROUP BY` on every poll.
117+
118+
Chart.js comes from a CDN with a subresource integrity hash, so a compromised
119+
CDN cannot substitute other code, and the CDN host is the only external origin
120+
the content security policy names.
121+
122+
A deployment with no outbound network access should vendor the file:
123+
124+
```ruby
125+
SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
126+
SolidObjects::Web.chart_library_integrity = nil
127+
```
128+
129+
A path below the mount is served from the dashboard's own asset directory and
130+
needs no policy exception. Setting the URL to `nil` renders the dashboard
131+
without charts and names no external origin at all.
132+
133+
Set these before the first request. The middleware stack and the compiled
134+
templates are built once and cached.
135+
136+
**Dead letters.** The exception, its message, and its backtrace, with a retry
137+
button.
138+
139+
## Actions
140+
141+
The dashboard changes only two things.
142+
143+
**Retry a dead letter** goes through `SolidObjects.dead_letters.retry`, which
144+
enqueues the original operation under an idempotency key. Pressing it twice
145+
produces one message rather than two.
146+
147+
A retry re-enters the mailbox, which refuses work the runtime cannot accept: an
148+
actor class that no longer exists, a full mailbox, a payload over the cap. The
149+
dashboard renders the dead letter again with the reason and a 422 status,
150+
rather than failing the request.
151+
152+
**Pause an instance** sets `paused_at`, and the activation manager stops
153+
claiming that identity. Two consequences matter:
154+
155+
- A pass already in flight finishes its turn. Pause is not a stop.
156+
- A synchronous caller waiting on a paused instance times out rather than
157+
receiving a result, because nothing will execute its message.
158+
159+
Resume clears the column and the mailbox drains in sequence order.
160+
161+
## Extensions
162+
163+
An extension adds pages by declaring routes on the application class. Its
164+
routes carry an authorization policy like every other route:
165+
166+
```ruby
167+
module Tenants
168+
def self.registered(application)
169+
application.get "/tenants", policy: { action: "index", resource: "tenants" } do
170+
@tenants = Tenant.order(:name)
171+
erb(:tenants)
172+
end
173+
end
174+
end
175+
176+
SolidObjects::Web.register(
177+
Tenants,
178+
tab: "Tenants",
179+
path: "/tenants",
180+
views: File.expand_path("../web/views", __dir__)
181+
)
182+
```
183+
184+
A registered view directory is searched before the packaged one, so an
185+
application can replace a single page without forking the gem. A template
186+
reads its arguments from `locals`, and a replacement `layout.erb` renders the
187+
page it wraps with `locals.fetch(:content)`.
188+
189+
Add Rack middleware in front of the dashboard with `SolidObjects::Web.use`,
190+
for example to require HTTP basic authentication in an environment that has no
191+
session-backed operator.
192+
193+
## Cost
194+
195+
The summary bar issues one grouped count per subsystem on every page, and each
196+
list page counts its own relation to page it. That is a fixed set of indexed
197+
aggregate queries, not a scan proportional to actor traffic, but it is not
198+
free: do not put the dashboard behind an uptime monitor that loads the whole
199+
page on an interval. `HEAD /` exists for that. It touches one table and
200+
returns no body.

docs/roadmap.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,25 @@
108108
handing the block a raw Cable connection.
109109
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
110110
distributed per-actor rate limits and global admission control do not.
111-
- Administration: actor and dead-letter views plus policy hooks exist; richer
112-
filtering, audit records, and bulk-safe tools do not.
111+
- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
112+
instances, mailbox, reminders, effects, broadcasts, dead letters, and
113+
processes, with actor-type and actor-id filtering, status filters, paging, a
114+
polled stats endpoint, Chart.js charts, and extension registration. The chart
115+
library is fetched from a CDN with a subresource integrity hash, which a
116+
deployment without outbound network access must replace with a vendored copy
117+
or turn off. Every route declares its
118+
own administration policy and a route declared without one raises at load
119+
time, so the deny-by-default posture is enforced by construction rather than
120+
by remembering to add a check. It changes only two things: an idempotent dead
121+
letter retry and instance pause/resume. What does not exist is audit records
122+
of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
123+
because `DeadLetterManager` exposes no bulk operation. Pause is an operator
124+
brake and not a stop, since a pass already in flight finishes its turn and a
125+
synchronous caller waiting on a paused instance times out. The page cost was
126+
reasoned about rather than measured: the summary bar issues a fixed set of
127+
indexed aggregate queries per page, which is why `HEAD /` exists for uptime
128+
monitors, but no dashboard latency has been benchmarked against a large
129+
table.
113130

114131
## Next milestones
115132

0 commit comments

Comments
 (0)