|
| 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. |
0 commit comments