mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 00:04:27 +00:00
Merge remote-tracking branch 'origin/main' into fix/issue-381-user-experience
This commit is contained in:
+18
-23
@@ -45,9 +45,8 @@ Role bitmasks mirror `AdminRolePermissions` in
|
||||
`internal/models/admin_permission.go`. For one-off permission combinations,
|
||||
pass a raw `BITMASK=N` instead of `ROLE`.
|
||||
|
||||
Once a super-admin exists they can grant the rest through the in-app user
|
||||
management screen, which goes through the audited `GrantAdminPermissions`
|
||||
path instead of raw SQL.
|
||||
Once a super-admin exists they can grant the rest from **Accounts > Admins**,
|
||||
which goes through the audited `GrantAdminPermissions` path instead of raw SQL.
|
||||
|
||||
Set up `.env.local` from `.env.example`:
|
||||
|
||||
@@ -74,25 +73,21 @@ This app is intentionally tinted differently from the dashboard. If you find you
|
||||
|
||||
These signals are layered on purpose. A single one (e.g. just the badge) is easy to overlook in a tab strip. Stacked, they make it obvious that the user is in the privileged surface.
|
||||
|
||||
## What's wired vs. stubbed
|
||||
## What is in it
|
||||
|
||||
**Real data:**
|
||||
Every nav entry is backed by real endpoints under `/admin/*`; there are no stub pages.
|
||||
|
||||
- Overview — `/admin/analytics/overview` plus `/admin/workers/managed` for the fleet card
|
||||
- Workers list — `/admin/workers/managed`
|
||||
- Worker detail — `/admin/workers/:id/managed`, `/admin/workers/:id/live-status`, `/admin/workers/:id/logs`, plus the SSH lifecycle mutations (`test`, `install`, `restart`, `uninstall`)
|
||||
- Audit Log — `/admin/audit-logs`
|
||||
- Settings (Encryption, Storage, Messaging, Cache, Transports) — `/admin/settings/backends` with `kind` filter; renders an "endpoint pending" placeholder when the registry isn't wired yet
|
||||
| Group | Pages |
|
||||
| --- | --- |
|
||||
| Overview | counters, trends, signups by channel, the instance problems strip |
|
||||
| Operations | Workers, Fleet (capacity, decision log, dedicated bindings), Mailboxes, Sync (backfill and fair-use throttle per mailbox), Warmup (pools, abuse signals, action history), Warmup Appeals, Warmup Content, Campaigns, Sends (in-flight reservations, dead letters, task failures, webhook delivery health) |
|
||||
| Accounts | Users, Organizations (with API keys, webhooks and transfer tabs), Limit requests, Outreach, Admins |
|
||||
| Insight | Live Events (the `admin:platform` socket firehose), Audit Log, Jobs (every background loop with last run, next run and "run now") |
|
||||
| Instance | Setup and health (findings and service probes), Configuration (settings, notifications, environment, effective limits), Transfers (workspace export and import) |
|
||||
|
||||
**Stubs (page exists, no backend wire-up yet):**
|
||||
Cmd/Ctrl K opens a command palette that jumps to any page and searches users, organizations, mailboxes and workers. Lists stay live through the realtime invalidation spine in `src/lib/realtime/RealtimeManager.tsx`; only pages whose data has no event (service probes, jobs, in-flight sends, capacity) poll.
|
||||
|
||||
- Mailboxes
|
||||
- Users
|
||||
- Organizations
|
||||
- Plans & Billing
|
||||
- Warmup pools
|
||||
- Campaigns
|
||||
- Analytics (cross-platform charts; the Overview page already feeds from the same family of endpoints)
|
||||
The customer docs describe the panel page by page at `docs/content/docs/development/admin-panel.mdx`.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -111,13 +106,13 @@ admin/
|
||||
├── global.css # design tokens (mirror of web/) + admin-only tokens
|
||||
├── app/
|
||||
│ ├── auth/LoginPage.tsx
|
||||
│ ├── dashboard/ # Overview, Workers, Audit, stubs
|
||||
│ └── settings/ # Encryption/Storage/Messaging/Cache/Transports
|
||||
│ └── dashboard/ # one file per page, tab bodies in subfolders
|
||||
├── components/
|
||||
│ ├── layout/ # AppShell, Sidebar, Topbar, AdminBadge, EnvPill, …
|
||||
│ ├── data/ # DataTable, Explorer facet rail
|
||||
│ ├── layout/ # AppShell, Sidebar, MobileNav, Topbar, CommandPalette, PageTabs, …
|
||||
│ └── ui/ # shadcn primitives copied from web/src/components/ui
|
||||
├── hooks/
|
||||
│ └── useMe.ts
|
||||
│ └── useMe.ts, useDocumentTitle.ts, useInstanceHealth.ts, …
|
||||
└── lib/
|
||||
├── env.ts
|
||||
├── utils.ts
|
||||
@@ -126,6 +121,6 @@ admin/
|
||||
├── client.ts # axios instance + Request<T>
|
||||
├── client/
|
||||
│ ├── auth/ # login, getMe, logout
|
||||
│ └── admin/ # workers, audit, analytics, settings
|
||||
│ └── admin/ # one module per backend area (workers, sync, sends, jobs, fleet, …)
|
||||
└── models/
|
||||
```
|
||||
|
||||
+2
-11
@@ -15,17 +15,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -40,20 +33,18 @@
|
||||
"axios": "^1.18.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"motion": "^12.40.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-turnstile": "^1.1.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.3.6"
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
|
||||
Generated
+25
-273
@@ -16,24 +16,9 @@ importers:
|
||||
'@fontsource/inter':
|
||||
specifier: ^5.2.8
|
||||
version: 5.2.8
|
||||
'@fontsource/poppins':
|
||||
specifier: ^5.2.7
|
||||
version: 5.2.7
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.4.0(react-hook-form@7.76.1(react@19.2.6))
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-avatar':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -43,12 +28,6 @@ importers:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-progress':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -91,9 +70,12 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.3.0
|
||||
version: 4.4.0
|
||||
input-otp:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -109,12 +91,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.1.1
|
||||
version: 19.2.6(react@19.2.6)
|
||||
react-hook-form:
|
||||
specifier: ^7.71.1
|
||||
version: 7.76.1(react@19.2.6)
|
||||
react-hot-toast:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-router-dom:
|
||||
specifier: ^7.18.1
|
||||
version: 7.18.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -130,9 +106,6 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.1.18
|
||||
version: 4.3.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.36.0
|
||||
@@ -537,14 +510,6 @@ packages:
|
||||
'@fontsource/inter@5.2.8':
|
||||
resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==}
|
||||
|
||||
'@fontsource/poppins@5.2.7':
|
||||
resolution: {integrity: sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==}
|
||||
|
||||
'@hookform/resolvers@5.4.0':
|
||||
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||
peerDependencies:
|
||||
react-hook-form: ^7.55.0
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -587,19 +552,6 @@ packages:
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.15':
|
||||
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
@@ -613,19 +565,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-avatar@1.1.11':
|
||||
resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-checkbox@1.3.3':
|
||||
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
|
||||
peerDependencies:
|
||||
@@ -639,19 +578,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12':
|
||||
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.7':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
@@ -683,15 +609,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.3':
|
||||
resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dialog@1.1.15':
|
||||
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
|
||||
peerDependencies:
|
||||
@@ -797,19 +714,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popover@1.1.15':
|
||||
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
@@ -875,19 +779,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-progress@1.1.8':
|
||||
resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11':
|
||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||
peerDependencies:
|
||||
@@ -1033,15 +924,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-is-hydrated@0.1.0':
|
||||
resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1':
|
||||
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
||||
peerDependencies:
|
||||
@@ -1340,9 +1222,6 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
||||
|
||||
@@ -1681,6 +1560,12 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cmdk@1.1.1:
|
||||
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -1721,8 +1606,8 @@ packages:
|
||||
resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
date-fns@4.3.0:
|
||||
resolution: {integrity: sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ==}
|
||||
date-fns@4.4.0:
|
||||
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
@@ -1966,11 +1851,6 @@ packages:
|
||||
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
goober@2.1.19:
|
||||
resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==}
|
||||
peerDependencies:
|
||||
csstype: ^3.0.10
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2331,19 +2211,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.6
|
||||
|
||||
react-hook-form@7.76.1:
|
||||
resolution: {integrity: sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
|
||||
react-hot-toast@2.6.0:
|
||||
resolution: {integrity: sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: '>=16'
|
||||
react-dom: '>=16'
|
||||
|
||||
react-refresh@0.18.0:
|
||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2578,11 +2445,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
vite@7.3.6:
|
||||
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2730,9 +2592,6 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@acemir/cssom@0.9.31': {}
|
||||
@@ -3036,13 +2895,6 @@ snapshots:
|
||||
|
||||
'@fontsource/inter@5.2.8': {}
|
||||
|
||||
'@fontsource/poppins@5.2.7': {}
|
||||
|
||||
'@hookform/resolvers@5.4.0(react-hook-form@7.76.1(react@19.2.6))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.76.1(react@19.2.6)
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -3082,20 +2934,6 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -3105,19 +2943,6 @@ snapshots:
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
|
||||
'@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.3(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
|
||||
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -3134,22 +2959,6 @@ snapshots:
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6)
|
||||
@@ -3174,12 +2983,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
|
||||
'@radix-ui/react-context@1.1.3(@types/react@19.2.15)(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
|
||||
'@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -3295,29 +3098,6 @@ snapshots:
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
|
||||
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.15)(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -3374,16 +3154,6 @@ snapshots:
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
|
||||
'@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.3(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -3549,13 +3319,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
|
||||
'@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.15)(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
use-sync-external-store: 1.6.0(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.15)(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
@@ -3781,8 +3544,6 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -4150,6 +3911,18 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6)
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -4191,7 +3964,7 @@ snapshots:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 15.1.0
|
||||
|
||||
date-fns@4.3.0: {}
|
||||
date-fns@4.4.0: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
@@ -4444,10 +4217,6 @@ snapshots:
|
||||
|
||||
globals@16.5.0: {}
|
||||
|
||||
goober@2.1.19(csstype@3.2.3):
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
@@ -4750,17 +4519,6 @@ snapshots:
|
||||
react: 19.2.6
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-hook-form@7.76.1(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
react-hot-toast@2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
goober: 2.1.19(csstype@3.2.3)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
react-refresh@0.18.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.15)(react@19.2.6):
|
||||
@@ -4970,10 +4728,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
vite@7.3.6(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.7
|
||||
@@ -5058,5 +4812,3 @@ snapshots:
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// Admins: who holds operator-panel bits. Gated on GrantAdminAccess by the
|
||||
// route. Grant and edit share one dialog; revoke confirms and warns when the
|
||||
// target is you or the last admin who can still grant access. No poll: the
|
||||
// list invalidates after each mutation.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Pencil, ShieldOff, UserPlus } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import { useMe } from "@/hooks/useMe";
|
||||
import { useCursorPager } from "@/lib/useCursorPager";
|
||||
import { AdminPerm } from "@/lib/auth/permissions";
|
||||
import {
|
||||
listAdminPermissions,
|
||||
listAdmins,
|
||||
revokeAdminPermissions,
|
||||
type AdminInfo,
|
||||
} from "@/lib/api/client/admin/admins";
|
||||
import { GrantAdminDialog, type GrantTarget } from "./admins/GrantAdminDialog";
|
||||
import { PermissionChips } from "./admins/PermissionChips";
|
||||
import { hasBit } from "./admins/permissions";
|
||||
import { fmtDate, userName } from "./fleet/format";
|
||||
|
||||
export default function AdminsPage() {
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const me = useMe();
|
||||
const pager = useCursorPager();
|
||||
|
||||
const adminsQ = useQuery({
|
||||
queryKey: ["admin", "admins", pager.cursor],
|
||||
queryFn: () => listAdmins(pager.cursor),
|
||||
});
|
||||
const catalogQ = useQuery({
|
||||
queryKey: ["admin", "permissions"],
|
||||
queryFn: listAdminPermissions,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const catalog = catalogQ.data ?? [];
|
||||
const rows = adminsQ.data?.data ?? [];
|
||||
|
||||
const [dialog, setDialog] = useState<{ open: boolean; target: GrantTarget | null }>({ open: false, target: null });
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (userId: string) => revokeAdminPermissions(userId),
|
||||
onSuccess: (_res, userId) => {
|
||||
toast.success("Admin access revoked");
|
||||
qc.invalidateQueries({ queryKey: ["admin", "admins"] });
|
||||
qc.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
if (userId === me.data?.id) qc.invalidateQueries({ queryKey: ["me"] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Revoke failed"),
|
||||
});
|
||||
|
||||
const granters = rows.filter((a) => hasBit(a.admin_permissions, AdminPerm.GrantAdminAccess));
|
||||
|
||||
async function onRevoke(a: AdminInfo) {
|
||||
const isSelf = a.id === me.data?.id;
|
||||
// `granters` only covers the current page, so the claim is only safe
|
||||
// when the whole list is on screen.
|
||||
const fullListShown = !pager.canPrev && !adminsQ.data?.pagination.has_more;
|
||||
const lastGranter =
|
||||
hasBit(a.admin_permissions, AdminPerm.GrantAdminAccess) && granters.length <= 1 && fullListShown;
|
||||
const warnings: string[] = [];
|
||||
if (isSelf) warnings.push("This is your own account: you will lose access to this panel immediately.");
|
||||
if (lastGranter)
|
||||
warnings.push(
|
||||
"This is the only admin who can grant admin access. After this, nobody can add or edit admins from the panel; it would take a database change to recover.",
|
||||
);
|
||||
const ok = await confirm({
|
||||
title: `Revoke all admin permissions from ${a.email}?`,
|
||||
description: [
|
||||
"Every operator-panel bit is removed. Their workspace roles are untouched.",
|
||||
...warnings,
|
||||
].join(" "),
|
||||
confirmLabel: isSelf ? "Revoke my access" : "Revoke",
|
||||
destructive: true,
|
||||
});
|
||||
if (ok) revoke.mutate(a.id);
|
||||
}
|
||||
|
||||
const columns: Column<AdminInfo>[] = [
|
||||
{
|
||||
id: "admin",
|
||||
header: "Admin",
|
||||
cell: (a) => (
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{userName(a)}
|
||||
{a.id === me.data?.id && (
|
||||
<span className="ml-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">you</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">{a.email}</div>
|
||||
</div>
|
||||
),
|
||||
csv: (a) => a.email,
|
||||
},
|
||||
{
|
||||
id: "granted",
|
||||
header: "Granted",
|
||||
cell: (a) => (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div>{fmtDate(a.admin_granted_at)}</div>
|
||||
{a.granted_by_user ? (
|
||||
<div className="text-[11px]">by {a.granted_by_user.email}</div>
|
||||
) : a.admin_granted_by ? (
|
||||
<div className="font-mono text-[10px]">by {a.admin_granted_by.slice(0, 8)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
csv: (a) => a.admin_granted_at || "",
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
header: "Permissions",
|
||||
cell: (a) => <PermissionChips mask={a.admin_permissions} catalog={catalog} />,
|
||||
csv: (a) => catalog.filter((p) => hasBit(a.admin_permissions, p.permission)).map((p) => p.name).join(" "),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (a) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialog({ open: true, target: a });
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-700 hover:bg-red-50"
|
||||
disabled={revoke.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onRevoke(a);
|
||||
}}
|
||||
>
|
||||
<ShieldOff className="size-3" />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Admins"
|
||||
description="Who can open this panel and what they can do in it. These permission bits gate the operator surface only; they are not workspace roles and grant nothing inside a customer's workspace."
|
||||
>
|
||||
<Button size="sm" onClick={() => setDialog({ open: true, target: null })}>
|
||||
<UserPlus className="size-4" />
|
||||
Grant admin
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(a) => a.id}
|
||||
loading={adminsQ.isLoading || catalogQ.isLoading}
|
||||
error={adminsQ.error ?? catalogQ.error}
|
||||
onRetry={() => {
|
||||
adminsQ.refetch();
|
||||
catalogQ.refetch();
|
||||
}}
|
||||
errorTitle="Failed to load admins"
|
||||
pager={{
|
||||
canPrev: pager.canPrev,
|
||||
canNext: !!adminsQ.data?.pagination.has_more,
|
||||
onPrev: pager.prev,
|
||||
onNext: () => pager.next(adminsQ.data?.pagination.next_cursor),
|
||||
page: pager.page,
|
||||
shown: rows.length,
|
||||
total: adminsQ.data?.pagination.total,
|
||||
}}
|
||||
storageKey="admin.admins"
|
||||
csvName="warmbly-admins"
|
||||
noun="admins"
|
||||
emptyTitle="No admins"
|
||||
emptyHint="Nobody holds operator bits yet, which should not be possible while you are reading this. Grant one above."
|
||||
/>
|
||||
|
||||
<GrantAdminDialog
|
||||
open={dialog.open}
|
||||
onOpenChange={(v) => setDialog((d) => ({ ...d, open: v }))}
|
||||
catalog={catalog}
|
||||
target={dialog.target}
|
||||
selfId={me.data?.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Platform-wide analytics. Trend cards on top (4-up), daily email
|
||||
// timeseries as a stacked bar chart, hourly load + per-worker load
|
||||
// underneath. No external chart library — the bars are CSS so the
|
||||
// admin bundle doesn't pay for recharts/d3 for one screen.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
Mail,
|
||||
Megaphone,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
getAnalyticsTrends,
|
||||
getDailyEmailStats,
|
||||
getHourlyEmailStats,
|
||||
getUserGrowthStats,
|
||||
getWorkerLoadStats,
|
||||
} from "@/lib/api/client/admin/analytics";
|
||||
import type { DailyEmailStat, WorkerLoadStat } from "@/lib/api/models/admin";
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Analytics"
|
||||
description="Platform-wide send, delivery, engagement, and worker-load metrics."
|
||||
/>
|
||||
|
||||
<TrendCards />
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="text-sm font-semibold mb-2">Email volume — last 30 days</h2>
|
||||
<DailyEmailChart />
|
||||
</section>
|
||||
|
||||
<section className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<HourlyChart />
|
||||
<WorkerLoadList />
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="text-sm font-semibold mb-2">User growth — last 30 days</h2>
|
||||
<UserGrowthChart />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendCards() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "analytics", "trends"],
|
||||
queryFn: getAnalyticsTrends,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-4 mb-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-4 mb-6">
|
||||
<TrendCard
|
||||
icon={<Users className="size-4" />}
|
||||
label="Users"
|
||||
pct={data?.users_growth_percent}
|
||||
/>
|
||||
<TrendCard
|
||||
icon={<Mail className="size-4" />}
|
||||
label="Emails sent"
|
||||
pct={data?.emails_growth_percent}
|
||||
/>
|
||||
<TrendCard
|
||||
icon={<Megaphone className="size-4" />}
|
||||
label="Campaigns"
|
||||
pct={data?.campaigns_growth_percent}
|
||||
/>
|
||||
<TrendCard
|
||||
icon={<BarChart3 className="size-4" />}
|
||||
label="Revenue"
|
||||
pct={data?.revenue_growth_percent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendCard({
|
||||
icon,
|
||||
label,
|
||||
pct,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
pct?: number;
|
||||
}) {
|
||||
const v = pct ?? 0;
|
||||
const up = v >= 0;
|
||||
const tone =
|
||||
v > 0 ? "text-emerald-600" : v < 0 ? "text-red-600" : "text-muted-foreground";
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-3 bg-card">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-semibold tabular-nums mt-1 ${tone}`}>
|
||||
{pct == null ? "—" : `${up && v > 0 ? "+" : ""}${v.toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
{v > 0 ? <TrendingUp className="size-3" /> : v < 0 ? <TrendingDown className="size-3" /> : null}
|
||||
vs. previous period
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyEmailChart() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "analytics", "emails", "daily"],
|
||||
queryFn: () => getDailyEmailStats(30),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-48" />;
|
||||
const rows = data?.data ?? [];
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground border border-border rounded-md p-4 bg-card">
|
||||
No email activity in the last 30 days.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxSent = Math.max(1, ...rows.map((r) => r.total_sent));
|
||||
return (
|
||||
<div className="border border-border rounded-lg bg-card p-4">
|
||||
<div className="flex items-end gap-1 h-40">
|
||||
{rows.map((r) => (
|
||||
<DayBar key={r.date} stat={r} maxSent={maxSent} />
|
||||
))}
|
||||
</div>
|
||||
<Legend />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayBar({ stat, maxSent }: { stat: DailyEmailStat; maxSent: number }) {
|
||||
const h = (n: number) => `${Math.max(1, (n / maxSent) * 100)}%`;
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col items-center group relative"
|
||||
title={`${new Date(stat.date).toLocaleDateString()}\nsent: ${stat.total_sent}\ndelivered: ${stat.total_delivered}\nbounced: ${stat.total_bounced}\nreplied: ${stat.total_replied}`}
|
||||
>
|
||||
<div className="w-full flex-1 flex flex-col justify-end gap-0.5">
|
||||
{stat.total_bounced > 0 && (
|
||||
<div
|
||||
className="w-full bg-red-400"
|
||||
style={{ height: h(stat.total_bounced) }}
|
||||
/>
|
||||
)}
|
||||
{stat.total_replied > 0 && (
|
||||
<div
|
||||
className="w-full bg-emerald-400"
|
||||
style={{ height: h(stat.total_replied) }}
|
||||
/>
|
||||
)}
|
||||
{stat.total_delivered > 0 && (
|
||||
<div
|
||||
className="w-full bg-[var(--admin-accent)]"
|
||||
style={{ height: h(stat.total_delivered - stat.total_replied - stat.total_bounced) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground mt-1 -rotate-45 origin-top-left whitespace-nowrap">
|
||||
{new Date(stat.date).toLocaleDateString(undefined, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground mt-3">
|
||||
<LegendDot color="bg-[var(--admin-accent)]" label="Delivered" />
|
||||
<LegendDot color="bg-emerald-400" label="Replied" />
|
||||
<LegendDot color="bg-red-400" label="Bounced" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={`inline-block size-2 rounded ${color}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HourlyChart() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "analytics", "emails", "hourly"],
|
||||
queryFn: getHourlyEmailStats,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-48" />;
|
||||
const rows = data?.data ?? [];
|
||||
const max = Math.max(1, ...rows.map((r) => r.total_sent));
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg bg-card p-4">
|
||||
<h3 className="text-xs font-semibold mb-3">Today by hour</h3>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No sends today.</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.hour}
|
||||
className="flex-1 flex flex-col items-center"
|
||||
title={`${r.hour}:00 — ${r.total_sent} sent`}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-[var(--admin-accent)]"
|
||||
style={{ height: `${Math.max(1, (r.total_sent / max) * 100)}%` }}
|
||||
/>
|
||||
<div className="text-[8px] text-muted-foreground mt-1">
|
||||
{r.hour}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerLoadList() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "analytics", "workers", "load"],
|
||||
queryFn: getWorkerLoadStats,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-48" />;
|
||||
const rows = (data?.data ?? []).slice().sort(
|
||||
(a, b) => b.emails_sent_today - a.emails_sent_today,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg bg-card p-4">
|
||||
<h3 className="text-xs font-semibold mb-3">Worker load today</h3>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No workers reporting.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{rows.slice(0, 10).map((w) => (
|
||||
<WorkerLoadRow key={w.worker_id} w={w} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerLoadRow({ w }: { w: WorkerLoadStat }) {
|
||||
return (
|
||||
<li className="flex items-center justify-between text-xs gap-3">
|
||||
<Link
|
||||
to={`/workers/${w.worker_id}`}
|
||||
className="truncate text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
{w.worker_name || w.worker_id.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{w.emails_sent_today.toLocaleString()} sent
|
||||
{w.queued_emails > 0 && (
|
||||
<span className="ml-1 text-amber-600">
|
||||
· {w.queued_emails} queued
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function UserGrowthChart() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "analytics", "users", "growth"],
|
||||
queryFn: () => getUserGrowthStats(30),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-48" />;
|
||||
const rows = data?.data ?? [];
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground border border-border rounded-md p-4 bg-card">
|
||||
No new users in the last 30 days.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const maxNew = Math.max(1, ...rows.map((r) => r.new_users));
|
||||
return (
|
||||
<div className="border border-border rounded-lg bg-card p-4">
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.date}
|
||||
className="flex-1 flex flex-col items-center"
|
||||
title={`${new Date(r.date).toLocaleDateString()}\n+${r.new_users} new (${r.total_users} total)`}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-emerald-500"
|
||||
style={{ height: `${Math.max(1, (r.new_users / maxNew) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-2">
|
||||
New users per day (hover for totals).
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +1,120 @@
|
||||
// Configuration: the resolved environment of the running backend, read only.
|
||||
// Nothing here can be written from the API, so the page's whole job is to
|
||||
// answer "is my variable actually being picked up, and does it need a
|
||||
// restart to change?". Sensitive keys show a fingerprint, never a value.
|
||||
// Configuration: everything an operator can read or change about this
|
||||
// deployment, in four tabs. Settings and notifications write to the instance
|
||||
// settings document; environment and limits are read only. ?tab= deep-links
|
||||
// a tab, and a dirty form is confirmed before a tab switch or a navigation
|
||||
// discards it.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ExternalLink, Search, Settings2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useBlocker, useSearchParams } from "react-router-dom";
|
||||
import { Bell, Gauge, Settings2, Terminal } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getInstanceConfig,
|
||||
type ConfigSource,
|
||||
type InstanceConfigEntry,
|
||||
type RuntimeChangeable,
|
||||
} from "@/lib/api/client/admin/instance";
|
||||
import { PageTabs } from "@/components/layout/PageTabs";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import { EnvironmentTab } from "./configuration/EnvironmentTab";
|
||||
import { LimitsTab } from "./configuration/LimitsTab";
|
||||
import { NotificationsTab } from "./configuration/NotificationsTab";
|
||||
import { SettingsTab } from "./configuration/SettingsTab";
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
deployment: "Deployment",
|
||||
addresses: "Addresses",
|
||||
database: "Database",
|
||||
cache: "Cache",
|
||||
mail: "Platform mail",
|
||||
auth: "Authentication",
|
||||
encryption: "Encryption",
|
||||
storage: "Storage",
|
||||
eventbus: "Event bus",
|
||||
workers: "Workers",
|
||||
tracking: "Tracking",
|
||||
captcha: "Captcha",
|
||||
observability: "Observability",
|
||||
};
|
||||
const TAB_IDS = ["settings", "notifications", "environment", "limits"] as const;
|
||||
type TabId = (typeof TAB_IDS)[number];
|
||||
|
||||
const GROUP_ORDER = Object.keys(GROUP_LABELS);
|
||||
|
||||
const SOURCE_STYLES: Record<ConfigSource, { label: string; className: string; title: string }> = {
|
||||
env: {
|
||||
label: "env",
|
||||
className: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
title: "Read from this process's environment.",
|
||||
},
|
||||
default: {
|
||||
label: "default",
|
||||
className: "border-zinc-300 bg-zinc-50 text-zinc-600",
|
||||
title: "No environment variable set, so the built-in default applies.",
|
||||
},
|
||||
derived: {
|
||||
label: "derived",
|
||||
className: "border-sky-300 bg-sky-50 text-sky-700",
|
||||
title: "Computed from other values rather than set directly.",
|
||||
},
|
||||
unset: {
|
||||
label: "unset",
|
||||
className: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
title: "Not set and there is no default: the feature it controls is off.",
|
||||
},
|
||||
};
|
||||
|
||||
const RESTART_STYLES: Record<RuntimeChangeable, { label: string; className: string }> = {
|
||||
"boot-only": {
|
||||
label: "Restart to change",
|
||||
className: "border-zinc-300 bg-zinc-50 text-zinc-600",
|
||||
},
|
||||
"per-request": {
|
||||
label: "Takes effect immediately",
|
||||
className: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
},
|
||||
};
|
||||
|
||||
// "eventbus" -> "Eventbus". Keeps a group the frontend has not learned yet
|
||||
// rendering instead of disappearing.
|
||||
function groupLabel(group: string): string {
|
||||
if (GROUP_LABELS[group]) return GROUP_LABELS[group];
|
||||
const spaced = group.replace(/[-_]+/g, " ").trim();
|
||||
if (!spaced) return "Other";
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
function isTabId(v: string | null): v is TabId {
|
||||
return TAB_IDS.includes(v as TabId);
|
||||
}
|
||||
|
||||
function groupRank(group: string): number {
|
||||
const i = GROUP_ORDER.indexOf(group);
|
||||
return i === -1 ? GROUP_ORDER.length : i;
|
||||
}
|
||||
|
||||
function matches(entry: InstanceConfigEntry, needle: string): boolean {
|
||||
if (!needle) return true;
|
||||
const q = needle.toLowerCase();
|
||||
if (entry.key.toLowerCase().includes(q)) return true;
|
||||
if (groupLabel(entry.group).toLowerCase().includes(q)) return true;
|
||||
if (entry.effect.toLowerCase().includes(q)) return true;
|
||||
// A sensitive entry never carries its value, so there is nothing to match.
|
||||
if (!entry.sensitive && entry.value.toLowerCase().includes(q)) return true;
|
||||
return false;
|
||||
}
|
||||
const DISCARD_PROMPT = {
|
||||
title: "Discard unsaved changes?",
|
||||
description:
|
||||
"You have edits that have not been saved. Leaving now throws them away.",
|
||||
confirmLabel: "Discard",
|
||||
destructive: true,
|
||||
};
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [params, setParams] = useSearchParams();
|
||||
const raw = params.get("tab");
|
||||
const tab: TabId = isTabId(raw) ? raw : "settings";
|
||||
const confirm = useConfirm();
|
||||
|
||||
const configQ = useQuery({
|
||||
queryKey: ["admin", "instance", "config"],
|
||||
queryFn: getInstanceConfig,
|
||||
retry: false,
|
||||
});
|
||||
// The active tab reports its dirty state; a ref keeps the blocker's
|
||||
// predicate current without re-registering it on every keystroke.
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const dirtyRef = useRef(false);
|
||||
const onDirtyChange = useCallback((d: boolean) => {
|
||||
dirtyRef.current = d;
|
||||
setDirty(d);
|
||||
}, []);
|
||||
|
||||
const entries = useMemo(() => configQ.data?.entries ?? [], [configQ.data]);
|
||||
const filtered = useMemo(
|
||||
() => entries.filter((e) => matches(e, search.trim())),
|
||||
[entries, search],
|
||||
// Only a pathname change counts: ?tab= switches are handled by setTab.
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
dirtyRef.current && currentLocation.pathname !== nextLocation.pathname,
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byGroup = new Map<string, InstanceConfigEntry[]>();
|
||||
for (const entry of filtered) {
|
||||
const list = byGroup.get(entry.group);
|
||||
if (list) list.push(entry);
|
||||
else byGroup.set(entry.group, [entry]);
|
||||
}
|
||||
return [...byGroup.entries()].sort(
|
||||
(a, b) => groupRank(a[0]) - groupRank(b[0]) || a[0].localeCompare(b[0]),
|
||||
const promptingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (blocker.state !== "blocked" || promptingRef.current) return;
|
||||
promptingRef.current = true;
|
||||
confirm(DISCARD_PROMPT).then((ok) => {
|
||||
promptingRef.current = false;
|
||||
if (ok) blocker.proceed();
|
||||
else blocker.reset();
|
||||
});
|
||||
}, [blocker, confirm]);
|
||||
|
||||
// A reload or tab close cannot show our dialog; the browser's own prompt
|
||||
// is the only thing that stands between the operator and lost edits.
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [dirty]);
|
||||
|
||||
function writeTab(next: TabId) {
|
||||
setParams(
|
||||
(prev) => {
|
||||
const p = new URLSearchParams(prev);
|
||||
if (next === "settings") p.delete("tab");
|
||||
else p.set("tab", next);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [filtered]);
|
||||
}
|
||||
|
||||
async function setTab(next: string) {
|
||||
if (!isTabId(next) || next === tab) return;
|
||||
if (dirtyRef.current && !(await confirm(DISCARD_PROMPT))) return;
|
||||
writeTab(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Configuration"
|
||||
description="Every value on this page comes from the environment of the running backend. Change it where you set your environment, then restart."
|
||||
>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/configuration/settings">
|
||||
<Settings2 className="size-4" />
|
||||
Instance settings
|
||||
</Link>
|
||||
</Button>
|
||||
</PageHeader>
|
||||
description="What this instance is set to: the settings and notification channels you can edit here, the environment the backend booted with, and the limits that result."
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search variables, groups or effects"
|
||||
className="h-8 pl-8 text-[12.5px]"
|
||||
/>
|
||||
</div>
|
||||
{entries.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{filtered.length} of {entries.length} variables
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<PageTabs
|
||||
tabs={[
|
||||
{ id: "settings", label: "Settings", icon: Settings2 },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
{ id: "environment", label: "Environment", icon: Terminal },
|
||||
{ id: "limits", label: "Limits", icon: Gauge },
|
||||
]}
|
||||
value={tab}
|
||||
onChange={(id) => void setTab(id)}
|
||||
/>
|
||||
|
||||
{configQ.isLoading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
{tab === "settings" && (
|
||||
<SettingsTab onDirtyChange={onDirtyChange} onSwitchTab={(t) => void setTab(t)} />
|
||||
)}
|
||||
|
||||
{configQ.isError && (
|
||||
<ErrorState
|
||||
error={configQ.error}
|
||||
title="Could not read this instance's configuration"
|
||||
onRetry={() => configQ.refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{configQ.data && entries.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
||||
The backend returned no configuration entries.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configQ.data && entries.length > 0 && filtered.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
||||
No variable matches "{search.trim()}".
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{groups.map(([group, groupEntries]) => (
|
||||
<section
|
||||
key={group}
|
||||
className="overflow-hidden rounded-lg border border-border bg-white"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{groupLabel(group)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{groupEntries.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{groupEntries.map((entry) => (
|
||||
<ConfigRow key={entry.key} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{tab === "notifications" && <NotificationsTab onDirtyChange={onDirtyChange} />}
|
||||
{tab === "environment" && <EnvironmentTab onSwitchTab={writeTab} />}
|
||||
{tab === "limits" && <LimitsTab onSwitchTab={writeTab} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigRow({ entry }: { entry: InstanceConfigEntry }) {
|
||||
const source = SOURCE_STYLES[entry.source] ?? SOURCE_STYLES.default;
|
||||
const restart = RESTART_STYLES[entry.runtime_changeable] ?? RESTART_STYLES["boot-only"];
|
||||
|
||||
return (
|
||||
// Anchored on the variable name so a check can deep-link to its row.
|
||||
<div id={entry.key} className="scroll-mt-20 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="text-[12.5px] font-semibold text-foreground">{entry.key}</code>
|
||||
<Badge variant="outline" className={`text-[10px] ${source.className}`} title={source.title}>
|
||||
{source.label}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`text-[10px] ${restart.className}`}>
|
||||
{restart.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5">
|
||||
{entry.sensitive ? (
|
||||
<SensitiveValue entry={entry} />
|
||||
) : (
|
||||
<PlainValue entry={entry} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.effect && (
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{entry.effect}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{entry.docs && (
|
||||
<a
|
||||
href={docsUrl(entry.docs)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Gated on the resolved value, not on entry.set: a default or derived value
|
||||
// resolves without any environment variable being present.
|
||||
function PlainValue({ entry }: { entry: InstanceConfigEntry }) {
|
||||
if (entry.value === "") {
|
||||
return <span className="text-xs text-muted-foreground">Not set</span>;
|
||||
}
|
||||
return (
|
||||
<code className="block break-all rounded bg-muted px-2 py-1 font-mono text-xs text-foreground">
|
||||
{entry.value}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// A sensitive value is never sent. The fingerprint is there so two services
|
||||
// can be compared (same AUTH_SECRET?) without disclosing either.
|
||||
function SensitiveValue({ entry }: { entry: InstanceConfigEntry }) {
|
||||
// A fingerprint is only minted for a non-empty resolved value, so it is the
|
||||
// reliable "has a value" signal; source covers a backend that omits it.
|
||||
const resolved = entry.fingerprint !== "" || entry.source !== "unset";
|
||||
if (!resolved) {
|
||||
return <span className="text-xs text-muted-foreground">Not set</span>;
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||
Set, value hidden
|
||||
</span>
|
||||
{entry.fingerprint && (
|
||||
<span
|
||||
className="font-mono text-[11px] text-muted-foreground"
|
||||
title="First 4 hex characters of the SHA-256 of the value. Two services holding the same secret show the same fingerprint."
|
||||
>
|
||||
fingerprint {entry.fingerprint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Fleet: placement as the operator sees it. Capacity (per-worker load vs.
|
||||
// effective capacity with the last hour's outcome counters), the decision log
|
||||
// the control loops write, and the dedicated worker bindings. The tab lives
|
||||
// in ?tab= so links deep-link.
|
||||
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Gauge, ListChecks, Lock } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { PageTabs } from "@/components/layout/PageTabs";
|
||||
import { CapacityTab } from "./fleet/CapacityTab";
|
||||
import { DecisionsTab } from "./fleet/DecisionsTab";
|
||||
import { DedicatedTab } from "./fleet/DedicatedTab";
|
||||
|
||||
const TABS = [
|
||||
{ id: "capacity", label: "Capacity", icon: Gauge },
|
||||
{ id: "decisions", label: "Decisions", icon: ListChecks },
|
||||
{ id: "dedicated", label: "Dedicated", icon: Lock },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
function isTab(v: string | null): v is TabId {
|
||||
return TABS.some((t) => t.id === v);
|
||||
}
|
||||
|
||||
export default function FleetPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const raw = params.get("tab");
|
||||
const tab: TabId = isTab(raw) ? raw : "capacity";
|
||||
|
||||
function setTab(id: string) {
|
||||
setParams(
|
||||
(p) => {
|
||||
p.set("tab", id);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Fleet"
|
||||
description="How mailboxes are spread across workers: load against capacity, what the placement loops decided, and which workers are reserved for one workspace."
|
||||
/>
|
||||
<PageTabs tabs={[...TABS]} value={tab} onChange={setTab} />
|
||||
{tab === "capacity" && <CapacityTab />}
|
||||
{tab === "decisions" && <DecisionsTab />}
|
||||
{tab === "dedicated" && <DedicatedTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +1,71 @@
|
||||
// Setup and health: everything this deployment is currently getting wrong,
|
||||
// as decided by the running backend. The endpoint returns only checks that
|
||||
// are not ok, so an empty response is a real all-clear and not a stub.
|
||||
// Setup and health: two views of "is this instance ok". Findings are the
|
||||
// backend's own verdicts about configuration and state; services are live
|
||||
// probes of what it runs on. ?tab= deep-links either one.
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpCircle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Activity, ListChecks } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { PageTabs } from "@/components/layout/PageTabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { InstanceFindings } from "./InstanceHealthPanel";
|
||||
import { UpdateDialog } from "@/components/layout/UpdateDialog";
|
||||
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
|
||||
import { buildLabel, isUpdating, useUpdateState } from "@/hooks/useUpdateState";
|
||||
import type { InstanceHealthSummary } from "@/lib/api/client/admin/instance";
|
||||
import { FindingsTab } from "./health/FindingsTab";
|
||||
import { ServicesTab } from "./health/ServicesTab";
|
||||
|
||||
const TAB_IDS = ["findings", "services"] as const;
|
||||
type TabId = (typeof TAB_IDS)[number];
|
||||
|
||||
function isTabId(v: string | null): v is TabId {
|
||||
return TAB_IDS.includes(v as TabId);
|
||||
}
|
||||
|
||||
export default function HealthPage() {
|
||||
const healthQ = useInstanceHealth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const raw = params.get("tab");
|
||||
const tab: TabId = isTabId(raw) ? raw : "findings";
|
||||
|
||||
const checks = healthQ.data?.checks ?? [];
|
||||
const summary = healthQ.data?.summary;
|
||||
// The findings count comes from the shared cache the sidebar badge reads,
|
||||
// so the tab badge and the nav badge never disagree.
|
||||
const healthQ = useInstanceHealth();
|
||||
const findings = healthQ.data?.checks?.length ?? 0;
|
||||
|
||||
function setTab(next: string) {
|
||||
setParams(
|
||||
(prev) => {
|
||||
const p = new URLSearchParams(prev);
|
||||
if (next === "findings") p.delete("tab");
|
||||
else p.set("tab", next);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Setup and health"
|
||||
description="Checks the backend runs against this instance: secrets, addresses, platform mail, accounts, workers and storage. Only findings that need a decision are listed."
|
||||
description="What the backend thinks is wrong with this instance, and whether the services it depends on are answering."
|
||||
>
|
||||
{healthQ.dataUpdatedAt > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last checked {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/configuration">Configuration</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => healthQ.refetch()}
|
||||
disabled={healthQ.isFetching}
|
||||
>
|
||||
<RefreshCw className={`size-4 ${healthQ.isFetching ? "animate-spin" : ""}`} />
|
||||
{healthQ.isFetching ? "Checking..." : "Run checks"}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<UpdateCard />
|
||||
|
||||
{healthQ.isLoading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthQ.isError && (
|
||||
<ErrorState
|
||||
error={healthQ.error}
|
||||
title="Could not run the instance checks"
|
||||
onRetry={() => healthQ.refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{healthQ.data && (
|
||||
<>
|
||||
{checks.length === 0 ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-emerald-800">
|
||||
No problems found
|
||||
</div>
|
||||
<p className="mt-0.5 text-[13px] leading-relaxed text-emerald-700">
|
||||
Every setup and health check passed. This page lists only the
|
||||
checks that need attention, so it stays empty while the
|
||||
instance is configured correctly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SummaryStrip summary={summary} total={checks.length} />
|
||||
<InstanceFindings checks={checks} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Version and update status, above the findings: the same facts as the pill
|
||||
// in the top bar, on the page an operator opens to ask "is this instance ok".
|
||||
function UpdateCard() {
|
||||
const updateQ = useUpdateState();
|
||||
// ?update=1 is how the dashboard's version pill deep-links an admin
|
||||
// straight into the dialog.
|
||||
const [params] = useSearchParams();
|
||||
const [open, setOpen] = useState(params.get("update") === "1");
|
||||
const state = updateQ.data;
|
||||
if (!state) return null;
|
||||
|
||||
const updating = isUpdating(state);
|
||||
const available = state.update_available;
|
||||
const tone = updating
|
||||
? "border-sky-200 bg-sky-50/60"
|
||||
: available
|
||||
? "border-amber-200 bg-amber-50/60"
|
||||
: "border-border bg-white";
|
||||
|
||||
return (
|
||||
<div className={`mb-5 flex flex-wrap items-center gap-3 rounded-lg border p-3 ${tone}`}>
|
||||
{updating ? (
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-sky-600" />
|
||||
) : available ? (
|
||||
<ArrowUpCircle className="size-4 shrink-0 text-amber-600" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 text-[13px]">
|
||||
<span className="font-semibold text-foreground">
|
||||
{updating
|
||||
? "Updating this instance"
|
||||
: available
|
||||
? `${state.latest?.tag && state.reason === "release" ? state.latest.tag : "A newer version"} is available`
|
||||
: "Up to date"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
running {buildLabel(state)}
|
||||
{state.updater.checkout && !state.updater.checkout.detached
|
||||
? ` on ${state.updater.checkout.branch}`
|
||||
: ""}
|
||||
{state.checked_at
|
||||
? `, checked ${new Date(state.checked_at).toLocaleTimeString()}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant={available ? "default" : "outline"} onClick={() => setOpen(true)}>
|
||||
{updating ? "Progress" : available ? "Update" : "Details"}
|
||||
</Button>
|
||||
<UpdateDialog open={open} onOpenChange={setOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryStrip({
|
||||
summary,
|
||||
total,
|
||||
}: {
|
||||
summary: InstanceHealthSummary | undefined;
|
||||
total: number;
|
||||
}) {
|
||||
const errors = summary?.error ?? 0;
|
||||
const warnings = summary?.warning ?? 0;
|
||||
const info = summary?.info ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<SummaryCard
|
||||
icon={XCircle}
|
||||
label="Errors"
|
||||
value={errors}
|
||||
sub="Something is broken right now"
|
||||
tone={errors > 0 ? "border-red-200 bg-red-50/60" : ""}
|
||||
iconClass={errors > 0 ? "text-red-600" : "text-muted-foreground"}
|
||||
<PageTabs
|
||||
tabs={[
|
||||
{
|
||||
id: "findings",
|
||||
label: "Findings",
|
||||
icon: ListChecks,
|
||||
badge: findings > 0 ? findings : undefined,
|
||||
},
|
||||
{ id: "services", label: "Services", icon: Activity },
|
||||
]}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={AlertTriangle}
|
||||
label="Warnings"
|
||||
value={warnings}
|
||||
sub="Works, but not the way you want"
|
||||
tone={warnings > 0 ? "border-amber-200 bg-amber-50/60" : ""}
|
||||
iconClass={warnings > 0 ? "text-amber-600" : "text-muted-foreground"}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Info}
|
||||
label="Worth knowing"
|
||||
value={info}
|
||||
sub={`${total} findings in total`}
|
||||
tone={info > 0 ? "border-sky-200 bg-sky-50/50" : ""}
|
||||
iconClass={info > 0 ? "text-sky-600" : "text-muted-foreground"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
tone,
|
||||
iconClass,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: number;
|
||||
sub: string;
|
||||
tone: string;
|
||||
iconClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg border border-border bg-white p-3 ${tone}`}>
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<Icon className={`size-3.5 ${iconClass}`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1.5 text-2xl font-semibold tabular-nums">{value}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>
|
||||
{tab === "findings" ? <FindingsTab /> : <ServicesTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Every background loop on the instance, grouped by the process that owns
|
||||
// it. "Run now" only sets a flag the owning loop checks on its next poll,
|
||||
// so the marker stays until that process clears it. Polls at 15s: the job
|
||||
// table has no realtime event.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Play } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { listJobs, runJob, type ScheduledJobRun } from "@/lib/api/client/admin/jobs";
|
||||
import { ExpandableText } from "@/app/dashboard/jobs/ExpandableText";
|
||||
import { absolute, humanDuration, humanInterval, relative } from "@/app/dashboard/jobs/format";
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
idle: "border-zinc-300 bg-zinc-50 text-zinc-600",
|
||||
running: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
ok: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
error: "border-red-300 bg-red-50 text-red-700",
|
||||
};
|
||||
|
||||
// backend and consumer first; anything new sorts after them by name.
|
||||
const SERVICE_ORDER = ["backend", "consumer"];
|
||||
|
||||
function serviceRank(s: string): number {
|
||||
const i = SERVICE_ORDER.indexOf(s);
|
||||
return i === -1 ? SERVICE_ORDER.length : i;
|
||||
}
|
||||
|
||||
export default function JobsPage() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "jobs"],
|
||||
queryFn: listJobs,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: (name: string) => runJob(name),
|
||||
onSuccess: () => {
|
||||
toast.success("Requested; the loop picks it up within 15 seconds");
|
||||
qc.invalidateQueries({ queryKey: ["admin", "jobs"] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message || "Failed to request a run"),
|
||||
});
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const by = new Map<string, ScheduledJobRun[]>();
|
||||
for (const job of data?.data ?? []) {
|
||||
const list = by.get(job.service) ?? [];
|
||||
list.push(job);
|
||||
by.set(job.service, list);
|
||||
}
|
||||
return [...by.entries()]
|
||||
.sort(([a], [b]) => serviceRank(a) - serviceRank(b) || a.localeCompare(b))
|
||||
.map(([service, jobs]) => [service, jobs.sort((a, b) => a.name.localeCompare(b.name))] as const);
|
||||
}, [data]);
|
||||
|
||||
const columns: Column<ScheduledJobRun>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "Job",
|
||||
cell: (j) => <span className="font-mono text-xs">{j.name}</span>,
|
||||
csv: (j) => j.name,
|
||||
},
|
||||
{
|
||||
id: "interval",
|
||||
header: "Interval",
|
||||
cell: (j) => <span className="text-xs text-muted-foreground">{humanInterval(j.interval_seconds)}</span>,
|
||||
csv: (j) => j.interval_seconds,
|
||||
},
|
||||
{
|
||||
id: "last_run",
|
||||
header: "Last run",
|
||||
cell: (j) =>
|
||||
j.last_started_at ? (
|
||||
<div>
|
||||
<div className="text-xs" title={absolute(j.last_started_at)}>
|
||||
{relative(j.last_started_at)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{j.last_status === "running" ? "still running" : `took ${humanDuration(j.last_duration_ms)}`}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">never</span>
|
||||
),
|
||||
csv: (j) => j.last_started_at || "",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: (j) => (
|
||||
<Badge variant="outline" className={`text-[10px] ${STATUS_TONE[j.last_status] ?? "border-zinc-300 text-zinc-600"}`}>
|
||||
{j.last_status === "running" && <Loader2 className="size-3 animate-spin" />}
|
||||
{j.last_status || "idle"}
|
||||
</Badge>
|
||||
),
|
||||
csv: (j) => j.last_status,
|
||||
},
|
||||
{
|
||||
id: "next_run",
|
||||
header: "Next run",
|
||||
cell: (j) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground" title={absolute(j.next_run_at)}>
|
||||
{relative(j.next_run_at, "—")}
|
||||
</span>
|
||||
{j.run_requested_at && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] border-[var(--admin-accent)]/40 bg-[var(--admin-accent-soft)] text-[var(--admin-accent-strong)]"
|
||||
title={`Run requested ${relative(j.run_requested_at)}; cleared when the ${j.service} picks it up`}
|
||||
>
|
||||
requested
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
csv: (j) => j.next_run_at || "",
|
||||
},
|
||||
{
|
||||
id: "counts",
|
||||
header: "Runs / errors",
|
||||
align: "right",
|
||||
cell: (j) => (
|
||||
<span className="text-xs tabular-nums">
|
||||
{j.run_count.toLocaleString()}
|
||||
<span className="text-muted-foreground"> / </span>
|
||||
<span className={j.error_count > 0 ? "text-red-700" : "text-muted-foreground"}>{j.error_count.toLocaleString()}</span>
|
||||
</span>
|
||||
),
|
||||
csv: (j) => `${j.run_count}/${j.error_count}`,
|
||||
},
|
||||
{
|
||||
id: "last_error",
|
||||
header: "Last error",
|
||||
className: "max-w-md",
|
||||
cell: (j) => <ExpandableText text={j.last_error} mono />,
|
||||
csv: (j) => j.last_error,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (j) => {
|
||||
const pending = run.isPending && run.variables === j.name;
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={pending || !!j.run_requested_at}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
run.mutate(j.name);
|
||||
}}
|
||||
title={j.run_requested_at ? "A run is already requested" : `Ask the ${j.service} to run this loop now`}
|
||||
>
|
||||
<Play className="size-3" /> {pending ? "Requesting…" : "Run now"}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const empty = !isLoading && !error && groups.length === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Jobs"
|
||||
description="Every background loop on this instance. A job runs in the process named in its service column, and Run now is picked up by that process at its next poll."
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<ErrorState error={error} title="Failed to load jobs" onRetry={() => refetch()} />
|
||||
) : empty ? (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground">
|
||||
<div className="font-medium text-foreground">No jobs have reported yet</div>
|
||||
<div className="mt-0.5 text-xs">
|
||||
Rows appear as soon as a service (the backend or the consumer) has booted on this build and registered its loops.
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<DataTable columns={columns} rows={[]} getRowId={(j) => j.name} loading storageKey="admin.jobs" noun="jobs" />
|
||||
) : (
|
||||
groups.map(([service, jobs]) => (
|
||||
<section key={service} className="mt-6 first:mt-0">
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<h2 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{service}</h2>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{jobs.length} {jobs.length === 1 ? "loop" : "loops"}
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={jobs}
|
||||
getRowId={(j) => j.name}
|
||||
storageKey="admin.jobs"
|
||||
csvName={`warmbly-jobs-${service}`}
|
||||
noun="jobs"
|
||||
/>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Mail, RefreshCw, Send, XCircle } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -166,8 +166,10 @@ export default function MailboxesPage() {
|
||||
const userId = params.get("user") || undefined;
|
||||
const workerParam = params.get("worker") || "";
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("active");
|
||||
// `?q=` seeds the search box so the command palette can land here on a
|
||||
// mailbox; a status of "all" keeps a disabled mailbox findable that way.
|
||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||
const [status, setStatus] = useState<StatusFilter>(params.get("q") ? "all" : "active");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [warmup, setWarmup] = useState<WarmupFilter>("all");
|
||||
const [workerId, setWorkerId] = useState(workerParam);
|
||||
@@ -196,6 +198,14 @@ export default function MailboxesPage() {
|
||||
setWorkerId(workerParam);
|
||||
}, [workerParam]);
|
||||
|
||||
// The page stays mounted when the palette lands here again with another
|
||||
// ?q=, so the search box follows the URL rather than only its first value.
|
||||
const qParam = params.get("q") ?? "";
|
||||
useEffect(() => {
|
||||
setQuery(qParam);
|
||||
if (qParam) setStatus("all");
|
||||
}, [qParam]);
|
||||
|
||||
const { data: workersData } = useQuery({ queryKey: ["admin", "workers", "managed"], queryFn: listManagedWorkers, staleTime: 60_000 });
|
||||
const workerOptions = [
|
||||
{ value: "any", label: "Any worker" },
|
||||
@@ -304,6 +314,7 @@ export default function MailboxesPage() {
|
||||
next.delete("org");
|
||||
next.delete("user");
|
||||
next.delete("worker");
|
||||
next.delete("q");
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Catch-all for paths that are not part of the admin app.
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Page not found"
|
||||
description="That route is not part of the admin app. It may have moved; the sidebar and the search palette list every page."
|
||||
/>
|
||||
<Link to="/" className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground">
|
||||
Back to overview
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,51 @@
|
||||
// Organization detail — composes /admin/organizations/:id and
|
||||
// /admin/organizations/:id/members into a single screen. Header summarises
|
||||
// owner + plan + lifecycle; the body shows usage-vs-limits side-by-side
|
||||
// and the members table.
|
||||
// owner + plan + lifecycle; the overview tab shows usage-vs-limits and the
|
||||
// members table, with API keys, webhooks and transfers on their own tabs
|
||||
// (?tab= so links deep-link).
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Crown, Shield, SlidersHorizontal } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
Ban,
|
||||
Crown,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { PageTabs } from "@/components/layout/PageTabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import {
|
||||
getOrganization,
|
||||
getOrganizationMembers,
|
||||
listOrganizationAPIKeys,
|
||||
listOrganizationWebhooks,
|
||||
revokeOrganizationAPIKey,
|
||||
type AdminOrgAPIKey,
|
||||
type AdminWebhookEndpointRow,
|
||||
} from "@/lib/api/client/admin/organizations";
|
||||
import { OrgTransferTab } from "./transfers/OrgTransferTab";
|
||||
import { fmtAgo, fmtDate, fmtDateTime } from "./fleet/format";
|
||||
import type {
|
||||
AdminOrgDetail,
|
||||
AdminOrgMember,
|
||||
@@ -25,9 +56,35 @@ import type {
|
||||
import { OrganizationOverridesDialog } from "./OrganizationOverridesDialog";
|
||||
import { OrganizationRiskCard, RiskBadge } from "./OrganizationRiskCard";
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview", icon: LayoutDashboard },
|
||||
{ id: "api-keys", label: "API keys", icon: KeyRound },
|
||||
{ id: "webhooks", label: "Webhooks", icon: Webhook },
|
||||
{ id: "transfer", label: "Transfer", icon: ArrowLeftRight },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
function isTab(v: string | null): v is TabId {
|
||||
return TABS.some((t) => t.id === v);
|
||||
}
|
||||
|
||||
export default function OrganizationDetailPage() {
|
||||
const { id = "" } = useParams<{ id: string }>();
|
||||
const [overridesOpen, setOverridesOpen] = useState(false);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const rawTab = params.get("tab");
|
||||
const tab: TabId = isTab(rawTab) ? rawTab : "overview";
|
||||
|
||||
function setTab(next: string) {
|
||||
setParams(
|
||||
(p) => {
|
||||
p.set("tab", next);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const orgQuery = useQuery({
|
||||
queryKey: ["admin", "organizations", id],
|
||||
@@ -64,6 +121,14 @@ export default function OrganizationDetailPage() {
|
||||
<StatusPills org={org} />
|
||||
</PageHeader>
|
||||
|
||||
<PageTabs tabs={[...TABS]} value={tab} onChange={setTab} />
|
||||
|
||||
{tab === "api-keys" && <APIKeysTab orgId={org.id} />}
|
||||
{tab === "webhooks" && <WebhooksTab orgId={org.id} />}
|
||||
{tab === "transfer" && <OrgTransferTab orgId={org.id} orgName={org.name} />}
|
||||
|
||||
{tab === "overview" && (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<SummaryCard title="Owner">
|
||||
<div className="text-sm font-medium">
|
||||
@@ -187,6 +252,8 @@ export default function OrganizationDetailPage() {
|
||||
<MembersTable members={membersQuery.data?.data ?? []} />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -448,3 +515,252 @@ function DetailSkeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- API keys ----
|
||||
|
||||
const KEY_STATUS_TONE: Record<string, string> = {
|
||||
active: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
revoked: "border-red-300 bg-red-50 text-red-700",
|
||||
expired: "border-zinc-300 bg-zinc-50 text-zinc-500",
|
||||
};
|
||||
|
||||
function APIKeysTab({ orgId }: { orgId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [revoking, setRevoking] = useState<AdminOrgAPIKey | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const keysQ = useQuery({
|
||||
queryKey: ["admin", "organizations", orgId, "api-keys"],
|
||||
queryFn: () => listOrganizationAPIKeys(orgId),
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (k: AdminOrgAPIKey) => revokeOrganizationAPIKey(orgId, k.id, reason.trim() || undefined),
|
||||
onSuccess: () => {
|
||||
toast.success("API key revoked");
|
||||
qc.invalidateQueries({ queryKey: ["admin", "organizations", orgId, "api-keys"] });
|
||||
setRevoking(null);
|
||||
setReason("");
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Revoke failed"),
|
||||
});
|
||||
|
||||
const keys = keysQ.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="mb-3 max-w-2xl text-[12.5px] text-muted-foreground">
|
||||
Keys the workspace minted for the public API. The secret is never shown; revoking is immediate and is
|
||||
recorded in the admin audit log with the reason.
|
||||
</p>
|
||||
{keysQ.isLoading ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : keysQ.error ? (
|
||||
<ErrorState error={keysQ.error} title="Failed to load API keys" onRetry={() => keysQ.refetch()} />
|
||||
) : keys.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
This workspace has not created any API keys.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Name</th>
|
||||
<th className="px-3 py-2 text-left">Key</th>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">User</th>
|
||||
<th className="px-3 py-2 text-left">Last used</th>
|
||||
<th className="px-3 py-2 text-right">Requests 7d</th>
|
||||
<th className="px-3 py-2 text-left">Expires</th>
|
||||
<th className="px-3 py-2 text-left">Created</th>
|
||||
<th className="px-3 py-2 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.id} className="border-t border-border">
|
||||
<td className="px-3 py-2 font-medium">{k.name || <span className="text-muted-foreground">untitled</span>}</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px]">
|
||||
{k.key_prefix}…{k.key_suffix}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline" className={`text-[10px] ${KEY_STATUS_TONE[k.status] ?? "border-zinc-300 text-zinc-600"}`}>
|
||||
{k.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">{k.user_email || k.user_id.slice(0, 8)}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground" title={fmtDateTime(k.last_used_at)}>
|
||||
{k.last_used_at ? fmtAgo(k.last_used_at) : "never"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-xs tabular-nums">{k.requests_last_7d.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{k.expires_at ? fmtDate(k.expires_at) : "never"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{fmtDate(k.created_at)}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{k.status === "active" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-700 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
setReason("");
|
||||
setRevoking(k);
|
||||
}}
|
||||
>
|
||||
<Ban className="size-3" />
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!revoking}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && !revoke.isPending) setRevoking(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke this API key?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{revoking?.name ? `"${revoking.name}"` : "This key"} ({revoking?.key_prefix}…{revoking?.key_suffix}) stops
|
||||
authenticating immediately. Anything the workspace built on it fails on its next request.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="revoke-reason" className="text-xs">
|
||||
Reason <span className="font-normal text-muted-foreground">(optional, goes to the audit log)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="revoke-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. leaked in a public repository"
|
||||
className="h-8 text-[12.5px]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevoking(null)} disabled={revoke.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => revoking && revoke.mutate(revoking)} disabled={revoke.isPending}>
|
||||
{revoke.isPending ? "Revoking…" : "Revoke key"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- webhooks ----
|
||||
|
||||
function WebhooksTab({ orgId }: { orgId: string }) {
|
||||
const hooksQ = useQuery({
|
||||
queryKey: ["admin", "organizations", orgId, "webhooks"],
|
||||
queryFn: () => listOrganizationWebhooks(orgId),
|
||||
});
|
||||
const hooks = hooksQ.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<p className="mb-3 max-w-2xl text-[12.5px] text-muted-foreground">
|
||||
Endpoints the workspace registered for event delivery. Consecutive failures and the last failure reason
|
||||
are what the delivery loop sees; drops are events skipped because the endpoint was disabled or over its
|
||||
failure ceiling.
|
||||
</p>
|
||||
{hooksQ.isLoading ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : hooksQ.error ? (
|
||||
<ErrorState error={hooksQ.error} title="Failed to load webhooks" onRetry={() => hooksQ.refetch()} />
|
||||
) : hooks.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
This workspace has no webhook endpoints.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Endpoint</th>
|
||||
<th className="px-3 py-2 text-left">Enabled</th>
|
||||
<th className="px-3 py-2 text-left">Events</th>
|
||||
<th className="px-3 py-2 text-right">Failures in a row</th>
|
||||
<th className="px-3 py-2 text-left">Last success</th>
|
||||
<th className="px-3 py-2 text-left">Last failure</th>
|
||||
<th className="px-3 py-2 text-right">7d delivered / failed / drops</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hooks.map((h: AdminWebhookEndpointRow) => (
|
||||
<tr key={h.id} className="border-t border-border align-top">
|
||||
<td className="px-3 py-2">
|
||||
<div className="max-w-xs truncate font-mono text-[11px]" title={h.url}>
|
||||
{h.url}
|
||||
</div>
|
||||
{h.description && <div className="text-[11px] text-muted-foreground">{h.description}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] ${h.enabled ? "border-emerald-300 bg-emerald-50 text-emerald-700" : "border-zinc-300 text-zinc-500"}`}
|
||||
>
|
||||
{h.enabled ? "enabled" : "disabled"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex max-w-xs flex-wrap gap-1">
|
||||
{(h.event_types ?? []).length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">all</span>
|
||||
) : (
|
||||
(h.event_types ?? []).map((t) => (
|
||||
<Badge key={t} variant="outline" className="font-mono text-[9px]">
|
||||
{t}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right text-xs tabular-nums ${h.consecutive_failures > 0 ? "font-medium text-red-600" : "text-muted-foreground"}`}>
|
||||
{h.consecutive_failures}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground" title={fmtDateTime(h.last_success_at)}>
|
||||
{h.last_success_at ? fmtAgo(h.last_success_at) : "never"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<div className="text-muted-foreground" title={fmtDateTime(h.last_failure_at)}>
|
||||
{h.last_failure_at ? fmtAgo(h.last_failure_at) : "never"}
|
||||
</div>
|
||||
{h.last_failure_reason && (
|
||||
<div className="max-w-xs truncate text-[11px] text-red-600" title={h.last_failure_reason}>
|
||||
{h.last_failure_reason}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-xs tabular-nums">
|
||||
{h.deliveries_last_7d.toLocaleString()}
|
||||
<span className="text-muted-foreground"> / </span>
|
||||
<span className={h.failed_last_7d > 0 ? "text-red-600" : ""}>{h.failed_last_7d.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground"> / </span>
|
||||
<span className={h.drops_last_7d > 0 ? "text-amber-700" : ""}>{h.drops_last_7d.toLocaleString()}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { useCursorPager } from "@/lib/useCursorPager";
|
||||
import { emptyRange, rangeActive, rangeWithin, rangeAfter, rangeBefore, type DateRange } from "@/lib/dateRange";
|
||||
import { listOrganizations } from "@/lib/api/client/admin/organizations";
|
||||
import { listPlans } from "@/lib/api/client/admin/plans";
|
||||
import type { AdminOrgListItem, OrgRiskState } from "@/lib/api/models/admin";
|
||||
import { RiskBadge } from "./OrganizationRiskCard";
|
||||
|
||||
@@ -216,7 +215,6 @@ export default function OrganizationsPage() {
|
||||
const nav = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("active");
|
||||
const [planId, setPlanId] = useState("");
|
||||
const [visibility, setVisibility] = useState<VisibilityFilter>("");
|
||||
const [subStatus, setSubStatus] = useState("");
|
||||
const [enterprise, setEnterprise] = useState(false);
|
||||
@@ -251,14 +249,8 @@ export default function OrganizationsPage() {
|
||||
const pager = useCursorPager();
|
||||
const { reset } = pager;
|
||||
|
||||
const { data: plansData } = useQuery({ queryKey: ["admin", "plans", "facet"], queryFn: listPlans, staleTime: 5 * 60_000 });
|
||||
const planOptions = [
|
||||
{ value: "any", label: "Any plan" },
|
||||
...(plansData?.data ?? []).map((p) => ({ value: p.id, label: p.name || "Untitled plan" })),
|
||||
];
|
||||
|
||||
const filterKey = JSON.stringify({
|
||||
query, status, planId, visibility, subStatus, enterprise, hasOverrides, risk, cancelAtPeriodEnd,
|
||||
query, status, visibility, subStatus, enterprise, hasOverrides, risk, cancelAtPeriodEnd,
|
||||
hasActiveSubscription, noSubscription, ownerBanned, hasActiveCampaigns, hasEmailAccounts,
|
||||
utmSource, utmMedium, hasAcquisition, noAcquisition,
|
||||
memMin, memMax, mbMin, mbMax, campMin, campMax, created, trialEnd, periodEnd, updated, sort,
|
||||
@@ -274,7 +266,6 @@ export default function OrganizationsPage() {
|
||||
listOrganizations({
|
||||
q: query.trim() || undefined,
|
||||
status: status === "all" ? "" : status,
|
||||
plan_id: planId || undefined,
|
||||
plan_visibility: visibility || undefined,
|
||||
subscription_status: subStatus || undefined,
|
||||
enterprise: enterprise || undefined,
|
||||
@@ -322,7 +313,6 @@ export default function OrganizationsPage() {
|
||||
const activeCount =
|
||||
(query ? 1 : 0) +
|
||||
(status !== "active" ? 1 : 0) +
|
||||
(planId ? 1 : 0) +
|
||||
(visibility ? 1 : 0) +
|
||||
(subStatus ? 1 : 0) +
|
||||
(risk ? 1 : 0) +
|
||||
@@ -336,7 +326,6 @@ export default function OrganizationsPage() {
|
||||
function resetAll() {
|
||||
setQuery("");
|
||||
setStatus("active");
|
||||
setPlanId("");
|
||||
setVisibility("");
|
||||
setSubStatus("");
|
||||
setEnterprise(false);
|
||||
@@ -386,14 +375,6 @@ export default function OrganizationsPage() {
|
||||
]}
|
||||
/>
|
||||
</FilterGroup>
|
||||
<FilterGroup label="Plan">
|
||||
<SelectFilter
|
||||
value={planId || "any"}
|
||||
onChange={(v) => setPlanId(v === "any" ? "" : v)}
|
||||
options={planOptions}
|
||||
placeholder="Any plan"
|
||||
/>
|
||||
</FilterGroup>
|
||||
<FilterGroup label="Plan visibility">
|
||||
<SelectFilter
|
||||
value={visibility || "any"}
|
||||
|
||||
@@ -1,191 +1,568 @@
|
||||
// Platform overview — count cards backed by /admin/analytics/overview.
|
||||
//
|
||||
// The endpoint returns a loose bag of numbers; we pluck the ones we
|
||||
// know about and gracefully render "—" for anything missing so the
|
||||
// page never crashes when the backend grows new fields.
|
||||
// Landing page: the instance problems strip, the platform counters, the
|
||||
// growth trends, the send/user timeseries and where signups came from. No
|
||||
// chart library: the bars are CSS so the admin bundle does not pay for one
|
||||
// screen. Every analytics endpoint here gates on ViewAnalytics, so the
|
||||
// queries are disabled (not failing) for an admin without that bit.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Mailbox,
|
||||
BarChart3,
|
||||
Mail,
|
||||
Megaphone,
|
||||
Send,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { InstanceProblemsPanel } from "./InstanceHealthPanel";
|
||||
import { getPlatformOverview } from "@/lib/api/client/admin/analytics";
|
||||
import { listManagedWorkers } from "@/lib/api/client/admin/workers";
|
||||
import type { ManagedWorker } from "@/lib/api/models/admin";
|
||||
import { useAdminPerm } from "@/hooks/useAdminPerm";
|
||||
import { AdminPerm } from "@/lib/auth/permissions";
|
||||
import {
|
||||
getAcquisition,
|
||||
getAnalyticsTrends,
|
||||
getDailyEmailStats,
|
||||
getHourlyEmailStats,
|
||||
getPlatformOverview,
|
||||
getUserGrowthStats,
|
||||
} from "@/lib/api/client/admin/analytics";
|
||||
import type { DailyEmailStat } from "@/lib/api/models/admin";
|
||||
|
||||
const RANGES = [7, 30, 90] as const;
|
||||
type RangeDays = (typeof RANGES)[number];
|
||||
|
||||
function formatNum(n: number | undefined): string {
|
||||
if (n === undefined || n === null || Number.isNaN(n)) return "—";
|
||||
return new Intl.NumberFormat("en-US").format(n);
|
||||
}
|
||||
|
||||
function formatPct(n: number | undefined): string {
|
||||
if (n === undefined || n === null || Number.isNaN(n)) return "—";
|
||||
// Backend may send a fraction (0.012) or a percent (1.2). Detect.
|
||||
const pct = Math.abs(n) <= 1 ? n * 100 : n;
|
||||
return `${pct.toFixed(2)}%`;
|
||||
export default function OverviewPage() {
|
||||
const canView = useAdminPerm(AdminPerm.ViewAnalytics);
|
||||
const [days, setDays] = useState<RangeDays>(30);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
description="What this instance is doing right now: problems that need a decision, the platform counters, and how sending and signups are trending."
|
||||
>
|
||||
<RangePicker value={days} onChange={setDays} />
|
||||
</PageHeader>
|
||||
|
||||
<InstanceProblemsPanel />
|
||||
|
||||
{!canView ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
||||
Platform counters and trends need the View analytics permission.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Counters />
|
||||
<TrendCards />
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-2 text-sm font-semibold">
|
||||
Email volume, last {days} days
|
||||
</h2>
|
||||
<DailyEmailChart days={days} />
|
||||
</section>
|
||||
|
||||
<section className="mt-6 grid gap-3 md:grid-cols-2">
|
||||
<HourlyChart />
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold">
|
||||
User growth, last {days} days
|
||||
</h2>
|
||||
<UserGrowthChart days={days} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<AcquisitionCard days={days} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
// One window drives every "last N days" query on the page, so the charts
|
||||
// and the acquisition card always describe the same period.
|
||||
function RangePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RangeDays;
|
||||
onChange: (d: RangeDays) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Date range"
|
||||
className="inline-flex h-8 items-center rounded-md border border-border bg-white p-0.5"
|
||||
>
|
||||
{RANGES.map((d) => {
|
||||
const active = d === value;
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onChange(d)}
|
||||
className={`h-7 rounded px-2.5 text-[12.5px] transition-colors ${
|
||||
active
|
||||
? "bg-[var(--admin-accent-soft)] font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{d}d
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The counters are the one query on this page that polls: nothing in the
|
||||
// realtime spine invalidates ["admin","analytics"], and EMAIL_SENT matches
|
||||
// no group on purpose, so without this the "today" numbers never move.
|
||||
function Counters() {
|
||||
const overviewQ = useQuery({
|
||||
queryKey: ["admin", "analytics", "overview"],
|
||||
queryFn: getPlatformOverview,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
// Workers list — used to render a quick health summary card even
|
||||
// when the analytics overview endpoint hasn't been wired yet.
|
||||
const workersQ = useQuery({
|
||||
queryKey: ["admin", "workers", "managed"],
|
||||
queryFn: listManagedWorkers,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const ov = overviewQ.data;
|
||||
const workers = workersQ.data?.data ?? [];
|
||||
|
||||
const computed = {
|
||||
workers_total: ov?.workers_total ?? workers.length,
|
||||
workers_active:
|
||||
ov?.workers_active ??
|
||||
workers.filter((w: ManagedWorker) => w.install_state === "installed" && isOnline(w)).length,
|
||||
workers_offline:
|
||||
ov?.workers_offline ??
|
||||
workers.filter((w: ManagedWorker) => w.install_state === "installed" && !isOnline(w)).length,
|
||||
mailboxes_connected:
|
||||
ov?.mailboxes_connected ??
|
||||
workers.reduce((a: number, w: ManagedWorker) => a + (w.account_count ?? 0), 0),
|
||||
};
|
||||
const loading = overviewQ.isLoading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Platform overview"
|
||||
description="Snapshot of the Warmbly control plane. Live counts pull from /admin/analytics/overview every minute."
|
||||
/>
|
||||
|
||||
<InstanceProblemsPanel />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<>
|
||||
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={Server}
|
||||
label="Workers active"
|
||||
value={formatNum(computed.workers_active)}
|
||||
sub={`${formatNum(computed.workers_total)} total · ${formatNum(computed.workers_offline)} offline`}
|
||||
loading={overviewQ.isLoading && workersQ.isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Mailbox}
|
||||
label="Mailboxes connected"
|
||||
value={formatNum(computed.mailboxes_connected)}
|
||||
sub="Across all assigned workers"
|
||||
loading={overviewQ.isLoading && workersQ.isLoading}
|
||||
value={formatNum(ov?.active_workers)}
|
||||
sub={`${formatNum(ov?.total_workers)} registered`}
|
||||
loading={loading}
|
||||
tone={
|
||||
ov && (ov.total_workers ?? 0) > 0 && (ov.active_workers ?? 0) === 0
|
||||
? "warn"
|
||||
: "neutral"
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Send}
|
||||
label="Emails sent · 24h"
|
||||
value={formatNum(ov?.emails_sent_24h)}
|
||||
sub={`${formatNum(ov?.emails_sent_7d)} over 7 days`}
|
||||
loading={overviewQ.isLoading}
|
||||
label="Emails sent today"
|
||||
value={formatNum(ov?.emails_sent_today)}
|
||||
sub={`${formatNum(ov?.total_emails_sent)} all time`}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Bounce rate · 7d"
|
||||
value={formatPct(ov?.bounce_rate_7d)}
|
||||
sub={`Complaint rate ${formatPct(ov?.complaint_rate_7d)}`}
|
||||
loading={overviewQ.isLoading}
|
||||
tone={shouldWarnBounce(ov?.bounce_rate_7d) ? "warn" : "neutral"}
|
||||
icon={Megaphone}
|
||||
label="Campaigns active"
|
||||
value={formatNum(ov?.active_campaigns)}
|
||||
sub={`${formatNum(ov?.total_campaigns)} created`}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Users active, 30d"
|
||||
value={formatNum(ov?.active_users)}
|
||||
sub={`${formatNum(ov?.total_users)} total`}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<MiniStat icon={UserPlus} label="New today" value={formatNum(ov?.new_users_today)} />
|
||||
<MiniStat
|
||||
icon={UserPlus}
|
||||
label="New this week"
|
||||
value={formatNum(ov?.new_users_this_week)}
|
||||
/>
|
||||
<MiniStat
|
||||
icon={Activity}
|
||||
label="Subscriptions"
|
||||
value={formatNum(ov?.active_subscriptions)}
|
||||
/>
|
||||
<MiniStat icon={Activity} label="Trialing" value={formatNum(ov?.trialing_users)} />
|
||||
<MiniStat
|
||||
icon={ShieldAlert}
|
||||
label="Warmup blocked"
|
||||
value={formatNum(ov?.warmup_blocked_count)}
|
||||
warn={(ov?.warmup_blocked_count ?? 0) > 0}
|
||||
/>
|
||||
<MiniStat
|
||||
icon={AlertTriangle}
|
||||
label="Pending appeals"
|
||||
value={formatNum(ov?.pending_appeals)}
|
||||
warn={(ov?.pending_appeals ?? 0) > 0}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Worker fleet</CardTitle>
|
||||
<CardDescription>
|
||||
Live snapshot from <code>/admin/workers/managed</code>. Click any row in the
|
||||
Workers page for SSH actions + logs.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{workersQ.isLoading && (
|
||||
<li className="py-4">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</li>
|
||||
)}
|
||||
{!workersQ.isLoading && workers.length === 0 && (
|
||||
<li className="py-6 text-sm text-muted-foreground text-center">
|
||||
No workers registered yet.
|
||||
</li>
|
||||
)}
|
||||
{workers.slice(0, 6).map((w) => (
|
||||
<li key={w.id} className="py-2 flex items-center gap-3 text-sm">
|
||||
<span
|
||||
className={`size-2 rounded-full ${
|
||||
isOnline(w) ? "bg-emerald-500" : "bg-zinc-300"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium truncate flex-1">
|
||||
{w.name || w.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{w.worker_type}
|
||||
{w.worker_type === "shared" &&
|
||||
(w.free_tier ? " · free" : " · premium")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{w.account_count} mailboxes
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
function TrendCards() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "analytics", "trends"],
|
||||
queryFn: getAnalyticsTrends,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
<CardDescription>Quick-glance counters.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
<StatRow icon={Users} label="Users (total)" value={formatNum(ov?.users_total)} />
|
||||
<StatRow
|
||||
icon={Activity}
|
||||
label="Users active · 30d"
|
||||
value={formatNum(ov?.users_active_30d)}
|
||||
/>
|
||||
<StatRow
|
||||
icon={Send}
|
||||
label="Campaigns running"
|
||||
value={formatNum(ov?.campaigns_running)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<TrendCard icon={<Users className="size-4" />} label="Users" pct={data?.users_growth_percent} />
|
||||
<TrendCard
|
||||
icon={<Mail className="size-4" />}
|
||||
label="Emails sent"
|
||||
pct={data?.emails_growth_percent}
|
||||
/>
|
||||
<TrendCard
|
||||
icon={<Megaphone className="size-4" />}
|
||||
label="Campaigns"
|
||||
pct={data?.campaigns_growth_percent}
|
||||
/>
|
||||
<TrendCard
|
||||
icon={<BarChart3 className="size-4" />}
|
||||
label="Revenue"
|
||||
pct={data?.revenue_growth_percent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendCard({ icon, label, pct }: { icon: React.ReactNode; label: string; pct?: number }) {
|
||||
const v = pct ?? 0;
|
||||
const tone = v > 0 ? "text-emerald-600" : v < 0 ? "text-red-600" : "text-muted-foreground";
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className={`mt-1 text-2xl font-semibold tabular-nums ${tone}`}>
|
||||
{pct == null ? "—" : `${v > 0 ? "+" : ""}${v.toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
{v > 0 ? (
|
||||
<TrendingUp className="size-3" />
|
||||
) : v < 0 ? (
|
||||
<TrendingDown className="size-3" />
|
||||
) : null}
|
||||
vs. previous period
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isOnline(w: ManagedWorker): boolean {
|
||||
if (!w.last_seen_at) return false;
|
||||
return Date.now() - new Date(w.last_seen_at).getTime() < 5 * 60_000;
|
||||
function DailyEmailChart({ days }: { days: number }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "analytics", "emails", "daily", days],
|
||||
queryFn: () => getDailyEmailStats(days),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-48" />;
|
||||
const rows = data ?? [];
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
No email activity in the last {days} days. Bars appear here once a campaign or
|
||||
warmup send goes out.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxSent = Math.max(1, ...rows.map((r) => r.total_sent));
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex h-40 items-end gap-1">
|
||||
{rows.map((r) => (
|
||||
<DayBar key={r.date} stat={r} maxSent={maxSent} dense={rows.length > 45} />
|
||||
))}
|
||||
</div>
|
||||
<Legend />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// CLAUDE.md sets the deliverability ceiling: stay well below the 0.1% /
|
||||
// 0.3% complaint thresholds. We start warning at 3% bounce so an admin
|
||||
// gets a visible nudge before provider-level enforcement kicks in.
|
||||
function shouldWarnBounce(rate?: number): boolean {
|
||||
if (rate === undefined) return false;
|
||||
const pct = Math.abs(rate) <= 1 ? rate * 100 : rate;
|
||||
return pct >= 3;
|
||||
function DayBar({ stat, maxSent, dense }: { stat: DailyEmailStat; maxSent: number; dense: boolean }) {
|
||||
const h = (n: number) => `${Math.max(1, (n / maxSent) * 100)}%`;
|
||||
const other = Math.max(0, stat.total_delivered - stat.total_replied - stat.total_bounced);
|
||||
return (
|
||||
<div
|
||||
className="group relative flex flex-1 flex-col items-center"
|
||||
title={`${new Date(stat.date).toLocaleDateString()}\nsent: ${stat.total_sent}\ndelivered: ${stat.total_delivered}\nbounced: ${stat.total_bounced}\nreplied: ${stat.total_replied}`}
|
||||
>
|
||||
<div className="flex w-full flex-1 flex-col justify-end gap-0.5">
|
||||
{stat.total_bounced > 0 && (
|
||||
<div className="w-full bg-red-400" style={{ height: h(stat.total_bounced) }} />
|
||||
)}
|
||||
{stat.total_replied > 0 && (
|
||||
<div className="w-full bg-emerald-400" style={{ height: h(stat.total_replied) }} />
|
||||
)}
|
||||
{other > 0 && (
|
||||
<div className="w-full bg-[var(--admin-accent)]" style={{ height: h(other) }} />
|
||||
)}
|
||||
</div>
|
||||
{/* Ninety labels do not fit; keep every third so the axis stays readable. */}
|
||||
<div
|
||||
className={`mt-1 origin-top-left -rotate-45 whitespace-nowrap text-[8px] text-muted-foreground ${
|
||||
dense && new Date(stat.date).getDate() % 3 !== 1 ? "invisible" : ""
|
||||
}`}
|
||||
>
|
||||
{new Date(stat.date).toLocaleDateString(undefined, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend() {
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<LegendDot color="bg-[var(--admin-accent)]" label="Delivered" />
|
||||
<LegendDot color="bg-emerald-400" label="Replied" />
|
||||
<LegendDot color="bg-red-400" label="Bounced" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={`inline-block size-2 rounded ${color}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HourlyChart() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "analytics", "emails", "hourly"],
|
||||
queryFn: getHourlyEmailStats,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const rows = data ?? [];
|
||||
const max = Math.max(1, ...rows.map((r) => r.total_sent));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold">Today by hour</h2>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40" />
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No sends today yet. The hourly profile fills in as workers send.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex h-32 items-end gap-1">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.hour}
|
||||
className="flex flex-1 flex-col items-center"
|
||||
title={`${r.hour}:00, ${r.total_sent} sent`}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-[var(--admin-accent)]"
|
||||
style={{ height: `${Math.max(1, (r.total_sent / max) * 100)}%` }}
|
||||
/>
|
||||
<div className="mt-1 text-[8px] text-muted-foreground">{r.hour}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserGrowthChart({ days }: { days: number }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "analytics", "users", "growth", days],
|
||||
queryFn: () => getUserGrowthStats(days),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-40" />;
|
||||
const rows = data ?? [];
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
No new users in the last {days} days.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const maxNew = Math.max(1, ...rows.map((r) => r.new_users));
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex h-32 items-end gap-1">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.date}
|
||||
className="flex flex-1 flex-col items-center"
|
||||
title={`${new Date(r.date).toLocaleDateString()}\n+${r.new_users} new (${r.total_users} total)`}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-emerald-500"
|
||||
style={{ height: `${Math.max(1, (r.new_users / maxNew) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-muted-foreground">
|
||||
New users per day (hover for totals).
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Where signups came from: the UTM channel and referrer each account was
|
||||
// tagged with at registration, and how many of them went on to pay.
|
||||
function AcquisitionCard({ days }: { days: number }) {
|
||||
const acqQ = useQuery({
|
||||
queryKey: ["admin", "analytics", "acquisition", days],
|
||||
queryFn: () => getAcquisition(days),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const a = acqQ.data;
|
||||
const channels = (a?.channels ?? []).slice(0, 6);
|
||||
const referrers = (a?.referrers ?? []).slice(0, 6);
|
||||
const maxChannel = Math.max(1, ...channels.map((c) => c.signups));
|
||||
const maxRef = Math.max(1, ...referrers.map((r) => r.signups));
|
||||
const rate = a && a.signups > 0 ? `${((a.converted / a.signups) * 100).toFixed(1)}%` : "—";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Acquisition</CardTitle>
|
||||
<CardDescription>
|
||||
Signups over the last {days} days by the UTM channel and referrer they arrived
|
||||
with, and how many of them converted to a paid plan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
{acqQ.isLoading && <Skeleton className="h-32 w-full" />}
|
||||
{acqQ.isError && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Acquisition data is unavailable on this backend.
|
||||
</p>
|
||||
)}
|
||||
{a && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MiniStat icon={UserPlus} label="Signups" value={formatNum(a.signups)} />
|
||||
<MiniStat
|
||||
icon={BarChart3}
|
||||
label="With a channel"
|
||||
value={formatNum(a.with_channel)}
|
||||
/>
|
||||
<MiniStat
|
||||
icon={TrendingUp}
|
||||
label="Converted"
|
||||
value={`${formatNum(a.converted)} · ${rate}`}
|
||||
/>
|
||||
<MiniStat
|
||||
icon={AlertTriangle}
|
||||
label="Trials ending, 7d"
|
||||
value={formatNum(a.trials_expiring_7d)}
|
||||
warn={a.trials_expiring_7d > 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<RankedList
|
||||
title="Top channels"
|
||||
empty="No signup carried a UTM source or medium in this window."
|
||||
rows={channels.map((c) => ({
|
||||
key: `${c.source}/${c.medium}`,
|
||||
label: c.medium ? `${c.source} / ${c.medium}` : c.source,
|
||||
value: c.signups,
|
||||
note: c.converted > 0 ? `${c.converted} paid` : undefined,
|
||||
pct: (c.signups / maxChannel) * 100,
|
||||
}))}
|
||||
/>
|
||||
<RankedList
|
||||
title="Top referrers"
|
||||
empty="No signup arrived with a referrer in this window."
|
||||
rows={referrers.map((r) => ({
|
||||
key: r.host,
|
||||
label: r.host,
|
||||
value: r.signups,
|
||||
pct: (r.signups / maxRef) * 100,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RankedList({
|
||||
title,
|
||||
empty,
|
||||
rows,
|
||||
}: {
|
||||
title: string;
|
||||
empty: string;
|
||||
rows: { key: string; label: string; value: number; note?: string; pct: number }[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{empty}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{rows.map((r) => (
|
||||
<li key={r.key} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-foreground">{r.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatNum(r.value)}
|
||||
{r.note && <span className="ml-1 text-emerald-600">· {r.note}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 h-1 w-full rounded bg-muted">
|
||||
<div
|
||||
className="h-1 rounded bg-[var(--admin-accent)]"
|
||||
style={{ width: `${Math.max(2, r.pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
@@ -208,28 +585,34 @@ function StatCard({ icon: Icon, label, value, sub, loading, tone = "neutral" }:
|
||||
<div className="mt-2 text-2xl font-semibold tabular-nums">
|
||||
{loading ? <Skeleton className="h-7 w-16" /> : value}
|
||||
</div>
|
||||
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
|
||||
{sub && <div className="mt-1 text-xs text-muted-foreground">{sub}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({
|
||||
function MiniStat({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
warn,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
warn?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
<div
|
||||
className={`rounded-md border px-3 py-2 ${
|
||||
warn ? "border-amber-200 bg-amber-50/60" : "border-border bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Icon className={`size-3 ${warn ? "text-amber-600" : ""}`} />
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{value}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums text-foreground">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// The send outcome loop as the operator sees it. Four tabs, deep-linked
|
||||
// through ?tab=: reservations still waiting on a worker result, tasks that
|
||||
// exhausted their retries, recent task failures, and customer webhook health.
|
||||
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertTriangle, Archive, Send, Webhook } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { PageTabs } from "@/components/layout/PageTabs";
|
||||
import { InFlightTab } from "@/app/dashboard/sends/InFlightTab";
|
||||
import { DeadLettersTab } from "@/app/dashboard/sends/DeadLettersTab";
|
||||
import { FailuresTab } from "@/app/dashboard/sends/FailuresTab";
|
||||
import { WebhooksTab } from "@/app/dashboard/sends/WebhooksTab";
|
||||
|
||||
const TABS = [
|
||||
{ id: "in-flight", label: "In flight", icon: Send },
|
||||
{ id: "dead-letters", label: "Dead letters", icon: Archive },
|
||||
{ id: "failures", label: "Failures", icon: AlertTriangle },
|
||||
{ id: "webhooks", label: "Webhooks", icon: Webhook },
|
||||
];
|
||||
const DEFAULT_TAB = "in-flight";
|
||||
|
||||
export default function SendsPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const raw = params.get("tab");
|
||||
const tab = TABS.some((t) => t.id === raw) ? (raw as string) : DEFAULT_TAB;
|
||||
|
||||
function setTab(id: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
if (id === DEFAULT_TAB) next.delete("tab");
|
||||
else next.set("tab", id);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Sends"
|
||||
description="Every campaign send between its dispatch and the worker's answer, the tasks that gave up, what tasks reported when they failed, and whether customer webhooks are getting through."
|
||||
/>
|
||||
<div className="mb-5">
|
||||
<PageTabs tabs={TABS} value={tab} onChange={setTab} />
|
||||
</div>
|
||||
{tab === "in-flight" && <InFlightTab />}
|
||||
{tab === "dead-letters" && <DeadLettersTab />}
|
||||
{tab === "failures" && <FailuresTab />}
|
||||
{tab === "webhooks" && <WebhooksTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Placeholder pages for nav entries we haven't wired backends for yet.
|
||||
// They exist so the sidebar never 404s and so it's obvious to a dev
|
||||
// which surface they should fill in next.
|
||||
|
||||
import { ComingSoon, PageHeader } from "@/components/layout/PageHeader";
|
||||
|
||||
interface StubProps {
|
||||
title: string;
|
||||
description: string;
|
||||
coming: string;
|
||||
}
|
||||
|
||||
function Stub({ title, description, coming }: StubProps) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title={title} description={description} />
|
||||
<ComingSoon label={coming} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Page not found" description="That route isn't part of the admin app." />
|
||||
<a href="/" className="text-sm underline text-muted-foreground">
|
||||
Back to overview
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// Mailbox sync governor. The platform copy of every mailbox's sync state:
|
||||
// backfill progress and the fair-use throttle the worker last reported via
|
||||
// SYNC_STATE. No polling here: the realtime spine invalidates
|
||||
// ["admin","sync"] on SYNC_STATE and account events.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Database,
|
||||
Gauge,
|
||||
Hourglass,
|
||||
MoreHorizontal,
|
||||
PauseCircle,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Explorer, FilterGroup, SearchFilter, SegmentedFilter } from "@/components/data/Explorer";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import { useCursorPager } from "@/lib/useCursorPager";
|
||||
import {
|
||||
ADMIN_SYNC_STATES,
|
||||
clearSyncThrottle,
|
||||
restartSyncBackfill,
|
||||
searchSync,
|
||||
type AdminSyncActionResult,
|
||||
type AdminSyncRow,
|
||||
type AdminSyncStateFilter,
|
||||
} from "@/lib/api/client/admin/sync";
|
||||
import { StatCard } from "@/app/dashboard/sends/StatCard";
|
||||
import { absolute, relative } from "@/app/dashboard/jobs/format";
|
||||
|
||||
const BACKFILL_TONE: Record<string, string> = {
|
||||
pending: "border-zinc-300 bg-zinc-50 text-zinc-600",
|
||||
running: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
complete: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
};
|
||||
|
||||
function isThrottled(row: AdminSyncRow): boolean {
|
||||
return !!row.throttled_until && new Date(row.throttled_until).getTime() > Date.now();
|
||||
}
|
||||
|
||||
function parseState(raw: string | null): AdminSyncStateFilter {
|
||||
return ADMIN_SYNC_STATES.includes(raw as AdminSyncStateFilter) ? (raw as AdminSyncStateFilter) : "all";
|
||||
}
|
||||
|
||||
export default function SyncPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const state = parseState(params.get("state"));
|
||||
const query = params.get("q") ?? "";
|
||||
const pager = useCursorPager();
|
||||
const { reset } = pager;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
function setParam(key: "state" | "q", value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
// "all" is the state filter's default, not a search term.
|
||||
const isDefault = key === "state" && value === "all";
|
||||
if (value && !isDefault) next.set(key, value);
|
||||
else next.delete(key);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
const filters = { state, q: query.trim() };
|
||||
const filterKey = JSON.stringify(filters);
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [filterKey, reset]);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "sync", filters, pager.cursor],
|
||||
queryFn: () =>
|
||||
searchSync({
|
||||
state: state === "all" ? undefined : state,
|
||||
q: filters.q || undefined,
|
||||
cursor: pager.cursor,
|
||||
limit: 50,
|
||||
}),
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// The worker is re-shipped the mailbox after the write; when that fails
|
||||
// the platform copy is right and the worker's live copy is not, which is
|
||||
// worth a warning rather than a success.
|
||||
function report(verb: string, res: AdminSyncActionResult) {
|
||||
if (res.reloaded) {
|
||||
toast.success(`${verb}; the worker reloaded the mailbox`);
|
||||
} else {
|
||||
toast.warning(`${verb}, but the worker was not reloaded: ${res.reload_error || "unknown error"}`);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["admin", "sync"] });
|
||||
}
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: (row: AdminSyncRow) => clearSyncThrottle(row.email_id),
|
||||
onSuccess: (res) => report("Throttle cleared", res),
|
||||
onError: (err: Error) => toast.error(err.message || "Failed to clear throttle"),
|
||||
});
|
||||
|
||||
const restart = useMutation({
|
||||
mutationFn: (row: AdminSyncRow) => restartSyncBackfill(row.email_id),
|
||||
onSuccess: (res) => report("Backfill restarted", res),
|
||||
onError: (err: Error) => toast.error(err.message || "Failed to restart backfill"),
|
||||
});
|
||||
|
||||
async function onRestart(row: AdminSyncRow) {
|
||||
const ok = await confirm({
|
||||
title: "Restart backfill?",
|
||||
description: `${row.email} will re-import its history from scratch: the backfill cursor is dropped and the worker starts again from the newest message, within the configured window. Mail already imported is kept; the pass counts against the mailbox's daily budget.`,
|
||||
confirmLabel: "Restart backfill",
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
restart.mutate(row);
|
||||
}
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const summary = data?.summary;
|
||||
const activeCount = (state !== "all" ? 1 : 0) + (query ? 1 : 0);
|
||||
|
||||
const columns: Column<AdminSyncRow>[] = [
|
||||
{
|
||||
id: "mailbox",
|
||||
header: "Mailbox",
|
||||
cell: (m) => (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">{m.email}</span>
|
||||
{m.account_status && m.account_status !== "active" && (
|
||||
<Badge variant="outline" className="text-[10px] border-zinc-300 text-zinc-600">
|
||||
{m.account_status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">{m.provider}</div>
|
||||
</div>
|
||||
),
|
||||
csv: (m) => m.email,
|
||||
},
|
||||
{
|
||||
id: "workspace",
|
||||
header: "Workspace",
|
||||
cell: (m) =>
|
||||
m.organization_id ? (
|
||||
<Link to={`/organizations/${m.organization_id}`} className="text-xs text-[var(--admin-accent-strong)] hover:underline">
|
||||
{m.organization_name || m.organization_id}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
csv: (m) => m.organization_name || "",
|
||||
},
|
||||
{
|
||||
id: "backfill",
|
||||
header: "Backfill",
|
||||
cell: (m) => (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className={`text-[10px] ${BACKFILL_TONE[m.backfill_status] ?? "border-zinc-300 text-zinc-600"}`}>
|
||||
{m.backfill_status || "unknown"}
|
||||
</Badge>
|
||||
{m.stalled && (
|
||||
<Badge variant="outline" className="text-[10px] border-red-300 bg-red-50 text-red-700" title="Running, but its state has not moved for an hour">
|
||||
stalled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground tabular-nums">
|
||||
{m.backfill_synced.toLocaleString()} synced
|
||||
{m.backfill_completed_at
|
||||
? ` · completed ${relative(m.backfill_completed_at)}`
|
||||
: m.backfill_started_at
|
||||
? ` · started ${relative(m.backfill_started_at)}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
csv: (m) => `${m.backfill_status}${m.stalled ? " (stalled)" : ""}: ${m.backfill_synced}`,
|
||||
},
|
||||
{
|
||||
id: "throttle",
|
||||
header: "Throttle",
|
||||
cell: (m) => {
|
||||
const throttled = isThrottled(m);
|
||||
return (
|
||||
<div>
|
||||
{throttled ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px] border-orange-300 bg-orange-50 text-orange-700">
|
||||
throttled
|
||||
</Badge>
|
||||
<span className="text-xs">{m.throttle_reason || "over budget"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground tabular-nums">
|
||||
{throttled && <span title={absolute(m.throttled_until)}>until {relative(m.throttled_until)}</span>}
|
||||
{throttled && m.deferred > 0 && " · "}
|
||||
{m.deferred > 0 && `${m.deferred.toLocaleString()} deferred`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
csv: (m) => (isThrottled(m) ? `${m.throttle_reason} until ${m.throttled_until}` : ""),
|
||||
},
|
||||
{
|
||||
id: "synced",
|
||||
header: "Last synced",
|
||||
cell: (m) => (
|
||||
<span className="text-xs text-muted-foreground" title={absolute(m.last_synced_at)}>
|
||||
{relative(m.last_synced_at)}
|
||||
</span>
|
||||
),
|
||||
csv: (m) => m.last_synced_at || "",
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "Updated",
|
||||
cell: (m) => (
|
||||
<span className="text-xs text-muted-foreground" title={absolute(m.updated_at)}>
|
||||
{relative(m.updated_at)}
|
||||
</span>
|
||||
),
|
||||
csv: (m) => m.updated_at,
|
||||
},
|
||||
{
|
||||
id: "worker",
|
||||
header: "Worker",
|
||||
defaultHidden: true,
|
||||
cell: (m) =>
|
||||
m.worker_id ? (
|
||||
<Link to={`/workers/${m.worker_id}`} className="font-mono text-[11px] text-[var(--admin-accent-strong)] hover:underline">
|
||||
{m.worker_id.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
csv: (m) => m.worker_id || "",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (m) => (
|
||||
<RowActions
|
||||
row={m}
|
||||
busy={(clear.isPending && clear.variables?.email_id === m.email_id) || (restart.isPending && restart.variables?.email_id === m.email_id)}
|
||||
onClear={() => clear.mutate(m)}
|
||||
onRestart={() => onRestart(m)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Sync"
|
||||
description="The platform copy of every mailbox's sync state: how far its history backfill has come and whether the fair-use governor is holding it back."
|
||||
/>
|
||||
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
|
||||
<StatCard icon={Database} label="Mailboxes" value={fmt(summary?.total)} loading={isLoading} />
|
||||
<StatCard icon={PauseCircle} label="Throttled" value={fmt(summary?.throttled)} loading={isLoading} tone={summary?.throttled ? "warn" : "neutral"} />
|
||||
<StatCard icon={Hourglass} label="Backfilling" value={fmt(summary?.backfilling)} loading={isLoading} />
|
||||
<StatCard icon={AlertTriangle} label="Stalled" value={fmt(summary?.stalled)} loading={isLoading} tone={summary?.stalled ? "danger" : "neutral"} />
|
||||
<StatCard icon={Clock} label="Pending" value={fmt(summary?.pending)} loading={isLoading} />
|
||||
<StatCard icon={CheckCircle2} label="Complete" value={fmt(summary?.complete)} loading={isLoading} />
|
||||
<StatCard icon={Gauge} label="Deferred" value={fmt(summary?.deferred)} loading={isLoading} sub="messages held for a later pass" />
|
||||
</div>
|
||||
|
||||
<Explorer
|
||||
activeCount={activeCount}
|
||||
onReset={() => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("state");
|
||||
next.delete("q");
|
||||
setParams(next, { replace: true });
|
||||
}}
|
||||
filters={
|
||||
<>
|
||||
<FilterGroup label="Search">
|
||||
<SearchFilter value={query} onChange={(v) => setParam("q", v)} placeholder="Mailbox or workspace…" />
|
||||
</FilterGroup>
|
||||
<FilterGroup label="State">
|
||||
<SegmentedFilter
|
||||
value={state}
|
||||
onChange={(v) => setParam("state", v)}
|
||||
options={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "throttled", label: "Throttled" },
|
||||
{ value: "backfilling", label: "Backfilling" },
|
||||
{ value: "stalled", label: "Stalled" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "complete", label: "Complete" },
|
||||
]}
|
||||
/>
|
||||
</FilterGroup>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(m) => m.email_id}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
errorTitle="Failed to load sync state"
|
||||
storageKey="admin.sync"
|
||||
csvName="warmbly-sync"
|
||||
noun="mailboxes"
|
||||
emptyTitle="No sync state"
|
||||
emptyHint={
|
||||
activeCount > 0
|
||||
? "No mailboxes match these filters."
|
||||
: "Rows appear once a worker has reported SYNC_STATE for a mailbox. Nothing has synced on this instance yet."
|
||||
}
|
||||
pager={{
|
||||
canPrev: pager.canPrev,
|
||||
canNext: !!data?.pagination?.has_more,
|
||||
onPrev: pager.prev,
|
||||
onNext: () => pager.next(data?.pagination?.next_cursor),
|
||||
page: pager.page,
|
||||
shown: rows.length,
|
||||
total: data?.pagination?.total ?? null,
|
||||
}}
|
||||
/>
|
||||
</Explorer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(n: number | undefined): string {
|
||||
return n === undefined ? "—" : n.toLocaleString();
|
||||
}
|
||||
|
||||
// Radix DropdownMenu closes on Escape and click-away on its own; the trigger
|
||||
// is always visible so the actions are reachable on touch.
|
||||
function RowActions({
|
||||
row,
|
||||
busy,
|
||||
onClear,
|
||||
onRestart,
|
||||
}: {
|
||||
row: AdminSyncRow;
|
||||
busy: boolean;
|
||||
onClear: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
const throttled = isThrottled(row);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon-xs" title="More" disabled={busy} onClick={(e) => e.stopPropagation()}>
|
||||
<MoreHorizontal className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
{throttled && (
|
||||
<DropdownMenuItem onSelect={onClear} className="text-[12.5px]">
|
||||
<Gauge /> Clear throttle
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={onRestart} className="text-[12.5px]">
|
||||
<RotateCcw /> Restart backfill
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Transfers: every workspace export and import on the instance. Exports can
|
||||
// be downloaded and deleted here; imports are started from the workspace's
|
||||
// own page (Organizations > workspace > Transfer) because they need a
|
||||
// destination. Polls at 15s only while a job is queued or running.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Download, ExternalLink, PackageOpen, Terminal, Trash2 } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
import {
|
||||
deleteOrgExport,
|
||||
downloadOrgExport,
|
||||
formatBytes,
|
||||
isTransferActive,
|
||||
listTransfers,
|
||||
saveBlob,
|
||||
type AdminTransferJob,
|
||||
} from "@/lib/api/client/admin/transfers";
|
||||
import { ExportDialog } from "./transfers/ExportDialog";
|
||||
import { KindPill, StatusPill } from "./transfers/TransferPills";
|
||||
import { fmtDateTime, shortId } from "./fleet/format";
|
||||
|
||||
const POLL = 15_000;
|
||||
|
||||
export default function TransfersPage() {
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [downloading, setDownloading] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "transfers", "all"],
|
||||
queryFn: () => listTransfers(200),
|
||||
refetchInterval: (q) => ((q.state.data?.data ?? []).some((j) => isTransferActive(j.status)) ? POLL : false),
|
||||
});
|
||||
const rows = data?.data ?? [];
|
||||
const active = rows.filter((j) => isTransferActive(j.status)).length;
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["admin", "transfers"] });
|
||||
|
||||
async function onDownload(j: AdminTransferJob) {
|
||||
setDownloading(j.id);
|
||||
try {
|
||||
const { blob, filename } = await downloadOrgExport(j.organization_id, j.id, j.organization_name);
|
||||
saveBlob(blob, filename);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "Download failed");
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
}
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (j: AdminTransferJob) => deleteOrgExport(j.organization_id, j.id),
|
||||
onSuccess: () => {
|
||||
toast.success("Archive deleted");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Delete failed"),
|
||||
});
|
||||
|
||||
async function onDelete(j: AdminTransferJob) {
|
||||
const ok = await confirm({
|
||||
title: `Delete the archive for ${j.organization_name}?`,
|
||||
description: "The stored file is removed and cannot be downloaded again. Run another export to rebuild it.",
|
||||
confirmLabel: "Delete",
|
||||
destructive: true,
|
||||
});
|
||||
if (ok) del.mutate(j);
|
||||
}
|
||||
|
||||
const columns: Column<AdminTransferJob>[] = [
|
||||
{ id: "kind", header: "Kind", cell: (j) => <KindPill kind={j.kind} />, csv: (j) => j.kind },
|
||||
{
|
||||
id: "org",
|
||||
header: "Workspace",
|
||||
cell: (j) => (
|
||||
<div>
|
||||
<Link
|
||||
to={`/organizations/${j.organization_id}?tab=transfer`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
{j.organization_name || shortId(j.organization_id)}
|
||||
</Link>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{shortId(j.id)}</div>
|
||||
</div>
|
||||
),
|
||||
csv: (j) => j.organization_name,
|
||||
},
|
||||
{
|
||||
id: "by",
|
||||
header: "Requested by",
|
||||
cell: (j) => <span className="text-xs">{j.requested_by_email || <span className="text-muted-foreground">system</span>}</span>,
|
||||
csv: (j) => j.requested_by_email,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: (j) => (
|
||||
<div>
|
||||
<StatusPill status={j.status} progress={j.progress_percent} stage={j.progress_stage} />
|
||||
{j.error_message && <div className="mt-0.5 max-w-xs text-[11px] text-red-600">{j.error_message}</div>}
|
||||
</div>
|
||||
),
|
||||
csv: (j) => j.status,
|
||||
},
|
||||
{ id: "size", header: "Size", align: "right", cell: (j) => <span className="text-xs tabular-nums">{formatBytes(j.archive_bytes)}</span>, csv: (j) => j.archive_bytes ?? "" },
|
||||
{ id: "groups", header: "Groups", align: "right", cell: (j) => <span className="text-xs tabular-nums">{j.groups?.length ?? 0}</span>, csv: (j) => j.groups?.length ?? 0 },
|
||||
{ id: "secrets", header: "Secrets", cell: (j) => <span className="text-xs">{j.include_secrets ? "yes" : "no"}</span>, csv: (j) => (j.include_secrets ? "yes" : "no") },
|
||||
{ id: "started", header: "Started", cell: (j) => <span className="text-xs text-muted-foreground">{fmtDateTime(j.started_at ?? j.created_at)}</span>, csv: (j) => j.started_at ?? j.created_at },
|
||||
{ id: "completed", header: "Completed", cell: (j) => <span className="text-xs text-muted-foreground">{fmtDateTime(j.completed_at)}</span>, csv: (j) => j.completed_at ?? "" },
|
||||
{ id: "expires", header: "Expires", cell: (j) => <span className="text-xs text-muted-foreground">{fmtDateTime(j.expires_at)}</span>, csv: (j) => j.expires_at ?? "", defaultHidden: true },
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (j) =>
|
||||
j.kind === "export" ? (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={j.status !== "completed" || downloading === j.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onDownload(j);
|
||||
}}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
{downloading === j.id ? "Fetching…" : "Download"}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-700 hover:bg-red-50"
|
||||
disabled={isTransferActive(j.status) || del.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onDelete(j);
|
||||
}}
|
||||
aria-label="Delete archive"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Transfers"
|
||||
description="Workspace archives across the instance: the same exports and imports owners build from Settings > Data, started by them or by an operator."
|
||||
>
|
||||
<Button size="sm" onClick={() => setExportOpen(true)}>
|
||||
<PackageOpen className="size-4" />
|
||||
Export a workspace
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="mb-4 flex items-start gap-2 rounded-md border border-border bg-card px-3 py-2.5 text-[12.5px] text-muted-foreground">
|
||||
<Terminal className="mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
These move one workspace at a time. A whole-instance backup, including every workspace, the
|
||||
encryption keys and blob storage, is <code>warmblyctl backup</code> and <code>warmblyctl restore</code>{" "}
|
||||
from a shell on the host.{" "}
|
||||
<a
|
||||
href={docsUrl("/development/warmblyctl/")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
warmblyctl docs <ExternalLink className="size-3" />
|
||||
</a>
|
||||
{active > 0 && <span className="ml-1.5 tabular-nums">· {active} running, refreshing every 15s</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(j) => `${j.kind}:${j.id}`}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
errorTitle="Failed to load transfers"
|
||||
storageKey="admin.transfers"
|
||||
csvName="warmbly-transfers"
|
||||
noun="jobs"
|
||||
emptyTitle="No transfers"
|
||||
emptyHint="No workspace has been exported or imported on this instance yet. Start an export above, or import on a workspace's Transfer tab."
|
||||
/>
|
||||
|
||||
<ExportDialog open={exportOpen} onOpenChange={setExportOpen} onStarted={invalidate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { useCursorPager } from "@/lib/useCursorPager";
|
||||
import { emptyRange, rangeActive, rangeWithin, rangeAfter, rangeBefore, type DateRange } from "@/lib/dateRange";
|
||||
import { searchUsers } from "@/lib/api/client/admin/users";
|
||||
import { listPlans } from "@/lib/api/client/admin/plans";
|
||||
import type { AdminUserDetail } from "@/lib/api/models/admin";
|
||||
|
||||
type StatusFilter = "active" | "banned" | "all";
|
||||
@@ -108,7 +107,6 @@ export default function UsersPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("active");
|
||||
// Plan / subscription
|
||||
const [planId, setPlanId] = useState("");
|
||||
const [subStatus, setSubStatus] = useState("");
|
||||
const [isEnterprise, setIsEnterprise] = useState(false);
|
||||
const [hasSubscription, setHasSubscription] = useState(false);
|
||||
@@ -142,14 +140,8 @@ export default function UsersPage() {
|
||||
const pager = useCursorPager();
|
||||
const { reset } = pager;
|
||||
|
||||
const { data: plansData } = useQuery({ queryKey: ["admin", "plans", "facet"], queryFn: listPlans, staleTime: 5 * 60_000 });
|
||||
const planOptions = [
|
||||
{ value: "any", label: "Any plan" },
|
||||
...(plansData?.data ?? []).map((p) => ({ value: p.id, label: p.name || "Untitled plan" })),
|
||||
];
|
||||
|
||||
const filterKey = JSON.stringify({
|
||||
query, status, planId, subStatus, isEnterprise, hasSubscription, hasActiveSubscription,
|
||||
query, status, subStatus, isEnterprise, hasSubscription, hasActiveSubscription,
|
||||
adminOnly, hasOverrides, freeTrialUsed, onboardingCompleted, deletionScheduled, hasAvatar,
|
||||
hasActiveCampaign, hasBanRecord, hasDedicatedWorker,
|
||||
orgMin, orgMax, mbMin, mbMax, campMin, campMax, maxOrgMin, maxOrgMax,
|
||||
@@ -166,7 +158,6 @@ export default function UsersPage() {
|
||||
searchUsers({
|
||||
q: query.trim() || undefined,
|
||||
status: status === "all" ? "" : status,
|
||||
plan_id: planId || undefined,
|
||||
subscription_status: subStatus || undefined,
|
||||
is_enterprise: isEnterprise || undefined,
|
||||
has_subscription: hasSubscription || undefined,
|
||||
@@ -213,7 +204,6 @@ export default function UsersPage() {
|
||||
const activeCount =
|
||||
(query ? 1 : 0) +
|
||||
(status !== "active" ? 1 : 0) +
|
||||
(planId ? 1 : 0) +
|
||||
(subStatus ? 1 : 0) +
|
||||
bools.filter(Boolean).length +
|
||||
ranges.filter(([a, b]) => a !== undefined || b !== undefined).length +
|
||||
@@ -223,7 +213,6 @@ export default function UsersPage() {
|
||||
function resetAll() {
|
||||
setQuery("");
|
||||
setStatus("active");
|
||||
setPlanId("");
|
||||
setSubStatus("");
|
||||
setIsEnterprise(false);
|
||||
setHasSubscription(false);
|
||||
@@ -254,7 +243,7 @@ export default function UsersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Users" description="Every account on the platform. Filter by identity, plan, subscription, account state, usage, and timeline — then drill in to ban, unban, or override limits." />
|
||||
<PageHeader title="Users" description="Every account on the platform. Filter by identity, subscription, account state, usage, and timeline — then drill in to ban, unban, or override limits." />
|
||||
<Explorer
|
||||
activeCount={activeCount}
|
||||
onReset={resetAll}
|
||||
@@ -274,14 +263,6 @@ export default function UsersPage() {
|
||||
]}
|
||||
/>
|
||||
</FilterGroup>
|
||||
<FilterGroup label="Plan">
|
||||
<SelectFilter
|
||||
value={planId || "any"}
|
||||
onChange={(v) => setPlanId(v === "any" ? "" : v)}
|
||||
options={planOptions}
|
||||
placeholder="Any plan"
|
||||
/>
|
||||
</FilterGroup>
|
||||
<FilterGroup label="Subscription">
|
||||
<SelectFilter
|
||||
value={subStatus || "any"}
|
||||
|
||||
@@ -114,8 +114,6 @@ function AppealsTab() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "warmup", "appeals", status, pager.cursor],
|
||||
queryFn: () => listWarmupAppeals(status, pager.cursor),
|
||||
// Keep the pending queue current without a manual refresh.
|
||||
refetchInterval: status === "pending" ? 15_000 : false,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
@@ -401,7 +399,6 @@ function BlockedTab() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "warmup", "blocked", pager.cursor],
|
||||
queryFn: () => listBlockedWarmupAccounts(pager.cursor),
|
||||
refetchInterval: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
@@ -7,21 +7,28 @@
|
||||
// 3. Blocked account list with unblock action
|
||||
// 4. Pending appeals with one-click approve/reject
|
||||
//
|
||||
// Everything refetches on a 30s interval so an ops investigator sees
|
||||
// pool drift in near-real time without needing to reload the tab.
|
||||
// Nothing here polls: the realtime spine's warmup group invalidates
|
||||
// ["admin","warmup"] on ACCOUNT and WARMUP events. The abuse and action
|
||||
// history tabs (?tab=) come from /admin/warmup/abuse and /admin/warmup/actions.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Flame,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { PageTabs } from "@/components/layout/PageTabs";
|
||||
import { SegmentedFilter } from "@/components/data/Explorer";
|
||||
import { StateLegend } from "@/components/StateLegend";
|
||||
import { MAILBOX_HEALTH_LEGEND } from "@/lib/legends";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -42,17 +49,46 @@ import {
|
||||
approveAppeal,
|
||||
getWarmupHealthSummary,
|
||||
listBlockedWarmupAccounts,
|
||||
listWarmupAbuse,
|
||||
listWarmupActions,
|
||||
listWarmupAppeals,
|
||||
listWarmupPools,
|
||||
rejectAppeal,
|
||||
unblockWarmupAccount,
|
||||
type WarmupAbuseWindow,
|
||||
} from "@/lib/api/client/admin/warmup";
|
||||
import type {
|
||||
AdminBlockedAccount,
|
||||
WarmupAppeal,
|
||||
} from "@/lib/api/models/admin";
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview", icon: LayoutDashboard },
|
||||
{ id: "abuse", label: "Abuse signals", icon: ShieldAlert },
|
||||
{ id: "actions", label: "Admin actions", icon: History },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
function isTab(v: string | null): v is TabId {
|
||||
return TABS.some((t) => t.id === v);
|
||||
}
|
||||
|
||||
export default function WarmupPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const raw = params.get("tab");
|
||||
const tab: TabId = isTab(raw) ? raw : "overview";
|
||||
|
||||
function setTab(next: string) {
|
||||
setParams(
|
||||
(p) => {
|
||||
p.set("tab", next);
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -62,6 +98,18 @@ export default function WarmupPage() {
|
||||
<StateLegend label="Health states explained" entries={MAILBOX_HEALTH_LEGEND} />
|
||||
</PageHeader>
|
||||
|
||||
<PageTabs tabs={[...TABS]} value={tab} onChange={setTab} />
|
||||
|
||||
{tab === "abuse" && <AbuseTab />}
|
||||
{tab === "actions" && <ActionsTab />}
|
||||
{tab === "overview" && <Overview />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Overview() {
|
||||
return (
|
||||
<div>
|
||||
<HealthSummary />
|
||||
|
||||
<section className="mt-6">
|
||||
@@ -86,7 +134,6 @@ function HealthSummary() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "warmup", "health"],
|
||||
queryFn: getWarmupHealthSummary,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -229,7 +276,6 @@ function PoolsList() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "warmup", "pools"],
|
||||
queryFn: listWarmupPools,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-24" />;
|
||||
@@ -302,7 +348,6 @@ function BlockedAccounts() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "warmup", "blocked"],
|
||||
queryFn: () => listBlockedWarmupAccounts(),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const unblock = useMutation({
|
||||
@@ -405,7 +450,6 @@ function AppealsQueue() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "warmup", "appeals", "pending"],
|
||||
queryFn: () => listWarmupAppeals("pending"),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="h-32" />;
|
||||
@@ -567,3 +611,188 @@ function ReviewAppealDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- abuse signals ----
|
||||
|
||||
const HEALTH_TONE: Record<string, string> = Object.fromEntries(
|
||||
MAILBOX_HEALTH_LEGEND.map((e) => [e.term, e.tone ?? ""]),
|
||||
);
|
||||
|
||||
function AbuseTab() {
|
||||
const [win, setWin] = useState<WarmupAbuseWindow>("7d");
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "warmup", "abuse", win],
|
||||
queryFn: () => listWarmupAbuse(win, 200),
|
||||
});
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-2xl text-[12.5px] text-muted-foreground">
|
||||
Mailboxes ranked by invalid warmup-token attempts: a warmup mail that arrives with a missing, expired
|
||||
or mismatched token is either a forged pool message or a mailbox misbehaving. Three or more invalid
|
||||
attempts in 24 hours auto-block the mailbox from the pool, as does a spam score above 50.
|
||||
</p>
|
||||
<div className="w-48">
|
||||
<SegmentedFilter<WarmupAbuseWindow>
|
||||
value={win}
|
||||
onChange={setWin}
|
||||
options={[
|
||||
{ value: "24h", label: "24h" },
|
||||
{ value: "7d", label: "7d" },
|
||||
{ value: "30d", label: "30d" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : error ? (
|
||||
<ErrorState error={error} title="Failed to load abuse signals" onRetry={() => refetch()} />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
No invalid warmup-token attempts in the last {win}. Rows appear when a pool mailbox receives
|
||||
warmup mail whose token does not verify.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Mailbox</th>
|
||||
<th className="px-3 py-2 text-left">Workspace</th>
|
||||
<th className="px-3 py-2 text-right">Invalid attempts</th>
|
||||
<th className="px-3 py-2 text-left">Last attempt</th>
|
||||
<th className="px-3 py-2 text-left">Blocked</th>
|
||||
<th className="px-3 py-2 text-right">Spam score</th>
|
||||
<th className="px-3 py-2 text-left">Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.email_account_id} className="border-t border-border">
|
||||
<td className="px-3 py-2 font-mono text-xs">{r.email}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{r.organization_id ? (
|
||||
<Link
|
||||
to={`/organizations/${r.organization_id}`}
|
||||
className="font-medium text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
{r.organization_name || r.organization_id.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-right tabular-nums ${
|
||||
r.attempts >= 3 ? "font-medium text-red-600" : ""
|
||||
}`}
|
||||
>
|
||||
{r.attempts}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{new Date(r.last_attempt_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.blocked ? (
|
||||
<Badge variant="outline" className="border-red-300 bg-red-50 text-[10px] text-red-700">
|
||||
blocked
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">no</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-right tabular-nums text-xs ${
|
||||
r.spam_score > 50 ? "font-medium text-red-600" : r.spam_score > 25 ? "text-amber-700" : ""
|
||||
}`}
|
||||
>
|
||||
{r.spam_score}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.health_state ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] ${HEALTH_TONE[r.health_state] ?? "border-zinc-300 text-zinc-600"}`}
|
||||
>
|
||||
{r.health_state}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- admin action history ----
|
||||
|
||||
function ActionsTab() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "warmup", "actions"],
|
||||
queryFn: () => listWarmupActions(200),
|
||||
});
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-3 max-w-2xl text-[12.5px] text-muted-foreground">
|
||||
Every manual block, unblock and appeal decision an admin made on a warmup mailbox, newest first.
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : error ? (
|
||||
<ErrorState error={error} title="Failed to load action history" onRetry={() => refetch()} />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
No admin actions recorded yet. Blocking or unblocking a mailbox, or reviewing an appeal, writes a row
|
||||
here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">When</th>
|
||||
<th className="px-3 py-2 text-left">Admin</th>
|
||||
<th className="px-3 py-2 text-left">Mailbox</th>
|
||||
<th className="px-3 py-2 text-left">Action</th>
|
||||
<th className="px-3 py-2 text-left">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((a) => (
|
||||
<tr key={a.id} className="border-t border-border">
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(a.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">{a.admin_email || a.admin_user_id.slice(0, 8)}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{a.email || a.email_account_id.slice(0, 8)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{a.action}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs max-w-md">{a.reason || <span className="text-muted-foreground">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
// apply config) sit in the header; the destructive and rarely-used ones (rotate
|
||||
// keys, OS update, reboot, uninstall, delete) are grouped in a Maintenance card
|
||||
// so they can't be hit by accident. Logs panel tails journald with a selectable
|
||||
// line count and an optional follow mode.
|
||||
// line count and an optional follow mode. The worker row, its mailboxes and
|
||||
// its stats are keyed under ["admin","workers"] so the realtime workers spine
|
||||
// refreshes them; only the SSH probes (live status, log follow) still poll.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
@@ -11,8 +13,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRightLeft,
|
||||
ArrowUpCircle,
|
||||
Check,
|
||||
Copy,
|
||||
Gauge,
|
||||
Download,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
@@ -25,6 +30,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
StopCircle,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { StateLegend } from "@/components/StateLegend";
|
||||
@@ -41,6 +47,15 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
applyWorkerConfig,
|
||||
deleteWorker,
|
||||
@@ -48,6 +63,9 @@ import {
|
||||
getWorkerEmails,
|
||||
getWorkerLogs,
|
||||
getWorkerLiveStatus,
|
||||
getWorkerStats,
|
||||
listManagedWorkers,
|
||||
reassignWorkerEmails,
|
||||
installWorker,
|
||||
rebootWorker,
|
||||
restartWorker,
|
||||
@@ -56,7 +74,9 @@ import {
|
||||
testWorker,
|
||||
uninstallWorker,
|
||||
upgradeWorker,
|
||||
type WorkerStats,
|
||||
} from "@/lib/api/client/admin/workers";
|
||||
import type { AdminWorkerEmail, ManagedWorker } from "@/lib/api/models/admin";
|
||||
|
||||
// Risk band (mailbox reputation tier) + health state (warmup/worker) tones.
|
||||
const RISK_TONE: Record<string, string> = {
|
||||
@@ -79,10 +99,16 @@ export default function WorkerDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const workerQ = useQuery({
|
||||
queryKey: ["admin", "worker", id],
|
||||
queryKey: ["admin", "workers", id],
|
||||
queryFn: () => getManagedWorker(id),
|
||||
enabled: !!id,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const statsQ = useQuery({
|
||||
queryKey: ["admin", "workers", id, "stats"],
|
||||
queryFn: () => getWorkerStats(id),
|
||||
enabled: !!id,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const liveQ = useQuery({
|
||||
@@ -123,15 +149,33 @@ export default function WorkerDetailPage() {
|
||||
};
|
||||
|
||||
const emailsQ = useQuery({
|
||||
queryKey: ["admin", "worker", id, "emails"],
|
||||
queryKey: ["admin", "workers", id, "emails"],
|
||||
queryFn: () => getWorkerEmails(id),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Mailbox selection for the reassign action; cleared when the list changes.
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [reassignOpen, setReassignOpen] = useState(false);
|
||||
const mailboxes = emailsQ.data?.data ?? [];
|
||||
const allSelected = mailboxes.length > 0 && mailboxes.every((m) => selected.has(m.id));
|
||||
|
||||
function toggleOne(mid: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mid)) next.delete(mid);
|
||||
else next.add(mid);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
setSelected(allSelected ? new Set() : new Set(mailboxes.map((m) => m.id)));
|
||||
}
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "workers"] });
|
||||
qc.invalidateQueries({ queryKey: ["admin", "worker", id] });
|
||||
qc.invalidateQueries({ queryKey: ["admin", "workers", "managed"] });
|
||||
};
|
||||
|
||||
const testMut = useMutation({
|
||||
@@ -404,7 +448,7 @@ export default function WorkerDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SSH target</CardTitle>
|
||||
@@ -498,14 +542,33 @@ export default function WorkerDetailPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CapacityCard stats={statsQ.data} loading={statsQ.isLoading} error={statsQ.isError} />
|
||||
</div>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Mailboxes</CardTitle>
|
||||
<CardTitle className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span>Mailboxes</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={mailboxes.length === 0}
|
||||
onClick={() => {
|
||||
if (selected.size === 0) setSelected(new Set(mailboxes.map((m) => m.id)));
|
||||
setReassignOpen(true);
|
||||
}}
|
||||
>
|
||||
<ArrowRightLeft className="size-4" />
|
||||
{selected.size > 0
|
||||
? `Move ${selected.size} to worker…`
|
||||
: `Move ${mailboxes.length} shown to worker…`}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Inboxes assigned to this worker and their health. Risk band drives which
|
||||
workers a mailbox may share — low-health inboxes are kept off trusted workers.
|
||||
Select rows to move them to another worker of the same tier.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
@@ -524,6 +587,13 @@ export default function WorkerDetailPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-muted-foreground text-xs uppercase">
|
||||
<tr>
|
||||
<th className="w-8 px-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all mailboxes"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-2 py-1.5 font-medium">Mailbox</th>
|
||||
<th className="text-left px-2 py-1.5 font-medium">Provider</th>
|
||||
<th className="text-left px-2 py-1.5 font-medium">Status</th>
|
||||
@@ -535,7 +605,18 @@ export default function WorkerDetailPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{(emailsQ.data.data ?? []).map((m) => (
|
||||
<tr key={m.id} className="border-t border-border">
|
||||
<tr
|
||||
key={m.id}
|
||||
onClick={() => toggleOne(m.id)}
|
||||
className={`border-t border-border cursor-pointer ${selected.has(m.id) ? "bg-[var(--admin-accent-soft)]" : "hover:bg-muted/40"}`}
|
||||
>
|
||||
<td className="px-2 py-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selected.has(m.id)}
|
||||
onCheckedChange={() => toggleOne(m.id)}
|
||||
aria-label={`Select ${m.email}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 font-mono text-xs">{m.email}</td>
|
||||
<td className="px-2 py-1.5 text-xs">
|
||||
{m.provider}
|
||||
@@ -591,6 +672,23 @@ export default function WorkerDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SelectionBar
|
||||
count={selected.size}
|
||||
onMove={() => setReassignOpen(true)}
|
||||
onClear={() => setSelected(new Set())}
|
||||
/>
|
||||
|
||||
<ReassignDialog
|
||||
open={reassignOpen}
|
||||
onOpenChange={setReassignOpen}
|
||||
source={w}
|
||||
mailboxes={mailboxes.filter((m) => selected.has(m.id))}
|
||||
onDone={() => {
|
||||
setSelected(new Set());
|
||||
invalidate();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -778,3 +876,208 @@ function KV({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapacityCard({
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
stats: WorkerStats | undefined;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
<Gauge className="size-4" />
|
||||
Capacity
|
||||
</CardTitle>
|
||||
<CardDescription>Send throughput and queue depth for this worker.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 text-sm space-y-2">
|
||||
{loading && <Skeleton className="h-4 w-2/3" />}
|
||||
{error && <div className="text-xs text-muted-foreground">Stats unavailable.</div>}
|
||||
{stats && (
|
||||
<>
|
||||
<KV label="Sent today" value={stats.emails_sent_today.toLocaleString()} />
|
||||
<KV label="This week" value={stats.emails_sent_this_week.toLocaleString()} />
|
||||
<KV label="All time" value={stats.total_emails_sent.toLocaleString()} />
|
||||
<KV
|
||||
label="Success"
|
||||
value={
|
||||
<span className={stats.success_rate < 90 && stats.total_emails_sent > 0 ? "text-amber-700" : ""}>
|
||||
{stats.success_rate.toFixed(1)}%
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<KV label="Avg delivery" value={`${Math.round(stats.average_delivery_time_ms)} ms`} />
|
||||
<KV
|
||||
label="Queue"
|
||||
value={<span className={stats.queue_depth > 0 ? "font-medium" : ""}>{stats.queue_depth.toLocaleString()}</span>}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Floating bottom-center bar for the mailbox selection. Fixed, so it stays
|
||||
// in view however long the table is.
|
||||
function SelectionBar({ count, onMove, onClear }: { count: number; onMove: () => void; onClear: () => void }) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 z-30 flex max-w-[calc(100vw-16px)] -translate-x-1/2 flex-wrap items-center justify-center gap-1.5 rounded-md border border-border bg-card px-2 py-1.5 shadow-[0_6px_20px_-4px_rgba(15,23,42,0.18),0_2px_4px_rgba(15,23,42,0.06)]">
|
||||
<div className="inline-flex h-7 items-center gap-1.5 rounded bg-[var(--admin-accent-soft)] px-2 text-[12px] font-medium text-[var(--admin-accent-strong)]">
|
||||
<Check className="size-3" />
|
||||
<span>{count} selected</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMove}
|
||||
className="inline-flex h-7 items-center gap-1.5 rounded px-2.5 text-[12px] font-medium text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<ArrowRightLeft className="size-3" />
|
||||
Move to worker…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="grid size-7 place-items-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HEALTH_LABEL: Record<string, string> = {
|
||||
healthy: "healthy",
|
||||
watch: "watch",
|
||||
throttled: "throttled",
|
||||
quarantined: "quarantined",
|
||||
blocked: "blocked",
|
||||
};
|
||||
|
||||
// Pick a target worker for the selected mailboxes. Mailboxes keep their
|
||||
// tier, so a worker in the other tier is listed but cannot be chosen.
|
||||
function ReassignDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
source,
|
||||
mailboxes,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
source: ManagedWorker;
|
||||
mailboxes: AdminWorkerEmail[];
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [target, setTarget] = useState("");
|
||||
|
||||
const workersQ = useQuery({
|
||||
queryKey: ["admin", "workers", "managed"],
|
||||
queryFn: listManagedWorkers,
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const candidates = (workersQ.data?.data ?? []).filter((x) => x.id !== source.id);
|
||||
const chosen = candidates.find((x) => x.id === target) ?? null;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
reassignWorkerEmails(
|
||||
target,
|
||||
mailboxes.map((m) => m.id),
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success(`${mailboxes.length} mailbox${mailboxes.length === 1 ? "" : "es"} moved to ${chosen?.name || target.slice(0, 8)}`);
|
||||
setTarget("");
|
||||
onDone();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Reassign failed"),
|
||||
});
|
||||
|
||||
const sameTier = !!chosen && chosen.free_tier === source.free_tier;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && mutation.isPending) return;
|
||||
if (!v) setTarget("");
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move {mailboxes.length} mailbox{mailboxes.length === 1 ? "" : "es"} to another worker</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sending and sync for these mailboxes continue from the target on its next heartbeat. Tier
|
||||
placement is strict: a {source.free_tier ? "free" : "premium"}-tier mailbox only runs on a{" "}
|
||||
{source.free_tier ? "free" : "premium"}-tier worker.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="max-h-28 overflow-auto rounded-md border border-border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed">
|
||||
{mailboxes.map((m) => (
|
||||
<div key={m.id} className="truncate">
|
||||
{m.email}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Target worker</div>
|
||||
<Select value={target || undefined} onValueChange={setTarget}>
|
||||
<SelectTrigger className="h-8 w-full text-[12.5px]">
|
||||
<SelectValue placeholder={workersQ.isLoading ? "Loading workers…" : "Pick a worker"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidates.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No other workers.</div>
|
||||
)}
|
||||
{candidates.map((x) => {
|
||||
const mismatch = x.free_tier !== source.free_tier;
|
||||
return (
|
||||
<SelectItem key={x.id} value={x.id} disabled={mismatch} className="text-[12.5px]">
|
||||
{x.name || x.id.slice(0, 8)} · {x.free_tier ? "free" : "premium"} · {x.worker_type} ·{" "}
|
||||
{HEALTH_LABEL[x.health_state] ?? x.health_state} · {x.account_count} mailbox{x.account_count === 1 ? "" : "es"}
|
||||
{mismatch ? " (other tier)" : ""}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{chosen && chosen.worker_type === "dedicated" && (
|
||||
<p className="text-[11px] text-amber-700">
|
||||
That worker is dedicated to one workspace; only move mailboxes that belong to it.
|
||||
</p>
|
||||
)}
|
||||
{chosen && chosen.health_state !== "healthy" && (
|
||||
<p className="text-[11px] text-amber-700">
|
||||
That worker is {chosen.health_state}; the assignment loop would not place new mailboxes there.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => mutation.mutate()} disabled={!chosen || !sameTier || mailboxes.length === 0 || mutation.isPending}>
|
||||
{mutation.isPending
|
||||
? "Moving…"
|
||||
: `Move ${mailboxes.length} to ${chosen ? chosen.name || chosen.id.slice(0, 8) : "worker"}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,7 +160,6 @@ export default function WorkersPage() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "workers", "managed"],
|
||||
queryFn: listManagedWorkers,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Grant or edit an admin's bits. Grant mode starts with a user search; edit
|
||||
// mode is prefilled from the row. A preset fills the checkboxes, and the mask
|
||||
// sent is always the OR of what is ticked.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { grantAdminPermissions, type PermissionInfo } from "@/lib/api/client/admin/admins";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UserPicker, type PickedUser } from "./UserPicker";
|
||||
import { userName } from "../fleet/format";
|
||||
import { groupByCategory, hasBit, humanize, matchPreset, presetMask, PRESETS } from "./permissions";
|
||||
|
||||
export interface GrantTarget {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
admin_permissions: number;
|
||||
}
|
||||
|
||||
export function GrantAdminDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
catalog,
|
||||
target,
|
||||
selfId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
catalog: PermissionInfo[];
|
||||
/** Present in edit mode; absent in grant mode. */
|
||||
target: GrantTarget | null;
|
||||
selfId?: string;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const edit = !!target;
|
||||
const [user, setUser] = useState<PickedUser | null>(null);
|
||||
const [mask, setMask] = useState(0);
|
||||
|
||||
// Reset per open so a reopened dialog never carries a stale draft.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setUser(target ? { ...target, first_name: target.first_name ?? "", last_name: target.last_name ?? "" } : null);
|
||||
setMask(target?.admin_permissions ?? 0);
|
||||
}, [open, target]);
|
||||
|
||||
// Picking a user who is already an admin starts from their current bits.
|
||||
function pickUser(u: PickedUser | null) {
|
||||
setUser(u);
|
||||
if (u && u.admin_permissions > 0) setMask(u.admin_permissions);
|
||||
}
|
||||
|
||||
const liveMask = catalog.reduce((m, c) => m | c.permission, 0);
|
||||
const shown = mask & liveMask;
|
||||
const active = matchPreset(shown, catalog);
|
||||
const count = catalog.filter((c) => hasBit(shown, c.permission)).length;
|
||||
const editingSelf = !!user && user.id === selfId;
|
||||
const grantBit = catalog.find((c) => c.name === "grant_admin_access")?.permission ?? 0;
|
||||
const dropsOwnGrant = editingSelf && grantBit > 0 && !hasBit(shown, grantBit);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => grantAdminPermissions(user!.id, shown),
|
||||
onSuccess: () => {
|
||||
toast.success(edit ? `Permissions updated for ${user?.email}` : `${user?.email} is now an admin`);
|
||||
qc.invalidateQueries({ queryKey: ["admin", "admins"] });
|
||||
qc.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
if (editingSelf) qc.invalidateQueries({ queryKey: ["me"] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Grant failed"),
|
||||
});
|
||||
|
||||
const canSubmit = !!user && shown > 0 && !mutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && mutation.isPending) return;
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-xl"
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (document.querySelector("[data-floating]")) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? "Edit admin permissions" : "Grant admin access"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
These bits gate the operator panel only. They are separate from any role the user holds inside a
|
||||
workspace.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">User</div>
|
||||
{edit && user ? (
|
||||
<div className="rounded-md border border-border bg-card px-2.5 py-1.5 text-[12.5px]">
|
||||
<span className="font-medium">{userName(user)}</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
) : (
|
||||
<UserPicker value={user} onChange={pickUser} autoFocus />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Preset</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
title={p.hint}
|
||||
onClick={() => setMask(presetMask(p.id, catalog))}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-[12px] transition-colors",
|
||||
active === p.id
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent-soft)] font-medium text-[var(--admin-accent-strong)]"
|
||||
: "border-border text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMask(0)}
|
||||
className="rounded-md border border-border px-2.5 py-1 text-[12px] text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{active ? PRESETS.find((p) => p.id === active)?.hint : "Custom selection."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 space-y-3 overflow-auto rounded-md border border-border bg-card p-3">
|
||||
{groupByCategory(catalog).map((g) => (
|
||||
<div key={g.category}>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{g.category}
|
||||
</div>
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{g.items.map((p) => {
|
||||
const on = hasBit(shown, p.permission);
|
||||
return (
|
||||
<label
|
||||
key={p.name}
|
||||
className="flex cursor-pointer items-start gap-2 rounded px-1.5 py-1 text-[12.5px] hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={on}
|
||||
onCheckedChange={(v) =>
|
||||
setMask((m) => (v ? m | p.permission : m & ~p.permission))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block leading-tight">{humanize(p.name)}</span>
|
||||
<span className="block text-[11px] leading-tight text-muted-foreground">
|
||||
{p.description}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{catalog.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">The permission catalog is empty.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dropsOwnGrant && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
|
||||
You are removing your own ability to grant admin access. You will not be able to undo this
|
||||
from the panel.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:items-center sm:justify-between">
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{count} of {catalog.length} bits · mask {shown}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => mutation.mutate()} disabled={!canSubmit}>
|
||||
{mutation.isPending ? "Saving…" : edit ? "Save permissions" : "Grant access"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// An admin's bits as chips grouped by catalog category. Bits the catalog does
|
||||
// not know (retired ones still stored on old super admins) are not shown.
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { PermissionInfo } from "@/lib/api/client/admin/admins";
|
||||
import { groupByCategory, hasBit, humanize } from "./permissions";
|
||||
|
||||
export function PermissionChips({ mask, catalog }: { mask: number; catalog: PermissionInfo[] }) {
|
||||
const groups = groupByCategory(catalog)
|
||||
.map((g) => ({ ...g, items: g.items.filter((p) => hasBit(mask, p.permission)) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
if (groups.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground">No live permissions</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{groups.map((g) => (
|
||||
<div key={g.category} className="flex flex-wrap items-center gap-1">
|
||||
<span className="mr-0.5 text-[10px] uppercase tracking-wider text-muted-foreground">{g.category}</span>
|
||||
{g.items.map((p) => (
|
||||
<Badge
|
||||
key={p.name}
|
||||
variant="outline"
|
||||
title={p.description}
|
||||
className={
|
||||
p.name === "grant_admin_access"
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent-soft)] text-[10px] text-[var(--admin-accent-strong)]"
|
||||
: "text-[10px]"
|
||||
}
|
||||
>
|
||||
{humanize(p.name)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// User search-and-pick for the grant dialog. Searches /admin/users?q= with a
|
||||
// short page; 2+ characters, debounced by SearchPicker.
|
||||
|
||||
import { SearchPicker } from "../fleet/SearchPicker";
|
||||
import { searchUsers } from "@/lib/api/client/admin/users";
|
||||
import type { AdminUserDetail } from "@/lib/api/models/admin";
|
||||
import { userName } from "../fleet/format";
|
||||
|
||||
export type PickedUser = Pick<AdminUserDetail, "id" | "email" | "first_name" | "last_name" | "admin_permissions">;
|
||||
|
||||
export function UserPicker({
|
||||
value,
|
||||
onChange,
|
||||
autoFocus,
|
||||
}: {
|
||||
value: PickedUser | null;
|
||||
onChange: (v: PickedUser | null) => void;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SearchPicker<PickedUser>
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
queryKey={["admin", "users", "picker"]}
|
||||
search={async (q) => (await searchUsers({ q, limit: 8 })).data ?? []}
|
||||
getKey={(u) => u.id}
|
||||
placeholder="Search users by email or name…"
|
||||
emptyText="No user matches."
|
||||
autoFocus={autoFocus}
|
||||
renderItem={(u) => (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">{userName(u)}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
{u.admin_permissions > 0 && (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-[var(--admin-accent-strong)]">admin</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
renderSelected={(u) => (
|
||||
<div className="truncate">
|
||||
<span className="font-medium">{userName(u)}</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">{u.email}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Helpers over the permission catalog from GET /admin/permissions.
|
||||
//
|
||||
// Presets mirror models.AdminRolePermissions by permission NAME so the bits
|
||||
// come from the catalog the backend serves; the grant endpoint takes only a
|
||||
// bitmask, so a preset is resolved here before it is sent. "super" is every
|
||||
// live bit in the catalog (the backend's AllAdminPermissions also carries the
|
||||
// retired bits, which IsSuperAdmin ignores).
|
||||
|
||||
import type { PermissionInfo } from "@/lib/api/client/admin/admins";
|
||||
|
||||
export type PresetId = "super" | "support" | "ops" | "analyst";
|
||||
|
||||
export const PRESETS: { id: PresetId; label: string; hint: string; names: string[] | "all" }[] = [
|
||||
{ id: "super", label: "Super", hint: "Every bit, including granting admin access.", names: "all" },
|
||||
{
|
||||
id: "support",
|
||||
label: "Support",
|
||||
hint: "Read users, campaigns and workspaces; triage warmup bans and appeals.",
|
||||
names: ["view_users", "view_campaigns", "view_warmup_pool", "manage_warmup_bans", "review_appeals", "view_audit_logs", "view_organizations"],
|
||||
},
|
||||
{
|
||||
id: "ops",
|
||||
label: "Ops",
|
||||
hint: "Run the fleet: workers, analytics, rate limits.",
|
||||
names: ["view_workers", "manage_workers", "view_analytics", "view_audit_logs", "manage_rate_limits", "view_organizations"],
|
||||
},
|
||||
{
|
||||
id: "analyst",
|
||||
label: "Analyst",
|
||||
hint: "Read-only across users, campaigns, analytics and workspaces.",
|
||||
names: ["view_users", "view_campaigns", "view_analytics", "view_audit_logs", "view_organizations"],
|
||||
},
|
||||
];
|
||||
|
||||
export function presetMask(preset: PresetId, catalog: PermissionInfo[]): number {
|
||||
const p = PRESETS.find((x) => x.id === preset);
|
||||
if (!p) return 0;
|
||||
if (p.names === "all") return catalog.reduce((m, c) => m | c.permission, 0);
|
||||
const byName = new Map(catalog.map((c) => [c.name, c.permission]));
|
||||
return p.names.reduce((m, n) => m | (byName.get(n) ?? 0), 0);
|
||||
}
|
||||
|
||||
// Which preset a mask is exactly, if any, so the dialog can highlight it.
|
||||
export function matchPreset(mask: number, catalog: PermissionInfo[]): PresetId | null {
|
||||
for (const p of PRESETS) {
|
||||
if (presetMask(p.id, catalog) === mask) return p.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function groupByCategory(catalog: PermissionInfo[]): { category: string; items: PermissionInfo[] }[] {
|
||||
const out: { category: string; items: PermissionInfo[] }[] = [];
|
||||
for (const p of catalog) {
|
||||
let g = out.find((x) => x.category === p.category);
|
||||
if (!g) {
|
||||
g = { category: p.category, items: [] };
|
||||
out.push(g);
|
||||
}
|
||||
g.items.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hasBit(mask: number, bit: number): boolean {
|
||||
return (mask & bit) === bit;
|
||||
}
|
||||
|
||||
export function humanize(name: string): string {
|
||||
return name.replace(/_/g, " ");
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Configuration, environment: the resolved environment of the running
|
||||
// backend, read only. Nothing here can be written from the API, so the tab's
|
||||
// whole job is to answer "is my variable actually being picked up, and does
|
||||
// it need a restart to change?". Sensitive keys show a fingerprint, never a
|
||||
// value.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ExternalLink, Search, Settings2 } from "lucide-react";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getInstanceConfig,
|
||||
type ConfigSource,
|
||||
type InstanceConfigEntry,
|
||||
type RuntimeChangeable,
|
||||
} from "@/lib/api/client/admin/instance";
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
deployment: "Deployment",
|
||||
addresses: "Addresses",
|
||||
database: "Database",
|
||||
cache: "Cache",
|
||||
mail: "Platform mail",
|
||||
auth: "Authentication",
|
||||
encryption: "Encryption",
|
||||
storage: "Storage",
|
||||
eventbus: "Event bus",
|
||||
workers: "Workers",
|
||||
tracking: "Tracking",
|
||||
captcha: "Captcha",
|
||||
observability: "Observability",
|
||||
};
|
||||
|
||||
const GROUP_ORDER = Object.keys(GROUP_LABELS);
|
||||
|
||||
const SOURCE_STYLES: Record<ConfigSource, { label: string; className: string; title: string }> = {
|
||||
env: {
|
||||
label: "env",
|
||||
className: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
title: "Read from this process's environment.",
|
||||
},
|
||||
default: {
|
||||
label: "default",
|
||||
className: "border-zinc-300 bg-zinc-50 text-zinc-600",
|
||||
title: "No environment variable set, so the built-in default applies.",
|
||||
},
|
||||
derived: {
|
||||
label: "derived",
|
||||
className: "border-sky-300 bg-sky-50 text-sky-700",
|
||||
title: "Computed from other values rather than set directly.",
|
||||
},
|
||||
unset: {
|
||||
label: "unset",
|
||||
className: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
title: "Not set and there is no default: the feature it controls is off.",
|
||||
},
|
||||
};
|
||||
|
||||
const RESTART_STYLES: Record<RuntimeChangeable, { label: string; className: string }> = {
|
||||
"boot-only": {
|
||||
label: "Restart to change",
|
||||
className: "border-zinc-300 bg-zinc-50 text-zinc-600",
|
||||
},
|
||||
"per-request": {
|
||||
label: "Takes effect immediately",
|
||||
className: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
},
|
||||
};
|
||||
|
||||
// "eventbus" -> "Eventbus". Keeps a group the frontend has not learned yet
|
||||
// rendering instead of disappearing.
|
||||
function groupLabel(group: string): string {
|
||||
if (GROUP_LABELS[group]) return GROUP_LABELS[group];
|
||||
const spaced = group.replace(/[-_]+/g, " ").trim();
|
||||
if (!spaced) return "Other";
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
function groupRank(group: string): number {
|
||||
const i = GROUP_ORDER.indexOf(group);
|
||||
return i === -1 ? GROUP_ORDER.length : i;
|
||||
}
|
||||
|
||||
function matches(entry: InstanceConfigEntry, needle: string): boolean {
|
||||
if (!needle) return true;
|
||||
const q = needle.toLowerCase();
|
||||
if (entry.key.toLowerCase().includes(q)) return true;
|
||||
if (groupLabel(entry.group).toLowerCase().includes(q)) return true;
|
||||
if (entry.effect.toLowerCase().includes(q)) return true;
|
||||
// A sensitive entry never carries its value, so there is nothing to match.
|
||||
if (!entry.sensitive && entry.value.toLowerCase().includes(q)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface EnvironmentTabProps {
|
||||
onSwitchTab?: (tab: "settings") => void;
|
||||
}
|
||||
|
||||
export function EnvironmentTab({ onSwitchTab }: EnvironmentTabProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const configQ = useQuery({
|
||||
queryKey: ["admin", "instance", "config"],
|
||||
queryFn: getInstanceConfig,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const entries = useMemo(() => configQ.data?.entries ?? [], [configQ.data]);
|
||||
const filtered = useMemo(
|
||||
() => entries.filter((e) => matches(e, search.trim())),
|
||||
[entries, search],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byGroup = new Map<string, InstanceConfigEntry[]>();
|
||||
for (const entry of filtered) {
|
||||
const list = byGroup.get(entry.group);
|
||||
if (list) list.push(entry);
|
||||
else byGroup.set(entry.group, [entry]);
|
||||
}
|
||||
return [...byGroup.entries()].sort(
|
||||
(a, b) => groupRank(a[0]) - groupRank(b[0]) || a[0].localeCompare(b[0]),
|
||||
);
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Every value here comes from the environment of the running backend. Change it
|
||||
where you set your environment, then restart. What can be edited in place
|
||||
lives under Settings.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => onSwitchTab?.("settings")}>
|
||||
<Settings2 className="size-4" />
|
||||
Instance settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search variables, groups or effects"
|
||||
className="h-8 pl-8 text-[12.5px]"
|
||||
/>
|
||||
</div>
|
||||
{entries.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{filtered.length} of {entries.length} variables
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{configQ.isLoading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configQ.isError && (
|
||||
<ErrorState
|
||||
error={configQ.error}
|
||||
title="Could not read this instance's configuration"
|
||||
onRetry={() => configQ.refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{configQ.data && entries.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
||||
The backend returned no configuration entries.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configQ.data && entries.length > 0 && filtered.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
||||
No variable matches "{search.trim()}".
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{groups.map(([group, groupEntries]) => (
|
||||
<section
|
||||
key={group}
|
||||
className="overflow-hidden rounded-lg border border-border bg-white"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{groupLabel(group)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{groupEntries.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{groupEntries.map((entry) => (
|
||||
<ConfigRow key={entry.key} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigRow({ entry }: { entry: InstanceConfigEntry }) {
|
||||
const source = SOURCE_STYLES[entry.source] ?? SOURCE_STYLES.default;
|
||||
const restart = RESTART_STYLES[entry.runtime_changeable] ?? RESTART_STYLES["boot-only"];
|
||||
|
||||
return (
|
||||
// Anchored on the variable name so a check can deep-link to its row.
|
||||
<div id={entry.key} className="scroll-mt-20 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="text-[12.5px] font-semibold text-foreground">{entry.key}</code>
|
||||
<Badge variant="outline" className={`text-[10px] ${source.className}`} title={source.title}>
|
||||
{source.label}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`text-[10px] ${restart.className}`}>
|
||||
{restart.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5">
|
||||
{entry.sensitive ? (
|
||||
<SensitiveValue entry={entry} />
|
||||
) : (
|
||||
<PlainValue entry={entry} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.effect && (
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{entry.effect}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{entry.docs && (
|
||||
<a
|
||||
href={docsUrl(entry.docs)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Gated on the resolved value, not on entry.set: a default or derived value
|
||||
// resolves without any environment variable being present.
|
||||
function PlainValue({ entry }: { entry: InstanceConfigEntry }) {
|
||||
if (entry.value === "") {
|
||||
return <span className="text-xs text-muted-foreground">Not set</span>;
|
||||
}
|
||||
return (
|
||||
<code className="block break-all rounded bg-muted px-2 py-1 font-mono text-xs text-foreground">
|
||||
{entry.value}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// A sensitive value is never sent. The fingerprint is there so two services
|
||||
// can be compared (same AUTH_SECRET?) without disclosing either.
|
||||
function SensitiveValue({ entry }: { entry: InstanceConfigEntry }) {
|
||||
// A fingerprint is only minted for a non-empty resolved value, so it is the
|
||||
// reliable "has a value" signal; source covers a backend that omits it.
|
||||
const resolved = entry.fingerprint !== "" || entry.source !== "unset";
|
||||
if (!resolved) {
|
||||
return <span className="text-xs text-muted-foreground">Not set</span>;
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||
Set, value hidden
|
||||
</span>
|
||||
{entry.fingerprint && (
|
||||
<span
|
||||
className="font-mono text-[11px] text-muted-foreground"
|
||||
title="First 4 hex characters of the SHA-256 of the value. Two services holding the same secret show the same fingerprint."
|
||||
>
|
||||
fingerprint {entry.fingerprint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+16
-14
@@ -1,11 +1,9 @@
|
||||
// Effective limits: what this instance is actually enforcing right now,
|
||||
// Configuration, limits: what this instance is actually enforcing right now,
|
||||
// after configuration, plan and product defaults have all been applied.
|
||||
// Read only, because every number here is owned by one of those layers.
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -14,7 +12,11 @@ import {
|
||||
type InstanceLimitGroup,
|
||||
} from "@/lib/api/client/admin/instance";
|
||||
|
||||
export default function LimitsPage() {
|
||||
interface LimitsTabProps {
|
||||
onSwitchTab?: (tab: "environment") => void;
|
||||
}
|
||||
|
||||
export function LimitsTab({ onSwitchTab }: LimitsTabProps) {
|
||||
const limitsQ = useQuery({
|
||||
queryKey: ["admin", "instance", "limits"],
|
||||
queryFn: getInstanceLimits,
|
||||
@@ -25,17 +27,17 @@ export default function LimitsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Effective limits"
|
||||
description="The caps and defaults this instance enforces, resolved from configuration and the product defaults. Read-only: change the matching variable or the organization's override instead."
|
||||
>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/configuration">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Configuration
|
||||
</Link>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
The caps and defaults this instance enforces, resolved from configuration and
|
||||
the product defaults. Read only: change the matching variable or the
|
||||
organization's override instead.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => onSwitchTab?.("environment")}>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Environment
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
{limitsQ.isLoading && (
|
||||
<div className="space-y-3">
|
||||
+46
-32
@@ -24,7 +24,6 @@ import {
|
||||
Trash2,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -109,7 +108,13 @@ function newChannel(): NotifyChannel {
|
||||
};
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
interface NotificationsTabProps {
|
||||
// Reported on every change so the page can confirm before a tab switch or
|
||||
// a navigation throws the edits away.
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function NotificationsTab({ onDirtyChange }: NotificationsTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: SETTINGS_KEY, queryFn: getInstanceSettings });
|
||||
const catalog = useQuery({ queryKey: EVENTS_KEY, queryFn: getNotificationEvents });
|
||||
@@ -153,10 +158,6 @@ export default function NotificationsPage() {
|
||||
return order.map((g) => ({ group: g, events: byGroup.get(g)! }));
|
||||
}, [catalog.data]);
|
||||
|
||||
if (settings.isError) {
|
||||
return <ErrorState error={settings.error as Error} onRetry={() => settings.refetch()} />;
|
||||
}
|
||||
|
||||
function update(id: string, patch: Partial<NotifyChannel>) {
|
||||
setChannels((prev) => (prev ?? []).map((c) => (c.id === id ? { ...c, ...patch } : c)));
|
||||
}
|
||||
@@ -198,6 +199,16 @@ export default function NotificationsPage() {
|
||||
const dirty =
|
||||
!!settings.data &&
|
||||
JSON.stringify(list) !== JSON.stringify(settings.data.notifications?.channels ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
if (settings.isError) {
|
||||
return <ErrorState error={settings.error as Error} onRetry={() => settings.refetch()} />;
|
||||
}
|
||||
|
||||
// Changing a channel's type clears its target on purpose, so a save with
|
||||
// one still empty would drop the channel server-side. Block it here and
|
||||
// say which one needs attention.
|
||||
@@ -205,32 +216,35 @@ export default function NotificationsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Notifications"
|
||||
description="Where this instance tells you something happened. Add a Discord or Slack webhook, a signed endpoint, or an address."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setChannels([...(channels ?? []), newChannel()])}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add channel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!dirty || incomplete.length > 0 || save.isPending}
|
||||
title={
|
||||
incomplete.length > 0
|
||||
? "Every channel needs a destination before you can save"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => save.mutate(list)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{save.isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Where this instance tells you something happened. Add a Discord or Slack
|
||||
webhook, a signed endpoint, or an address.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setChannels([...(channels ?? []), newChannel()])}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add channel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!dirty || incomplete.length > 0 || save.isPending}
|
||||
title={
|
||||
incomplete.length > 0
|
||||
? "Every channel needs a destination before you can save"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => save.mutate(list)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{save.isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.isLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
+42
-19
@@ -1,13 +1,11 @@
|
||||
// Instance settings: the only writable configuration in the product. These
|
||||
// keys are deliberately disjoint from the environment, so there is no
|
||||
// Configuration, settings: the only writable configuration in the product.
|
||||
// These keys are deliberately disjoint from the environment, so there is no
|
||||
// precedence to resolve and nothing here can be overwritten at the next boot.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Save, SlidersHorizontal } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { Save } from "lucide-react";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -157,7 +155,14 @@ function syncFieldValid(raw: string, min: number, max: number): boolean {
|
||||
return raw.trim() !== "" && Number.isInteger(n) && n >= min && n <= max;
|
||||
}
|
||||
|
||||
export default function InstanceSettingsPage() {
|
||||
interface SettingsTabProps {
|
||||
// Reported on every change so the page can confirm before a tab switch or
|
||||
// a navigation throws the edits away.
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onSwitchTab?: (tab: "environment" | "limits") => void;
|
||||
}
|
||||
|
||||
export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
|
||||
const qc = useQueryClient();
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
|
||||
@@ -204,6 +209,11 @@ export default function InstanceSettingsPage() {
|
||||
form.authGraceHours !== String(server.deliverability.auth_grace_hours) ||
|
||||
retentionDirty ||
|
||||
syncDirty);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [dirty, onDirtyChange]);
|
||||
const syncValid =
|
||||
form !== null && SYNC_FIELDS.every((f) => syncFieldValid(form.sync[f.key], f.min, f.max));
|
||||
const retentionValid =
|
||||
@@ -275,16 +285,11 @@ export default function InstanceSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Instance settings"
|
||||
description="These settings are stored in the database and are not read from the environment."
|
||||
>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/configuration">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Configuration
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Stored in the database and never read from the environment. Everything the
|
||||
environment owns is on the Environment tab.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={save}
|
||||
@@ -293,7 +298,7 @@ export default function InstanceSettingsPage() {
|
||||
<Save className="size-4" />
|
||||
{saveMut.isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
{settingsQ.isLoading && (
|
||||
<div className="space-y-3">
|
||||
@@ -372,7 +377,11 @@ export default function InstanceSettingsPage() {
|
||||
<CardTitle>Access</CardTitle>
|
||||
<CardDescription>
|
||||
Who may create an account on this instance. The registration mode
|
||||
itself is owned by the environment and is listed on Configuration.
|
||||
itself is owned by the environment and is listed under{" "}
|
||||
<TabLink onClick={() => onSwitchTab?.("environment")}>
|
||||
Environment
|
||||
</TabLink>
|
||||
.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
@@ -404,7 +413,8 @@ export default function InstanceSettingsPage() {
|
||||
rolls; nothing is dropped, and replies to the mailbox's own
|
||||
outreach are never held. Changes apply the next time a mailbox is
|
||||
loaded onto a worker (within a few minutes). The fixed pacing
|
||||
numbers are listed under Limits.
|
||||
numbers are listed under{" "}
|
||||
<TabLink onClick={() => onSwitchTab?.("limits")}>Limits</TabLink>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-3 pt-0 md:grid-cols-2">
|
||||
@@ -615,3 +625,16 @@ export default function InstanceSettingsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// An inline link inside descriptive copy that switches tabs instead of leaving the page.
|
||||
function TabLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="font-medium text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Fleet capacity: every worker against its capacity-view row. There is no
|
||||
// realtime event for the rolling 1h counters, so this view polls at 30s.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { StateLegend } from "@/components/StateLegend";
|
||||
import { WORKER_HEALTH_LEGEND, WORKER_RISK_POOL_LEGEND } from "@/lib/legends";
|
||||
import { getFleetCapacity, type AdminFleetWorkerRow } from "@/lib/api/client/admin/fleet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HealthPill, LiveDot, RiskPoolPill, TierPill, TypePill } from "./tones";
|
||||
import { fmtAgo } from "./format";
|
||||
|
||||
function UtilizationBar({ row }: { row: AdminFleetWorkerRow }) {
|
||||
const u = row.utilization ?? 0;
|
||||
const pct = Math.max(0, Math.min(100, Math.round(u * 100)));
|
||||
const hot = u > 0.8;
|
||||
const cold = u < 0.5;
|
||||
return (
|
||||
<div className="min-w-[160px]">
|
||||
<div className="flex items-center justify-between text-[11px] tabular-nums">
|
||||
<span>
|
||||
{row.load_score.toFixed(2)}
|
||||
<span className="text-muted-foreground"> / {row.effective_capacity.toFixed(2)}</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
hot ? "text-red-600" : cold ? "text-muted-foreground" : "text-emerald-700",
|
||||
)}
|
||||
>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full",
|
||||
hot ? "bg-gradient-to-r from-amber-500 to-red-500" : cold ? "bg-zinc-300" : "bg-emerald-500",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pair({ a, b, tone }: { a: number; b: number; tone?: string }) {
|
||||
return (
|
||||
<span className="tabular-nums text-xs">
|
||||
<span className={tone}>{a.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground"> / {b.toLocaleString()}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Count({ n, warnAbove = 0 }: { n: number; warnAbove?: number }) {
|
||||
return (
|
||||
<span className={cn("tabular-nums text-xs", n > warnAbove ? "font-medium text-red-600" : "text-muted-foreground")}>
|
||||
{n.toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: Column<AdminFleetWorkerRow>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "Worker",
|
||||
sortable: true,
|
||||
cell: (w) => (
|
||||
<div>
|
||||
<Link
|
||||
to={`/workers/${w.worker_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
{w.name || w.worker_id.slice(0, 8)}
|
||||
</Link>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{w.ip_addr}</div>
|
||||
</div>
|
||||
),
|
||||
csv: (w) => w.name || w.worker_id,
|
||||
},
|
||||
{ id: "tier", header: "Tier", cell: (w) => <TierPill freeTier={w.free_tier} />, csv: (w) => (w.free_tier ? "free" : "premium") },
|
||||
{ id: "type", header: "Type", cell: (w) => <TypePill type={w.worker_type} />, csv: (w) => w.worker_type },
|
||||
{ id: "pool", header: "Risk pool", cell: (w) => <RiskPoolPill pool={w.risk_pool} />, csv: (w) => w.risk_pool },
|
||||
{
|
||||
id: "egress",
|
||||
header: "Egress",
|
||||
cell: (w) => <span className="font-mono text-[11px]">{w.egress_kind}</span>,
|
||||
csv: (w) => w.egress_kind,
|
||||
defaultHidden: true,
|
||||
},
|
||||
{ id: "health", header: "Health", cell: (w) => <HealthPill state={w.health_state} />, csv: (w) => w.health_state },
|
||||
{
|
||||
id: "live",
|
||||
header: "Live",
|
||||
cell: (w) => <LiveDot live={w.live} title={w.last_seen_at ? `last seen ${fmtAgo(w.last_seen_at)}` : "no heartbeat"} />,
|
||||
csv: (w) => (w.live ? "live" : "offline"),
|
||||
},
|
||||
{
|
||||
id: "accounts",
|
||||
header: "Accounts",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
cell: (w) => <span className="tabular-nums">{w.account_count}</span>,
|
||||
csv: (w) => w.account_count,
|
||||
},
|
||||
{
|
||||
id: "utilization",
|
||||
header: "Load / capacity",
|
||||
sortable: true,
|
||||
cell: (w) => <UtilizationBar row={w} />,
|
||||
csv: (w) => `${w.load_score.toFixed(2)}/${w.effective_capacity.toFixed(2)} (${Math.round(w.utilization * 100)}%)`,
|
||||
},
|
||||
{
|
||||
id: "sends",
|
||||
header: "Sends 1h",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
cell: (w) => <Pair a={w.sends_succeeded_1h} b={w.sends_attempted_1h} tone="text-foreground" />,
|
||||
csv: (w) => `${w.sends_succeeded_1h}/${w.sends_attempted_1h}`,
|
||||
},
|
||||
{
|
||||
id: "bounces",
|
||||
header: "Bounces 1h",
|
||||
align: "right",
|
||||
cell: (w) => <Pair a={w.bounces_hard_1h} b={w.bounces_soft_1h} tone={w.bounces_hard_1h > 0 ? "text-red-600 font-medium" : "text-foreground"} />,
|
||||
csv: (w) => `${w.bounces_hard_1h} hard / ${w.bounces_soft_1h} soft`,
|
||||
},
|
||||
{ id: "complaints", header: "Complaints 1h", align: "right", cell: (w) => <Count n={w.complaints_1h} />, csv: (w) => w.complaints_1h },
|
||||
{ id: "auth", header: "Auth errors 1h", align: "right", cell: (w) => <Count n={w.auth_errors_1h} />, csv: (w) => w.auth_errors_1h },
|
||||
{
|
||||
id: "tags",
|
||||
header: "Tags",
|
||||
cell: (w) =>
|
||||
w.tags && w.tags.length ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{w.tags.map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
csv: (w) => (w.tags || []).join(" "),
|
||||
defaultHidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
function compare(a: AdminFleetWorkerRow, b: AdminFleetWorkerRow, by: string): number {
|
||||
switch (by) {
|
||||
case "name":
|
||||
return (a.name || a.worker_id).localeCompare(b.name || b.worker_id);
|
||||
case "accounts":
|
||||
return a.account_count - b.account_count;
|
||||
case "utilization":
|
||||
return a.utilization - b.utilization;
|
||||
case "sends":
|
||||
return a.sends_attempted_1h - b.sends_attempted_1h;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function CapacityTab() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "fleet", "capacity"],
|
||||
queryFn: getFleetCapacity,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const [sort, setSort] = useState<{ by: string; desc: boolean }>({ by: "utilization", desc: true });
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = data?.data ?? [];
|
||||
return sort.by ? [...all].sort((a, b) => compare(a, b, sort.by) * (sort.desc ? -1 : 1)) : all;
|
||||
}, [data, sort]);
|
||||
|
||||
const hot = rows.filter((r) => r.utilization > 0.8).length;
|
||||
const cold = rows.filter((r) => r.utilization < 0.5).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-card px-3 py-2 text-[12.5px] text-muted-foreground">
|
||||
<span>
|
||||
The rebalancer drains a worker above <span className="font-medium text-foreground">80%</span> utilization
|
||||
onto peers below <span className="font-medium text-foreground">50%</span> in the same tier, and never
|
||||
across tiers. Counters are the last hour; this view refreshes every 30s.
|
||||
{rows.length > 0 && (
|
||||
<span className="ml-1.5 tabular-nums">
|
||||
({hot} hot, {cold} cold of {rows.length})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-3">
|
||||
<StateLegend label="Risk pools" entries={WORKER_RISK_POOL_LEGEND} />
|
||||
<StateLegend label="Health states" entries={WORKER_HEALTH_LEGEND} />
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(w) => w.worker_id}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
errorTitle="Failed to load fleet capacity"
|
||||
sort={sort.by ? sort : undefined}
|
||||
onSortChange={setSort}
|
||||
storageKey="admin.fleet.capacity"
|
||||
csvName="warmbly-fleet-capacity"
|
||||
noun="workers"
|
||||
emptyTitle="No workers"
|
||||
emptyHint="Capacity rows appear once a worker has registered and heartbeated. Add one under Workers."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Convert a shared worker into a dedicated one bound to a workspace. The
|
||||
// backend refuses a worker that still holds mailboxes unless a drain target
|
||||
// is named, so the dialog requires one whenever the chosen worker is loaded.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { convertWorkerToDedicated } from "@/lib/api/client/admin/fleet";
|
||||
import { listManagedWorkers } from "@/lib/api/client/admin/workers";
|
||||
import type { ManagedWorker } from "@/lib/api/models/admin";
|
||||
import { OrgPicker, type PickedOrg } from "./OrgPicker";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function workerLabel(w: ManagedWorker): string {
|
||||
return `${w.name || w.id.slice(0, 8)} · ${w.free_tier ? "free" : "premium"} · ${w.health_state} · ${w.account_count} mailbox${w.account_count === 1 ? "" : "es"}`;
|
||||
}
|
||||
|
||||
export function ConvertDedicatedDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [workerId, setWorkerId] = useState("");
|
||||
const [org, setOrg] = useState<PickedOrg | null>(null);
|
||||
const [subscriptionId, setSubscriptionId] = useState("");
|
||||
const [drainTo, setDrainTo] = useState("");
|
||||
|
||||
const workersQ = useQuery({
|
||||
queryKey: ["admin", "workers", "managed"],
|
||||
queryFn: listManagedWorkers,
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const workers = workersQ.data?.data ?? [];
|
||||
const shared = workers.filter((w) => w.worker_type === "shared");
|
||||
const worker = workers.find((w) => w.id === workerId) ?? null;
|
||||
const needsDrain = !!worker && worker.account_count > 0;
|
||||
// Mailboxes keep their tier when drained, so the target must match it.
|
||||
const drainTargets = workers.filter((w) => w.id !== workerId && (!worker || w.free_tier === worker.free_tier));
|
||||
|
||||
const subOk = UUID_RE.test(subscriptionId.trim());
|
||||
const canSubmit = !!workerId && !!org && subOk && (!needsDrain || !!drainTo);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
convertWorkerToDedicated(workerId, {
|
||||
organization_id: org!.id,
|
||||
subscription_id: subscriptionId.trim(),
|
||||
drain_to_worker_id: drainTo || null,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
toast.success(
|
||||
res.new_assignment
|
||||
? `Worker is now dedicated to ${org?.name}${res.accounts_drained ? ` (${res.accounts_drained} mailboxes drained)` : ""}`
|
||||
: "Binding already existed; worker type set to dedicated",
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ["admin", "workers"] });
|
||||
qc.invalidateQueries({ queryKey: ["admin", "fleet"] });
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Conversion failed"),
|
||||
});
|
||||
|
||||
function reset() {
|
||||
setWorkerId("");
|
||||
setOrg(null);
|
||||
setSubscriptionId("");
|
||||
setDrainTo("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && mutation.isPending) return;
|
||||
if (!v) reset();
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
onEscapeKeyDown={(e) => {
|
||||
// A picker popover owns Escape while it is open.
|
||||
if (document.querySelector("[data-floating]")) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Convert a worker to dedicated</DialogTitle>
|
||||
<DialogDescription>
|
||||
The worker leaves the shared pool and only this workspace's mailboxes are placed on it.
|
||||
Its existing mailboxes must be drained to another worker of the same tier first.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Shared worker</Label>
|
||||
<Select value={workerId || undefined} onValueChange={(v) => { setWorkerId(v); setDrainTo(""); }}>
|
||||
<SelectTrigger className="h-8 w-full text-[12.5px]">
|
||||
<SelectValue placeholder={workersQ.isLoading ? "Loading workers…" : "Pick a shared worker"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shared.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No shared workers.</div>
|
||||
)}
|
||||
{shared.map((w) => (
|
||||
<SelectItem key={w.id} value={w.id} className="text-[12.5px]">
|
||||
{workerLabel(w)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Workspace</Label>
|
||||
<OrgPicker value={org} onChange={setOrg} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sub-id" className="text-xs">
|
||||
Subscription id
|
||||
</Label>
|
||||
<Input
|
||||
id="sub-id"
|
||||
value={subscriptionId}
|
||||
onChange={(e) => setSubscriptionId(e.target.value)}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
className="h-8 font-mono text-[12px]"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
The workspace's subscription row (a UUID). The organization page shows only the plan and
|
||||
status, so read the id from the <code>subscriptions</code> table for this workspace, or from
|
||||
the Stripe subscription's metadata.
|
||||
{subscriptionId && !subOk && <span className="ml-1 text-red-600">Not a UUID.</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
Drain mailboxes to{" "}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{needsDrain ? `(required: ${worker!.account_count} assigned)` : "(optional)"}
|
||||
</span>
|
||||
</Label>
|
||||
<Select value={drainTo || undefined} onValueChange={setDrainTo} disabled={!workerId}>
|
||||
<SelectTrigger className="h-8 w-full text-[12.5px]">
|
||||
<SelectValue placeholder={needsDrain ? "Pick where the current mailboxes go" : "Leave as is"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{drainTargets.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No other worker in this tier.</div>
|
||||
)}
|
||||
{drainTargets.map((w) => (
|
||||
<SelectItem key={w.id} value={w.id} className="text-[12.5px]">
|
||||
{workerLabel(w)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => mutation.mutate()} disabled={!canSubmit || mutation.isPending}>
|
||||
{mutation.isPending ? "Converting…" : "Convert to dedicated"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Decision log: what the placement and rebalance loops did and why. Filtered
|
||||
// server-side by kind and worker; before/after JSON expands inline. No event
|
||||
// fires when a loop writes a decision, so the list polls at 30s.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { SelectFilter } from "@/components/data/Explorer";
|
||||
import { listFleetDecisions, type AdminFleetDecision } from "@/lib/api/client/admin/fleet";
|
||||
import { listManagedWorkers } from "@/lib/api/client/admin/workers";
|
||||
import { fmtAgo, fmtDateTime, shortId } from "./format";
|
||||
|
||||
const LIMIT = 200;
|
||||
|
||||
function pretty(v: unknown): string {
|
||||
if (v == null) return "";
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
function DecisionRow({ d }: { d: AdminFleetDecision }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasDiff = d.before != null || d.after != null;
|
||||
return (
|
||||
<>
|
||||
<tr className="border-t border-border">
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap" title={fmtDateTime(d.created_at)}>
|
||||
{fmtAgo(d.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{d.kind}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{d.worker_id ? (
|
||||
<Link to={`/workers/${d.worker_id}`} className="font-medium text-[var(--admin-accent-strong)] hover:underline">
|
||||
{d.worker_name || shortId(d.worker_id)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-muted-foreground">{shortId(d.mailbox_id)}</td>
|
||||
<td className="px-3 py-2 text-xs max-w-md">{d.reason || <span className="text-muted-foreground">—</span>}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{d.triggered_by || "—"}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{hasDiff && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
{open ? "Hide" : "Diff"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{open && hasDiff && (
|
||||
<tr className="border-t border-border/60 bg-muted/30">
|
||||
<td colSpan={7} className="px-3 py-2">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Before</div>
|
||||
<pre className="max-h-64 overflow-auto rounded border border-border bg-card p-2 font-mono text-[11px] leading-relaxed">
|
||||
{pretty(d.before) || "(none)"}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">After</div>
|
||||
<pre className="max-h-64 overflow-auto rounded border border-border bg-card p-2 font-mono text-[11px] leading-relaxed">
|
||||
{pretty(d.after) || "(none)"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DecisionsTab() {
|
||||
const [kind, setKind] = useState("");
|
||||
const [workerId, setWorkerId] = useState("");
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "fleet", "decisions", kind, workerId],
|
||||
queryFn: () => listFleetDecisions({ kind: kind || undefined, worker_id: workerId || undefined, limit: LIMIT }),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// The kind facet comes from the unfiltered log so an option never
|
||||
// disappears when it is selected; same key as the base query, so it is
|
||||
// one request when no filter is active.
|
||||
const kindsQ = useQuery({
|
||||
queryKey: ["admin", "fleet", "decisions", "", ""],
|
||||
queryFn: () => listFleetDecisions({ limit: LIMIT }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const kinds = Array.from(new Set((kindsQ.data?.data ?? []).map((d) => d.kind))).sort();
|
||||
|
||||
const workersQ = useQuery({
|
||||
queryKey: ["admin", "workers", "managed"],
|
||||
queryFn: listManagedWorkers,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const workers = workersQ.data?.data ?? [];
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-end gap-3">
|
||||
<div className="w-52">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Kind</div>
|
||||
<SelectFilter
|
||||
value={kind || "any"}
|
||||
onChange={(v) => setKind(v === "any" ? "" : v)}
|
||||
options={[{ value: "any", label: "Any kind" }, ...kinds.map((k) => ({ value: k, label: k }))]}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-64">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Worker</div>
|
||||
<SelectFilter
|
||||
value={workerId || "any"}
|
||||
onChange={(v) => setWorkerId(v === "any" ? "" : v)}
|
||||
options={[
|
||||
{ value: "any", label: "Any worker" },
|
||||
...workers.map((w) => ({ value: w.id, label: w.name || w.id.slice(0, 8) })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="pb-2 text-[12.5px] text-muted-foreground">
|
||||
{isLoading ? "Loading…" : `${rows.length} decision${rows.length === 1 ? "" : "s"}`}
|
||||
{rows.length >= LIMIT && " (newest " + LIMIT + ")"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<ErrorState error={error} title="Failed to load the decision log" onRetry={() => refetch()} />
|
||||
) : isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
No decisions recorded{kind || workerId ? " for these filters" : ""}. Rows appear when the assignment
|
||||
loop places a mailbox, the rebalancer moves one, or a worker's health changes.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">When</th>
|
||||
<th className="px-3 py-2 text-left">Kind</th>
|
||||
<th className="px-3 py-2 text-left">Worker</th>
|
||||
<th className="px-3 py-2 text-left">Mailbox</th>
|
||||
<th className="px-3 py-2 text-left">Reason</th>
|
||||
<th className="px-3 py-2 text-left">Triggered by</th>
|
||||
<th className="px-3 py-2 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((d) => (
|
||||
<DecisionRow key={d.id} d={d} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Dedicated bindings: which worker is reserved for which workspace. Keyed
|
||||
// under ["admin","workers"] so the realtime workers spine refreshes it; no poll.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Unlink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import {
|
||||
listDedicatedAssignments,
|
||||
releaseDedicatedWorker,
|
||||
type AdminDedicatedAssignment,
|
||||
} from "@/lib/api/client/admin/fleet";
|
||||
import { LiveDot } from "./tones";
|
||||
import { fmtDate, shortId } from "./format";
|
||||
import { ConvertDedicatedDialog } from "./ConvertDedicatedDialog";
|
||||
|
||||
export function DedicatedTab() {
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [convertOpen, setConvertOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "workers", "dedicated"],
|
||||
queryFn: listDedicatedAssignments,
|
||||
});
|
||||
|
||||
const release = useMutation({
|
||||
mutationFn: (orgId: string) => releaseDedicatedWorker(orgId),
|
||||
onSuccess: (res) => {
|
||||
toast.success(
|
||||
`${res.accounts_moved} mailbox${res.accounts_moved === 1 ? "" : "es"} moved to shared workers` +
|
||||
(res.returned_to_shared ? "; worker returned to the shared pool" : "; worker still bound to another workspace"),
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ["admin", "workers"] });
|
||||
qc.invalidateQueries({ queryKey: ["admin", "fleet"] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Release failed"),
|
||||
});
|
||||
|
||||
async function onRelease(a: AdminDedicatedAssignment) {
|
||||
const ok = await confirm({
|
||||
title: `Release ${a.worker_name || shortId(a.worker_id)} from ${a.organization_name}?`,
|
||||
description: `The workspace's ${a.account_count} mailbox${a.account_count === 1 ? "" : "es"} move back onto shared premium workers and the worker returns to the shared pool once no other workspace binds it. A mailbox with no live shared target stays where it is.`,
|
||||
confirmLabel: "Release",
|
||||
destructive: true,
|
||||
});
|
||||
if (ok) release.mutate(a.organization_id);
|
||||
}
|
||||
|
||||
const columns: Column<AdminDedicatedAssignment>[] = [
|
||||
{
|
||||
id: "worker",
|
||||
header: "Worker",
|
||||
cell: (a) => (
|
||||
<div>
|
||||
<Link to={`/workers/${a.worker_id}`} className="font-medium text-[var(--admin-accent-strong)] hover:underline">
|
||||
{a.worker_name || shortId(a.worker_id)}
|
||||
</Link>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{a.worker_id}</div>
|
||||
</div>
|
||||
),
|
||||
csv: (a) => a.worker_name || a.worker_id,
|
||||
},
|
||||
{ id: "live", header: "Live", cell: (a) => <LiveDot live={a.worker_live} />, csv: (a) => (a.worker_live ? "live" : "offline") },
|
||||
{
|
||||
id: "org",
|
||||
header: "Workspace",
|
||||
cell: (a) => (
|
||||
<Link to={`/organizations/${a.organization_id}`} className="font-medium text-[var(--admin-accent-strong)] hover:underline">
|
||||
{a.organization_name || shortId(a.organization_id)}
|
||||
</Link>
|
||||
),
|
||||
csv: (a) => a.organization_name,
|
||||
},
|
||||
{
|
||||
id: "sub",
|
||||
header: "Subscription",
|
||||
cell: (a) => (
|
||||
<span className="font-mono text-[11px] text-muted-foreground" title={a.subscription_id}>
|
||||
{shortId(a.subscription_id)}
|
||||
</span>
|
||||
),
|
||||
csv: (a) => a.subscription_id,
|
||||
},
|
||||
{ id: "assigned", header: "Assigned", cell: (a) => <span className="text-xs text-muted-foreground">{fmtDate(a.assigned_at)}</span>, csv: (a) => a.assigned_at },
|
||||
{ id: "accounts", header: "Accounts", align: "right", cell: (a) => <span className="tabular-nums">{a.account_count}</span>, csv: (a) => a.account_count },
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (a) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onRelease(a);
|
||||
}}
|
||||
disabled={release.isPending}
|
||||
>
|
||||
<Unlink className="size-3" />
|
||||
Release
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[12.5px] text-muted-foreground max-w-2xl">
|
||||
A dedicated worker carries one workspace's mailboxes and nothing else, so its IP reputation is that
|
||||
workspace's alone. Placement still respects the mailbox tier and the warmup pool policy.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setConvertOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Convert a worker to dedicated
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={data?.data ?? []}
|
||||
getRowId={(a) => a.id}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
errorTitle="Failed to load dedicated assignments"
|
||||
storageKey="admin.fleet.dedicated"
|
||||
csvName="warmbly-dedicated-workers"
|
||||
noun="assignments"
|
||||
emptyTitle="No dedicated workers"
|
||||
emptyHint="Every worker is shared. Convert one above to reserve it for a single workspace."
|
||||
/>
|
||||
<ConvertDedicatedDialog open={convertOpen} onOpenChange={setConvertOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Organization search-and-pick, used by the convert-to-dedicated and export
|
||||
// dialogs. Searches /admin/organizations?q= with a short page.
|
||||
|
||||
import { SearchPicker } from "./SearchPicker";
|
||||
import { listOrganizations } from "@/lib/api/client/admin/organizations";
|
||||
import type { AdminOrgListItem } from "@/lib/api/models/admin";
|
||||
|
||||
export type PickedOrg = Pick<AdminOrgListItem, "id" | "name" | "owner_email">;
|
||||
|
||||
export function OrgPicker({
|
||||
value,
|
||||
onChange,
|
||||
autoFocus,
|
||||
disabled,
|
||||
}: {
|
||||
value: PickedOrg | null;
|
||||
onChange: (v: PickedOrg | null) => void;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SearchPicker<PickedOrg>
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
queryKey={["admin", "organizations", "picker"]}
|
||||
search={async (q) => (await listOrganizations({ q, limit: 8 })).data ?? []}
|
||||
getKey={(o) => o.id}
|
||||
placeholder="Search workspaces by name or owner email…"
|
||||
emptyText="No workspace matches."
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
renderItem={(o) => (
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{o.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{o.owner_email}</div>
|
||||
</div>
|
||||
)}
|
||||
renderSelected={(o) => (
|
||||
<div className="truncate">
|
||||
<span className="font-medium">{o.name}</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">{o.owner_email}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Debounced type-to-search picker for records too numerous for a Select
|
||||
// (organizations, users). Closes on click-away (capture phase, so a dialog's
|
||||
// mousedown stopPropagation cannot swallow it) and on Escape, which it stops
|
||||
// from reaching the surrounding dialog so only the innermost layer closes.
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SearchPickerProps<T> {
|
||||
value: T | null;
|
||||
onChange: (v: T | null) => void;
|
||||
search: (q: string) => Promise<T[]>;
|
||||
/** Query key prefix; the debounced term is appended. */
|
||||
queryKey: string[];
|
||||
getKey: (t: T) => string;
|
||||
renderItem: (t: T) => React.ReactNode;
|
||||
renderSelected: (t: T) => React.ReactNode;
|
||||
placeholder?: string;
|
||||
minChars?: number;
|
||||
emptyText?: string;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SearchPicker<T>({
|
||||
value,
|
||||
onChange,
|
||||
search,
|
||||
queryKey,
|
||||
getKey,
|
||||
renderItem,
|
||||
renderSelected,
|
||||
placeholder = "Search…",
|
||||
minChars = 2,
|
||||
emptyText = "No matches.",
|
||||
autoFocus,
|
||||
disabled,
|
||||
}: SearchPickerProps<T>) {
|
||||
const [term, setTerm] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
const listId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(term.trim()), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [term]);
|
||||
|
||||
const enabled = debounced.length >= minChars;
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: [...queryKey, debounced],
|
||||
queryFn: () => search(debounced),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const items = enabled ? (data ?? []) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
return () => document.removeEventListener("mousedown", onDown, true);
|
||||
}, [open]);
|
||||
|
||||
function pick(t: T) {
|
||||
onChange(t);
|
||||
setTerm("");
|
||||
setDebounced("");
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Escape") {
|
||||
if (!open) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!open || items.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.min(items.length - 1, h + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.max(0, h - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const t = items[highlight];
|
||||
if (t) pick(t);
|
||||
}
|
||||
}
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-card px-2.5 py-1.5 text-[12.5px]">
|
||||
<div className="min-w-0 flex-1">{renderSelected(value)}</div>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(null)}
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={term}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
onChange={(e) => {
|
||||
setTerm(e.target.value);
|
||||
setHighlight(0);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="h-8 pl-8 pr-8 text-[12.5px]"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={open && items[highlight] ? `${listId}-${highlight}` : undefined}
|
||||
/>
|
||||
{isFetching && (
|
||||
<Loader2 className="absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{open && term.trim().length > 0 && (
|
||||
<div
|
||||
data-floating
|
||||
id={listId}
|
||||
role="listbox"
|
||||
className="absolute left-0 right-0 z-40 mt-1 max-h-64 overflow-auto rounded-md border border-border bg-popover p-1 shadow-md"
|
||||
>
|
||||
{!enabled ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
Type at least {minChars} characters.
|
||||
</div>
|
||||
) : items.length === 0 && !isFetching ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">{emptyText}</div>
|
||||
) : (
|
||||
items.map((t, i) => (
|
||||
<button
|
||||
key={getKey(t)}
|
||||
id={`${listId}-${i}`}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
type="button"
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
onClick={() => pick(t)}
|
||||
className={cn(
|
||||
"block w-full rounded px-2 py-1.5 text-left text-[12.5px]",
|
||||
i === highlight ? "bg-muted" : "hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{renderItem(t)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Small formatting helpers shared by the fleet, transfers and admins pages.
|
||||
|
||||
export function shortId(id: string | null | undefined): string {
|
||||
return id ? id.slice(0, 8) : "—";
|
||||
}
|
||||
|
||||
export function fmtDateTime(iso: string | null | undefined): string {
|
||||
return iso ? new Date(iso).toLocaleString() : "—";
|
||||
}
|
||||
|
||||
export function fmtDate(iso: string | null | undefined): string {
|
||||
return iso ? new Date(iso).toLocaleDateString() : "—";
|
||||
}
|
||||
|
||||
// "3m ago" style relative time for feeds; falls back to the date past a week.
|
||||
export function fmtAgo(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const s = Math.round(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 48) return `${h}h ago`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 8) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function userName(u: { first_name?: string; last_name?: string; email: string }): string {
|
||||
return `${u.first_name ?? ""} ${u.last_name ?? ""}`.trim() || u.email;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Badge tones shared by the fleet tabs, derived from the legends so the
|
||||
// pills here read the same as everywhere else.
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { WORKER_HEALTH_LEGEND, WORKER_RISK_POOL_LEGEND } from "@/lib/legends";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HEALTH_TONE = Object.fromEntries(WORKER_HEALTH_LEGEND.map((e) => [e.term, e.tone ?? ""]));
|
||||
const RISK_TONE = Object.fromEntries(WORKER_RISK_POOL_LEGEND.map((e) => [e.term, e.tone ?? ""]));
|
||||
const FALLBACK = "border-zinc-300 text-zinc-600";
|
||||
|
||||
export function HealthPill({ state }: { state: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn("text-[10px]", HEALTH_TONE[state] ?? FALLBACK)}>
|
||||
{state || "unknown"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function RiskPoolPill({ pool }: { pool: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn("text-[10px]", RISK_TONE[pool] ?? FALLBACK)}>
|
||||
{pool || "—"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function TierPill({ freeTier }: { freeTier: boolean }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
freeTier ? "border-zinc-300 text-zinc-700" : "border-purple-300 bg-purple-50 text-purple-700",
|
||||
)}
|
||||
>
|
||||
{freeTier ? "free" : "premium"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function TypePill({ type }: { type: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
type === "dedicated" ? "border-sky-300 bg-sky-50 text-sky-700" : "border-zinc-300 text-zinc-700",
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveDot({ live, title }: { live: boolean; title?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs" title={title}>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
live ? "bg-emerald-500" : "bg-zinc-300",
|
||||
)}
|
||||
/>
|
||||
<span className={live ? "text-emerald-700" : "text-muted-foreground"}>
|
||||
{live ? "live" : "offline"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Setup and health, findings: everything this deployment is currently
|
||||
// getting wrong, as decided by the running backend. The endpoint returns
|
||||
// only checks that are not ok, so an empty response is a real all-clear.
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpCircle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { InstanceFindings } from "../InstanceHealthPanel";
|
||||
import { UpdateDialog } from "@/components/layout/UpdateDialog";
|
||||
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
|
||||
import { buildLabel, isUpdating, useUpdateState } from "@/hooks/useUpdateState";
|
||||
import type { InstanceHealthSummary } from "@/lib/api/client/admin/instance";
|
||||
|
||||
export function FindingsTab() {
|
||||
const healthQ = useInstanceHealth();
|
||||
|
||||
const checks = healthQ.data?.checks ?? [];
|
||||
const summary = healthQ.data?.summary;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Checks the backend runs against this instance: secrets, addresses, platform
|
||||
mail, accounts, workers and storage. Only findings that need a decision are
|
||||
listed.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{healthQ.dataUpdatedAt > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last checked {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => healthQ.refetch()}
|
||||
disabled={healthQ.isFetching}
|
||||
>
|
||||
<RefreshCw className={`size-4 ${healthQ.isFetching ? "animate-spin" : ""}`} />
|
||||
{healthQ.isFetching ? "Checking..." : "Run checks"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UpdateCard />
|
||||
|
||||
{healthQ.isLoading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthQ.isError && (
|
||||
<ErrorState
|
||||
error={healthQ.error}
|
||||
title="Could not run the instance checks"
|
||||
onRetry={() => healthQ.refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{healthQ.data && (
|
||||
<>
|
||||
{checks.length === 0 ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-emerald-800">
|
||||
No problems found
|
||||
</div>
|
||||
<p className="mt-0.5 text-[13px] leading-relaxed text-emerald-700">
|
||||
Every setup and health check passed. This tab lists only the
|
||||
checks that need attention, so it stays empty while the
|
||||
instance is configured correctly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SummaryStrip summary={summary} total={checks.length} />
|
||||
<InstanceFindings checks={checks} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Version and update status, above the findings: the same facts as the pill
|
||||
// in the top bar, on the page an operator opens to ask "is this instance ok".
|
||||
function UpdateCard() {
|
||||
const updateQ = useUpdateState();
|
||||
// ?update=1 is how the dashboard's version pill deep-links an admin
|
||||
// straight into the dialog.
|
||||
const [params] = useSearchParams();
|
||||
const [open, setOpen] = useState(params.get("update") === "1");
|
||||
const state = updateQ.data;
|
||||
if (!state) return null;
|
||||
|
||||
const updating = isUpdating(state);
|
||||
const available = state.update_available;
|
||||
const tone = updating
|
||||
? "border-sky-200 bg-sky-50/60"
|
||||
: available
|
||||
? "border-amber-200 bg-amber-50/60"
|
||||
: "border-border bg-white";
|
||||
|
||||
return (
|
||||
<div className={`mb-5 flex flex-wrap items-center gap-3 rounded-lg border p-3 ${tone}`}>
|
||||
{updating ? (
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-sky-600" />
|
||||
) : available ? (
|
||||
<ArrowUpCircle className="size-4 shrink-0 text-amber-600" />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 text-[13px]">
|
||||
<span className="font-semibold text-foreground">
|
||||
{updating
|
||||
? "Updating this instance"
|
||||
: available
|
||||
? `${state.latest?.tag && state.reason === "release" ? state.latest.tag : "A newer version"} is available`
|
||||
: "Up to date"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
running {buildLabel(state)}
|
||||
{state.updater.checkout && !state.updater.checkout.detached
|
||||
? ` on ${state.updater.checkout.branch}`
|
||||
: ""}
|
||||
{state.checked_at
|
||||
? `, checked ${new Date(state.checked_at).toLocaleTimeString()}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant={available ? "default" : "outline"} onClick={() => setOpen(true)}>
|
||||
{updating ? "Progress" : available ? "Update" : "Details"}
|
||||
</Button>
|
||||
<UpdateDialog open={open} onOpenChange={setOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryStrip({
|
||||
summary,
|
||||
total,
|
||||
}: {
|
||||
summary: InstanceHealthSummary | undefined;
|
||||
total: number;
|
||||
}) {
|
||||
const errors = summary?.error ?? 0;
|
||||
const warnings = summary?.warning ?? 0;
|
||||
const info = summary?.info ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<SummaryCard
|
||||
icon={XCircle}
|
||||
label="Errors"
|
||||
value={errors}
|
||||
sub="Something is broken right now"
|
||||
tone={errors > 0 ? "border-red-200 bg-red-50/60" : ""}
|
||||
iconClass={errors > 0 ? "text-red-600" : "text-muted-foreground"}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={AlertTriangle}
|
||||
label="Warnings"
|
||||
value={warnings}
|
||||
sub="Works, but not the way you want"
|
||||
tone={warnings > 0 ? "border-amber-200 bg-amber-50/60" : ""}
|
||||
iconClass={warnings > 0 ? "text-amber-600" : "text-muted-foreground"}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Info}
|
||||
label="Worth knowing"
|
||||
value={info}
|
||||
sub={`${total} findings in total`}
|
||||
tone={info > 0 ? "border-sky-200 bg-sky-50/50" : ""}
|
||||
iconClass={info > 0 ? "text-sky-600" : "text-muted-foreground"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
tone,
|
||||
iconClass,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: number;
|
||||
sub: string;
|
||||
tone: string;
|
||||
iconClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-white p-3", tone)}>
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<Icon className={`size-3.5 ${iconClass}`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1.5 text-2xl font-semibold tabular-nums">{value}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+43
-47
@@ -1,26 +1,18 @@
|
||||
// System Status — live health probes against the platform's backing
|
||||
// services. The backend runs the probes on each refresh, so every fetch
|
||||
// is a real round-trip to postgres/redis/kafka/etc, not a cached view.
|
||||
// Setup and health, services: live probes against the platform's backing
|
||||
// services. The backend runs them on each request, so every fetch is a real
|
||||
// round-trip to postgres, redis, the event bus and friends, not a cached view.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, RefreshCw, XCircle } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MailStatusCard } from "./MailStatusCard";
|
||||
import {
|
||||
getSystemStatus,
|
||||
type SystemComponentStatus,
|
||||
} from "@/lib/api/client/admin/system";
|
||||
import { MailStatusCard } from "../MailStatusCard";
|
||||
import { getSystemStatus, type SystemComponentStatus } from "@/lib/api/client/admin/system";
|
||||
|
||||
export const SYSTEM_STATUS_KEY = ["admin", "system", "status"] as const;
|
||||
|
||||
// What breaks when each known component is down. Unknown names fall back
|
||||
// to a generic line so new probes render without a frontend change.
|
||||
@@ -28,6 +20,7 @@ const COMPONENT_BLURBS: Record<string, string> = {
|
||||
postgres: "Primary datastore. Everything depends on it.",
|
||||
redis: "Caching, rate limits, and the realtime event bridge in dev.",
|
||||
kafka: "Worker command and result transport. Nothing sends without it.",
|
||||
nats: "Worker command and result transport. Nothing sends without it.",
|
||||
"schema-registry": "Tracking-event encoding. Open and click events cannot serialize without it.",
|
||||
realtime: "Live dashboard updates (websockets).",
|
||||
tracking: "Open and click tracking ingestion.",
|
||||
@@ -35,16 +28,18 @@ const COMPONENT_BLURBS: Record<string, string> = {
|
||||
|
||||
const GENERIC_BLURB = "Backing service probed by the backend health check.";
|
||||
|
||||
// "schema-registry" → "Schema registry".
|
||||
// "schema-registry" -> "Schema registry".
|
||||
function titleCase(name: string): string {
|
||||
const spaced = name.replace(/[-_]+/g, " ").trim();
|
||||
if (!spaced) return name;
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
export default function SystemStatusPage() {
|
||||
export function ServicesTab() {
|
||||
// Probes have no realtime event, so this is a deliberate poll; the query
|
||||
// only lives while the tab is mounted.
|
||||
const statusQ = useQuery({
|
||||
queryKey: ["admin", "system", "status"],
|
||||
queryKey: SYSTEM_STATUS_KEY,
|
||||
queryFn: getSystemStatus,
|
||||
refetchInterval: 15_000,
|
||||
retry: false,
|
||||
@@ -55,32 +50,33 @@ export default function SystemStatusPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="System Status"
|
||||
description="Live health probes against the platform's backing services, run by the backend on each refresh."
|
||||
>
|
||||
{statusQ.data && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last checked {new Date(statusQ.data.checked_at).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => statusQ.refetch()}
|
||||
disabled={statusQ.isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 ${statusQ.isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{statusQ.isFetching ? "Checking..." : "Run checks"}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Live health probes against the platform's backing services and its own
|
||||
mail transport, run by the backend on each refresh.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusQ.data && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last checked {new Date(statusQ.data.checked_at).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => statusQ.refetch()}
|
||||
disabled={statusQ.isFetching}
|
||||
>
|
||||
<RefreshCw className={`size-4 ${statusQ.isFetching ? "animate-spin" : ""}`} />
|
||||
{statusQ.isFetching ? "Checking..." : "Run checks"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusQ.isLoading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
@@ -100,12 +96,12 @@ export default function SystemStatusPage() {
|
||||
{statusQ.data && (
|
||||
<>
|
||||
{failing.length === 0 ? (
|
||||
<div className="mb-4 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 flex items-center gap-2">
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span className="font-medium">All systems operational</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 flex items-center gap-2">
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<XCircle className="size-4 shrink-0" />
|
||||
<span>
|
||||
<span className="font-medium">
|
||||
@@ -119,12 +115,12 @@ export default function SystemStatusPage() {
|
||||
)}
|
||||
|
||||
{components.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="mb-4 text-sm text-muted-foreground">
|
||||
The status endpoint returned no components.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<MailStatusCard />
|
||||
{components.map((c) => (
|
||||
<ComponentCard key={c.name} component={c} />
|
||||
@@ -154,7 +150,7 @@ function ComponentCard({ component: c }: { component: SystemComponentStatus }) {
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<CardContent className="space-y-2 pt-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{COMPONENT_BLURBS[c.name] ?? GENERIC_BLURB}
|
||||
</p>
|
||||
@@ -163,7 +159,7 @@ function ComponentCard({ component: c }: { component: SystemComponentStatus }) {
|
||||
<span className="tabular-nums text-foreground">{c.latency_ms} ms</span>
|
||||
</div>
|
||||
{c.error && (
|
||||
<div className="rounded-md bg-red-100/60 p-2 font-mono text-[11px] text-red-800 break-words">
|
||||
<div className="break-words rounded-md bg-red-100/60 p-2 font-mono text-[11px] text-red-800">
|
||||
{c.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Long free text in a table cell (an error message, a failure reason):
|
||||
// truncated with the full text as a tooltip, and a toggle to show it all.
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ExpandableText({
|
||||
text,
|
||||
max = 90,
|
||||
className,
|
||||
mono,
|
||||
}: {
|
||||
text: string;
|
||||
max?: number;
|
||||
className?: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!text) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const long = text.length > max;
|
||||
const shown = open || !long ? text : `${text.slice(0, max).trimEnd()}…`;
|
||||
return (
|
||||
<span className={cn("text-xs", mono && "font-mono text-[11px]", className)}>
|
||||
<span className={open ? "whitespace-pre-wrap break-words" : "break-words"} title={long && !open ? text : undefined}>
|
||||
{shown}
|
||||
</span>
|
||||
{long && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="ml-1 text-[11px] text-[var(--admin-accent-strong)] hover:underline"
|
||||
>
|
||||
{open ? "less" : "more"}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Time formatting shared by the operations pages (Sync, Sends, Jobs): every
|
||||
// timestamp there reads as an age or a countdown, never as a raw date.
|
||||
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
|
||||
export function relative(ts: string | null | undefined, empty = "never"): string {
|
||||
if (!ts) return empty;
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return formatDistanceToNowStrict(d, { addSuffix: true });
|
||||
}
|
||||
|
||||
export function absolute(ts: string | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
return Number.isNaN(d.getTime()) ? "" : d.toLocaleString();
|
||||
}
|
||||
|
||||
export function humanSeconds(seconds: number): string {
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
if (total < 60) return `${total}s`;
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
if (m < 60) return s ? `${m}m ${s}s` : `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
if (h < 24) return rm ? `${h}h ${rm}m` : `${h}h`;
|
||||
const d = Math.floor(h / 24);
|
||||
const rh = h % 24;
|
||||
return rh ? `${d}d ${rh}h` : `${d}d`;
|
||||
}
|
||||
|
||||
export function humanDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return humanSeconds(ms / 1000);
|
||||
}
|
||||
|
||||
export function humanInterval(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return "on demand";
|
||||
return `every ${humanSeconds(seconds)}`;
|
||||
}
|
||||
|
||||
export function shortId(id: string | null | undefined): string {
|
||||
return id ? id.slice(0, 8) : "";
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Task dead letters: tasks that exhausted their retries, with a replay per
|
||||
// pending row. Nothing publishes an event when one is written, so it polls
|
||||
// at 60s.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import { useCursorPager } from "@/lib/useCursorPager";
|
||||
import {
|
||||
listDeadLetters,
|
||||
replayDeadLetter,
|
||||
type AdminDeadLetterRow,
|
||||
type AdminDeadLetterStatus,
|
||||
} from "@/lib/api/client/admin/sends";
|
||||
import { StatusSegments } from "@/app/dashboard/sends/StatusSegments";
|
||||
import { ExpandableText } from "@/app/dashboard/jobs/ExpandableText";
|
||||
import { absolute, relative, shortId } from "@/app/dashboard/jobs/format";
|
||||
|
||||
type StatusFilter = AdminDeadLetterStatus | "all";
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
pending: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
replayed: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
failed: "border-red-300 bg-red-50 text-red-700",
|
||||
};
|
||||
|
||||
export function DeadLettersTab() {
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const pager = useCursorPager();
|
||||
const { reset } = pager;
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [status, reset]);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "sends", "dead-letters", status, pager.cursor],
|
||||
queryFn: () => listDeadLetters({ status, cursor: pager.cursor, limit: 50 }),
|
||||
refetchInterval: 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const replay = useMutation({
|
||||
mutationFn: (row: AdminDeadLetterRow) => replayDeadLetter(row.id),
|
||||
onSuccess: () => {
|
||||
toast.success("Dead letter replayed; the task is back on the queue");
|
||||
qc.invalidateQueries({ queryKey: ["admin", "sends", "dead-letters"] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message || "Failed to replay"),
|
||||
});
|
||||
|
||||
async function onReplay(row: AdminDeadLetterRow) {
|
||||
const ok = await confirm({
|
||||
title: "Replay this dead letter?",
|
||||
description: `Re-dispatches the ${row.task_type} task${row.organization_name ? ` for ${row.organization_name}` : ""}. If it is a send, this puts mail on the wire again, so make sure the failure it died on is fixed first. Last error: ${row.last_error || "none recorded"}`,
|
||||
confirmLabel: "Replay",
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
replay.mutate(row);
|
||||
}
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const counts = {
|
||||
pending: data?.pending ?? 0,
|
||||
replayed: data?.replayed ?? 0,
|
||||
failed: data?.failed ?? 0,
|
||||
};
|
||||
|
||||
const columns: Column<AdminDeadLetterRow>[] = [
|
||||
{
|
||||
id: "type",
|
||||
header: "Task",
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<div className="font-mono text-xs">{r.task_type}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{shortId(r.task_id)}</div>
|
||||
</div>
|
||||
),
|
||||
csv: (r) => r.task_type,
|
||||
},
|
||||
{
|
||||
id: "workspace",
|
||||
header: "Workspace",
|
||||
cell: (r) =>
|
||||
r.organization_id ? (
|
||||
<Link to={`/organizations/${r.organization_id}`} className="text-xs text-[var(--admin-accent-strong)] hover:underline">
|
||||
{r.organization_name || r.organization_id}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
csv: (r) => r.organization_name || "",
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Last error",
|
||||
className: "max-w-md",
|
||||
cell: (r) => <ExpandableText text={r.last_error} mono />,
|
||||
csv: (r) => r.last_error,
|
||||
},
|
||||
{
|
||||
id: "attempts",
|
||||
header: "Attempts",
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<span className="text-xs tabular-nums">
|
||||
{r.attempts} / {r.max_attempts}
|
||||
</span>
|
||||
),
|
||||
csv: (r) => `${r.attempts}/${r.max_attempts}`,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: (r) => (
|
||||
<Badge variant="outline" className={`text-[10px] ${STATUS_TONE[r.status] ?? "border-zinc-300 text-zinc-600"}`}>
|
||||
{r.status}
|
||||
</Badge>
|
||||
),
|
||||
csv: (r) => r.status,
|
||||
},
|
||||
{
|
||||
id: "next_retry",
|
||||
header: "Next retry",
|
||||
cell: (r) => (
|
||||
<span className="text-xs text-muted-foreground" title={absolute(r.next_retry_at)}>
|
||||
{relative(r.next_retry_at, "—")}
|
||||
</span>
|
||||
),
|
||||
csv: (r) => r.next_retry_at || "",
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
cell: (r) => (
|
||||
<span className="text-xs text-muted-foreground" title={absolute(r.created_at)}>
|
||||
{relative(r.created_at)}
|
||||
</span>
|
||||
),
|
||||
csv: (r) => r.created_at,
|
||||
},
|
||||
{
|
||||
id: "replayed_at",
|
||||
header: "Replayed",
|
||||
defaultHidden: true,
|
||||
cell: (r) => <span className="text-xs text-muted-foreground">{relative(r.replayed_at, "—")}</span>,
|
||||
csv: (r) => r.replayed_at || "",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (r) =>
|
||||
r.status === "pending" ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={replay.isPending && replay.variables?.id === r.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onReplay(r);
|
||||
}}
|
||||
title="Re-dispatch this task"
|
||||
>
|
||||
<RotateCcw className="size-3" /> Replay
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Tasks that exhausted their retries. A pending row can be replayed once the cause is fixed; replayed and failed rows are kept for the record.
|
||||
</p>
|
||||
<StatusSegments
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={[
|
||||
{ value: "all", label: "All", count: counts.pending + counts.replayed + counts.failed },
|
||||
{ value: "pending", label: "Pending", count: counts.pending },
|
||||
{ value: "replayed", label: "Replayed", count: counts.replayed },
|
||||
{ value: "failed", label: "Failed", count: counts.failed },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(r) => r.id}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
errorTitle="Failed to load dead letters"
|
||||
storageKey="admin.sends.dead-letters"
|
||||
csvName="warmbly-dead-letters"
|
||||
noun="dead letters"
|
||||
emptyTitle="No dead letters"
|
||||
emptyHint={
|
||||
status === "all"
|
||||
? "No task has exhausted its retries on this instance. Rows appear when the task runner gives up on one."
|
||||
: `No ${status} dead letters.`
|
||||
}
|
||||
pager={{
|
||||
canPrev: pager.canPrev,
|
||||
canNext: !!data?.pagination?.has_more,
|
||||
onPrev: pager.prev,
|
||||
onNext: () => pager.next(data?.pagination?.next_cursor),
|
||||
page: pager.page,
|
||||
shown: rows.length,
|
||||
total: data?.pagination?.total ?? null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Recent task failures: what a task reported when it stopped, with the
|
||||
// mailbox and workspace it belongs to. Polls at 60s; there is no event.
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { listTaskFailures, type AdminTaskFailureRow } from "@/lib/api/client/admin/sends";
|
||||
import { ExpandableText } from "@/app/dashboard/jobs/ExpandableText";
|
||||
import { absolute, relative, shortId } from "@/app/dashboard/jobs/format";
|
||||
|
||||
const TASK_TONE: Record<string, string> = {
|
||||
pending: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
processing: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
completed: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
failed: "border-red-300 bg-red-50 text-red-700",
|
||||
};
|
||||
|
||||
const columns: Column<AdminTaskFailureRow>[] = [
|
||||
{
|
||||
id: "title",
|
||||
header: "Failure",
|
||||
className: "max-w-md",
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<div className="text-xs font-medium">{r.title || "Task failed"}</div>
|
||||
<ExpandableText text={r.message} className="text-muted-foreground" />
|
||||
</div>
|
||||
),
|
||||
csv: (r) => `${r.title}: ${r.message}`,
|
||||
},
|
||||
{
|
||||
id: "task",
|
||||
header: "Task",
|
||||
cell: (r) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-xs">{r.task_type}</span>
|
||||
<Badge variant="outline" className={`text-[10px] ${TASK_TONE[r.task_status] ?? "border-zinc-300 text-zinc-600"}`}>
|
||||
{r.task_status}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
csv: (r) => `${r.task_type} (${r.task_status})`,
|
||||
},
|
||||
{
|
||||
id: "mailbox",
|
||||
header: "Mailbox",
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<div className="text-xs">{r.mailbox_email || "—"}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{shortId(r.email_account_id)}</div>
|
||||
</div>
|
||||
),
|
||||
csv: (r) => r.mailbox_email,
|
||||
},
|
||||
{
|
||||
id: "workspace",
|
||||
header: "Workspace",
|
||||
cell: (r) =>
|
||||
r.organization_id ? (
|
||||
<Link to={`/organizations/${r.organization_id}`} className="text-xs text-[var(--admin-accent-strong)] hover:underline">
|
||||
{r.organization_name || r.organization_id}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
csv: (r) => r.organization_name || "",
|
||||
},
|
||||
{
|
||||
id: "occurred",
|
||||
header: "Occurred",
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<span className="text-xs text-muted-foreground" title={absolute(r.occurred_at)}>
|
||||
{relative(r.occurred_at)}
|
||||
</span>
|
||||
),
|
||||
csv: (r) => r.occurred_at,
|
||||
},
|
||||
];
|
||||
|
||||
export function FailuresTab() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "sends", "failures"],
|
||||
queryFn: () => listTaskFailures(100),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-4 max-w-2xl text-sm text-muted-foreground">
|
||||
The most recent failures tasks recorded about themselves: auth errors, provider refusals, send exceptions. Newest first.
|
||||
</p>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(r) => `${r.task_id}:${r.occurred_at}`}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
errorTitle="Failed to load task failures"
|
||||
storageKey="admin.sends.failures"
|
||||
csvName="warmbly-task-failures"
|
||||
noun="failures"
|
||||
emptyTitle="No recent failures"
|
||||
emptyHint="No task has recorded a failure recently. Rows appear when a send, sync or warmup task stops with an error."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Reserved campaign sends no worker result has resolved. A row lives between
|
||||
// ReserveSend and the EMAIL_SENT / EMAIL_FAILED answer; the consumer's stuck
|
||||
// send reclaimer resolves anything older than the reclaim window. No realtime
|
||||
// event covers reservations, so this polls at 30s.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, Clock, Hourglass, Play, Send, Timer } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import { listInFlightSends, type AdminInFlightSend } from "@/lib/api/client/admin/sends";
|
||||
import { runJob, STUCK_SEND_RECLAIMER_JOB } from "@/lib/api/client/admin/jobs";
|
||||
import { StatCard } from "@/app/dashboard/sends/StatCard";
|
||||
import { absolute, humanSeconds, relative, shortId } from "@/app/dashboard/jobs/format";
|
||||
|
||||
const TASK_TONE: Record<string, string> = {
|
||||
pending: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
processing: "border-amber-300 bg-amber-50 text-amber-700",
|
||||
completed: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
failed: "border-red-300 bg-red-50 text-red-700",
|
||||
};
|
||||
|
||||
export function InFlightTab() {
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "sends", "in-flight"],
|
||||
queryFn: () => listInFlightSends(200),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: () => runJob(STUCK_SEND_RECLAIMER_JOB),
|
||||
onSuccess: () => {
|
||||
toast.success("Reclaimer requested; the consumer's reclaim loop runs it at its next poll, within about 15 seconds");
|
||||
qc.invalidateQueries({ queryKey: ["admin", "jobs"] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message || "Failed to request the reclaimer"),
|
||||
});
|
||||
|
||||
const summary = data?.summary;
|
||||
const reclaimAfter = summary?.reclaim_after_minutes;
|
||||
const windowSeconds = (reclaimAfter ?? 0) * 60;
|
||||
|
||||
async function onRun() {
|
||||
const ok = await confirm({
|
||||
title: "Run the stuck-send reclaimer now?",
|
||||
description: `This asks the consumer's reclaim loop to run at its next poll, within about 15 seconds. Every reservation older than ${reclaimAfter ?? "the reclaim window in"} minutes is resolved: a task that already carries a Message-ID is stamped as sent, anything else is walked back as a failed attempt and retried on the next routing tick.`,
|
||||
confirmLabel: "Run reclaimer",
|
||||
});
|
||||
if (!ok) return;
|
||||
run.mutate();
|
||||
}
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
const columns: Column<AdminInFlightSend>[] = [
|
||||
{
|
||||
id: "campaign",
|
||||
header: "Campaign",
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<Link to={`/campaigns/${r.campaign_id}`} className="font-medium text-[var(--admin-accent-strong)] hover:underline">
|
||||
{r.campaign_name || shortId(r.campaign_id)}
|
||||
</Link>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{shortId(r.campaign_id)}</div>
|
||||
</div>
|
||||
),
|
||||
csv: (r) => r.campaign_name,
|
||||
},
|
||||
{
|
||||
id: "workspace",
|
||||
header: "Workspace",
|
||||
cell: (r) =>
|
||||
r.organization_id ? (
|
||||
<Link to={`/organizations/${r.organization_id}`} className="text-xs text-[var(--admin-accent-strong)] hover:underline">
|
||||
{r.organization_name || r.organization_id}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
csv: (r) => r.organization_name || "",
|
||||
},
|
||||
{ id: "contact", header: "Contact", cell: (r) => <span className="text-xs">{r.contact_email}</span>, csv: (r) => r.contact_email },
|
||||
{
|
||||
id: "mailbox",
|
||||
header: "Mailbox",
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<div className="text-xs">{r.mailbox_email || "—"}</div>
|
||||
{r.worker_id && (
|
||||
<Link to={`/workers/${r.worker_id}`} className="font-mono text-[10px] text-muted-foreground hover:underline">
|
||||
worker {shortId(r.worker_id)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
csv: (r) => r.mailbox_email,
|
||||
},
|
||||
{
|
||||
id: "task",
|
||||
header: "Task",
|
||||
cell: (r) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className={`text-[10px] ${TASK_TONE[r.task_status] ?? "border-zinc-300 text-zinc-600"}`}>
|
||||
{r.task_status || "unknown"}
|
||||
</Badge>
|
||||
{r.task_id && <span className="font-mono text-[10px] text-muted-foreground">{shortId(r.task_id)}</span>}
|
||||
</div>
|
||||
),
|
||||
csv: (r) => r.task_status,
|
||||
},
|
||||
{
|
||||
id: "message_id",
|
||||
header: "Message ID",
|
||||
cell: (r) =>
|
||||
r.has_message_id ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
title="The worker put the mail on the wire and only the stamp was lost; the reclaimer stamps it rather than retry"
|
||||
>
|
||||
on the wire
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">none</span>
|
||||
),
|
||||
csv: (r) => (r.has_message_id ? "yes" : "no"),
|
||||
},
|
||||
{
|
||||
id: "dispatched",
|
||||
header: "Dispatched",
|
||||
align: "right",
|
||||
cell: (r) => {
|
||||
const late = windowSeconds > 0 && r.age_seconds >= windowSeconds;
|
||||
return (
|
||||
<span className={`text-xs tabular-nums ${late ? "text-red-700" : "text-muted-foreground"}`} title={absolute(r.dispatched_at)}>
|
||||
{humanSeconds(r.age_seconds)} ago
|
||||
</span>
|
||||
);
|
||||
},
|
||||
csv: (r) => r.dispatched_at,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
A reservation is written before SEND_EMAIL goes on the bus and resolved by exactly one worker result.
|
||||
{reclaimAfter !== undefined && (
|
||||
<> The reclaimer sweeps every 5 minutes and resolves anything older than {reclaimAfter} minutes.</>
|
||||
)}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="shrink-0 text-xs" onClick={onRun} disabled={run.isPending}>
|
||||
<Play className="size-3" />
|
||||
{run.isPending ? "Requesting…" : "Run reclaimer now"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatCard icon={Send} label="In flight" value={fmt(summary?.total)} loading={isLoading} />
|
||||
<StatCard icon={Timer} label="Under 5 min" value={fmt(summary?.under_5m)} loading={isLoading} />
|
||||
<StatCard icon={Clock} label="Under 30 min" value={fmt(summary?.under_30m)} loading={isLoading} />
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Past reclaim window"
|
||||
value={fmt(summary?.past_reclaim_window)}
|
||||
loading={isLoading}
|
||||
tone={summary?.past_reclaim_window ? "danger" : "neutral"}
|
||||
sub={reclaimAfter !== undefined ? `older than ${reclaimAfter} min` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Hourglass}
|
||||
label="Oldest"
|
||||
value={summary?.oldest_dispatched_at ? relative(summary.oldest_dispatched_at) : "—"}
|
||||
loading={isLoading}
|
||||
sub={summary?.oldest_dispatched_at ? absolute(summary.oldest_dispatched_at) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(r) => `${r.campaign_id}:${r.contact_id}:${r.sequence_id}`}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
errorTitle="Failed to load in-flight sends"
|
||||
storageKey="admin.sends.in-flight"
|
||||
csvName="warmbly-in-flight-sends"
|
||||
noun="sends"
|
||||
emptyTitle="Nothing in flight"
|
||||
emptyHint="No reserved send is waiting on a worker result. Rows appear between a SEND_EMAIL dispatch and its EMAIL_SENT or EMAIL_FAILED answer."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(n: number | undefined): string {
|
||||
return n === undefined ? "—" : n.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Stat tile for the operations pages, the same shape as OverviewPage's.
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
loading,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
sub?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
tone?: "neutral" | "warn" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
tone === "warn" && "border-amber-300 bg-amber-50/40",
|
||||
tone === "danger" && "border-red-300 bg-red-50/40",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tabular-nums">
|
||||
{loading ? <Skeleton className="h-7 w-16" /> : value}
|
||||
</div>
|
||||
{sub && <div className="mt-1 text-xs text-muted-foreground">{sub}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Segmented status filter with a count per option. Explorer's SegmentedFilter
|
||||
// is a fixed three-column grid with no badges, so status rails that need
|
||||
// counts (dead letters) use this one; same visual language.
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function StatusSegments<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { value: T; label: string; count?: number }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex flex-wrap gap-0.5 rounded-md border border-border bg-card p-0.5 text-[11px]">
|
||||
{options.map((o) => {
|
||||
const active = value === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded px-2 py-1 transition-colors",
|
||||
active
|
||||
? "bg-[var(--admin-accent)] font-medium text-white"
|
||||
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
{o.count !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 text-[10px] font-semibold leading-4 tabular-nums",
|
||||
active ? "bg-white/25 text-white" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{o.count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Customer webhook delivery health across the instance, and the endpoints
|
||||
// that are currently failing. Polls at 30s; no realtime event covers the
|
||||
// delivery queue.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, CheckCircle2, Clock, RotateCcw, Trash2, XCircle, Ban } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable, type Column } from "@/components/data/DataTable";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import { getWebhookHealth, reclaimWebhookDeliveries, type AdminWebhookEndpointRow } from "@/lib/api/client/admin/sends";
|
||||
import { StatCard } from "@/app/dashboard/sends/StatCard";
|
||||
import { ExpandableText } from "@/app/dashboard/jobs/ExpandableText";
|
||||
import { absolute, relative } from "@/app/dashboard/jobs/format";
|
||||
|
||||
const columns: Column<AdminWebhookEndpointRow>[] = [
|
||||
{
|
||||
id: "workspace",
|
||||
header: "Workspace",
|
||||
cell: (r) => (
|
||||
<Link to={`/organizations/${r.organization_id}`} className="text-xs text-[var(--admin-accent-strong)] hover:underline">
|
||||
{r.organization_name || r.organization_id}
|
||||
</Link>
|
||||
),
|
||||
csv: (r) => r.organization_name,
|
||||
},
|
||||
{
|
||||
id: "url",
|
||||
header: "Endpoint",
|
||||
className: "max-w-sm",
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<div className="truncate font-mono text-[11px]" title={r.url}>
|
||||
{r.url}
|
||||
</div>
|
||||
{r.description && <div className="truncate text-[11px] text-muted-foreground">{r.description}</div>}
|
||||
</div>
|
||||
),
|
||||
csv: (r) => r.url,
|
||||
},
|
||||
{
|
||||
id: "consecutive",
|
||||
header: "Consecutive failures",
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<span className={`text-xs tabular-nums ${r.consecutive_failures > 0 ? "font-medium text-red-700" : "text-muted-foreground"}`}>
|
||||
{r.consecutive_failures.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
csv: (r) => r.consecutive_failures,
|
||||
},
|
||||
{
|
||||
id: "last_failure",
|
||||
header: "Last failure",
|
||||
className: "max-w-md",
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<ExpandableText text={r.last_failure_reason} mono />
|
||||
{r.last_failure_at && (
|
||||
<div className="text-[11px] text-muted-foreground" title={absolute(r.last_failure_at)}>
|
||||
{relative(r.last_failure_at)}
|
||||
{r.last_success_at && ` · last success ${relative(r.last_success_at)}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
csv: (r) => r.last_failure_reason,
|
||||
},
|
||||
{
|
||||
id: "week",
|
||||
header: "Last 7 days",
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<span className="text-xs tabular-nums text-muted-foreground" title="delivered / failed / dropped">
|
||||
<span className="text-emerald-700">{r.deliveries_last_7d.toLocaleString()}</span>
|
||||
{" / "}
|
||||
<span className={r.failed_last_7d > 0 ? "text-red-700" : undefined}>{r.failed_last_7d.toLocaleString()}</span>
|
||||
{" / "}
|
||||
<span className={r.drops_last_7d > 0 ? "text-amber-700" : undefined}>{r.drops_last_7d.toLocaleString()}</span>
|
||||
</span>
|
||||
),
|
||||
csv: (r) => `${r.deliveries_last_7d}/${r.failed_last_7d}/${r.drops_last_7d}`,
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
header: "Enabled",
|
||||
cell: (r) =>
|
||||
r.enabled ? (
|
||||
<span className="text-xs text-emerald-600">enabled</span>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-zinc-300 text-zinc-600">
|
||||
disabled
|
||||
</Badge>
|
||||
),
|
||||
csv: (r) => (r.enabled ? "yes" : "no"),
|
||||
},
|
||||
];
|
||||
|
||||
export function WebhooksTab() {
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["admin", "sends", "webhooks"],
|
||||
queryFn: getWebhookHealth,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const reclaim = useMutation({
|
||||
mutationFn: reclaimWebhookDeliveries,
|
||||
onSuccess: (res) => {
|
||||
toast.success(`Reclaimed ${res.reclaimed.toLocaleString()} stuck ${res.reclaimed === 1 ? "delivery" : "deliveries"}`);
|
||||
qc.invalidateQueries({ queryKey: ["admin", "sends", "webhooks"] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message || "Failed to reclaim deliveries"),
|
||||
});
|
||||
|
||||
async function onReclaim() {
|
||||
const ok = await confirm({
|
||||
title: "Reclaim stuck deliveries?",
|
||||
description: `${
|
||||
data
|
||||
? `Deliveries claimed longer ago than the ${data.lease_minutes} minute lease`
|
||||
: "Deliveries claimed longer ago than the lease window"
|
||||
} are handed back to the queue so a delivery worker can pick them up again. An endpoint may receive a duplicate if the original attempt did complete after its lease expired.`,
|
||||
confirmLabel: "Reclaim",
|
||||
});
|
||||
if (!ok) return;
|
||||
reclaim.mutate();
|
||||
}
|
||||
|
||||
const rows = data?.failing_endpoints ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Instance-wide delivery of customer webhooks.
|
||||
{data && <> A delivery is stale once it has been claimed for more than {data.lease_minutes} minutes without a result.</>}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="shrink-0 text-xs" onClick={onReclaim} disabled={reclaim.isPending}>
|
||||
<RotateCcw className="size-3" />
|
||||
{reclaim.isPending ? "Reclaiming…" : "Reclaim stuck deliveries"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<StatCard icon={AlertTriangle} label="Stale in flight" value={fmt(data?.in_flight_stale)} loading={isLoading} tone={data?.in_flight_stale ? "warn" : "neutral"} sub={data ? `lease ${data.lease_minutes} min` : undefined} />
|
||||
<StatCard icon={Clock} label="Pending due" value={fmt(data?.pending_due)} loading={isLoading} />
|
||||
<StatCard icon={CheckCircle2} label="Delivered 24h" value={fmt(data?.delivered_last_24h)} loading={isLoading} />
|
||||
<StatCard icon={XCircle} label="Failed 24h" value={fmt(data?.failed_last_24h)} loading={isLoading} tone={data?.failed_last_24h ? "warn" : "neutral"} />
|
||||
<StatCard icon={Ban} label="Abandoned 24h" value={fmt(data?.abandoned_last_24h)} loading={isLoading} tone={data?.abandoned_last_24h ? "danger" : "neutral"} />
|
||||
<StatCard icon={Trash2} label="Drops 7d" value={fmt(data?.drops_last_7d)} loading={isLoading} sub="queue full, never attempted" />
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Failing endpoints</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(r) => r.id}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
errorTitle="Failed to load webhook health"
|
||||
storageKey="admin.sends.webhooks"
|
||||
csvName="warmbly-failing-webhooks"
|
||||
noun="endpoints"
|
||||
emptyTitle="No failing endpoints"
|
||||
emptyHint="Every customer endpoint accepted its last delivery. Rows appear when an endpoint fails consecutively, worst first."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(n: number | undefined): string {
|
||||
return n === undefined ? "—" : n.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Start an archive build for a workspace. With `org` fixed (organization
|
||||
// detail page) the picker is hidden; without it the operator searches first.
|
||||
// Secrets only travel under a passphrase, which is used once and never stored.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
createOrgExport,
|
||||
expandGroups,
|
||||
MIN_EXPORT_PASSPHRASE,
|
||||
ORG_DATA_GROUP_CATALOG,
|
||||
type OrgDataGroup,
|
||||
} from "@/lib/api/client/admin/transfers";
|
||||
import { OrgPicker, type PickedOrg } from "../fleet/OrgPicker";
|
||||
import { GroupPicker } from "./GroupPicker";
|
||||
|
||||
function defaultGroups(): Set<OrgDataGroup> {
|
||||
return expandGroups(ORG_DATA_GROUP_CATALOG.filter((g) => !g.heavy).map((g) => g.key));
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
org,
|
||||
onStarted,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
org?: { id: string; name: string } | null;
|
||||
onStarted?: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [picked, setPicked] = useState<PickedOrg | null>(null);
|
||||
const [groups, setGroups] = useState<Set<OrgDataGroup>>(defaultGroups);
|
||||
const [secrets, setSecrets] = useState(false);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [confirmPass, setConfirmPass] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPicked(null);
|
||||
setGroups(defaultGroups());
|
||||
setSecrets(false);
|
||||
setPassphrase("");
|
||||
setConfirmPass("");
|
||||
}, [open]);
|
||||
|
||||
const target = org ?? (picked ? { id: picked.id, name: picked.name } : null);
|
||||
const passOk = !secrets || (passphrase.length >= MIN_EXPORT_PASSPHRASE && passphrase === confirmPass);
|
||||
const heavyOn = ORG_DATA_GROUP_CATALOG.filter((g) => g.heavy && groups.has(g.key));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createOrgExport(target!.id, {
|
||||
groups: Array.from(groups),
|
||||
include_secrets: secrets,
|
||||
passphrase: secrets ? passphrase : undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(`Export started for ${target?.name}`);
|
||||
qc.invalidateQueries({ queryKey: ["admin", "transfers"] });
|
||||
onStarted?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Could not start the export"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && mutation.isPending) return;
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-2xl"
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (document.querySelector("[data-floating]")) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export a workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Builds the same archive an owner gets from Settings > Data. It stays downloadable for 7 days.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!org && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Workspace</Label>
|
||||
<OrgPicker value={picked} onChange={setPicked} autoFocus />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Data groups</Label>
|
||||
<GroupPicker selected={groups} onChange={setGroups} />
|
||||
{heavyOn.length > 0 && (
|
||||
<p className="text-[11px] text-amber-700">
|
||||
{heavyOn.map((g) => g.label).join(", ")} can multiply the archive size on a busy workspace.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border bg-card p-3">
|
||||
<label className="flex items-start gap-3">
|
||||
<Switch checked={secrets} onCheckedChange={setSecrets} className="mt-0.5" />
|
||||
<span>
|
||||
<span className="block text-[12.5px] font-medium">Include credentials</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
Re-seals mailbox and integration credentials into the archive under a passphrase, so
|
||||
the destination brings mailboxes back up without every user reconnecting.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{secrets && (
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="exp-pass" className="text-xs">
|
||||
Passphrase
|
||||
</Label>
|
||||
<Input
|
||||
id="exp-pass"
|
||||
aria-describedby="exp-pass-help"
|
||||
aria-invalid={!!passphrase && passphrase.length < MIN_EXPORT_PASSPHRASE}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
className="h-8 text-[12.5px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="exp-pass2" className="text-xs">
|
||||
Repeat passphrase
|
||||
</Label>
|
||||
<Input
|
||||
id="exp-pass2"
|
||||
aria-describedby="exp-pass-help"
|
||||
aria-invalid={!!confirmPass && passphrase !== confirmPass}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPass}
|
||||
onChange={(e) => setConfirmPass(e.target.value)}
|
||||
className="h-8 text-[12.5px]"
|
||||
/>
|
||||
</div>
|
||||
<p id="exp-pass-help" className="text-[11px] text-muted-foreground sm:col-span-2">
|
||||
At least {MIN_EXPORT_PASSPHRASE} characters. It is never stored: whoever imports the
|
||||
archive needs it, and there is no recovery.
|
||||
{passphrase && passphrase.length < MIN_EXPORT_PASSPHRASE && (
|
||||
<span className="ml-1 text-red-600">Too short.</span>
|
||||
)}
|
||||
{passphrase.length >= MIN_EXPORT_PASSPHRASE && confirmPass && passphrase !== confirmPass && (
|
||||
<span className="ml-1 text-red-600">Passphrases differ.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => mutation.mutate()} disabled={!target || !passOk || mutation.isPending}>
|
||||
{mutation.isPending ? "Starting…" : "Start export"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Data group toggles for export and import. Ticking a group pulls in what it
|
||||
// cannot travel without (as the server would); unticking one that others
|
||||
// depend on explains itself instead of silently dropping them.
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
dependentsOf,
|
||||
expandGroups,
|
||||
ORG_DATA_GROUP_CATALOG,
|
||||
type OrgDataGroup,
|
||||
} from "@/lib/api/client/admin/transfers";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function GroupPicker({
|
||||
selected,
|
||||
onChange,
|
||||
/** Restrict to these keys (an archive's contents on import). */
|
||||
available,
|
||||
disabled,
|
||||
}: {
|
||||
selected: Set<OrgDataGroup>;
|
||||
onChange: (next: Set<OrgDataGroup>) => void;
|
||||
available?: OrgDataGroup[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const groups = available
|
||||
? ORG_DATA_GROUP_CATALOG.filter((g) => available.includes(g.key))
|
||||
: ORG_DATA_GROUP_CATALOG;
|
||||
|
||||
function toggle(key: OrgDataGroup) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
for (const d of dependentsOf(key, next)) next.delete(d.key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
onChange(expandGroups(next));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
{groups.map((g) => {
|
||||
const on = g.required || selected.has(g.key);
|
||||
const deps = dependentsOf(g.key, selected);
|
||||
return (
|
||||
<label
|
||||
key={g.key}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md border px-2.5 py-2 text-[12.5px]",
|
||||
on ? "border-border bg-card" : "border-border/60 bg-muted/20",
|
||||
g.required || disabled ? "cursor-default" : "cursor-pointer hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={on}
|
||||
disabled={g.required || disabled}
|
||||
onCheckedChange={() => toggle(g.key)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-medium leading-tight">{g.label}</span>
|
||||
{g.required && <Badge variant="outline" className="text-[9px]">required</Badge>}
|
||||
{g.heavy && (
|
||||
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-[9px] text-amber-700">
|
||||
heavy
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[11px] leading-tight text-muted-foreground">{g.description}</span>
|
||||
{on && deps.length > 0 && !g.required && (
|
||||
<span className="block text-[10px] leading-tight text-muted-foreground">
|
||||
Needed by {deps.map((d) => d.label).join(", ")}.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Import an archive into a workspace: choose the file, run preflight (reads
|
||||
// the manifest and reports conflicts without writing), then apply. Multipart
|
||||
// field names match the dashboard's Settings > Data flow: "file", "options"
|
||||
// (JSON), "passphrase".
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, FileArchive, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import {
|
||||
createOrgImport,
|
||||
expandGroups,
|
||||
formatBytes,
|
||||
preflightOrgImport,
|
||||
totalRows,
|
||||
type OrgDataGroup,
|
||||
type OrgImportConflict,
|
||||
type OrgImportPreflight,
|
||||
} from "@/lib/api/client/admin/transfers";
|
||||
import { GroupPicker } from "./GroupPicker";
|
||||
import { fmtDateTime } from "../fleet/format";
|
||||
|
||||
export function ImportArchiveDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
orgId,
|
||||
orgName,
|
||||
onStarted,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
onStarted?: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [report, setReport] = useState<OrgImportPreflight | null>(null);
|
||||
const [groups, setGroups] = useState<Set<OrgDataGroup>>(new Set());
|
||||
const [conflict, setConflict] = useState<OrgImportConflict>("skip");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setFile(null);
|
||||
setPassphrase("");
|
||||
setReport(null);
|
||||
setGroups(new Set());
|
||||
setConflict("skip");
|
||||
}, [open]);
|
||||
|
||||
function pickFile(next: File | null) {
|
||||
setFile(next);
|
||||
setReport(null);
|
||||
}
|
||||
|
||||
const preflight = useMutation({
|
||||
mutationFn: () => preflightOrgImport(orgId, file!, passphrase),
|
||||
onSuccess: (r) => {
|
||||
setReport(r);
|
||||
setGroups(expandGroups(r.archive.groups));
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
setReport(null);
|
||||
toast.error(e.message || "That archive could not be read");
|
||||
},
|
||||
});
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: () =>
|
||||
createOrgImport(orgId, file!, { groups: Array.from(groups), conflict_strategy: conflict }, passphrase),
|
||||
onSuccess: () => {
|
||||
toast.success(`Import started into ${orgName}`);
|
||||
qc.invalidateQueries({ queryKey: ["admin", "transfers"] });
|
||||
onStarted?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Could not start the import"),
|
||||
});
|
||||
|
||||
async function onApply() {
|
||||
if (!file || !report) return;
|
||||
const conflictTotal = Object.values(report.conflicts ?? {}).reduce((a, b) => a + b, 0);
|
||||
const ok = await confirm({
|
||||
title: `Import "${report.archive.organization_name}" into ${orgName}?`,
|
||||
description:
|
||||
conflict === "overwrite" && conflictTotal > 0
|
||||
? `${conflictTotal.toLocaleString()} existing row(s) in this workspace will be replaced with the archive's versions. That cannot be undone.`
|
||||
: `The archive's contents are added to this workspace. Rows that already exist here are kept as they are.`,
|
||||
confirmLabel: "Start import",
|
||||
destructive: conflict === "overwrite",
|
||||
});
|
||||
if (ok) apply.mutate();
|
||||
}
|
||||
|
||||
const busy = preflight.isPending || apply.isPending;
|
||||
const conflictTotal = Object.values(report?.conflicts ?? {}).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && busy) return;
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import an archive into {orgName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preflight reads the archive and reports what would land, what already exists, and who has no
|
||||
account here, without writing anything.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 rounded-md border border-dashed border-border px-3 py-3 sm:flex-row sm:items-center">
|
||||
<FileArchive className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] font-medium leading-tight">{file ? file.name : "Choose an archive"}</div>
|
||||
<div className="text-[11px] leading-tight text-muted-foreground">
|
||||
{file ? formatBytes(file.size) : "A .warmbly.zip exported from this or another instance."}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||
{file ? "Choose another" : "Choose file"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="imp-pass" className="text-xs">
|
||||
Export passphrase <span className="font-normal text-muted-foreground">(only if the archive carries credentials)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="imp-pass"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={passphrase}
|
||||
onChange={(e) => {
|
||||
setPassphrase(e.target.value);
|
||||
setReport(null);
|
||||
}}
|
||||
className="h-8 text-[12.5px]"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => preflight.mutate()} disabled={busy}>
|
||||
{preflight.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{report ? "Check again" : "Check archive"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-border bg-card p-3 text-[12.5px]">
|
||||
<div className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
<Row k="Source workspace" v={report.archive.organization_name} />
|
||||
<Row k="Exported" v={fmtDateTime(report.archive.exported_at)} />
|
||||
<Row k="Source instance" v={report.archive.source_instance || "—"} />
|
||||
<Row k="App version" v={report.archive.source_app_version || "—"} />
|
||||
<Row k="Rows" v={totalRows(report.archive.row_counts).toLocaleString()} />
|
||||
<Row k="Blobs" v={String(report.archive.blob_count)} />
|
||||
<Row
|
||||
k="Credentials"
|
||||
v={
|
||||
!report.archive.has_secrets
|
||||
? "not in archive; mailboxes will need reconnecting"
|
||||
: report.secrets_unsealed
|
||||
? "unsealed; mailboxes reconnect automatically"
|
||||
: "sealed; wrong or missing passphrase"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
k="Conflicts"
|
||||
v={conflictTotal === 0 ? "none" : `${conflictTotal.toLocaleString()} existing row(s)`}
|
||||
/>
|
||||
</div>
|
||||
{(report.unknown_members?.length ?? 0) > 0 && (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{report.unknown_members!.length} member(s) have no account on this instance and arrive as
|
||||
pending invitations: {report.unknown_members!.map((m) => m.email).join(", ")}.
|
||||
</p>
|
||||
)}
|
||||
{(report.skipped_tables?.length ?? 0) > 0 && (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
Skipped (unknown here): {report.skipped_tables!.join(", ")}.
|
||||
</p>
|
||||
)}
|
||||
{(report.warnings?.length ?? 0) > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{report.warnings!.map((w, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5 text-[11px] text-amber-800">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
{w}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Groups to apply</Label>
|
||||
<GroupPicker selected={groups} onChange={setGroups} available={report.archive.groups} disabled={busy} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">When a row already exists here</Label>
|
||||
<Select value={conflict} onValueChange={(v) => setConflict(v as OrgImportConflict)}>
|
||||
<SelectTrigger className="h-8 w-full text-[12.5px] sm:w-80">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip" className="text-[12.5px]">
|
||||
Skip: keep the row that is already here
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite" className="text-[12.5px]">
|
||||
Overwrite: replace it with the archive's version
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void onApply()} disabled={!report || busy}>
|
||||
{apply.isPending ? "Starting…" : "Apply import"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-muted-foreground">{k}</span>
|
||||
<span className="truncate text-right font-medium">{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// One workspace's exports and imports, with the export dialog and the
|
||||
// import flow. Polls at 15s only while a job is queued or running.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Download, PackageOpen, Trash2, Upload } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { useConfirm } from "@/components/ConfirmDialog";
|
||||
import {
|
||||
deleteOrgExport,
|
||||
downloadOrgExport,
|
||||
formatBytes,
|
||||
isTransferActive,
|
||||
listOrgExports,
|
||||
listOrgImports,
|
||||
saveBlob,
|
||||
totalRows,
|
||||
type OrgExportJob,
|
||||
type OrgImportJob,
|
||||
} from "@/lib/api/client/admin/transfers";
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
import { ImportArchiveDialog } from "./ImportArchiveDialog";
|
||||
import { StatusPill } from "./TransferPills";
|
||||
import { fmtDateTime } from "../fleet/format";
|
||||
|
||||
const POLL = 15_000;
|
||||
|
||||
export function OrgTransferTab({ orgId, orgName }: { orgId: string; orgName: string }) {
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
const exportsQ = useQuery({
|
||||
queryKey: ["admin", "transfers", "org", orgId, "exports"],
|
||||
queryFn: () => listOrgExports(orgId),
|
||||
refetchInterval: (q) => ((q.state.data?.data ?? []).some((j) => isTransferActive(j.status)) ? POLL : false),
|
||||
});
|
||||
const importsQ = useQuery({
|
||||
queryKey: ["admin", "transfers", "org", orgId, "imports"],
|
||||
queryFn: () => listOrgImports(orgId),
|
||||
refetchInterval: (q) => ((q.state.data?.data ?? []).some((j) => isTransferActive(j.status)) ? POLL : false),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["admin", "transfers"] });
|
||||
|
||||
const [downloading, setDownloading] = useState<string | null>(null);
|
||||
async function onDownload(job: OrgExportJob) {
|
||||
setDownloading(job.id);
|
||||
try {
|
||||
const { blob, filename } = await downloadOrgExport(orgId, job.id, orgName);
|
||||
saveBlob(blob, filename);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "Download failed");
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
}
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: string) => deleteOrgExport(orgId, id),
|
||||
onSuccess: () => {
|
||||
toast.success("Archive deleted");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || "Delete failed"),
|
||||
});
|
||||
|
||||
async function onDelete(job: OrgExportJob) {
|
||||
const ok = await confirm({
|
||||
title: "Delete this archive?",
|
||||
description: "The stored file is removed. The job stays in the history as deleted; run another export to rebuild it.",
|
||||
confirmLabel: "Delete",
|
||||
destructive: true,
|
||||
});
|
||||
if (ok) del.mutate(job.id);
|
||||
}
|
||||
|
||||
const exports = exportsQ.data?.data ?? [];
|
||||
const imports = importsQ.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="max-w-2xl text-[12.5px] text-muted-foreground">
|
||||
The same archives the owner builds from Settings > Data, started here on their behalf. Finished
|
||||
exports stay downloadable for 7 days.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">
|
||||
Exports{exports.length > 0 && <span className="ml-1.5 font-normal text-muted-foreground">({exports.length})</span>}
|
||||
</h2>
|
||||
<Button size="sm" onClick={() => setExportOpen(true)}>
|
||||
<PackageOpen className="size-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
{exportsQ.isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : exportsQ.error ? (
|
||||
<ErrorState error={exportsQ.error} title="Failed to load exports" onRetry={() => exportsQ.refetch()} />
|
||||
) : exports.length === 0 ? (
|
||||
<Empty>No exports yet. Start one above to build a portable archive of this workspace.</Empty>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Groups</th>
|
||||
<th className="px-3 py-2 text-left">Secrets</th>
|
||||
<th className="px-3 py-2 text-right">Rows</th>
|
||||
<th className="px-3 py-2 text-right">Size</th>
|
||||
<th className="px-3 py-2 text-left">Started</th>
|
||||
<th className="px-3 py-2 text-left">Expires</th>
|
||||
<th className="px-3 py-2 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exports.map((j) => (
|
||||
<tr key={j.id} className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<StatusPill status={j.status} progress={j.progress_percent} stage={j.progress_stage} />
|
||||
{j.error_message && <div className="mt-0.5 max-w-xs text-[11px] text-red-600">{j.error_message}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">{j.groups?.length ?? 0}</td>
|
||||
<td className="px-3 py-2 text-xs">{j.include_secrets ? "yes" : "no"}</td>
|
||||
<td className="px-3 py-2 text-right text-xs tabular-nums">{totalRows(j.row_counts).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-xs tabular-nums">{formatBytes(j.archive_bytes)}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{fmtDateTime(j.started_at ?? j.created_at)}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{fmtDateTime(j.expires_at)}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={j.status !== "completed" || downloading === j.id}
|
||||
onClick={() => void onDownload(j)}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
{downloading === j.id ? "Fetching…" : "Download"}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="text-red-700 hover:bg-red-50"
|
||||
disabled={isTransferActive(j.status) || del.isPending}
|
||||
onClick={() => void onDelete(j)}
|
||||
aria-label="Delete archive"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">
|
||||
Imports{imports.length > 0 && <span className="ml-1.5 font-normal text-muted-foreground">({imports.length})</span>}
|
||||
</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<Upload className="size-4" />
|
||||
Import archive
|
||||
</Button>
|
||||
</div>
|
||||
{importsQ.isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : importsQ.error ? (
|
||||
<ErrorState error={importsQ.error} title="Failed to load imports" onRetry={() => importsQ.refetch()} />
|
||||
) : imports.length === 0 ? (
|
||||
<Empty>No imports yet. Apply an archive from another instance (or an older export) above.</Empty>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Status</th>
|
||||
<th className="px-3 py-2 text-left">Source</th>
|
||||
<th className="px-3 py-2 text-left">Groups</th>
|
||||
<th className="px-3 py-2 text-left">Conflicts</th>
|
||||
<th className="px-3 py-2 text-right">Rows</th>
|
||||
<th className="px-3 py-2 text-right">Size</th>
|
||||
<th className="px-3 py-2 text-left">Started</th>
|
||||
<th className="px-3 py-2 text-left">Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{imports.map((j: OrgImportJob) => (
|
||||
<tr key={j.id} className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<StatusPill status={j.status} progress={j.progress_percent} stage={j.progress_stage} />
|
||||
{j.error_message && <div className="mt-0.5 max-w-xs text-[11px] text-red-600">{j.error_message}</div>}
|
||||
{(j.warnings?.length ?? 0) > 0 && (
|
||||
<div className="mt-0.5 max-w-xs text-[11px] text-amber-700">{j.warnings!.join(" · ")}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{j.source_manifest ? (
|
||||
<>
|
||||
<div>{j.source_manifest.organization_name}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{j.source_manifest.source_instance || "unknown instance"}</div>
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">{j.groups?.length ?? 0}</td>
|
||||
<td className="px-3 py-2 text-xs">{j.conflict_strategy}</td>
|
||||
<td className="px-3 py-2 text-right text-xs tabular-nums">{totalRows(j.row_counts).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-xs tabular-nums">{formatBytes(j.archive_bytes)}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{fmtDateTime(j.started_at ?? j.created_at)}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{fmtDateTime(j.completed_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ExportDialog open={exportOpen} onOpenChange={setExportOpen} org={{ id: orgId, name: orgName }} onStarted={invalidate} />
|
||||
<ImportArchiveDialog open={importOpen} onOpenChange={setImportOpen} orgId={orgId} orgName={orgName} onStarted={invalidate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ children }: { children: React.ReactNode }) {
|
||||
return <div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Status and kind pills for transfer jobs, shared by the instance-wide list
|
||||
// and the per-workspace tab.
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { OrgTransferStatus } from "@/lib/api/client/admin/transfers";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_TONE: Record<OrgTransferStatus, string> = {
|
||||
queued: "border-zinc-300 text-zinc-700",
|
||||
running: "border-sky-300 bg-sky-50 text-sky-700",
|
||||
completed: "border-emerald-300 bg-emerald-50 text-emerald-700",
|
||||
failed: "border-red-300 bg-red-50 text-red-700",
|
||||
expired: "border-zinc-300 bg-zinc-50 text-zinc-500",
|
||||
};
|
||||
|
||||
export function StatusPill({
|
||||
status,
|
||||
progress,
|
||||
stage,
|
||||
}: {
|
||||
status: OrgTransferStatus;
|
||||
progress?: number;
|
||||
stage?: string;
|
||||
}) {
|
||||
const running = status === "running";
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Badge variant="outline" className={cn("text-[10px]", STATUS_TONE[status] ?? "border-zinc-300 text-zinc-600")}>
|
||||
{running && <Loader2 className="size-3 animate-spin" />}
|
||||
{status}
|
||||
{running && progress != null && <span className="tabular-nums">{progress}%</span>}
|
||||
</Badge>
|
||||
{running && stage && <span className="text-[10px] text-muted-foreground">{stage}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KindPill({ kind }: { kind: "export" | "import" }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
kind === "export" ? "border-zinc-300 text-zinc-700" : "border-purple-300 bg-purple-50 text-purple-700",
|
||||
)}
|
||||
>
|
||||
{kind}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// In-app confirmation, the admin's replacement for window.confirm.
|
||||
//
|
||||
// const confirm = useConfirm();
|
||||
// if (!(await confirm({ title, description, confirmLabel, destructive }))) return;
|
||||
//
|
||||
// One provider (mounted in the app shell) renders a Radix Dialog with
|
||||
// role="alertdialog"; Escape, the backdrop and Cancel all resolve false.
|
||||
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>;
|
||||
|
||||
const ConfirmContext = createContext<ConfirmFn | null>(null);
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
||||
const resolver = useRef<((ok: boolean) => void) | null>(null);
|
||||
|
||||
const settle = useCallback((ok: boolean) => {
|
||||
resolver.current?.(ok);
|
||||
resolver.current = null;
|
||||
setOptions(null);
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback<ConfirmFn>(
|
||||
(next) => {
|
||||
// A second call while one is open answers the first with "no".
|
||||
resolver.current?.(false);
|
||||
setOptions(next);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolver.current = resolve;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
<DialogPrimitive.Root open={options !== null} onOpenChange={(open) => !open && settle(false)}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[60] bg-black/50" />
|
||||
<DialogPrimitive.Content
|
||||
role="alertdialog"
|
||||
data-floating=""
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[60] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border border-border p-6 shadow-lg duration-200 outline-none sm:max-w-md"
|
||||
>
|
||||
{options && (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
{options.destructive && (
|
||||
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--admin-danger)_12%,transparent)] text-[var(--admin-danger)]">
|
||||
<AlertTriangle className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex flex-col gap-1.5">
|
||||
<DialogPrimitive.Title className="text-base leading-snug font-semibold text-foreground">
|
||||
{options.title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-sm text-muted-foreground">
|
||||
{options.description ?? "This action cannot be undone."}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => settle(false)}>
|
||||
{options.cancelLabel ?? "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
autoFocus
|
||||
onClick={() => settle(true)}
|
||||
className={cn(
|
||||
options.destructive &&
|
||||
"bg-[var(--admin-danger)] text-white hover:bg-[var(--admin-danger)]/90 focus-visible:ring-[var(--admin-danger)]/30",
|
||||
)}
|
||||
>
|
||||
{options.confirmLabel ?? "Confirm"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// useConfirm returns the confirm function; it throws when the provider is
|
||||
// missing so a page never falls back to a silent "no".
|
||||
export function useConfirm(): ConfirmFn {
|
||||
const ctx = useContext(ConfirmContext);
|
||||
if (!ctx) throw new Error("useConfirm must be used inside ConfirmProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,28 +1,38 @@
|
||||
// Authenticated app shell: 3px admin stripe → sidebar + topbar + outlet.
|
||||
// Route guard lives in RequireAdmin, which wraps this in main.tsx.
|
||||
// Route guard lives in RequireAdmin, which wraps this in main.tsx. The
|
||||
// confirm provider and the command palette are mounted here so every page
|
||||
// under the shell can use them.
|
||||
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ConfirmProvider } from "@/components/ConfirmDialog";
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
||||
{/* The 3px stripe across the very top of the viewport. Cheap,
|
||||
always-visible signal that the user is in the admin surface. */}
|
||||
<div className="h-[3px] w-full admin-stripe shrink-0" />
|
||||
useDocumentTitle();
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="w-full px-4 md:px-6 lg:px-8 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
return (
|
||||
<ConfirmProvider>
|
||||
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
||||
{/* The 3px stripe across the very top of the viewport. Cheap,
|
||||
always-visible signal that the user is in the admin surface. */}
|
||||
<div className="h-[3px] w-full admin-stripe shrink-0" />
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="w-full px-4 md:px-6 lg:px-8 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CommandPalette />
|
||||
</ConfirmProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// Cmd/Ctrl+K palette. "Go to" lists every nav item the admin can open;
|
||||
// typing two or more characters searches users, organizations, mailboxes
|
||||
// and workers live; "Actions" holds the shortcuts that are not pages.
|
||||
// Open state lives in a tiny module store so the Topbar button and the
|
||||
// keyboard shortcut share one palette without a context provider.
|
||||
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Building2,
|
||||
ClipboardCopy,
|
||||
Mailbox,
|
||||
RefreshCw,
|
||||
Server,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { useMe } from "@/hooks/useMe";
|
||||
import { UPDATE_JOB_KEY, UPDATE_STATE_KEY } from "@/hooks/useUpdateState";
|
||||
import { AdminPerm, hasAdminPerm } from "@/lib/auth/permissions";
|
||||
import { searchUsers } from "@/lib/api/client/admin/users";
|
||||
import { listOrganizations } from "@/lib/api/client/admin/organizations";
|
||||
import { searchMailboxes } from "@/lib/api/client/admin/mailboxes";
|
||||
import { listManagedWorkers } from "@/lib/api/client/admin/workers";
|
||||
import { checkForUpdates } from "@/lib/api/client/admin/updates";
|
||||
import { visibleNavGroups } from "./Sidebar";
|
||||
|
||||
// ---- open state -----------------------------------------------------------
|
||||
|
||||
let paletteOpen = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function setPaletteOpen(next: boolean) {
|
||||
if (paletteOpen === next) return;
|
||||
paletteOpen = next;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function openCommandPalette() {
|
||||
setPaletteOpen(true);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function usePaletteOpen() {
|
||||
return useSyncExternalStore(subscribe, () => paletteOpen, () => false);
|
||||
}
|
||||
|
||||
export const IS_MAC =
|
||||
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
const SEARCH_MIN = 2;
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
const SEARCH_LIMIT = 5;
|
||||
|
||||
function useDebounced(value: string, ms: number): string {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setDebounced(value), ms);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function includes(haystack: string, needle: string): boolean {
|
||||
return haystack.toLowerCase().includes(needle.toLowerCase());
|
||||
}
|
||||
|
||||
// heldPermissionNames lists the AdminPerm keys set in the mask, so the copy
|
||||
// action adapts when bits are added or retired.
|
||||
function heldPermissionNames(mask: number | undefined): string[] {
|
||||
if (typeof mask !== "number") return [];
|
||||
return Object.entries(AdminPerm)
|
||||
.filter(([, bit]) => (mask & bit) === bit)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
// ---- component ------------------------------------------------------------
|
||||
|
||||
export function CommandPalette() {
|
||||
const open = usePaletteOpen();
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const qc = useQueryClient();
|
||||
const { data: me } = useMe();
|
||||
const mask = me?.admin_permissions;
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const debounced = useDebounced(query.trim(), SEARCH_DEBOUNCE_MS);
|
||||
const searching = open && debounced.length >= SEARCH_MIN;
|
||||
|
||||
// Cmd/Ctrl+K toggles from anywhere, including inside inputs.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setPaletteOpen(!paletteOpen);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// A navigation from anywhere (palette item, back button) closes it.
|
||||
const routeKey = location.pathname + location.search;
|
||||
useEffect(() => {
|
||||
setPaletteOpen(false);
|
||||
}, [routeKey]);
|
||||
|
||||
// Start every session with an empty query.
|
||||
useEffect(() => {
|
||||
if (!open) setQuery("");
|
||||
}, [open]);
|
||||
|
||||
const canUsers = hasAdminPerm(mask, AdminPerm.ViewUsers);
|
||||
const canOrgs = hasAdminPerm(mask, AdminPerm.ViewOrganizations);
|
||||
const canWorkers = hasAdminPerm(mask, AdminPerm.ViewWorkers);
|
||||
const canUpdates = hasAdminPerm(mask, AdminPerm.ManageSettings);
|
||||
|
||||
const usersQ = useQuery({
|
||||
queryKey: ["admin", "palette", "users", debounced],
|
||||
queryFn: () => searchUsers({ q: debounced, limit: SEARCH_LIMIT }),
|
||||
enabled: searching && canUsers,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const orgsQ = useQuery({
|
||||
queryKey: ["admin", "palette", "organizations", debounced],
|
||||
queryFn: () => listOrganizations({ q: debounced, limit: SEARCH_LIMIT }),
|
||||
enabled: searching && canOrgs,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const mailboxesQ = useQuery({
|
||||
queryKey: ["admin", "palette", "mailboxes", debounced],
|
||||
queryFn: () => searchMailboxes({ q: debounced, limit: SEARCH_LIMIT }),
|
||||
enabled: searching && canUsers,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const workersQ = useQuery({
|
||||
queryKey: ["admin", "workers", "managed"],
|
||||
queryFn: listManagedWorkers,
|
||||
enabled: searching && canWorkers,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const workers = useMemo(() => {
|
||||
if (!searching) return [];
|
||||
return (workersQ.data?.data ?? [])
|
||||
.filter(
|
||||
(w) =>
|
||||
includes(w.name, debounced) ||
|
||||
includes(w.id, debounced) ||
|
||||
includes(w.ip_addr ?? "", debounced) ||
|
||||
includes(w.ssh_host ?? "", debounced),
|
||||
)
|
||||
.slice(0, SEARCH_LIMIT);
|
||||
}, [workersQ.data, debounced, searching]);
|
||||
|
||||
const pages = useMemo(
|
||||
() =>
|
||||
visibleNavGroups(mask)
|
||||
.flatMap((g) => g.items)
|
||||
.filter((item) => !query.trim() || includes(item.label, query.trim())),
|
||||
[mask, query],
|
||||
);
|
||||
|
||||
const checkMut = useMutation({
|
||||
mutationFn: checkForUpdates,
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(UPDATE_STATE_KEY, data);
|
||||
qc.setQueryData(UPDATE_JOB_KEY, data);
|
||||
toast.success(
|
||||
data.update_available ? "A newer version is available" : "This instance is up to date",
|
||||
);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message || "Could not check for updates"),
|
||||
});
|
||||
|
||||
function go(to: string) {
|
||||
setPaletteOpen(false);
|
||||
nav(to);
|
||||
}
|
||||
|
||||
async function copyPermissions() {
|
||||
setPaletteOpen(false);
|
||||
const names = heldPermissionNames(mask);
|
||||
const text = names.length
|
||||
? `${names.join(", ")} (mask ${mask})`
|
||||
: "No admin permission bits reported";
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("Admin permissions copied");
|
||||
} catch {
|
||||
toast.error("Could not copy to the clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
id: "copy-permissions",
|
||||
label: "Copy my admin permissions",
|
||||
icon: ClipboardCopy,
|
||||
run: copyPermissions,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
id: "check-updates",
|
||||
label: "Check for updates",
|
||||
icon: RefreshCw,
|
||||
run: () => {
|
||||
setPaletteOpen(false);
|
||||
checkMut.mutate();
|
||||
},
|
||||
show: canUpdates,
|
||||
},
|
||||
].filter((a) => a.show && (!query.trim() || includes(a.label, query.trim())));
|
||||
|
||||
const users = usersQ.data?.data ?? [];
|
||||
const orgs = orgsQ.data?.data ?? [];
|
||||
const mailboxes = mailboxesQ.data?.data ?? [];
|
||||
const loading =
|
||||
searching &&
|
||||
(usersQ.isFetching || orgsQ.isFetching || mailboxesQ.isFetching || workersQ.isFetching);
|
||||
const nothing =
|
||||
pages.length === 0 &&
|
||||
actions.length === 0 &&
|
||||
users.length === 0 &&
|
||||
orgs.length === 0 &&
|
||||
mailboxes.length === 0 &&
|
||||
workers.length === 0;
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setPaletteOpen} shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Go to a page, or search users, organizations, mailboxes and workers"
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
{nothing && (
|
||||
<CommandEmpty>
|
||||
{loading
|
||||
? "Searching"
|
||||
: searching
|
||||
? "Nothing matches that. Try an email, a name or a worker."
|
||||
: "No page matches that."}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
{pages.length > 0 && (
|
||||
<CommandGroup heading="Go to">
|
||||
{pages.map((item) => (
|
||||
<CommandItem key={item.to} value={`page:${item.to}`} onSelect={() => go(item.to)}>
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
<span className="ml-auto font-mono text-[11px] text-muted-foreground">
|
||||
{item.to}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<CommandGroup heading="Users">
|
||||
{users.map((u) => (
|
||||
<CommandItem key={u.id} value={`user:${u.id}`} onSelect={() => go(`/users/${u.id}`)}>
|
||||
<UserIcon className="size-4" />
|
||||
<span className="truncate">{u.email}</span>
|
||||
<span className="ml-auto truncate text-[11px] text-muted-foreground">
|
||||
{[u.first_name, u.last_name].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{orgs.length > 0 && (
|
||||
<CommandGroup heading="Organizations">
|
||||
{orgs.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
value={`org:${o.id}`}
|
||||
onSelect={() => go(`/organizations/${o.id}`)}
|
||||
>
|
||||
<Building2 className="size-4" />
|
||||
<span className="truncate">{o.name}</span>
|
||||
<span className="ml-auto truncate text-[11px] text-muted-foreground">
|
||||
{o.owner_email}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{mailboxes.length > 0 && (
|
||||
<CommandGroup heading="Mailboxes">
|
||||
{mailboxes.map((m) => (
|
||||
<CommandItem
|
||||
key={m.id}
|
||||
value={`mailbox:${m.id}`}
|
||||
onSelect={() => go(`/mailboxes?q=${encodeURIComponent(m.email)}`)}
|
||||
>
|
||||
<Mailbox className="size-4" />
|
||||
<span className="truncate">{m.email}</span>
|
||||
<span className="ml-auto truncate text-[11px] text-muted-foreground">
|
||||
{m.org_name || m.owner_email}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{workers.length > 0 && (
|
||||
<CommandGroup heading="Workers">
|
||||
{workers.map((w) => (
|
||||
<CommandItem key={w.id} value={`worker:${w.id}`} onSelect={() => go(`/workers/${w.id}`)}>
|
||||
<Server className="size-4" />
|
||||
<span className="truncate">{w.name}</span>
|
||||
<span className="ml-auto truncate font-mono text-[11px] text-muted-foreground">
|
||||
{w.ssh_host || w.ip_addr}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{actions.length > 0 && (
|
||||
<CommandGroup heading="Actions">
|
||||
{actions.map((a) => (
|
||||
<CommandItem key={a.id} value={`action:${a.id}`} onSelect={a.run}>
|
||||
<a.icon className="size-4" />
|
||||
<span>{a.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Mobile navigation drawer. Below `md` the sidebar is hidden, so the
|
||||
// Topbar's hamburger opens this left sheet with the same nav model.
|
||||
// Closes on a tap (onNavigate), on Escape and on the backdrop (Radix), and
|
||||
// on any route change so a browser back button never leaves it open.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
|
||||
import { AdminBadge } from "./AdminBadge";
|
||||
import { NavList, SidebarBrand } from "./Sidebar";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MobileNav({ open, onOpenChange }: Props) {
|
||||
const location = useLocation();
|
||||
const routeKey = location.pathname + location.search;
|
||||
const lastRoute = useRef(routeKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastRoute.current === routeKey) return;
|
||||
lastRoute.current = routeKey;
|
||||
onOpenChange(false);
|
||||
}, [routeKey, onOpenChange]);
|
||||
|
||||
// The sheet body is hidden at md but its overlay is not, so a resize to
|
||||
// desktop with the drawer open would leave an invisible click shield.
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(min-width: 768px)");
|
||||
const closeOnDesktop = () => {
|
||||
if (media.matches) onOpenChange(false);
|
||||
};
|
||||
closeOnDesktop();
|
||||
media.addEventListener("change", closeOnDesktop);
|
||||
return () => media.removeEventListener("change", closeOnDesktop);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton
|
||||
className="w-72 max-w-[85vw] gap-0 bg-sidebar admin-sidebar-pattern p-0 md:hidden"
|
||||
>
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
<SheetDescription className="sr-only">Admin panel sections</SheetDescription>
|
||||
<div className="flex h-14 shrink-0 items-center gap-2 px-4 pr-12 border-b border-sidebar-border">
|
||||
<SidebarBrand />
|
||||
<AdminBadge compact />
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-3 space-y-5">
|
||||
<NavList onNavigate={() => onOpenChange(false)} />
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -33,17 +33,3 @@ export function PageHeader({ title, description, children, className }: PageHead
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Used by stub pages so the "coming soon" treatment is visually
|
||||
// consistent across the app instead of every page reinventing it.
|
||||
export function ComingSoon({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-10 text-center">
|
||||
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
This surface is part of the admin app, but the page implementation
|
||||
will land in a follow-up iteration.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Tab bar for pages that split into sections (Setup and health,
|
||||
// Configuration, ...). Controlled: the page owns the value and usually
|
||||
// mirrors it into `?tab=` so links deep-link. Same shape as the dashboard's
|
||||
// drawer tabs: icon + label, amber underline on the active one.
|
||||
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PageTab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: PageTab[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageTabs({ tabs, value, onChange, className }: Props) {
|
||||
function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
const idx = tabs.findIndex((t) => t.id === value);
|
||||
if (idx < 0) return;
|
||||
e.preventDefault();
|
||||
const step = e.key === "ArrowRight" ? 1 : -1;
|
||||
const next = tabs[(idx + step + tabs.length) % tabs.length];
|
||||
onChange(next.id);
|
||||
const el = e.currentTarget.querySelector<HTMLButtonElement>(`[data-tab="${next.id}"]`);
|
||||
el?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
"shrink-0 flex items-center gap-1 border-b border-border overflow-x-auto no-scrollbar mb-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tabs.map(({ id, label, icon: Icon, badge }) => {
|
||||
const active = id === value;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
data-tab={id}
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onChange(id)}
|
||||
className={cn(
|
||||
"relative h-10 px-2.5 inline-flex items-center gap-1.5 text-[12.5px] whitespace-nowrap transition-colors",
|
||||
active
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 text-[10px] font-semibold leading-4 tabular-nums",
|
||||
active
|
||||
? "bg-[var(--admin-accent-soft)] text-[var(--admin-accent-strong)]"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{active && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 rounded-t bg-[var(--admin-accent)]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Error boundary for the authenticated shell. A page that throws during
|
||||
// render (or a loader that rejects) lands here instead of a blank screen:
|
||||
// the message, the backend code and request id when it was an API error,
|
||||
// and a way to retry. Reported once to Sentry when a DSN is configured.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { isRouteErrorResponse, Link, useRouteError } from "react-router-dom";
|
||||
import { AlertTriangle, House, RotateCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { APIError, SessionExpiredError } from "@/lib/api/client";
|
||||
import { captureException } from "@/lib/observability";
|
||||
|
||||
export function RouteError() {
|
||||
const error = useRouteError();
|
||||
const reported = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (reported.current) return;
|
||||
reported.current = true;
|
||||
captureException(error);
|
||||
}, [error]);
|
||||
|
||||
const api = error instanceof APIError ? error : null;
|
||||
let title = "This page hit an error";
|
||||
let message: string;
|
||||
if (error instanceof SessionExpiredError) {
|
||||
title = "Your session expired";
|
||||
message = "Sign in again to keep working.";
|
||||
} else if (isRouteErrorResponse(error)) {
|
||||
title = `${error.status} ${error.statusText}`.trim();
|
||||
message = typeof error.data === "string" ? error.data : "The router could not render this route.";
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message || "An unexpected error occurred.";
|
||||
} else {
|
||||
message = "An unexpected error occurred.";
|
||||
}
|
||||
|
||||
const meta = [
|
||||
api?.status ? `HTTP ${api.status}` : null,
|
||||
api?.code ? `code ${api.code}` : null,
|
||||
api?.requestId ? `request ${api.requestId}` : null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-12 max-w-lg rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--admin-danger)_12%,transparent)] text-[var(--admin-danger)]">
|
||||
<AlertTriangle className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-foreground">{title}</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground break-words">{message}</p>
|
||||
{meta.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-x-2 gap-y-1 font-mono text-[11px] text-muted-foreground select-text">
|
||||
{meta.map((m, i) => (
|
||||
<span key={m}>
|
||||
{i > 0 && <span className="mr-2 opacity-50">·</span>}
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => window.location.reload()}>
|
||||
<RotateCw className="size-3.5" />
|
||||
Try again
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/">
|
||||
<House className="size-3.5" />
|
||||
Back to overview
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
// Left rail navigation. Mirrors the dashboard's general structure
|
||||
// (icon + label rows, collapsible sections) but uses the admin-tinted
|
||||
// (icon + label rows, grouped sections) but uses the admin-tinted
|
||||
// sidebar background and amber accent for active items so it never
|
||||
// gets confused with the dashboard's sidebar.
|
||||
//
|
||||
// NAV_GROUPS is the one nav model: the mobile drawer, the command palette
|
||||
// and the document title all read it, so a route added here is reachable
|
||||
// and titled everywhere at once.
|
||||
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
Bell,
|
||||
Activity,
|
||||
BarChart3,
|
||||
ArrowLeftRight,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
FileText,
|
||||
Flame,
|
||||
Gauge,
|
||||
@@ -16,14 +20,16 @@ import {
|
||||
LayoutDashboard,
|
||||
Mailbox,
|
||||
Megaphone,
|
||||
Network,
|
||||
Radio,
|
||||
Ruler,
|
||||
RefreshCw,
|
||||
Send,
|
||||
SendHorizonal,
|
||||
Server,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
UserCog,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -34,7 +40,7 @@ import { findingCount, useInstanceHealth, worstSeverity } from "@/hooks/useInsta
|
||||
import type { CheckSeverity } from "@/lib/api/client/admin/instance";
|
||||
import { AdminBadge } from "./AdminBadge";
|
||||
|
||||
interface NavItem {
|
||||
export interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
@@ -45,39 +51,46 @@ interface NavItem {
|
||||
healthBadge?: boolean;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
export interface NavGroup {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const GROUPS: NavGroup[] = [
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: "Overview",
|
||||
items: [{ to: "/", label: "Overview", icon: LayoutDashboard, end: true }],
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
items: [
|
||||
{ to: "/", label: "Overview", icon: LayoutDashboard, end: true },
|
||||
{ to: "/workers", label: "Workers", icon: Server, end: true },
|
||||
{ to: "/mailboxes", label: "Mailboxes", icon: Mailbox },
|
||||
{ to: "/warmup", label: "Warmup", icon: Flame, end: true },
|
||||
{ to: "/warmup/appeals", label: "Warmup Appeals", icon: ShieldCheck },
|
||||
{ to: "/warmup-content", label: "Warmup Content", icon: Sparkles },
|
||||
{ to: "/campaigns", label: "Campaigns", icon: Megaphone },
|
||||
{ to: "/workers", label: "Workers", icon: Server, end: true, perm: AdminPerm.ViewWorkers },
|
||||
{ to: "/fleet", label: "Fleet", icon: Network, perm: AdminPerm.ViewWorkers },
|
||||
{ to: "/mailboxes", label: "Mailboxes", icon: Mailbox, perm: AdminPerm.ViewUsers },
|
||||
{ to: "/sync", label: "Sync", icon: RefreshCw, perm: AdminPerm.ViewUsers },
|
||||
{ to: "/warmup", label: "Warmup", icon: Flame, end: true, perm: AdminPerm.ViewWarmupPool },
|
||||
{ to: "/warmup/appeals", label: "Warmup Appeals", icon: ShieldCheck, perm: AdminPerm.ReviewAppeals },
|
||||
{ to: "/warmup-content", label: "Warmup Content", icon: Sparkles, perm: AdminPerm.ViewWarmupPool },
|
||||
{ to: "/campaigns", label: "Campaigns", icon: Megaphone, perm: AdminPerm.ViewCampaigns },
|
||||
{ to: "/sends", label: "Sends", icon: SendHorizonal, perm: AdminPerm.ViewCampaigns },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Accounts",
|
||||
items: [
|
||||
{ to: "/users", label: "Users", icon: Users },
|
||||
{ to: "/organizations", label: "Organizations", icon: Building2 },
|
||||
{ to: "/limit-requests", label: "Limit requests", icon: Gauge },
|
||||
{ to: "/outreach", label: "Outreach", icon: Send },
|
||||
{ to: "/users", label: "Users", icon: Users, perm: AdminPerm.ViewUsers },
|
||||
{ to: "/organizations", label: "Organizations", icon: Building2, perm: AdminPerm.ViewOrganizations },
|
||||
{ to: "/limit-requests", label: "Limit requests", icon: Gauge, perm: AdminPerm.ViewOrganizations },
|
||||
{ to: "/outreach", label: "Outreach", icon: Send, perm: AdminPerm.ViewOrganizations },
|
||||
{ to: "/admins", label: "Admins", icon: UserCog, perm: AdminPerm.GrantAdminAccess },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Insight",
|
||||
items: [
|
||||
{ to: "/analytics", label: "Analytics", icon: BarChart3 },
|
||||
{ to: "/events", label: "Live Events", icon: Radio },
|
||||
{ to: "/audit", label: "Audit Log", icon: FileText },
|
||||
{ to: "/audit", label: "Audit Log", icon: FileText, perm: AdminPerm.ViewAuditLogs },
|
||||
{ to: "/jobs", label: "Jobs", icon: CalendarClock, perm: AdminPerm.ViewAnalytics },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -95,44 +108,29 @@ const GROUPS: NavGroup[] = [
|
||||
label: "Configuration",
|
||||
icon: SlidersHorizontal,
|
||||
perm: AdminPerm.ManageSettings,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
to: "/configuration/settings",
|
||||
label: "Instance settings",
|
||||
icon: Settings2,
|
||||
perm: AdminPerm.ManageSettings,
|
||||
},
|
||||
{
|
||||
to: "/configuration/notifications",
|
||||
label: "Notifications",
|
||||
icon: Bell,
|
||||
perm: AdminPerm.ManageSettings,
|
||||
},
|
||||
{
|
||||
to: "/limits",
|
||||
label: "Effective limits",
|
||||
icon: Ruler,
|
||||
perm: AdminPerm.ViewAnalytics,
|
||||
},
|
||||
{
|
||||
to: "/system",
|
||||
label: "System Status",
|
||||
icon: Activity,
|
||||
perm: AdminPerm.ViewAnalytics,
|
||||
to: "/transfers",
|
||||
label: "Transfers",
|
||||
icon: ArrowLeftRight,
|
||||
perm: AdminPerm.ViewOrganizations,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { data: me } = useMe();
|
||||
const mask = me?.admin_permissions;
|
||||
const canReadHealth = hasAdminPerm(mask, AdminPerm.ViewAnalytics);
|
||||
const healthQ = useInstanceHealth({ enabled: canReadHealth });
|
||||
const findings = findingCount(healthQ.data);
|
||||
const worst = worstSeverity(healthQ.data);
|
||||
// visibleNavGroups drops every item the signed-in admin cannot open and
|
||||
// every group that ends up empty.
|
||||
export function visibleNavGroups(mask: number | undefined): NavGroup[] {
|
||||
return NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter(
|
||||
(item) => item.perm === undefined || hasAdminPerm(mask, item.perm),
|
||||
),
|
||||
})).filter((group) => group.items.length > 0);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -142,48 +140,12 @@ export function Sidebar() {
|
||||
>
|
||||
{/* h-14 matches the Topbar so the two headers sit on one line. */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between gap-2 px-4 border-b border-sidebar-border">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Logo className="size-6 shrink-0 text-foreground" />
|
||||
<div className="min-w-0 leading-none">
|
||||
<div className="text-sm font-semibold text-sidebar-foreground leading-none truncate">
|
||||
Warmbly
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1">
|
||||
Control plane
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarBrand />
|
||||
<AdminBadge compact />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-3 space-y-5">
|
||||
{GROUPS.map((group) => {
|
||||
const items = group.items.filter(
|
||||
(item) => item.perm === undefined || hasAdminPerm(mask, item.perm),
|
||||
);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div className="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<li key={item.to}>
|
||||
<SidebarLink
|
||||
{...item}
|
||||
badge={
|
||||
item.healthBadge && findings > 0
|
||||
? { count: findings, severity: worst }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<NavList />
|
||||
</nav>
|
||||
|
||||
<div className="px-4 py-3 border-t border-sidebar-border flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
@@ -194,6 +156,62 @@ export function Sidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarBrand() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Logo className="size-6 shrink-0 text-foreground" />
|
||||
<div className="min-w-0 leading-none">
|
||||
<div className="text-sm font-semibold text-sidebar-foreground leading-none truncate">
|
||||
Warmbly
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1">
|
||||
Control plane
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NavList renders the permission-filtered groups. Shared by the desktop rail
|
||||
// and the mobile drawer; onNavigate lets the drawer close itself on a tap.
|
||||
export function NavList({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { data: me } = useMe();
|
||||
const mask = me?.admin_permissions;
|
||||
const canReadHealth = hasAdminPerm(mask, AdminPerm.ViewAnalytics);
|
||||
const healthQ = useInstanceHealth({ enabled: canReadHealth });
|
||||
const findings = findingCount(healthQ.data);
|
||||
const worst = worstSeverity(healthQ.data);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleNavGroups(mask).map((group) => (
|
||||
<div key={group.label}>
|
||||
{group.label !== "Overview" && (
|
||||
<div className="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.to}>
|
||||
<SidebarLink
|
||||
{...item}
|
||||
onNavigate={onNavigate}
|
||||
badge={
|
||||
item.healthBadge && findings > 0
|
||||
? { count: findings, severity: worst }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface CountBadge {
|
||||
count: number;
|
||||
severity: CheckSeverity | null;
|
||||
@@ -205,11 +223,19 @@ const BADGE_TONES: Record<CheckSeverity, string> = {
|
||||
info: "bg-sky-600 text-white",
|
||||
};
|
||||
|
||||
function SidebarLink({ to, label, icon: Icon, end, badge }: NavItem & { badge?: CountBadge }) {
|
||||
function SidebarLink({
|
||||
to,
|
||||
label,
|
||||
icon: Icon,
|
||||
end,
|
||||
badge,
|
||||
onNavigate,
|
||||
}: NavItem & { badge?: CountBadge; onNavigate?: () => void }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
@@ -238,8 +264,3 @@ function SidebarLink({ to, label, icon: Icon, end, badge }: NavItem & { badge?:
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// Standalone export so other surfaces (mobile drawer, breadcrumbs)
|
||||
// can reuse the same icon-per-route mapping if needed later.
|
||||
export const NAV_GROUPS = GROUPS;
|
||||
export type { NavItem };
|
||||
|
||||
@@ -1,25 +1,56 @@
|
||||
// Sticky top bar. Holds the env pill, the version pill (which becomes the
|
||||
// update indicator), a search slot (unused for now, reserved for the cmd-K
|
||||
// palette we'll add later), and the user menu.
|
||||
// Sticky top bar. Holds the hamburger (mobile only), the env pill, the
|
||||
// version pill (which becomes the update indicator), the search button that
|
||||
// opens the command palette, and the user menu.
|
||||
// The 3px admin stripe sits *above* this bar in AppShell so it's the
|
||||
// first thing the eye lands on.
|
||||
|
||||
import { useState } from "react";
|
||||
import { Menu, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IS_MAC, openCommandPalette } from "./CommandPalette";
|
||||
import { EnvPill } from "./EnvPill";
|
||||
import { MobileNav } from "./MobileNav";
|
||||
import { UpdatePill } from "./UpdatePill";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
|
||||
export function Topbar() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="h-14 shrink-0 border-b border-border bg-background/80 backdrop-blur sticky top-0 z-30">
|
||||
<div className="h-full flex items-center gap-3 px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="md:hidden -ml-1"
|
||||
aria-label="Open navigation"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
<MobileNav open={mobileOpen} onOpenChange={setMobileOpen} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvPill />
|
||||
<UpdatePill />
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
Connected to <code className="text-foreground font-mono">/admin/*</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex-1 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCommandPalette}
|
||||
aria-label="Search and go to"
|
||||
className="group inline-flex h-8 w-full max-w-md items-center gap-2 rounded-md border border-border bg-muted/40 px-2.5 text-[12.5px] text-muted-foreground transition-colors hover:border-[var(--admin-accent)] hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent-soft)]"
|
||||
>
|
||||
<Search className="size-3.5 shrink-0" />
|
||||
<span className="hidden sm:inline truncate">Search or go to</span>
|
||||
<kbd className="ml-auto hidden sm:inline-flex items-center gap-0.5 rounded border border-border bg-background px-1.5 font-mono text-[10px] leading-4 text-muted-foreground">
|
||||
{IS_MAC ? "⌘" : "Ctrl"}
|
||||
<span>K</span>
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// cmdk primitives for the admin command palette. Copied from
|
||||
// web/src/components/ui/command.tsx and tinted to the admin theme (amber
|
||||
// selection wash, tighter rows).
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command palette",
|
||||
description = "Search pages, records and actions",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
shouldFilter,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
// Off when the caller filters itself (async search results).
|
||||
shouldFilter?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent
|
||||
data-floating=""
|
||||
className={cn(
|
||||
"top-[12%] translate-y-0 overflow-hidden p-0 sm:max-w-xl",
|
||||
className
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
<Command shouldFilter={shouldFilter} className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b border-border px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[min(360px,60dvh)] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm text-muted-foreground"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-[var(--admin-accent-soft)] data-[selected=true]:text-[var(--admin-accent-strong)] [&_svg:not([class*='text-'])]:text-muted-foreground data-[selected=true]:[&_svg]:text-[var(--admin-accent-strong)] relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Side drawer built on Radix Dialog. Copied from web/src/components/ui/sheet.tsx;
|
||||
// the mobile nav is its only consumer today.
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 overflow-y-auto border-l border-border sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 overflow-y-auto border-r border-border sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b border-border",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t border-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// "<Page> | Warmbly Admin" on every navigation. The nav model is the source
|
||||
// for list pages; detail routes get a small map here. Anything unmapped is
|
||||
// "Not found", which is what the catch-all route renders anyway.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { NAV_GROUPS } from "@/components/layout/Sidebar";
|
||||
|
||||
const BRAND = "Warmbly Admin";
|
||||
|
||||
const NAV_TITLES: Record<string, string> = Object.fromEntries(
|
||||
NAV_GROUPS.flatMap((g) => g.items.map((item) => [item.to, item.label])),
|
||||
);
|
||||
|
||||
const STATIC_TITLES: Record<string, string> = {
|
||||
...NAV_TITLES,
|
||||
"/workers/new": "New worker",
|
||||
"/warmup-content/overview": "Warmup content overview",
|
||||
"/warmup-content/library": "Warmup content library",
|
||||
"/warmup-content/jobs": "Warmup content jobs",
|
||||
"/auth/login": "Sign in",
|
||||
};
|
||||
|
||||
const PARAM_ROUTES: Array<{ pattern: RegExp; title: string }> = [
|
||||
{ pattern: /^\/users\/[^/]+$/, title: "User" },
|
||||
{ pattern: /^\/organizations\/[^/]+$/, title: "Organization" },
|
||||
{ pattern: /^\/workers\/[^/]+$/, title: "Worker" },
|
||||
];
|
||||
|
||||
export function titleForPath(pathname: string): string {
|
||||
const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
||||
const label =
|
||||
STATIC_TITLES[path] ?? PARAM_ROUTES.find((r) => r.pattern.test(path))?.title ?? "Not found";
|
||||
return `${label} | ${BRAND}`;
|
||||
}
|
||||
|
||||
export function useDocumentTitle() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
document.title = titleForPath(pathname);
|
||||
}, [pathname]);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// /admin/admins/* and /admin/permissions — who holds operator-panel bits.
|
||||
// Shapes mirror AdminInfo / AdminsResult in internal/models/admin.go and
|
||||
// PermissionInfo in internal/models/admin_permission.go.
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
import type { AdminUserSummary } from "@/lib/api/models/admin";
|
||||
|
||||
export interface AdminInfo {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
/** Bitmask, not a list: models.AdminPermission serializes as a uint32. */
|
||||
admin_permissions: number;
|
||||
admin_granted_at?: string | null;
|
||||
admin_granted_by?: string | null;
|
||||
granted_by_user?: AdminUserSummary | null;
|
||||
}
|
||||
|
||||
export interface AdminsResult {
|
||||
data: AdminInfo[] | null;
|
||||
pagination: {
|
||||
total?: number | null;
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
name: string;
|
||||
/** The single bit this permission occupies. */
|
||||
permission: number;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export function listAdmins(cursor?: string, limit = 100): Promise<AdminsResult> {
|
||||
const usp = new URLSearchParams();
|
||||
if (cursor) usp.set("cursor", cursor);
|
||||
usp.set("limit", String(limit));
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/admins?${usp.toString()}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
// The backend wraps the catalog as { permissions: [...] }; unwrap it here.
|
||||
export function listAdminPermissions(): Promise<PermissionInfo[]> {
|
||||
return Request<{ permissions: PermissionInfo[] | null }>({
|
||||
method: "GET",
|
||||
url: "/admin/permissions",
|
||||
authorization: true,
|
||||
}).then((r) => r.permissions ?? []);
|
||||
}
|
||||
|
||||
// GrantAdminRequest takes only the bitmask; presets are resolved client-side.
|
||||
export function grantAdminPermissions(
|
||||
userId: string,
|
||||
permissions: number,
|
||||
): Promise<{ message: string }> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/admins/${userId}/grant`,
|
||||
authorization: true,
|
||||
data: { permissions },
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeAdminPermissions(userId: string): Promise<{ message: string }> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/admins/${userId}/revoke`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,38 @@
|
||||
// /admin/analytics/*
|
||||
//
|
||||
// Shapes mirror internal/models/admin.go (PlatformOverview, AnalyticsTrends,
|
||||
// the *Stats rows) and admin_ops.go (AdminAcquisition). The timeseries
|
||||
// handlers take start_date/end_date and answer {stats: [...]}, so the
|
||||
// helpers below translate a "last N days" window and unwrap the rows.
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
import type {
|
||||
AnalyticsTrends,
|
||||
DailyEmailStat,
|
||||
HourlyEmailStat,
|
||||
PlatformOverview,
|
||||
UserGrowthStat,
|
||||
WorkerLoadStat,
|
||||
} from "@/lib/api/models/admin";
|
||||
|
||||
export function getPlatformOverview(): Promise<PlatformOverview> {
|
||||
// models.PlatformOverview. Optional so a page renders "—" for a field an
|
||||
// older backend does not send instead of crashing.
|
||||
export interface AdminPlatformOverview {
|
||||
total_users?: number;
|
||||
active_users?: number;
|
||||
new_users_today?: number;
|
||||
new_users_this_week?: number;
|
||||
total_campaigns?: number;
|
||||
active_campaigns?: number;
|
||||
total_emails_sent?: number;
|
||||
emails_sent_today?: number;
|
||||
total_workers?: number;
|
||||
active_workers?: number;
|
||||
warmup_blocked_count?: number;
|
||||
pending_appeals?: number;
|
||||
active_subscriptions?: number;
|
||||
trialing_users?: number;
|
||||
}
|
||||
|
||||
export function getPlatformOverview(): Promise<AdminPlatformOverview> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: "/admin/analytics/overview",
|
||||
@@ -26,34 +48,71 @@ export function getAnalyticsTrends(): Promise<AnalyticsTrends> {
|
||||
});
|
||||
}
|
||||
|
||||
export function getDailyEmailStats(days = 30): Promise<{ data: DailyEmailStat[] }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/analytics/emails/daily?days=${days}`,
|
||||
authorization: true,
|
||||
});
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function getHourlyEmailStats(): Promise<{ data: HourlyEmailStat[] }> {
|
||||
return Request({
|
||||
// parseDateRange on the backend reads start_date/end_date as YYYY-MM-DD.
|
||||
function rangeQuery(days: number): string {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
return `start_date=${isoDate(start)}&end_date=${isoDate(end)}`;
|
||||
}
|
||||
|
||||
export async function getDailyEmailStats(days = 30): Promise<DailyEmailStat[]> {
|
||||
const res = await Request<{ stats: DailyEmailStat[] | null }>({
|
||||
method: "GET",
|
||||
url: `/admin/analytics/emails/daily?${rangeQuery(days)}`,
|
||||
authorization: true,
|
||||
});
|
||||
return res.stats ?? [];
|
||||
}
|
||||
|
||||
export async function getHourlyEmailStats(): Promise<HourlyEmailStat[]> {
|
||||
const res = await Request<{ stats: HourlyEmailStat[] | null }>({
|
||||
method: "GET",
|
||||
url: "/admin/analytics/emails/hourly",
|
||||
authorization: true,
|
||||
});
|
||||
return res.stats ?? [];
|
||||
}
|
||||
|
||||
export function getWorkerLoadStats(): Promise<{ data: WorkerLoadStat[] }> {
|
||||
export async function getUserGrowthStats(days = 30): Promise<UserGrowthStat[]> {
|
||||
const res = await Request<{ stats: UserGrowthStat[] | null }>({
|
||||
method: "GET",
|
||||
url: `/admin/analytics/users/growth?${rangeQuery(days)}`,
|
||||
authorization: true,
|
||||
});
|
||||
return res.stats ?? [];
|
||||
}
|
||||
|
||||
// models.AdminAcquisition: signups by channel over a window.
|
||||
export interface AdminAcquisitionChannel {
|
||||
source: string;
|
||||
medium: string;
|
||||
signups: number;
|
||||
converted: number;
|
||||
}
|
||||
|
||||
export interface AdminAcquisitionReferrer {
|
||||
host: string;
|
||||
signups: number;
|
||||
}
|
||||
|
||||
export interface AdminAcquisition {
|
||||
days: number;
|
||||
signups: number;
|
||||
with_channel: number;
|
||||
converted: number;
|
||||
trials_expiring_7d: number;
|
||||
channels: AdminAcquisitionChannel[] | null;
|
||||
referrers: AdminAcquisitionReferrer[] | null;
|
||||
}
|
||||
|
||||
export function getAcquisition(days = 30): Promise<AdminAcquisition> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: "/admin/analytics/workers/load",
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getUserGrowthStats(days = 30): Promise<{ data: UserGrowthStat[] }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/analytics/users/growth?days=${days}`,
|
||||
url: `/admin/analytics/acquisition?days=${days}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// /admin/fleet/* — placement as the operator sees it: every worker against
|
||||
// its capacity row, the decision log the control loops write, and the
|
||||
// dedicated bindings. Shapes mirror the fleet section of
|
||||
// internal/models/admin_ops.go (snake_case, as the backend serializes them).
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
import type {
|
||||
WorkerEgressKind,
|
||||
WorkerHealthState,
|
||||
WorkerRiskPool,
|
||||
WorkerType,
|
||||
} from "@/lib/api/models/admin";
|
||||
|
||||
export interface AdminFleetWorkerRow {
|
||||
worker_id: string;
|
||||
name: string;
|
||||
ip_addr: string;
|
||||
active: boolean;
|
||||
free_tier: boolean;
|
||||
worker_type: WorkerType;
|
||||
risk_pool: WorkerRiskPool;
|
||||
egress_kind: WorkerEgressKind;
|
||||
health_state: WorkerHealthState;
|
||||
install_state: string;
|
||||
last_seen_at?: string | null;
|
||||
live: boolean;
|
||||
account_count: number;
|
||||
tags: string[] | null;
|
||||
|
||||
load_score: number;
|
||||
base_capacity: number;
|
||||
health_multiplier: number;
|
||||
age_multiplier: number;
|
||||
effective_capacity: number;
|
||||
/** Load over effective capacity; the rebalancer calls a worker hot above 0.8 and cold below 0.5. */
|
||||
utilization: number;
|
||||
sends_attempted_1h: number;
|
||||
sends_succeeded_1h: number;
|
||||
bounces_hard_1h: number;
|
||||
bounces_soft_1h: number;
|
||||
complaints_1h: number;
|
||||
auth_errors_1h: number;
|
||||
}
|
||||
|
||||
export interface AdminFleetDecision {
|
||||
id: number;
|
||||
kind: string;
|
||||
worker_id?: string | null;
|
||||
worker_name: string;
|
||||
mailbox_id?: string | null;
|
||||
/** Raw JSON as the control loop recorded it; shape depends on `kind`. */
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
reason: string;
|
||||
triggered_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminDedicatedAssignment {
|
||||
id: string;
|
||||
worker_id: string;
|
||||
worker_name: string;
|
||||
worker_live: boolean;
|
||||
organization_id: string;
|
||||
organization_name: string;
|
||||
subscription_id: string;
|
||||
assigned_at: string;
|
||||
released_at?: string | null;
|
||||
account_count: number;
|
||||
}
|
||||
|
||||
export interface AdminConvertDedicatedRequest {
|
||||
organization_id: string;
|
||||
subscription_id: string;
|
||||
drain_to_worker_id?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminConvertDedicatedResponse {
|
||||
ok: boolean;
|
||||
accounts_drained: number;
|
||||
new_assignment: boolean;
|
||||
}
|
||||
|
||||
export interface AdminReleaseDedicatedResponse {
|
||||
ok: boolean;
|
||||
worker_id: string;
|
||||
accounts_moved: number;
|
||||
accounts_remaining: number;
|
||||
returned_to_shared: boolean;
|
||||
}
|
||||
|
||||
export interface FleetDecisionsParams {
|
||||
kind?: string;
|
||||
worker_id?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function getFleetCapacity(): Promise<{ data: AdminFleetWorkerRow[] | null }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: "/admin/fleet/capacity",
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function listFleetDecisions(
|
||||
params: FleetDecisionsParams = {},
|
||||
): Promise<{ data: AdminFleetDecision[] | null }> {
|
||||
const usp = new URLSearchParams();
|
||||
if (params.kind) usp.set("kind", params.kind);
|
||||
if (params.worker_id) usp.set("worker_id", params.worker_id);
|
||||
if (params.limit) usp.set("limit", String(params.limit));
|
||||
const q = usp.toString();
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/fleet/decisions${q ? `?${q}` : ""}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function listDedicatedAssignments(): Promise<{ data: AdminDedicatedAssignment[] | null }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: "/admin/fleet/dedicated",
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Moves the workspace's mailboxes back to shared premium workers, releases the
|
||||
// binding, and returns the worker to the shared pool when nothing else binds it.
|
||||
export function releaseDedicatedWorker(orgId: string): Promise<AdminReleaseDedicatedResponse> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/fleet/dedicated/${orgId}/release`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
// The backend refuses a worker that still carries mailboxes unless
|
||||
// drain_to_worker_id names where they go first.
|
||||
export function convertWorkerToDedicated(
|
||||
workerId: string,
|
||||
body: AdminConvertDedicatedRequest,
|
||||
): Promise<AdminConvertDedicatedResponse> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/workers/${workerId}/convert-dedicated`,
|
||||
authorization: true,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// /admin/jobs/* — every background loop on the instance and "run now".
|
||||
// Shapes mirror ScheduledJobRun in internal/models/admin_ops.go.
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
|
||||
export type ScheduledJobStatus = "idle" | "running" | "ok" | "error";
|
||||
|
||||
export interface ScheduledJobRun {
|
||||
name: string;
|
||||
// The process that owns the loop: backend | consumer.
|
||||
service: string;
|
||||
interval_seconds: number;
|
||||
last_started_at?: string;
|
||||
last_finished_at?: string;
|
||||
last_duration_ms: number;
|
||||
last_status: ScheduledJobStatus | string;
|
||||
last_error: string;
|
||||
run_count: number;
|
||||
error_count: number;
|
||||
// Set by "run now"; the owning loop clears it when it picks the request up.
|
||||
run_requested_at?: string;
|
||||
next_run_at?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminJobsResult {
|
||||
data: ScheduledJobRun[];
|
||||
}
|
||||
|
||||
export interface AdminRunJobResult {
|
||||
requested: boolean;
|
||||
}
|
||||
|
||||
// The consumer loop that resolves reservations nobody answered
|
||||
// (StartStuckSendReclaimer); the Sends page runs it on demand.
|
||||
export const STUCK_SEND_RECLAIMER_JOB = "stuck_send_reclaimer";
|
||||
|
||||
export function listJobs(): Promise<AdminJobsResult> {
|
||||
return Request({ method: "GET", url: "/admin/jobs", authorization: true });
|
||||
}
|
||||
|
||||
export function runJob(name: string): Promise<AdminRunJobResult> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/jobs/${encodeURIComponent(name)}/run`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -113,3 +113,72 @@ export function clearOrganizationRiskSignal(
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- API keys and webhooks (operator view) ----
|
||||
// Shapes mirror AdminOrgAPIKey / AdminWebhookEndpointRow in
|
||||
// internal/models/admin_ops.go.
|
||||
|
||||
export interface AdminOrgAPIKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
key_suffix: string;
|
||||
status: string;
|
||||
permissions: number;
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
last_used_at?: string | null;
|
||||
expires_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
created_at: string;
|
||||
requests_last_7d: number;
|
||||
}
|
||||
|
||||
export interface AdminWebhookEndpointRow {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
organization_name: string;
|
||||
url: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
event_types: string[] | null;
|
||||
consecutive_failures: number;
|
||||
last_success_at?: string | null;
|
||||
last_failure_at?: string | null;
|
||||
last_failure_reason: string;
|
||||
deliveries_last_7d: number;
|
||||
failed_last_7d: number;
|
||||
drops_last_7d: number;
|
||||
}
|
||||
|
||||
export function listOrganizationAPIKeys(id: string): Promise<{ data: AdminOrgAPIKey[] | null }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/organizations/${id}/api-keys`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
// An empty reason is recorded as "revoked by platform admin" server-side.
|
||||
export function revokeOrganizationAPIKey(
|
||||
id: string,
|
||||
keyId: string,
|
||||
reason?: string,
|
||||
): Promise<{ id: string; organization_id: string; status: string }> {
|
||||
return Request({
|
||||
method: "DELETE",
|
||||
url: `/admin/organizations/${id}/api-keys/${keyId}`,
|
||||
authorization: true,
|
||||
data: reason ? { reason } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function listOrganizationWebhooks(
|
||||
id: string,
|
||||
): Promise<{ data: AdminWebhookEndpointRow[] | null }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/organizations/${id}/webhooks`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// /admin/plans — plan catalog.
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
import { buildSearchQuery } from "@/lib/api/client/admin/query";
|
||||
import type {
|
||||
AdminPlanSearch,
|
||||
AdminPlansResult,
|
||||
Plan,
|
||||
UpdatePlanRequest,
|
||||
} from "@/lib/api/models/admin";
|
||||
|
||||
// listPlans is the no-arg variant used as a facet-dropdown source (it is passed
|
||||
// directly as a react-query queryFn, so it must NOT accept the query context as
|
||||
// params). It returns the first page; callers read `.data`.
|
||||
export function listPlans(): Promise<AdminPlansResult> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: "/admin/plans",
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
// searchPlans is the faceted variant for the Plans explorer page.
|
||||
export function searchPlans(
|
||||
params: AdminPlanSearch = {},
|
||||
): Promise<AdminPlansResult> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/plans${buildSearchQuery(params as Record<string, unknown>)}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlan(
|
||||
id: string,
|
||||
body: UpdatePlanRequest,
|
||||
): Promise<Plan> {
|
||||
return Request({
|
||||
method: "PATCH",
|
||||
url: `/admin/plans/${id}`,
|
||||
authorization: true,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// /admin/sends/*, /admin/tasks/*, /admin/webhooks/* — the send outcome loop
|
||||
// as the operator sees it: reservations still waiting on a worker result,
|
||||
// tasks that exhausted their retries, recent task failures, and customer
|
||||
// webhook delivery health. Shapes mirror internal/models/admin_ops.go.
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
import { buildSearchQuery } from "@/lib/api/client/admin/query";
|
||||
import type { AdminOpsPagination } from "@/lib/api/client/admin/sync";
|
||||
|
||||
// ---- in-flight sends ----
|
||||
|
||||
// A reserved campaign send no EMAIL_SENT / EMAIL_FAILED has resolved yet.
|
||||
export interface AdminInFlightSend {
|
||||
campaign_id: string;
|
||||
campaign_name: string;
|
||||
organization_id?: string;
|
||||
organization_name: string;
|
||||
contact_id: string;
|
||||
contact_email: string;
|
||||
sequence_id: string;
|
||||
task_id?: string;
|
||||
task_status: string;
|
||||
// The worker did put the mail on the wire and only the stamp was lost;
|
||||
// the reclaimer will stamp it rather than retry.
|
||||
has_message_id: boolean;
|
||||
email_account_id?: string;
|
||||
mailbox_email: string;
|
||||
worker_id?: string;
|
||||
dispatched_at: string;
|
||||
age_seconds: number;
|
||||
}
|
||||
|
||||
export interface AdminInFlightSummary {
|
||||
total: number;
|
||||
under_5m: number;
|
||||
under_30m: number;
|
||||
past_reclaim_window: number;
|
||||
oldest_dispatched_at?: string;
|
||||
// config.CampaignSendReclaimAfterMinutes.
|
||||
reclaim_after_minutes: number;
|
||||
}
|
||||
|
||||
export interface AdminInFlightResult {
|
||||
summary: AdminInFlightSummary;
|
||||
data: AdminInFlightSend[];
|
||||
}
|
||||
|
||||
// ---- dead letters ----
|
||||
|
||||
export type AdminDeadLetterStatus = "pending" | "replayed" | "failed";
|
||||
|
||||
export interface AdminDeadLetterRow {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_type: string;
|
||||
payload: Record<string, unknown>;
|
||||
last_error: string;
|
||||
attempts: number;
|
||||
max_attempts: number;
|
||||
status: AdminDeadLetterStatus | string;
|
||||
next_retry_at?: string;
|
||||
replayed_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
organization_id?: string;
|
||||
organization_name: string;
|
||||
}
|
||||
|
||||
export interface AdminDeadLettersSearch {
|
||||
status?: AdminDeadLetterStatus | "all";
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminDeadLettersResult {
|
||||
data: AdminDeadLetterRow[];
|
||||
pagination: AdminOpsPagination | null;
|
||||
// Counts by status across the instance, independent of the filter.
|
||||
pending: number;
|
||||
replayed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
// ---- task failures ----
|
||||
|
||||
export interface AdminTaskFailureRow {
|
||||
task_id: string;
|
||||
task_type: string;
|
||||
task_status: string;
|
||||
title: string;
|
||||
message: string;
|
||||
email_account_id: string;
|
||||
mailbox_email: string;
|
||||
organization_id?: string;
|
||||
organization_name: string;
|
||||
occurred_at: string;
|
||||
}
|
||||
|
||||
export interface AdminTaskFailuresResult {
|
||||
data: AdminTaskFailureRow[];
|
||||
}
|
||||
|
||||
// ---- webhooks ----
|
||||
|
||||
export interface AdminWebhookEndpointRow {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
organization_name: string;
|
||||
url: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
event_types: string[];
|
||||
consecutive_failures: number;
|
||||
last_success_at?: string;
|
||||
last_failure_at?: string;
|
||||
last_failure_reason: string;
|
||||
deliveries_last_7d: number;
|
||||
failed_last_7d: number;
|
||||
drops_last_7d: number;
|
||||
}
|
||||
|
||||
export interface AdminWebhookHealth {
|
||||
// Deliveries claimed longer ago than the lease.
|
||||
in_flight_stale: number;
|
||||
pending_due: number;
|
||||
delivered_last_24h: number;
|
||||
failed_last_24h: number;
|
||||
abandoned_last_24h: number;
|
||||
drops_last_7d: number;
|
||||
lease_minutes: number;
|
||||
// Endpoints with consecutive failures, worst first.
|
||||
failing_endpoints: AdminWebhookEndpointRow[];
|
||||
}
|
||||
|
||||
export interface AdminWebhookReclaimResult {
|
||||
reclaimed: number;
|
||||
}
|
||||
|
||||
// ---- calls ----
|
||||
|
||||
export function listInFlightSends(limit?: number): Promise<AdminInFlightResult> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/sends/in-flight${buildSearchQuery({ limit })}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function listDeadLetters(params: AdminDeadLettersSearch = {}): Promise<AdminDeadLettersResult> {
|
||||
const { status, ...rest } = params;
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/tasks/dead-letters${buildSearchQuery({
|
||||
...rest,
|
||||
status: status && status !== "all" ? status : undefined,
|
||||
})}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function replayDeadLetter(id: string): Promise<void> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/tasks/dead-letters/${id}/replay`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function listTaskFailures(limit?: number): Promise<AdminTaskFailuresResult> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/tasks/failures${buildSearchQuery({ limit })}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getWebhookHealth(): Promise<AdminWebhookHealth> {
|
||||
return Request({ method: "GET", url: "/admin/webhooks/health", authorization: true });
|
||||
}
|
||||
|
||||
export function reclaimWebhookDeliveries(): Promise<AdminWebhookReclaimResult> {
|
||||
return Request({ method: "POST", url: "/admin/webhooks/reclaim", authorization: true });
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// /admin/sync/* — the mailbox sync governor: every mailbox's backfill and
|
||||
// fair-use throttle state as the platform recorded it from SYNC_STATE.
|
||||
// Shapes mirror internal/models/admin_ops.go (snake_case).
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
import { buildSearchQuery } from "@/lib/api/client/admin/query";
|
||||
|
||||
export interface AdminOpsPagination {
|
||||
total: number | null;
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export type AdminSyncStateFilter =
|
||||
| "all"
|
||||
| "throttled"
|
||||
| "backfilling"
|
||||
| "stalled"
|
||||
| "pending"
|
||||
| "complete";
|
||||
|
||||
export const ADMIN_SYNC_STATES: AdminSyncStateFilter[] = [
|
||||
"all",
|
||||
"throttled",
|
||||
"backfilling",
|
||||
"stalled",
|
||||
"pending",
|
||||
"complete",
|
||||
];
|
||||
|
||||
export interface AdminSyncSearch {
|
||||
state?: AdminSyncStateFilter;
|
||||
q?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminSyncSummary {
|
||||
total: number;
|
||||
throttled: number;
|
||||
backfilling: number;
|
||||
stalled: number;
|
||||
pending: number;
|
||||
complete: number;
|
||||
deferred: number;
|
||||
}
|
||||
|
||||
export interface AdminSyncRow {
|
||||
email_id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
account_status: string;
|
||||
organization_id?: string;
|
||||
organization_name: string;
|
||||
worker_id?: string;
|
||||
|
||||
backfill_status: string;
|
||||
backfill_synced: number;
|
||||
backfill_since?: string;
|
||||
backfill_started_at?: string;
|
||||
backfill_completed_at?: string;
|
||||
|
||||
throttled_until?: string;
|
||||
throttle_reason: string;
|
||||
deferred: number;
|
||||
// A running backfill whose state has not moved for an hour.
|
||||
stalled: boolean;
|
||||
last_synced_at?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminSyncResult {
|
||||
data: AdminSyncRow[];
|
||||
pagination: AdminOpsPagination | null;
|
||||
summary: AdminSyncSummary;
|
||||
}
|
||||
|
||||
// Both POSTs re-ship the mailbox to its worker after the write; `reloaded`
|
||||
// reports whether that took, and `reload_error` says why when it did not.
|
||||
export interface AdminSyncActionResult {
|
||||
email_id: string;
|
||||
cleared?: boolean;
|
||||
reset?: boolean;
|
||||
reloaded: boolean;
|
||||
reload_error?: string;
|
||||
}
|
||||
|
||||
export function searchSync(params: AdminSyncSearch = {}): Promise<AdminSyncResult> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/sync${buildSearchQuery(params as Record<string, unknown>)}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSyncThrottle(emailId: string): Promise<AdminSyncActionResult> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/sync/${emailId}/clear-throttle`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function restartSyncBackfill(emailId: string): Promise<AdminSyncActionResult> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/sync/${emailId}/restart-backfill`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// /admin/transfers and /admin/organizations/:id/{exports,imports} — the
|
||||
// operator's side of workspace archives. Same jobs owners build from
|
||||
// Settings > Data, keyed by organization id instead of the caller's own org.
|
||||
// Shapes mirror internal/models/org_transfer.go and the transfer section of
|
||||
// internal/models/admin_ops.go.
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
|
||||
export type OrgTransferStatus = "queued" | "running" | "completed" | "failed" | "expired";
|
||||
|
||||
export type OrgDataGroup =
|
||||
| "core"
|
||||
| "contacts"
|
||||
| "campaigns"
|
||||
| "crm"
|
||||
| "automations"
|
||||
| "ai"
|
||||
| "warmup"
|
||||
| "inbox"
|
||||
| "sending"
|
||||
| "events"
|
||||
| "logs"
|
||||
| "billing";
|
||||
|
||||
export type OrgImportConflict = "skip" | "overwrite";
|
||||
|
||||
export interface OrgDataGroupInfo {
|
||||
key: OrgDataGroup;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
heavy: boolean;
|
||||
requires?: OrgDataGroup[];
|
||||
}
|
||||
|
||||
// The admin API has no catalog endpoint (the org-scoped one needs an org
|
||||
// context), so this is models.OrgDataGroupCatalog copied verbatim. Keep the
|
||||
// two in step when a group is added.
|
||||
export const ORG_DATA_GROUP_CATALOG: OrgDataGroupInfo[] = [
|
||||
{ key: "core", label: "Workspace", description: "Organization, members, roles, teams, mailboxes, API keys, webhooks, and settings.", required: true, heavy: false },
|
||||
{ key: "contacts", label: "Contacts", description: "Contacts, categories, notes, activities, and the suppression list.", required: false, heavy: false },
|
||||
{ key: "campaigns", label: "Campaigns", description: "Campaigns, sequences, senders, attachments, and per-campaign settings.", required: false, heavy: false, requires: ["contacts"] },
|
||||
{ key: "crm", label: "CRM", description: "Pipelines, deals, tasks, and meeting bookings.", required: false, heavy: false },
|
||||
{ key: "automations", label: "Automations", description: "Automations, connected integrations, and lead sync sources.", required: false, heavy: false },
|
||||
{ key: "ai", label: "Assistant", description: "Assistant sessions and messages, skills, MCP servers, and AI settings.", required: false, heavy: false },
|
||||
{ key: "warmup", label: "Warmup", description: "Warmup participation, routing rules, statistics, and appeals.", required: false, heavy: false },
|
||||
{ key: "inbox", label: "Inbox", description: "Unified inbox threads, message bodies, and mailbox sync state.", required: false, heavy: true, requires: ["contacts"] },
|
||||
{ key: "sending", label: "Send history", description: "Queued and completed send tasks with their payloads.", required: false, heavy: true },
|
||||
{ key: "events", label: "Delivery events", description: "Bounces, complaints, opens, clicks, and placement tests.", required: false, heavy: true },
|
||||
{ key: "logs", label: "Logs", description: "Audit log, campaign logs, and notifications.", required: false, heavy: true, requires: ["campaigns"] },
|
||||
{ key: "billing", label: "Billing history", description: "Subscription, credit ledger, and referral records. Read-only on import: the destination instance owns billing.", required: false, heavy: false },
|
||||
];
|
||||
|
||||
// MinOrgExportPassphraseLength in org_transfer.go.
|
||||
export const MIN_EXPORT_PASSPHRASE = 12;
|
||||
|
||||
// Expands a selection over the catalog's dependencies, mirroring the server's
|
||||
// NormalizeGroups. Required groups are always in.
|
||||
export function expandGroups(
|
||||
selected: Iterable<OrgDataGroup>,
|
||||
catalog: OrgDataGroupInfo[] = ORG_DATA_GROUP_CATALOG,
|
||||
): Set<OrgDataGroup> {
|
||||
const byKey = new Map(catalog.map((g) => [g.key, g]));
|
||||
const out = new Set<OrgDataGroup>(catalog.filter((g) => g.required).map((g) => g.key));
|
||||
const queue = [...selected];
|
||||
while (queue.length > 0) {
|
||||
const key = queue.pop() as OrgDataGroup;
|
||||
if (out.has(key) || !byKey.has(key)) continue;
|
||||
out.add(key);
|
||||
queue.push(...(byKey.get(key)?.requires ?? []));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The selected groups that depend on `key`, so unticking it can explain itself.
|
||||
// dependentsOf walks the requirement graph transitively, so unticking a group
|
||||
// also drops everything that needs it through another group.
|
||||
export function dependentsOf(
|
||||
key: OrgDataGroup,
|
||||
selected: Set<OrgDataGroup>,
|
||||
catalog: OrgDataGroupInfo[] = ORG_DATA_GROUP_CATALOG,
|
||||
): OrgDataGroupInfo[] {
|
||||
const out = new Map<OrgDataGroup, OrgDataGroupInfo>();
|
||||
const queue: OrgDataGroup[] = [key];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
for (const g of catalog) {
|
||||
if (out.has(g.key) || !selected.has(g.key) || g.key === key) continue;
|
||||
if ((g.requires ?? []).includes(current)) {
|
||||
out.set(g.key, g);
|
||||
queue.push(g.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...out.values()];
|
||||
}
|
||||
|
||||
export interface OrgArchiveUser {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string;
|
||||
is_owner: boolean;
|
||||
}
|
||||
|
||||
export interface OrgArchiveInfo {
|
||||
format_version: number;
|
||||
source_instance: string;
|
||||
source_app_version: string;
|
||||
organization_id: string;
|
||||
organization_name: string;
|
||||
exported_at: string;
|
||||
groups: OrgDataGroup[];
|
||||
has_secrets: boolean;
|
||||
row_counts: Record<string, number> | null;
|
||||
blob_count: number;
|
||||
members: OrgArchiveUser[] | null;
|
||||
}
|
||||
|
||||
export interface OrgExportJob {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
requested_by?: string | null;
|
||||
status: OrgTransferStatus;
|
||||
groups: OrgDataGroup[];
|
||||
include_secrets: boolean;
|
||||
format_version: number;
|
||||
progress_percent: number;
|
||||
progress_stage: string;
|
||||
archive_bytes?: number | null;
|
||||
archive_sha256?: string | null;
|
||||
row_counts: Record<string, number> | null;
|
||||
error_message?: string | null;
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
expires_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OrgImportJob {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
requested_by?: string | null;
|
||||
status: OrgTransferStatus;
|
||||
archive_bytes?: number | null;
|
||||
archive_sha256?: string | null;
|
||||
source_manifest?: OrgArchiveInfo | null;
|
||||
groups: OrgDataGroup[];
|
||||
conflict_strategy: OrgImportConflict;
|
||||
progress_percent: number;
|
||||
progress_stage: string;
|
||||
row_counts: Record<string, number> | null;
|
||||
warnings: string[] | null;
|
||||
error_message?: string | null;
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OrgImportPreflight {
|
||||
archive: OrgArchiveInfo;
|
||||
secrets_unsealed: boolean;
|
||||
conflicts: Record<string, number> | null;
|
||||
unknown_members: OrgArchiveUser[] | null;
|
||||
skipped_tables: string[] | null;
|
||||
warnings: string[] | null;
|
||||
}
|
||||
|
||||
export interface AdminTransferJob {
|
||||
kind: "export" | "import";
|
||||
id: string;
|
||||
organization_id: string;
|
||||
organization_name: string;
|
||||
requested_by?: string | null;
|
||||
requested_by_email: string;
|
||||
status: OrgTransferStatus;
|
||||
groups: OrgDataGroup[] | null;
|
||||
include_secrets: boolean;
|
||||
progress_percent: number;
|
||||
progress_stage: string;
|
||||
archive_bytes?: number | null;
|
||||
error_message?: string | null;
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
expires_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateOrgExportRequest {
|
||||
groups?: OrgDataGroup[];
|
||||
include_secrets?: boolean;
|
||||
passphrase?: string;
|
||||
}
|
||||
|
||||
// The import endpoint's "options" multipart field; passphrase travels as its
|
||||
// own field and the archive as "file".
|
||||
export interface CreateOrgImportOptions {
|
||||
groups?: OrgDataGroup[];
|
||||
conflict_strategy?: OrgImportConflict;
|
||||
}
|
||||
|
||||
export function isTransferActive(status: OrgTransferStatus): boolean {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
export function totalRows(counts: Record<string, number> | null | undefined): number {
|
||||
if (!counts) return 0;
|
||||
return Object.values(counts).reduce((sum, n) => sum + n, 0);
|
||||
}
|
||||
|
||||
export function formatBytes(n: number | null | undefined): string {
|
||||
if (n == null) return "—";
|
||||
if (n < 1024) return `${n} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let v = n / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function listTransfers(limit = 100): Promise<{ data: AdminTransferJob[] | null }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/transfers?limit=${limit}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function listOrgExports(orgId: string): Promise<{ data: OrgExportJob[] | null }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/organizations/${orgId}/exports`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function createOrgExport(orgId: string, body: CreateOrgExportRequest): Promise<OrgExportJob> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/organizations/${orgId}/exports`,
|
||||
authorization: true,
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrgExport(orgId: string, exportId: string): Promise<OrgExportJob> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/organizations/${orgId}/exports/${exportId}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOrgExport(orgId: string, exportId: string): Promise<void> {
|
||||
return Request({
|
||||
method: "DELETE",
|
||||
url: `/admin/organizations/${orgId}/exports/${exportId}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Mirrors orgtransfer.ArchiveFilename so the saved file matches what the
|
||||
// backend names it; Request() drops response headers, so Content-Disposition
|
||||
// is not available here.
|
||||
export function archiveFilename(orgName: string, exportId: string): string {
|
||||
let slug = orgName
|
||||
.toLowerCase()
|
||||
.replace(/[ _.]/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (!slug) slug = "workspace";
|
||||
if (slug.length > 48) slug = slug.slice(0, 48).replace(/^-+|-+$/g, "");
|
||||
return `${slug}-${exportId.slice(0, 8)}.warmbly.zip`;
|
||||
}
|
||||
|
||||
// The download endpoint is bearer-authenticated, so a plain anchor cannot
|
||||
// fetch it: pull the bytes through the client and hand them to the browser.
|
||||
export async function downloadOrgExport(
|
||||
orgId: string,
|
||||
exportId: string,
|
||||
orgName: string,
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const blob = await Request<Blob>({
|
||||
method: "GET",
|
||||
url: `/admin/organizations/${orgId}/exports/${exportId}/download`,
|
||||
authorization: true,
|
||||
responseType: "blob",
|
||||
});
|
||||
return { blob, filename: archiveFilename(orgName, exportId) };
|
||||
}
|
||||
|
||||
export function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function listOrgImports(orgId: string): Promise<{ data: OrgImportJob[] | null }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/organizations/${orgId}/imports`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function preflightOrgImport(
|
||||
orgId: string,
|
||||
file: File,
|
||||
passphrase: string,
|
||||
): Promise<OrgImportPreflight> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (passphrase) form.append("passphrase", passphrase);
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/organizations/${orgId}/imports/preflight`,
|
||||
authorization: true,
|
||||
data: form,
|
||||
});
|
||||
}
|
||||
|
||||
export function createOrgImport(
|
||||
orgId: string,
|
||||
file: File,
|
||||
options: CreateOrgImportOptions,
|
||||
passphrase: string,
|
||||
): Promise<OrgImportJob> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("options", JSON.stringify(options));
|
||||
if (passphrase) form.append("passphrase", passphrase);
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/organizations/${orgId}/imports`,
|
||||
authorization: true,
|
||||
data: form,
|
||||
});
|
||||
}
|
||||
@@ -110,3 +110,51 @@ export function rejectAppeal(
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- abuse signals and admin action history ----
|
||||
// Shapes mirror AdminWarmupAbuseRow / AdminWarmupAction in
|
||||
// internal/models/admin_ops.go.
|
||||
|
||||
export type WarmupAbuseWindow = "24h" | "7d" | "30d";
|
||||
|
||||
export interface AdminWarmupAbuseRow {
|
||||
email_account_id: string;
|
||||
email: string;
|
||||
organization_id?: string | null;
|
||||
organization_name: string;
|
||||
attempts: number;
|
||||
last_attempt_at: string;
|
||||
blocked: boolean;
|
||||
spam_score: number;
|
||||
health_state: string;
|
||||
}
|
||||
|
||||
export interface AdminWarmupAction {
|
||||
id: string;
|
||||
admin_user_id: string;
|
||||
admin_email: string;
|
||||
email_account_id: string;
|
||||
email: string;
|
||||
action: string;
|
||||
reason?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function listWarmupAbuse(
|
||||
window: WarmupAbuseWindow = "7d",
|
||||
limit = 100,
|
||||
): Promise<{ data: AdminWarmupAbuseRow[] | null }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/warmup/abuse?window=${window}&limit=${limit}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function listWarmupActions(limit = 200): Promise<{ data: AdminWarmupAction[] | null }> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/warmup/actions?limit=${limit}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,3 +185,39 @@ export function listAllWorkerTags(): Promise<{ data: string[] }> {
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- capacity and reassignment (used by the worker detail page) ----
|
||||
|
||||
// WorkerStats in internal/models/admin.go.
|
||||
export interface WorkerStats {
|
||||
worker_id: string;
|
||||
total_emails_sent: number;
|
||||
emails_sent_today: number;
|
||||
emails_sent_this_week: number;
|
||||
average_delivery_time_ms: number;
|
||||
success_rate: number;
|
||||
queue_depth: number;
|
||||
}
|
||||
|
||||
export function getWorkerStats(id: string): Promise<WorkerStats> {
|
||||
return Request({
|
||||
method: "GET",
|
||||
url: `/admin/workers/${id}/stats`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /admin/workers/:id/reassign moves email_ids onto the worker in the
|
||||
// URL. ReassignEmailsRequest also requires new_worker_id; the handler ignores
|
||||
// it in favour of the path, so both name the target.
|
||||
export function reassignWorkerEmails(
|
||||
targetWorkerId: string,
|
||||
emailIds: string[],
|
||||
): Promise<{ message: string }> {
|
||||
return Request({
|
||||
method: "POST",
|
||||
url: `/admin/workers/${targetWorkerId}/reassign`,
|
||||
authorization: true,
|
||||
data: { email_ids: emailIds, new_worker_id: targetWorkerId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,16 +247,6 @@ export interface HourlyEmailStat {
|
||||
total_sent: number;
|
||||
}
|
||||
|
||||
export interface WorkerLoadStat {
|
||||
worker_id: string;
|
||||
worker_name: string;
|
||||
emails_sent_today: number;
|
||||
queued_emails: number;
|
||||
connected_emails: number;
|
||||
cpu_usage?: number;
|
||||
memory_usage?: number;
|
||||
}
|
||||
|
||||
export interface UserGrowthStat {
|
||||
date: string;
|
||||
new_users: number;
|
||||
@@ -407,82 +397,6 @@ export interface AdminLimitRequestSearch {
|
||||
sort_desc?: boolean;
|
||||
}
|
||||
|
||||
// /admin/plans — plan catalog and custom-plan management.
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
max_contacts: number;
|
||||
daily_emails: number;
|
||||
ai_generation: boolean;
|
||||
account_limit: number;
|
||||
price: number;
|
||||
discounted_price: number;
|
||||
duration: { id: string; title: string } | string;
|
||||
savings: number;
|
||||
public: boolean;
|
||||
stripe_price_id?: string | null;
|
||||
stripe_product_id?: string | null;
|
||||
dedicated_workers: number;
|
||||
daily_campaign_limit?: number | null;
|
||||
max_campaigns?: number | null;
|
||||
max_active_campaigns?: number | null;
|
||||
max_team_members?: number | null;
|
||||
max_email_accounts?: number | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UpdatePlanRequest {
|
||||
name?: string;
|
||||
max_contacts?: number;
|
||||
daily_emails?: number;
|
||||
ai_generation?: boolean;
|
||||
account_limit?: number;
|
||||
price?: number;
|
||||
discounted_price?: number;
|
||||
dedicated_workers?: number;
|
||||
daily_campaign_limit?: number;
|
||||
max_campaigns?: number;
|
||||
max_active_campaigns?: number;
|
||||
max_team_members?: number;
|
||||
max_email_accounts?: number;
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminPlanSearch {
|
||||
q?: string;
|
||||
visibility?: "public" | "private" | "";
|
||||
duration?: string; // "month" | "year"; "" = any
|
||||
ai_generation?: boolean;
|
||||
has_stripe?: boolean;
|
||||
has_subscribers?: boolean;
|
||||
// Numeric ranges
|
||||
price_min?: number;
|
||||
price_max?: number;
|
||||
daily_emails_min?: number;
|
||||
daily_emails_max?: number;
|
||||
account_limit_min?: number;
|
||||
account_limit_max?: number;
|
||||
// Date range
|
||||
created_within?: number;
|
||||
created_after?: string;
|
||||
created_before?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
sort_by?: "price" | "name" | "daily_emails" | "account_limit" | "created_at";
|
||||
sort_desc?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminPlansResult {
|
||||
data: Plan[];
|
||||
pagination: {
|
||||
total?: number | null;
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// /admin/campaigns/* — platform-wide campaign admin (force-stop runaway
|
||||
// campaigns, inspect engagement counters per campaign).
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Platform admin permission bits, mirroring internal/models/admin_permission.go.
|
||||
// /auth/me serializes the bitmask as a single uint32, so the frontend has to
|
||||
// know the bit positions to gate nav and pages the way the backend gates routes.
|
||||
//
|
||||
// Bits 2, 3 and 15 to 18 are retired (edit/impersonate users, enterprise
|
||||
// inquiries, plans, billing). They stay in stored masks and must not be reused.
|
||||
|
||||
export const AdminPerm = {
|
||||
ViewUsers: 1 << 0,
|
||||
BanUsers: 1 << 1,
|
||||
EditUsers: 1 << 2,
|
||||
ImpersonateUsers: 1 << 3,
|
||||
ViewWorkers: 1 << 4,
|
||||
ManageWorkers: 1 << 5,
|
||||
ViewWarmupPool: 1 << 6,
|
||||
@@ -18,10 +19,6 @@ export const AdminPerm = {
|
||||
ViewAuditLogs: 1 << 12,
|
||||
ManageRateLimits: 1 << 13,
|
||||
ManageSettings: 1 << 14,
|
||||
ViewEnterpriseInquiries: 1 << 15,
|
||||
ManageEnterpriseInquiries: 1 << 16,
|
||||
ManagePlans: 1 << 17,
|
||||
ManageBilling: 1 << 18,
|
||||
GrantAdminAccess: 1 << 19,
|
||||
ViewOrganizations: 1 << 20,
|
||||
ManageOrganizations: 1 << 21,
|
||||
|
||||
@@ -50,6 +50,16 @@ const SPINE: SpineGroup[] = [
|
||||
match: (n) => n.includes("WORKER"),
|
||||
keys: [["admin", "workers"]],
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
match: (n) => n.includes("SYNC_STATE") || n.includes("EMAIL_ACCOUNT") || n.includes("ACCOUNT"),
|
||||
keys: [["admin", "sync"]],
|
||||
},
|
||||
{
|
||||
id: "audit",
|
||||
match: (n) => n.includes("AUDIT"),
|
||||
keys: [["admin", "audit"]],
|
||||
},
|
||||
];
|
||||
|
||||
export default function RealtimeManager() {
|
||||
|
||||
+97
-106
@@ -1,4 +1,4 @@
|
||||
import { StrictMode } from "react";
|
||||
import { StrictMode, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./global.css";
|
||||
import {
|
||||
@@ -6,31 +6,33 @@ import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource/poppins/600.css";
|
||||
import "@fontsource/poppins/700.css";
|
||||
|
||||
import { initErrorReporting } from "@/lib/observability";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import { RequireAdmin } from "@/components/layout/RequireAdmin";
|
||||
import { RouteError } from "@/components/layout/RouteError";
|
||||
|
||||
import LoginPage from "@/app/auth/LoginPage";
|
||||
import OverviewPage from "@/app/dashboard/OverviewPage";
|
||||
import WorkersPage from "@/app/dashboard/WorkersPage";
|
||||
import WorkerDetailPage from "@/app/dashboard/WorkerDetailPage";
|
||||
import WorkerNewPage from "@/app/dashboard/WorkerNewPage";
|
||||
import FleetPage from "@/app/dashboard/FleetPage";
|
||||
import AuditPage from "@/app/dashboard/AuditPage";
|
||||
import OrganizationsPage from "@/app/dashboard/OrganizationsPage";
|
||||
import OrganizationDetailPage from "@/app/dashboard/OrganizationDetailPage";
|
||||
import UsersPage from "@/app/dashboard/UsersPage";
|
||||
import UserDetailPage from "@/app/dashboard/UserDetailPage";
|
||||
import AdminsPage from "@/app/dashboard/AdminsPage";
|
||||
import WarmupPage from "@/app/dashboard/WarmupPage";
|
||||
import WarmupAppealsPage from "@/app/dashboard/WarmupAppealsPage";
|
||||
import WarmupContentLayout from "@/app/dashboard/warmup-content/WarmupContentLayout";
|
||||
@@ -38,21 +40,20 @@ import WarmupContentOverviewPage from "@/app/dashboard/warmup-content/OverviewPa
|
||||
import WarmupContentLibraryPage from "@/app/dashboard/warmup-content/LibraryPage";
|
||||
import WarmupContentJobsPage from "@/app/dashboard/warmup-content/JobsPage";
|
||||
import CampaignsPage from "@/app/dashboard/CampaignsPage";
|
||||
import SendsPage from "@/app/dashboard/SendsPage";
|
||||
import LimitRequestsPage from "@/app/dashboard/LimitRequestsPage";
|
||||
import OutreachPage from "@/app/dashboard/OutreachPage";
|
||||
import AnalyticsPage from "@/app/dashboard/AnalyticsPage";
|
||||
import MailboxesPage from "@/app/dashboard/MailboxesPage";
|
||||
import SyncPage from "@/app/dashboard/SyncPage";
|
||||
import EventsPage from "@/app/dashboard/EventsPage";
|
||||
import SystemStatusPage from "@/app/dashboard/SystemStatusPage";
|
||||
import JobsPage from "@/app/dashboard/JobsPage";
|
||||
import HealthPage from "@/app/dashboard/HealthPage";
|
||||
import ConfigurationPage from "@/app/dashboard/ConfigurationPage";
|
||||
import InstanceSettingsPage from "@/app/dashboard/InstanceSettingsPage";
|
||||
import NotificationsPage from "@/app/dashboard/NotificationsPage";
|
||||
import LimitsPage from "@/app/dashboard/LimitsPage";
|
||||
import TransfersPage from "@/app/dashboard/TransfersPage";
|
||||
import NotFoundPage from "@/app/dashboard/NotFoundPage";
|
||||
import RealtimeManager from "@/lib/realtime/RealtimeManager";
|
||||
import { RequirePermission } from "@/components/layout/RequirePermission";
|
||||
import { AdminPerm } from "@/lib/auth/permissions";
|
||||
import { NotFoundPage } from "@/app/dashboard/StubPages";
|
||||
|
||||
// Mirror of web/src/main.tsx's tuned defaults. The admin app sees less
|
||||
// traffic than the dashboard, so the staleness window is a touch wider:
|
||||
@@ -74,6 +75,39 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
// Every gated route carries the same permission bit the backend gates its
|
||||
// endpoint on, with the human name RequirePermission shows on denial.
|
||||
const PERM_LABELS: Record<number, string> = {
|
||||
[AdminPerm.ViewUsers]: "View users",
|
||||
[AdminPerm.ViewWorkers]: "View workers",
|
||||
[AdminPerm.ViewWarmupPool]: "View warmup pool",
|
||||
[AdminPerm.ReviewAppeals]: "Review appeals",
|
||||
[AdminPerm.ViewCampaigns]: "View campaigns",
|
||||
[AdminPerm.ViewAnalytics]: "View analytics",
|
||||
[AdminPerm.ViewAuditLogs]: "View audit logs",
|
||||
[AdminPerm.ManageSettings]: "Manage settings",
|
||||
[AdminPerm.GrantAdminAccess]: "Grant admin access",
|
||||
[AdminPerm.ViewOrganizations]: "View organizations",
|
||||
};
|
||||
|
||||
function gated(perm: number, page: ReactNode) {
|
||||
return (
|
||||
<RequirePermission perm={perm} permissionLabel={PERM_LABELS[perm] ?? "required"}>
|
||||
{page}
|
||||
</RequirePermission>
|
||||
);
|
||||
}
|
||||
|
||||
// Old paths that docs link. The query string travels so `?tab=` survives
|
||||
// and a moved page can pin the tab it replaced.
|
||||
function Redirect({ to, tab }: { to: string; tab?: string }) {
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (tab && !params.has("tab")) params.set("tab", tab);
|
||||
const search = params.toString();
|
||||
return <Navigate to={search ? `${to}?${search}` : to} replace />;
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/auth/login",
|
||||
@@ -85,110 +119,67 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{
|
||||
element: <AppShellWithKey />,
|
||||
errorElement: <RouteError />,
|
||||
children: [
|
||||
{ index: true, element: <OverviewPage /> },
|
||||
{ path: "workers", element: <WorkersPage /> },
|
||||
// Before :id so "new" isn't parsed as a worker id.
|
||||
{ path: "workers/new", element: <WorkerNewPage /> },
|
||||
{ path: "workers/:id", element: <WorkerDetailPage /> },
|
||||
{ path: "mailboxes", element: <MailboxesPage /> },
|
||||
{ path: "users", element: <UsersPage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "organizations", element: <OrganizationsPage /> },
|
||||
{ path: "organizations/:id", element: <OrganizationDetailPage /> },
|
||||
{ path: "warmup", element: <WarmupPage /> },
|
||||
{ path: "warmup/appeals", element: <WarmupAppealsPage /> },
|
||||
{
|
||||
path: "warmup-content",
|
||||
element: <WarmupContentLayout />,
|
||||
// Pathless: a page that throws renders RouteError inside
|
||||
// the shell instead of replacing the whole app.
|
||||
errorElement: <RouteError />,
|
||||
children: [
|
||||
{ index: true, element: <OverviewPage /> },
|
||||
|
||||
// Operations
|
||||
{ path: "workers", element: gated(AdminPerm.ViewWorkers, <WorkersPage />) },
|
||||
// Before :id so "new" isn't parsed as a worker id.
|
||||
{ path: "workers/new", element: gated(AdminPerm.ViewWorkers, <WorkerNewPage />) },
|
||||
{ path: "workers/:id", element: gated(AdminPerm.ViewWorkers, <WorkerDetailPage />) },
|
||||
{ path: "fleet", element: gated(AdminPerm.ViewWorkers, <FleetPage />) },
|
||||
{ path: "mailboxes", element: gated(AdminPerm.ViewUsers, <MailboxesPage />) },
|
||||
{ path: "sync", element: gated(AdminPerm.ViewUsers, <SyncPage />) },
|
||||
{ path: "warmup", element: gated(AdminPerm.ViewWarmupPool, <WarmupPage />) },
|
||||
{ path: "warmup/appeals", element: gated(AdminPerm.ReviewAppeals, <WarmupAppealsPage />) },
|
||||
{
|
||||
index: true,
|
||||
element: (
|
||||
<Navigate to="/warmup-content/overview" replace />
|
||||
),
|
||||
path: "warmup-content",
|
||||
element: gated(AdminPerm.ViewWarmupPool, <WarmupContentLayout />),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/warmup-content/overview" replace /> },
|
||||
{ path: "overview", element: <WarmupContentOverviewPage /> },
|
||||
{ path: "library", element: <WarmupContentLibraryPage /> },
|
||||
{ path: "jobs", element: <WarmupContentJobsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "overview", element: <WarmupContentOverviewPage /> },
|
||||
{ path: "library", element: <WarmupContentLibraryPage /> },
|
||||
{ path: "jobs", element: <WarmupContentJobsPage /> },
|
||||
{ path: "campaigns", element: gated(AdminPerm.ViewCampaigns, <CampaignsPage />) },
|
||||
{ path: "sends", element: gated(AdminPerm.ViewCampaigns, <SendsPage />) },
|
||||
|
||||
// Accounts
|
||||
{ path: "users", element: gated(AdminPerm.ViewUsers, <UsersPage />) },
|
||||
{ path: "users/:id", element: gated(AdminPerm.ViewUsers, <UserDetailPage />) },
|
||||
{ path: "organizations", element: gated(AdminPerm.ViewOrganizations, <OrganizationsPage />) },
|
||||
{ path: "organizations/:id", element: gated(AdminPerm.ViewOrganizations, <OrganizationDetailPage />) },
|
||||
{ path: "limit-requests", element: gated(AdminPerm.ViewOrganizations, <LimitRequestsPage />) },
|
||||
{ path: "outreach", element: gated(AdminPerm.ViewOrganizations, <OutreachPage />) },
|
||||
{ path: "admins", element: gated(AdminPerm.GrantAdminAccess, <AdminsPage />) },
|
||||
|
||||
// Insight
|
||||
{ path: "events", element: <EventsPage /> },
|
||||
{ path: "audit", element: gated(AdminPerm.ViewAuditLogs, <AuditPage />) },
|
||||
{ path: "jobs", element: gated(AdminPerm.ViewAnalytics, <JobsPage />) },
|
||||
|
||||
// Instance
|
||||
{ path: "health", element: gated(AdminPerm.ViewAnalytics, <HealthPage />) },
|
||||
{ path: "configuration", element: gated(AdminPerm.ManageSettings, <ConfigurationPage />) },
|
||||
{ path: "transfers", element: gated(AdminPerm.ViewOrganizations, <TransfersPage />) },
|
||||
|
||||
// Retired paths that docs still link.
|
||||
{ path: "analytics", element: <Redirect to="/" /> },
|
||||
{ path: "system", element: <Redirect to="/health" tab="services" /> },
|
||||
{ path: "configuration/settings", element: <Redirect to="/configuration" tab="settings" /> },
|
||||
{ path: "configuration/notifications", element: <Redirect to="/configuration" tab="notifications" /> },
|
||||
{ path: "limits", element: <Redirect to="/configuration" tab="limits" /> },
|
||||
|
||||
{ path: "*", element: <NotFoundPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "campaigns", element: <CampaignsPage /> },
|
||||
{ path: "limit-requests", element: <LimitRequestsPage /> },
|
||||
{ path: "outreach", element: <OutreachPage /> },
|
||||
{ path: "analytics", element: <AnalyticsPage /> },
|
||||
{ path: "events", element: <EventsPage /> },
|
||||
// Instance: the operator's view of this deployment. Each
|
||||
// route carries the same permission bit the backend gates
|
||||
// its endpoint on.
|
||||
{
|
||||
path: "health",
|
||||
element: (
|
||||
<RequirePermission
|
||||
perm={AdminPerm.ViewAnalytics}
|
||||
permissionLabel="View analytics"
|
||||
>
|
||||
<HealthPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "configuration",
|
||||
element: (
|
||||
<RequirePermission
|
||||
perm={AdminPerm.ManageSettings}
|
||||
permissionLabel="Manage settings"
|
||||
>
|
||||
<ConfigurationPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "configuration/settings",
|
||||
element: (
|
||||
<RequirePermission
|
||||
perm={AdminPerm.ManageSettings}
|
||||
permissionLabel="Manage settings"
|
||||
>
|
||||
<InstanceSettingsPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "configuration/notifications",
|
||||
element: (
|
||||
<RequirePermission
|
||||
perm={AdminPerm.ManageSettings}
|
||||
permissionLabel="Manage settings"
|
||||
>
|
||||
<NotificationsPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "limits",
|
||||
element: (
|
||||
<RequirePermission
|
||||
perm={AdminPerm.ViewAnalytics}
|
||||
permissionLabel="View analytics"
|
||||
>
|
||||
<LimitsPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "system",
|
||||
element: (
|
||||
<RequirePermission
|
||||
perm={AdminPerm.ViewAnalytics}
|
||||
permissionLabel="View analytics"
|
||||
>
|
||||
<SystemStatusPage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{ path: "audit", element: <AuditPage /> },
|
||||
{ path: "*", element: <NotFoundPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -53,7 +53,7 @@ The plaintext variant works, and then keeps sitting in your process environment
|
||||
| `invite_only` (self-host default) | Refused, and the dashboard does not render the form | Works. The link carries the token that permits the signup | Provisions only for an address that holds a live invitation, or when `SSO_AUTO_PROVISION=true` |
|
||||
| `true` | Refused | Refused, and members cannot create new invitations either | Refused unless `SSO_AUTO_PROVISION=true` |
|
||||
|
||||
One database-backed setting sits on top of `invite_only`. Turning **`access.allow_invited_signup`** off under **Instance > Instance settings** makes an invitation stop being a self-service signup capability: both the password path and the single sign-on path then answer with `registration_closed`, and an administrator creates every account with `warmblyctl user create`. It is on by default, so the table above is what a stock instance does.
|
||||
One database-backed setting sits on top of `invite_only`. Turning **`access.allow_invited_signup`** off under **Instance > Configuration > Settings** makes an invitation stop being a self-service signup capability: both the password path and the single sign-on path then answer with `registration_closed`, and an administrator creates every account with `warmblyctl user create`. It is on by default, so the table above is what a stock instance does.
|
||||
|
||||
### The first-launch exemption
|
||||
|
||||
@@ -180,13 +180,13 @@ The cleanest fix is still to point `MAIL_TRANSPORT` at a real relay. See [platfo
|
||||
|
||||
| Property | Behavior |
|
||||
|---|---|
|
||||
| Expiry | 7 days by default. Change it with `invitations.ttl_hours` under **Instance > Instance settings**, clamped to 1 to 720 hours. It is read when the invitation is written, so a change applies to new invitations, not to outstanding ones |
|
||||
| Expiry | 7 days by default. Change it with `invitations.ttl_hours` under **Instance > Configuration > Settings**, clamped to 1 to 720 hours. It is read when the invitation is written, so a change applies to new invitations, not to outstanding ones |
|
||||
| Address binding | The invitation is bound to the address it was issued for. Registering a different address with the same token is refused |
|
||||
| Re-inviting the same address | Replaces the row in place: one row per address per workspace, a rotated token, a reset expiry, and the previously copied link dead |
|
||||
| Copy link | Returns the token for a pending invitation so you can build and share the URL yourself |
|
||||
| Cancel | Revokes it immediately |
|
||||
| Expired rows | No longer shown in the dashboard. Re-inviting the address replaces the expired row |
|
||||
| Turning links off | Set `invitations.links_enabled` to false under **Instance > Instance settings** if invitations must arrive by mail and nowhere else. The copy action then fails with an explanation instead of returning a token. Only do this once mail actually delivers |
|
||||
| Turning links off | Set `invitations.links_enabled` to false under **Instance > Configuration > Settings** if invitations must arrive by mail and nowhere else. The copy action then fails with an explanation instead of returning a token. Only do this once mail actually delivers |
|
||||
|
||||
Invitations join an existing workspace and never create one. To stand up a **second organization** you need `DISABLE_REGISTRATION=false`, or an administrator running `warmblyctl user create`.
|
||||
|
||||
@@ -226,9 +226,9 @@ Pair single sign-on with `DISABLE_PASSWORD_LOGIN=true` for an SSO-only deploymen
|
||||
|
||||
## Platform admins
|
||||
|
||||
A workspace role and a platform admin bit are different things. Roles govern what someone can do inside one organization. The platform admin bits govern the operator panel on `:5174`: workers, users, organizations, warmup, analytics and audit across the whole instance.
|
||||
A workspace role and a platform admin bit are different things. Roles govern what someone can do inside one organization. The platform admin bits govern the [admin panel](/development/admin-panel/) on `:5174`: workers, users, organizations, warmup, the overview and audit across the whole instance.
|
||||
|
||||
The first owner receives every admin bit when they claim the instance. After that, admins are granted from **Instance > Admins** in the admin panel, either by preset or per bit.
|
||||
The first owner receives every admin bit when they claim the instance. After that, admins are granted from **Accounts > Admins** in the admin panel, either by preset or per bit, or from a shell as below.
|
||||
|
||||
| Preset | Roughly |
|
||||
|---|---|
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Admin panel
|
||||
description: The operator surface on :5174, page by page, with the platform admin bit each one needs.
|
||||
---
|
||||
|
||||
The admin panel is the operator's view of an instance: every worker, user, organization, mailbox and background loop, across every workspace. It is a separate app from the customer dashboard, it holds nothing a customer needs, and every page in it is gated on a [platform admin bit](/development/accounts-and-access/#platform-admins) rather than a workspace role.
|
||||
|
||||
## Reaching it
|
||||
|
||||
The panel runs on `:5174` in the default stack (`http://localhost:5174` on a stock install, or the host you proxied to it). Sign in with the same account as the dashboard. The first owner holds every admin bit from the moment they claim the instance, and `warmblyctl user create --admin` creates a further account that already holds them. A page the account has no bit for says which bit it needs instead of rendering.
|
||||
|
||||
The old paths still redirect, so a bookmark or a link in an older version of these docs lands on the right tab.
|
||||
|
||||
## Command palette
|
||||
|
||||
`Cmd K` (`Ctrl K` on Linux and Windows) opens the command palette from any page. It jumps to any page by name and searches users, organizations, mailboxes and workers, so a support request that names an email address is one keystroke from the record.
|
||||
|
||||
## Overview
|
||||
|
||||
The landing page. Instance-wide counters and their trends, acquisition by channel, and a strip of open instance problems at the top, which is the same list as the Findings tab of [Setup and health](/development/instance-health/).
|
||||
|
||||
## Operations
|
||||
|
||||
| Page | What it answers |
|
||||
|---|---|
|
||||
| **Workers** | Every worker, its heartbeat, tier and the mailboxes placed on it. Detail pages carry stats, the assigned mailbox list, and reassignment |
|
||||
| **Fleet** | Capacity per worker with utilization, the control loops' decision log (what the placement and rebalance loops did and why), and dedicated worker bindings, with convert and release |
|
||||
| **Mailboxes** | Every connected mailbox across every workspace, with its state and the worker it sits on |
|
||||
| **Sync** | The platform copy of each mailbox's backfill progress and fair-use throttle, with clear-throttle and restart-backfill. The budgets it enforces are the four `sync.*` values under [Configuration > Settings](/development/configuration/#settings-stored-in-the-database); the worker-side engine is the sync governor described under [anti-abuse layers](/development/architecture/#anti-abuse-layers) |
|
||||
| **Warmup** | The pools, the blocked list, abuse signals (invalid warmup-token attempts), and the block and unblock history |
|
||||
| **Warmup Appeals** | Appeals from blocked mailboxes, to approve or reject |
|
||||
| **Warmup Content** | The generated and reviewed warmup conversation bank, its library and generation jobs |
|
||||
| **Campaigns** | Every campaign on the instance with its state and owner |
|
||||
| **Sends** | In-flight reservations no worker has answered, and the reclaimer that resolves them after 30 minutes; dead letters with replay; task failures; customer webhook delivery health, with reclaim |
|
||||
|
||||
## Accounts
|
||||
|
||||
| Page | What it answers |
|
||||
|---|---|
|
||||
| **Users** | Every account, with ban and unban, ban history and rate-limit overrides |
|
||||
| **Organizations** | Every workspace, with its members, plan, abuse posture, API keys, webhooks and transfer tabs |
|
||||
| **Limit requests** | Customer requests for a higher allowance, to approve or decline |
|
||||
| **Outreach** | Mail an operator sends from the instance to a user, a workspace or an address, and the delivery state of each message sent so far |
|
||||
| **Admins** | Who holds platform admin bits, and grant and revoke by preset or per bit. This replaces the CLI-only path; `warmblyctl user grant-admin` still works |
|
||||
|
||||
## Insight
|
||||
|
||||
| Page | What it answers |
|
||||
|---|---|
|
||||
| **Live Events** | The event bus as it happens |
|
||||
| **Audit Log** | Who did what, from where, across the instance |
|
||||
| **Jobs** | Every background loop on the instance with its last run, next run and last error, and a **Run now** that the owning process picks up within about 15 seconds. The backend's and the consumer's loops are in the same list |
|
||||
|
||||
## Instance
|
||||
|
||||
| Page | What it answers |
|
||||
|---|---|
|
||||
| **Setup and health** | Two tabs. **Findings** is the [instance health](/development/instance-health/) check list, and the sidebar badge counts its open findings. **Services** is the old System Status page: the per-service probes and the platform mail card with its connection check and send test |
|
||||
| **Configuration** | Four tabs. **Settings** is the database-backed settings document; **Notifications** is the [operator notification](/development/operator-notifications/) channels; **Environment** is the read-only [configuration reference](/development/configuration/) view of what the backend resolved; **Effective limits** lists the compiled pacing constants |
|
||||
| **Transfers** | Workspace export and import for any organization, from the operator side, with the same archives as `warmblyctl org export` and `org import`. A whole-instance backup stays `warmblyctl backup` and `restore` |
|
||||
|
||||
## Permission per page
|
||||
|
||||
| Page | Bit |
|
||||
|---|---|
|
||||
| Overview, Live Events | none beyond signing in as an admin |
|
||||
| Workers, Fleet | `view_workers` |
|
||||
| Mailboxes, Sync, Users | `view_users` |
|
||||
| Warmup, Warmup Content | `view_warmup_pool` |
|
||||
| Warmup Appeals | `review_appeals` |
|
||||
| Campaigns, Sends | `view_campaigns` |
|
||||
| Organizations, Limit requests, Outreach, Transfers | `view_organizations` |
|
||||
| Admins | `grant_admin_access` |
|
||||
| Audit Log | `view_audit_logs` |
|
||||
| Jobs, Setup and health | `view_analytics` |
|
||||
| Configuration | `manage_settings` |
|
||||
|
||||
Detail pages need the same bit as the list they open from. Actions inside a page can need more than the page itself: reading an organization's abuse posture or the Outreach list needs `view_organizations`, while changing a posture or sending outreach needs `manage_organizations`, as [accounts and access](/development/accounts-and-access/#workspace-abuse-posture) lists.
|
||||
|
||||
## See also
|
||||
|
||||
- [Accounts and access](/development/accounts-and-access/) for the presets and the shell commands
|
||||
- [Instance health](/development/instance-health/) for every finding the Setup and health page can show
|
||||
- [Configuration reference](/development/configuration/) for what the Environment and Settings tabs hold
|
||||
- [warmblyctl](/development/warmblyctl/) for the operator commands the panel mirrors
|
||||
@@ -42,7 +42,7 @@ flowchart LR
|
||||
| Realtime | Elixir (Phoenix) | Control | WebSocket fanout |
|
||||
| Worker | Go | Execution | One per VPS; subscribes to a per-worker event bus topic; never opens a Postgres connection |
|
||||
| Web | React (Vite) | n/a | Dashboard frontend |
|
||||
| Admin | React (Vite) | n/a | Platform admin panel |
|
||||
| Admin | React (Vite) | n/a | [Platform admin panel](/development/admin-panel/) |
|
||||
|
||||
## Data flow
|
||||
|
||||
|
||||
@@ -610,7 +610,7 @@ Restore into an empty database before the backend starts, then start it and it a
|
||||
| Opens and clicks are not recorded | `TRACKING_DOMAIN` does not match the host nginx proxies to `:3000`, or `BACKEND_INTERNAL_URL` is unreachable from the tracking service |
|
||||
| Mailbox connects, then fails about an hour later | The worker env is missing the `BOX_*` OAuth client; only the worker refreshes tokens |
|
||||
|
||||
`warmblyctl status` runs the same instance checks the admin panel's System Status page shows and exits non-zero on anything at error severity. The [troubleshooting](/development/troubleshooting/) page covers symptoms that are not specific to running without Docker.
|
||||
`warmblyctl status` runs the same instance checks the admin panel's **Instance > Setup and health > Services** tab shows and exits non-zero on anything at error severity. The [troubleshooting](/development/troubleshooting/) page covers symptoms that are not specific to running without Docker.
|
||||
|
||||
## Quick reference
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ The environment is authoritative. Warmbly never lets a web form overwrite a sett
|
||||
That rule has three consequences worth stating before the tables:
|
||||
|
||||
- **Everything on this page is set in the environment of the running process.** With Docker Compose that is the `.env` next to `docker-compose.yml`. With Kubernetes it is the pod spec or a secret. With a bare binary it is the shell or the systemd unit.
|
||||
- **Most resolved values are visible, read only, in the admin panel** under **Instance > Configuration** (`http://localhost:5174/configuration` on a stock install). Each row shows the variable name, the value Warmbly actually resolved, where it came from (`env`, `default`, `derived` or `unset`), and whether changing it needs a restart. That page is how you answer "is my variable actually being picked up", without reading source.
|
||||
- **Most resolved values are visible, read only, in the admin panel** under **Instance > Configuration > Environment** (`http://localhost:5174/configuration?tab=environment` on a stock install). Each row shows the variable name, the value Warmbly actually resolved, where it came from (`env`, `default`, `derived` or `unset`), and whether changing it needs a restart. That page is how you answer "is my variable actually being picked up", without reading source.
|
||||
- **A handful of settings are stored in the database instead**, because no environment variable owns them. They are listed in [settings stored in the database](#settings-stored-in-the-database) and are the only settings editable from a browser.
|
||||
|
||||
Secrets are never returned by any API. The configuration page shows a sensitive key as set or unset plus a four character fingerprint of its value, which is enough to confirm that two services hold the same `AUTH_SECRET` without disclosing it to anyone.
|
||||
@@ -21,7 +21,7 @@ The configuration registry runs inside the backend process, so every value it sh
|
||||
|
||||
| How | What you get |
|
||||
|---|---|
|
||||
| **Instance > Configuration** in the admin panel | The backend's entries with resolved value, source, group and restart requirement |
|
||||
| **Instance > Configuration > Environment** in the admin panel | The backend's entries with resolved value, source, group and restart requirement |
|
||||
| `make doctor` | The health checks from a shell, including the configuration problems they detect |
|
||||
| `GET /admin/instance/config` | The same list as JSON, behind the `manage_settings` admin permission |
|
||||
|
||||
@@ -502,7 +502,7 @@ The shipped default accepts a websocket upgrade from **any** origin. A token is
|
||||
|
||||
## Settings stored in the database
|
||||
|
||||
These are the only settings a browser can change, and no environment variable owns any of them. They live in the admin panel under **Instance > Instance settings** (`/configuration/settings`). Reads are cached for 30 seconds in each process, so a change takes effect everywhere within that window.
|
||||
These are the only settings a browser can change, and no environment variable owns any of them. They live in the admin panel under **Instance > Configuration > Settings** (`/configuration?tab=settings`). Reads are cached for 30 seconds in each process, so a change takes effect everywhere within that window.
|
||||
|
||||
| Setting | Type | Default | What it does |
|
||||
|---|---|---|---|
|
||||
@@ -519,7 +519,7 @@ These are the only settings a browser can change, and no environment variable ow
|
||||
| `deliverability.enforce_domain_auth` | boolean | `true` | Whether a sending domain that fails SPF or DMARC stops cold campaign sending and warmup sending from every mailbox on it. Off keeps the check running and still shows the state and the Advisor card, it just never blocks |
|
||||
| `deliverability.auth_grace_hours` | integer, 1 to 720 | `72` | How long a domain must stay failing before the gate applies. The clock starts when the background check first sees the failure, so this is also how much warning the owner gets |
|
||||
|
||||
The four `sync.*` values are read by the backend when a mailbox is loaded onto a worker (on connect, on reassignment, and by the reconciler's periodic republish), so a change reaches every mailbox within a few minutes without a restart. The fixed pacing numbers around them (burst per five minutes, hourly, backfill pace, the flood threshold and the chronic-overage rule) are compiled constants listed under **Instance > Limits**; see [Mailboxes](/guides/mailboxes/#what-gets-synced) for how the budgets behave.
|
||||
The four `sync.*` values are read by the backend when a mailbox is loaded onto a worker (on connect, on reassignment, and by the reconciler's periodic republish), so a change reaches every mailbox within a few minutes without a restart. The fixed pacing numbers around them (burst per five minutes, hourly, backfill pace, the flood threshold and the chronic-overage rule) are compiled constants listed under **Instance > Configuration > Effective limits**; see [Mailboxes](/guides/mailboxes/#what-gets-synced) for how the budgets behave. The four `sync.*` values also have a companion read view on **Operations > Sync**, which shows each mailbox's backfill progress and fair-use throttle against them and can clear a throttle or restart a backfill.
|
||||
|
||||
The three `retention.*` values are read by the pruning sweeps on every pass, so shortening one takes effect on the next sweep rather than at the next restart. Deletion is permanent and there is no grace period: what already sits outside a shortened window goes on that sweep. See [data control](/development/data-control/#what-is-kept-and-for-how-long) for what each log holds and what a shorter window costs.
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ A remote worker writes bodies to its own disk, so the dashboard on the control-p
|
||||
|
||||
## What is kept, and for how long
|
||||
|
||||
Every window below lives in the database, not the environment, and is edited under **Instance > Instance settings** in the admin panel. A sweep runs a few times a day and reads the current value on every pass, so a change takes effect without a restart.
|
||||
Every window below lives in the database, not the environment, and is edited under **Instance > Configuration > Settings** in the admin panel. A sweep runs a few times a day and reads the current value on every pass, so a change takes effect without a restart.
|
||||
|
||||
### Mailbox import and sync
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5173 # dashbo
|
||||
|
||||
If one does not print `200`, `make logs backend` (or the service in question) will say why, and [troubleshooting](#troubleshooting) covers the usual causes.
|
||||
|
||||
The admin panel's **System Status** page shows the same probes plus the platform mail transport, with a live connection check and a send-test button.
|
||||
The admin panel's **Instance > Setup and health > Services** tab shows the same probes plus the platform mail transport, with a live connection check and a send-test button.
|
||||
|
||||
## What is running
|
||||
|
||||
@@ -415,7 +415,7 @@ Credentials are never sent over an unencrypted connection. If the relay does not
|
||||
|
||||
The backend dials the relay once at boot and logs the result, so a broken configuration shows up in `docker compose logs backend` rather than as a failed password reset weeks later.
|
||||
|
||||
The admin panel's **System Status** page has a Platform mail card with the current transport, a live connection check that shows the SMTP error verbatim, and a send-test button.
|
||||
The admin panel's **Instance > Setup and health > Services** tab has a Platform mail card with the current transport, a live connection check that shows the SMTP error verbatim, and a send-test button.
|
||||
|
||||
### Deliverability
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ The last two are unrecoverable. Every mailbox credential and every per-organizat
|
||||
|
||||
### What is kept, and for how long
|
||||
|
||||
Two groups, both editable afterwards in **Instance > Instance settings**:
|
||||
Two groups, both editable afterwards in **Instance > Configuration > Settings**:
|
||||
|
||||
| Setting | Default | What it governs |
|
||||
|---|---|---|
|
||||
|
||||
@@ -7,7 +7,7 @@ Warmbly checks its own deployment and tells you what is wrong in the words of th
|
||||
|
||||
## Setup and health
|
||||
|
||||
The page lives in the **admin panel** on `:5174`, under **Instance > Setup and health**. It is operator tooling, so it is not in the customer-facing dashboard, and every route behind it is gated on a platform admin permission.
|
||||
The page lives in the [admin panel](/development/admin-panel/) on `:5174`, under **Instance > Setup and health**, on its **Findings** tab; the **Services** tab next to it holds the per-service probes and the platform mail card. It is operator tooling, so it is not in the customer-facing dashboard, and every route behind it is gated on a platform admin permission.
|
||||
|
||||
The same list is the top panel of the admin overview, and the sidebar entry carries a badge with the number of open findings, because nobody visits a health page speculatively.
|
||||
|
||||
@@ -195,7 +195,7 @@ The same for Sign in with Apple, which needs `APPLE_APP_ID` (the Services ID), `
|
||||
|
||||
### single_platform_admin
|
||||
|
||||
Exactly one account holds platform admin permissions. If you lose access to it there is no way to grant admin from inside the product. Add a second from **Instance > Admins**, or with `warmblyctl user grant-admin`. See [platform admins](/development/accounts-and-access/#platform-admins).
|
||||
Exactly one account holds platform admin permissions. If you lose access to it there is no way to grant admin from inside the product. Add a second from **Accounts > Admins**, or with `warmblyctl user grant-admin`. See [platform admins](/development/accounts-and-access/#platform-admins).
|
||||
|
||||
### bootstrap_password_still_set
|
||||
|
||||
@@ -239,7 +239,7 @@ Redis is not reachable. Rate limits, the organization key cache, the realtime br
|
||||
|
||||
The backend turned `WEBSOCKET_URL` into a `/health` URL and the realtime service did not answer it, so the dashboard will not update live and presence stays empty. Anything below a `500` counts as an answer, so an auth wall does not trip this.
|
||||
|
||||
A reachable realtime service that still leaves the dashboard dead has a different cause, and no check reports it: `AUTH_SECRET` on the backend and `JWT_SECRET` on the realtime service must be identical or every token is rejected. **Instance > Configuration** shows a four character fingerprint for each sensitive value so you can compare the two without either being disclosed.
|
||||
A reachable realtime service that still leaves the dashboard dead has a different cause, and no check reports it: `AUTH_SECRET` on the backend and `JWT_SECRET` on the realtime service must be identical or every token is rejected. **Instance > Configuration > Environment** shows a four character fingerprint for each sensitive value so you can compare the two without either being disclosed.
|
||||
|
||||
## Storage
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"bare-metal",
|
||||
"first-run",
|
||||
"accounts-and-access",
|
||||
"admin-panel",
|
||||
"warmblyctl",
|
||||
"data-control",
|
||||
"configuration",
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Operator notifications
|
||||
description: Send instance events to Discord, Slack, a signed webhook, or an email address.
|
||||
---
|
||||
|
||||
Your instance can tell you when something happens on it: a new signup, a worker going offline, a workspace asking for more capacity. Configure the destinations in the admin panel under **Configuration > Notifications**.
|
||||
Your instance can tell you when something happens on it: a new signup, a worker going offline, a workspace asking for more capacity. Configure the destinations in the admin panel under **Instance > Configuration > Notifications**.
|
||||
|
||||
These are operator alerts, not customer alerts. They are instance-wide and go to you. The per-workspace event delivery your customers configure is [Webhooks](/guides/webhooks/), which is a separate, queued and retried system.
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ Newer builds return the invite-only refusal with its own machine code, `registra
|
||||
|---|---|
|
||||
| The backend exits at boot | Read the first log lines. The usual causes are a published default secret with `APP_ENV=prod` (the message names `ALLOW_INSECURE_DEFAULTS`), cloud provider defaults outside compose, a `CREDENTIALS_ENCRYPTION_KEY` that is not exactly 64 hex characters, or a missing `EMAIL_ADDRESS`, `EMAIL_NAME` or `GEODB_PATH` |
|
||||
| The dashboard loads but every request fails | Check `http://localhost:5173/config.js` returns the right `API_URL`. It is generated at container start from `WARMBLY_API_URL`. If the URL is right, the origin is probably missing from `CORS_ALLOW_ORIGINS` |
|
||||
| Nothing updates live and presence is empty | `AUTH_SECRET` must equal realtime's `JWT_SECRET`, and `PUBSUB_ENABLED` must agree across backend, consumer and realtime. Compare the fingerprints under **Instance > Configuration** |
|
||||
| Nothing updates live and presence is empty | `AUTH_SECRET` must equal realtime's `JWT_SECRET`, and `PUBSUB_ENABLED` must agree across backend, consumer and realtime. Compare the fingerprints under **Instance > Configuration > Environment** |
|
||||
| Workers or tracking get 401s | `INTERNAL_API_TOKEN` must match on the backend, the workers (as `ENCRYPTED_KEYS_WORKER_TOKEN`) and tracking. Unset fails closed |
|
||||
| "No mailbox workers are available" when connecting a mailbox | No worker has a heartbeat inside the last 10 minutes. Check `make status` shows `worker` running and `make logs worker` is clean. An empty `ENCRYPTED_KEYS_BACKEND_URL` or worker token lets a worker start and never register, silently |
|
||||
| Connecting a mailbox fails with `SERVER_UNREACHABLE` on a reachable host | The security setting does not match the server. A server expecting STARTTLS looks unreachable to a client attempting implicit TLS, and vice versa. Any port from 1 to 65535 is accepted, so the port alone no longer decides: set **Security** to SSL / TLS for a server that is encrypted from the first byte (usually SMTP `465`, IMAP `993`) and STARTTLS for one that upgrades in place (usually SMTP `587` or `2525`, IMAP `143`) |
|
||||
@@ -85,7 +85,7 @@ Newer builds return the invite-only refusal with its own machine code, `registra
|
||||
| An IMAP mailbox connects but no mail ever arrives | Check the sync card in the mailbox drawer for a folder count. Zero folders on a reachable server means the server refused `STATUS` for every folder; the worker log names each one it skipped. A connected mailbox whose inbox is genuinely empty is normal |
|
||||
| A mailbox shows an error that is already fixed | Connection errors clear themselves: the first sync pass that reaches the server again resolves them. Errors that need you to act, such as wrong credentials or a domain-authentication refusal, stay until you reconnect the mailbox or fix the cause |
|
||||
| An IMAP mailbox stopped syncing and the log is quiet | Sessions dropped by the server, or by a firewall that removed the mapping without closing the connection, are re-dialed on the next pass, and every pass that cannot reach the server is retried on a widening interval up to five minutes. If a mailbox is still stuck, `make logs worker` shows the folder cursors; a mailbox held by the sync budget says so in its drawer instead |
|
||||
| A folder is missing from the unibox | Up to `100` folders per mailbox are synced (**Instance settings > Limits**). Past that, the inbox and the special folders are kept and the rest follow the server's order, and the mailbox drawer's **Sync** card names how many were left out. Gmail's All Mail, Starred and Important are label views over other folders and are deliberately never synced. A folder can also be skipped when the mail server lists the same folder name twice, which the Sync card says as well. A folder sharing its `UIDVALIDITY` with another folder is no longer a reason to skip anything: folders are identified by name, which is what IMAP guarantees is unique. Both notes clear themselves on the next pass once the cause is gone |
|
||||
| A folder is missing from the unibox | Up to `100` folders per mailbox are synced (**Instance > Configuration > Effective limits**). Past that, the inbox and the special folders are kept and the rest follow the server's order, and the mailbox drawer's **Sync** card names how many were left out. Gmail's All Mail, Starred and Important are label views over other folders and are deliberately never synced. A folder can also be skipped when the mail server lists the same folder name twice, which the Sync card says as well. A folder sharing its `UIDVALIDITY` with another folder is no longer a reason to skip anything: folders are identified by name, which is what IMAP guarantees is unique. Both notes clear themselves on the next pass once the cause is gone |
|
||||
| Scheduled sends never fire | Delayed sends run through the in-process Postgres task poller (`TASKS_PROVIDER=local`), so the backend must be running |
|
||||
| Every send dead-letters with `permission denied` on `/data/blobs` | The `blobs` volume was created before the images owned that path, so it is still `root:root` while the services run as uid 1000. Fix it once with `docker compose -p warmbly exec -u root backend chown -R warmbly:warmbly /data/blobs`. The `blob_fs_root` health check reports it, and volumes created from current images are already correct |
|
||||
| `email account <id> not found in worker` | The mailbox is assigned to a worker that no longer exists, usually because the worker was recreated and came back with a fresh UUID. The reconciler releases and re-places it on a live worker within its interval. Compose workers now keep their id in the `worker_state` volume (`WORKER_STATE_DIR`), so this stops recurring once that volume exists; removing the volume or unsetting `WORKER_STATE_DIR` reintroduces the churn. See [worker identity](/development/deployment-guide/#worker-identity) |
|
||||
|
||||
@@ -254,7 +254,7 @@ docker compose -p warmbly exec backend warmblyctl user grant-admin --email you@e
|
||||
|
||||
The role replaces the account's existing mask rather than adding to it, and re-granting the role it already holds is reported as no change. The permissions are read from the session, so the account has to sign out and back in before the panel reflects the grant.
|
||||
|
||||
The account must already exist. `make grant-admin EMAIL=... ROLE=...` wraps this for a compose install.
|
||||
The account must already exist. `make grant-admin EMAIL=... ROLE=...` wraps this for a compose install. The admin panel now offers the same under **Accounts > Admins**, by preset or per bit, so this command is no longer the only way to add a second admin.
|
||||
|
||||
## user revoke-admin
|
||||
|
||||
@@ -360,7 +360,7 @@ docker compose -p warmbly exec backend warmblyctl org export \
|
||||
--org you@example.com --out /tmp/workspace.warmbly.zip
|
||||
```
|
||||
|
||||
Writes one workspace to a single archive file: the organization, its members and roles, mailboxes, campaigns, sequences, contacts, suppression, CRM, inbox history, and the send state that stops a migrated mailbox from sending twice its daily volume on the day it moves. It is the same archive the dashboard produces under **Settings > Data**, so either side of a migration can use either tool.
|
||||
Writes one workspace to a single archive file: the organization, its members and roles, mailboxes, campaigns, sequences, contacts, suppression, CRM, inbox history, and the send state that stops a migrated mailbox from sending twice its daily volume on the day it moves. It is the same archive the dashboard produces under **Settings > Data**, so either side of a migration can use either tool. The admin panel now offers the same for any organization under **Instance > Transfers**.
|
||||
|
||||
| Flag | Does |
|
||||
|---|---|
|
||||
@@ -403,7 +403,7 @@ Applies an archive to a workspace on this instance. It always prints what the ar
|
||||
| `--passphrase-stdin` | Reads that passphrase from stdin |
|
||||
| `--dry-run` | Prints the report and writes nothing |
|
||||
|
||||
Run it with `--dry-run` first. The report is the same preflight the dashboard shows, and it costs nothing.
|
||||
Run it with `--dry-run` first. The report is the same preflight the dashboard shows, and it costs nothing. The admin panel offers the same import, preflight included, under **Instance > Transfers**.
|
||||
|
||||
The whole import runs in one transaction: if any part fails, nothing lands and the workspace is untouched. Members are matched to accounts on this instance by email address, and anyone without one has their rows reassigned to the workspace owner, named in the report before you commit. An archive carries no password material, so it can never create an account here.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user