Skip to content

Commit f199640

Browse files
authored
Merge pull request #894 from timgit/pro-overlay
Dashboard: readonly mode
2 parents 74ac6c3 + 41b616b commit f199640

47 files changed

Lines changed: 1413 additions & 185 deletions

Some content is hidden

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

docs/dashboard.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ The dashboard is configured via environment variables:
4747
| `PORT` | Server port | `3000` |
4848
| `PGBOSS_DASHBOARD_AUTH_USERNAME` | Basic auth username (optional) | - |
4949
| `PGBOSS_DASHBOARD_AUTH_PASSWORD` | Basic auth password (optional) | - |
50+
| `PGBOSS_DASHBOARD_READ_ONLY` | Set to `1` to disable every mutating action (see [Read-only mode](#read-only-mode)) | - |
5051
| `PGBOSS_DASHBOARD_BASE_PATH` | Sub-path to serve the dashboard under, e.g. `/pgboss` (build-time only, see [Serving under a sub-path](#serving-under-a-sub-path)) | `/` |
5152
| `PGBOSS_DASHBOARD_QUERY_TIMEOUT` | Max milliseconds per dashboard query before server-side cancellation (`statement_timeout`). Requires a restart to change. | `60000` |
5253

@@ -63,6 +64,23 @@ npx pg-boss-dashboard
6364

6465
Both variables must be provided together. If only one is set, the dashboard will throw an error on startup.
6566

67+
### Read-only mode
68+
69+
Set `PGBOSS_DASHBOARD_READ_ONLY=1` to serve the dashboard as a viewer:
70+
71+
```bash
72+
PGBOSS_DASHBOARD_READ_ONLY=1 \
73+
DATABASE_URL="postgres://localhost/mydb" \
74+
npx pg-boss-dashboard
75+
```
76+
77+
Every page still loads and every query still runs. What changes:
78+
79+
- The server rejects every non-`GET`/`HEAD` request with `403`, so sending, retrying, cancelling, resuming, deleting, creating queues, and scheduling are all refused — including a request crafted by hand.
80+
- The controls for those actions are not rendered, and `/send`, `/queues/create`, and `/schedules/new` explain themselves instead of showing a form.
81+
82+
This is a global switch rather than a permission system: everyone who can reach the dashboard sees the same read-only view. It is independent of basic authentication and can be combined with it.
83+
6684
### Multi-Database Configuration
6785

6886
To monitor multiple pg-boss instances, separate connection strings with a pipe (`|`):

packages/dashboard/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# The Pro overlay is cloned in at build time and is never part of this
2+
# repository. See app/lib/pro-overlay.ts.
3+
app/pro

packages/dashboard/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2026 Tim Jones
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

packages/dashboard/README.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ DATABASE_URL="postgres://user:password@localhost:5432/mydb" npx pg-boss-dashboar
1313

1414
Open http://localhost:3000 in your browser.
1515

16-
Requires Node.js 22.12+ and a PostgreSQL database with a pg-boss schema (pg-boss 12.24+ recommended). For configuration, multi-database setup, production deployment, warning persistence, and troubleshooting, see the [documentation](https://pgboss.io/dashboard).
1716

1817
## Development
1918

@@ -27,7 +26,7 @@ cd pg-boss/packages/dashboard
2726
# Install dependencies
2827
npm install
2928

30-
# Initialize local database with pg-boss schema and test queues
29+
# Initialize local database with pg-boss schema and demo data
3130
npm run dev:init-db
3231

3332
# Start development server with hot reloading
@@ -44,10 +43,6 @@ npm run build
4443
npm start
4544
```
4645

47-
The `dev:init-db` script creates the pg-boss schema and populates it with sample queues and jobs for testing. It connects to `postgres://postgres:postgres@127.0.0.1:5432/pgboss` by default.
48-
49-
The `dev:worker` script starts a worker that processes jobs from the same pg-boss instance as the dashboard. This is useful for testing the dashboard while jobs are being processed. The worker will stay running until you stop it with Ctrl+C.
50-
5146
### Testing
5247

5348
```bash

packages/dashboard/app/components/error-card.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1+
import { isRouteErrorResponse } from 'react-router'
12
import { DbLink } from './db-link'
23
import { Card, CardContent } from './ui/card'
34

45
interface ErrorCardProps {
56
title: string
67
message?: string
8+
/**
9+
* The boundary's error, forwarded from `ErrorBoundary({ error })`.
10+
*
11+
* When it is a thrown `Response` the server wrote an explanation worth showing —
12+
* a read-only refusal, or a "Queue not found" 404 — so it wins over the generic
13+
* copy below. Optional: a boundary with nothing better to say still renders fine.
14+
*/
15+
error?: unknown
716
backTo?: {
817
href: string
918
label: string
@@ -13,14 +22,23 @@ interface ErrorCardProps {
1322
export function ErrorCard ({
1423
title,
1524
message = 'Please check your database connection and try again.',
25+
error,
1626
backTo,
1727
}: ErrorCardProps) {
28+
const response = isRouteErrorResponse(error) ? error : undefined
29+
const body = typeof response?.data === 'string' ? response.data.trim() : ''
30+
31+
// A loader that simply threw has no server-authored text, so the caller's copy
32+
// stands. Only a real message displaces it.
33+
const heading = response?.statusText || title
34+
const detail = body || message
35+
1836
return (
1937
<div className="p-6">
2038
<Card>
2139
<CardContent className="py-8 text-center">
22-
<p className="text-red-600 dark:text-red-400 font-medium">{title}</p>
23-
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">{message}</p>
40+
<p className="text-red-600 dark:text-red-400 font-medium">{heading}</p>
41+
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">{detail}</p>
2442
{backTo && (
2543
<DbLink
2644
to={backTo.href}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import overlay from '~pro'
2+
import type { ProSlots } from '~/lib/pro-contract'
3+
4+
/**
5+
* Renders an overlay slot, or nothing when no overlay is present. Keep
6+
* `ProSlots` to the regions a feature actually needs — add one when a feature
7+
* demands it, never speculatively.
8+
*/
9+
export function ProSlot ({ name }: { name: keyof ProSlots }) {
10+
const Component = overlay.slots[name]
11+
return Component ? <Component /> : null
12+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Card, CardContent } from '~/components/ui/card'
2+
import { Eye } from 'lucide-react'
3+
4+
/**
5+
* Stands in for a form when the dashboard is read-only. The three creation routes
6+
* (`/send`, `/queues/create`, `/schedules/new`) stay reachable — a bookmark or a
7+
* stale link should explain itself rather than 404 — but they render this instead
8+
* of a form that could only ever be refused.
9+
*/
10+
export function ReadOnlyNotice ({ action }: { action: string }) {
11+
return (
12+
<Card>
13+
<CardContent className="flex items-start gap-3 py-6">
14+
<Eye className="h-5 w-5 shrink-0 text-gray-400 dark:text-gray-500" aria-hidden />
15+
<div className="space-y-1">
16+
<p className="font-medium text-gray-900 dark:text-gray-100">
17+
This dashboard is read-only
18+
</p>
19+
<p className="text-sm text-gray-600 dark:text-gray-400">
20+
{action} is disabled because the server was started with
21+
{' '}<code className="font-mono text-xs">PGBOSS_DASHBOARD_READ_ONLY=1</code>.
22+
Unset it to restore write access.
23+
</p>
24+
</div>
25+
</CardContent>
26+
</Card>
27+
)
28+
}

packages/dashboard/app/components/sidebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { NavLink, useRouteLoaderData, useSearchParams, useNavigate, useLocation } from 'react-router'
22
import { useState, useRef, useEffect, useCallback } from 'react'
33
import { createPortal } from 'react-dom'
4+
import overlay from '~pro'
5+
import { ProSlot } from '~/components/pro-slot'
46
import { ThemeToggle } from '~/components/ui/theme-toggle'
57
import { ColorThemePicker } from '~/components/ui/color-theme-picker'
68
import { cn } from '~/lib/utils'
@@ -30,6 +32,7 @@ const navigation = [
3032
{ name: 'Schedules', href: '/schedules', icon: SchedulesIcon },
3133
{ name: 'Migrations', href: '/migrations', icon: MigrationsIcon },
3234
{ name: 'Warnings', href: '/warnings', icon: WarningIcon },
35+
...overlay.nav,
3336
]
3437

3538
function HomeIcon ({ className }: { className?: string }) {
@@ -280,6 +283,7 @@ export function AppSidebar () {
280283
</SidebarContent>
281284

282285
<SidebarFooter>
286+
<ProSlot name="sidebarFooter" />
283287
<div className="flex flex-col px-2">
284288
<p className="px-2 mb-1 text-xs font-medium text-sidebar-foreground/50 uppercase tracking-wider group-data-[state=collapsed]:hidden">Theme</p>
285289
<ThemeToggle />
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ComponentType } from 'react'
2+
3+
/**
4+
* The contract between this package and an optional Pro overlay.
5+
*
6+
* Types only — there is never an implementation here, so the stub in
7+
* `pro-stub.ts` and any overlay typecheck against one source.
8+
*
9+
* The overlay has two halves, resolved by different mechanisms because React
10+
* Router's config loader runs outside the Vite module graph:
11+
*
12+
* - **Config-time** (`app/pro/routes.ts`) — route definitions, resolved by
13+
* relative path in `pro-routes.ts`.
14+
* - **Runtime** (`app/pro/index.tsx`) — everything below, resolved through the
15+
* `~pro` alias like any other module.
16+
*/
17+
18+
export interface ProNavItem {
19+
name: string
20+
href: string
21+
icon: ComponentType<{ className?: string }>
22+
}
23+
24+
/** Named regions of the free UI an overlay may render into. */
25+
export interface ProSlots {
26+
/** Above the theme controls in the sidebar footer. */
27+
sidebarFooter?: ComponentType
28+
}
29+
30+
export interface ProOverlay {
31+
nav: ProNavItem[]
32+
slots: ProSlots
33+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { existsSync } from 'node:fs'
2+
import { dirname, join } from 'node:path'
3+
import { fileURLToPath, pathToFileURL } from 'node:url'
4+
import type { RouteConfigEntry } from '@react-router/dev/routes'
5+
6+
/**
7+
* Build-time resolution of the optional Pro overlay. See `pro-contract.ts` for
8+
* what an overlay provides.
9+
*
10+
* The overlay is a directory at `app/pro/`, absent from this repository and
11+
* cloned in by a Pro build. Presence alone does not enable it — `PGBOSS_PRO=1`
12+
* must also be set, so a build is never silently different from what was asked
13+
* for. Setting the flag without an overlay is a hard error rather than a
14+
* quiet fallback.
15+
*
16+
* Two halves, resolved two ways, because React Router's config loader runs
17+
* outside the Vite module graph and honours neither `resolve.alias` nor `~`:
18+
*
19+
* - **Config-time** — `proRoutes()`, imported by relative path from
20+
* `app/routes.ts`. Anything reachable from `app/pro/routes.ts` must avoid `~`
21+
* imports for the same reason. Route definitions are pure data, so that costs
22+
* the overlay nothing.
23+
* - **Runtime** — `proAlias()` gives Vite and Vitest the target for `~pro`,
24+
* which resolves to the no-op `pro-stub.ts` in every ordinary build.
25+
*/
26+
27+
const here = dirname(fileURLToPath(import.meta.url))
28+
29+
/** Where a Pro build clones the overlay. The default for every caller but the tests. */
30+
export const overlayDir = join(here, '..', 'pro')
31+
export const stubPath = join(here, 'pro-stub.ts')
32+
33+
/** Read at call time rather than import time, so tests can exercise both states. */
34+
export function proEnabled (): boolean {
35+
return process.env.PGBOSS_PRO === '1'
36+
}
37+
38+
function requireOverlay (dir: string): void {
39+
if (!existsSync(dir)) {
40+
throw new Error(
41+
`PGBOSS_PRO=1 but no overlay is present at ${dir}. ` +
42+
'Clone the Pro overlay into that directory before building, or unset PGBOSS_PRO.'
43+
)
44+
}
45+
}
46+
47+
/**
48+
* Target for the `~pro` alias: the overlay's runtime entry, or the stub.
49+
*
50+
* `dir` exists so the tests can point at a scratch directory they own. Nothing
51+
* here ever writes to or removes `dir`, and no test may pass `overlayDir` — a
52+
* developer's overlay clone is live, uncommitted work.
53+
*/
54+
export function proAlias (dir: string = overlayDir): string {
55+
if (!proEnabled()) {
56+
return stubPath
57+
}
58+
59+
requireOverlay(dir)
60+
return join(dir, 'index.tsx')
61+
}
62+
63+
/** Routes the overlay adds, appended to the free route table. See `proAlias` on `dir`. */
64+
export async function proRoutes (dir: string = overlayDir): Promise<RouteConfigEntry[]> {
65+
if (!proEnabled()) {
66+
return []
67+
}
68+
69+
requireOverlay(dir)
70+
71+
// The specifier is computed so TypeScript does not try to resolve a directory
72+
// that is absent from every build but a Pro one.
73+
const entry = pathToFileURL(join(dir, 'routes.ts')).href
74+
const { default: routes } = await import(/* @vite-ignore */ entry) as { default: RouteConfigEntry[] }
75+
return routes
76+
}

0 commit comments

Comments
 (0)