From 800c4a062feff796378c9b6a17d8efb4b99d37b8 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 7 Sep 2026 21:40:38 -0700 Subject: [PATCH 1/3] feat: frontend half of the admin panel upgrade: drop ten unused dependencies, the dead worker-load and plan requests, the stub pages and the retired permission names, fix the analytics client that sent the wrong query parameters and the mail test toasts that used an unmounted toaster, add a Cmd-K command palette with live user, organization, mailbox and worker search, a mobile nav drawer, document titles, a Sentry-wired route error boundary and permission gates on every route, replace polling with the realtime spine wherever an event exists, fold Analytics into Overview with a signups-by-channel card, merge System Status into Setup and health and Settings, Notifications and Effective limits into Configuration as tabs with redirects from every old path, add the Sync, Sends, Jobs, Fleet, Admins and Transfers pages plus API-key, webhook and transfer tabs on the organization page, mailbox reassignment on the worker page and abuse and action-history tabs on Warmup, and add the admin-panel docs page with every nav reference updated --- admin/README.md | 41 +- admin/package.json | 13 +- admin/pnpm-lock.yaml | 298 +------- admin/src/app/dashboard/AdminsPage.tsx | 204 ++++++ admin/src/app/dashboard/AnalyticsPage.tsx | 340 --------- admin/src/app/dashboard/ConfigurationPage.tsx | 367 +++------- admin/src/app/dashboard/FleetPage.tsx | 53 ++ admin/src/app/dashboard/HealthPage.tsx | 249 ++----- admin/src/app/dashboard/JobsPage.tsx | 213 ++++++ admin/src/app/dashboard/MailStatusCard.tsx | 2 +- admin/src/app/dashboard/MailboxesPage.tsx | 7 +- admin/src/app/dashboard/NotFoundPage.tsx | 18 + .../app/dashboard/OrganizationDetailPage.tsx | 326 ++++++++- admin/src/app/dashboard/OrganizationsPage.tsx | 21 +- admin/src/app/dashboard/OverviewPage.tsx | 679 ++++++++++++++---- admin/src/app/dashboard/SendsPage.tsx | 49 ++ admin/src/app/dashboard/StubPages.tsx | 31 - admin/src/app/dashboard/SyncPage.tsx | 386 ++++++++++ admin/src/app/dashboard/TransfersPage.tsx | 205 ++++++ admin/src/app/dashboard/UsersPage.tsx | 21 +- admin/src/app/dashboard/WarmupAppealsPage.tsx | 3 - admin/src/app/dashboard/WarmupPage.tsx | 241 ++++++- admin/src/app/dashboard/WorkerDetailPage.tsx | 319 +++++++- admin/src/app/dashboard/WorkersPage.tsx | 1 - .../app/dashboard/admins/GrantAdminDialog.tsx | 215 ++++++ .../app/dashboard/admins/PermissionChips.tsx | 38 + admin/src/app/dashboard/admins/UserPicker.tsx | 49 ++ admin/src/app/dashboard/admins/permissions.ts | 70 ++ .../configuration/EnvironmentTab.tsx | 298 ++++++++ .../LimitsTab.tsx} | 30 +- .../NotificationsTab.tsx} | 78 +- .../SettingsTab.tsx} | 61 +- admin/src/app/dashboard/fleet/CapacityTab.tsx | 222 ++++++ .../fleet/ConvertDedicatedDialog.tsx | 197 +++++ .../src/app/dashboard/fleet/DecisionsTab.tsx | 183 +++++ .../src/app/dashboard/fleet/DedicatedTab.tsx | 141 ++++ admin/src/app/dashboard/fleet/OrgPicker.tsx | 46 ++ .../src/app/dashboard/fleet/SearchPicker.tsx | 172 +++++ admin/src/app/dashboard/fleet/format.ts | 32 + admin/src/app/dashboard/fleet/tones.tsx | 70 ++ .../src/app/dashboard/health/FindingsTab.tsx | 226 ++++++ .../ServicesTab.tsx} | 90 ++- .../src/app/dashboard/jobs/ExpandableText.tsx | 41 ++ admin/src/app/dashboard/jobs/format.ts | 47 ++ .../app/dashboard/sends/DeadLettersTab.tsx | 227 ++++++ admin/src/app/dashboard/sends/FailuresTab.tsx | 111 +++ admin/src/app/dashboard/sends/InFlightTab.tsx | 206 ++++++ admin/src/app/dashboard/sends/StatCard.tsx | 42 ++ .../app/dashboard/sends/StatusSegments.tsx | 48 ++ admin/src/app/dashboard/sends/WebhooksTab.tsx | 175 +++++ .../app/dashboard/transfers/ExportDialog.tsx | 185 +++++ .../app/dashboard/transfers/GroupPicker.tsx | 84 +++ .../transfers/ImportArchiveDialog.tsx | 276 +++++++ .../dashboard/transfers/OrgTransferTab.tsx | 243 +++++++ .../app/dashboard/transfers/TransferPills.tsx | 51 ++ admin/src/components/ConfirmDialog.tsx | 108 +++ admin/src/components/layout/AppShell.tsx | 42 +- .../src/components/layout/CommandPalette.tsx | 350 +++++++++ admin/src/components/layout/MobileNav.tsx | 47 ++ admin/src/components/layout/PageHeader.tsx | 14 - admin/src/components/layout/PageTabs.tsx | 86 +++ admin/src/components/layout/RouteError.tsx | 79 ++ admin/src/components/layout/Sidebar.tsx | 211 +++--- admin/src/components/layout/Topbar.tsx | 45 +- admin/src/components/ui/command.tsx | 190 +++++ admin/src/components/ui/sheet.tsx | 144 ++++ admin/src/hooks/useDocumentTitle.ts | 42 ++ admin/src/lib/api/client/admin/admins.ts | 76 ++ admin/src/lib/api/client/admin/analytics.ts | 101 ++- admin/src/lib/api/client/admin/fleet.ts | 151 ++++ admin/src/lib/api/client/admin/jobs.ts | 48 ++ .../src/lib/api/client/admin/organizations.ts | 69 ++ admin/src/lib/api/client/admin/plans.ts | 44 -- admin/src/lib/api/client/admin/sends.ts | 183 +++++ admin/src/lib/api/client/admin/sync.ts | 111 +++ admin/src/lib/api/client/admin/transfers.ts | 335 +++++++++ admin/src/lib/api/client/admin/warmup.ts | 48 ++ admin/src/lib/api/client/admin/workers.ts | 36 + admin/src/lib/api/models/admin.ts | 86 --- admin/src/lib/auth/permissions.ts | 9 +- admin/src/lib/realtime/RealtimeManager.tsx | 10 + admin/src/main.tsx | 203 +++--- .../docs/development/accounts-and-access.mdx | 10 +- docs/content/docs/development/admin-panel.mdx | 85 +++ .../content/docs/development/architecture.mdx | 2 +- docs/content/docs/development/bare-metal.mdx | 2 +- .../docs/development/configuration.mdx | 8 +- .../content/docs/development/data-control.mdx | 2 +- .../docs/development/deployment-guide.mdx | 4 +- docs/content/docs/development/install.mdx | 2 +- .../docs/development/instance-health.mdx | 6 +- docs/content/docs/development/meta.json | 1 + .../development/operator-notifications.mdx | 2 +- .../docs/development/troubleshooting.mdx | 4 +- docs/content/docs/development/warmblyctl.mdx | 6 +- 95 files changed, 9146 insertions(+), 1897 deletions(-) create mode 100644 admin/src/app/dashboard/AdminsPage.tsx delete mode 100644 admin/src/app/dashboard/AnalyticsPage.tsx create mode 100644 admin/src/app/dashboard/FleetPage.tsx create mode 100644 admin/src/app/dashboard/JobsPage.tsx create mode 100644 admin/src/app/dashboard/NotFoundPage.tsx create mode 100644 admin/src/app/dashboard/SendsPage.tsx delete mode 100644 admin/src/app/dashboard/StubPages.tsx create mode 100644 admin/src/app/dashboard/SyncPage.tsx create mode 100644 admin/src/app/dashboard/TransfersPage.tsx create mode 100644 admin/src/app/dashboard/admins/GrantAdminDialog.tsx create mode 100644 admin/src/app/dashboard/admins/PermissionChips.tsx create mode 100644 admin/src/app/dashboard/admins/UserPicker.tsx create mode 100644 admin/src/app/dashboard/admins/permissions.ts create mode 100644 admin/src/app/dashboard/configuration/EnvironmentTab.tsx rename admin/src/app/dashboard/{LimitsPage.tsx => configuration/LimitsTab.tsx} (81%) rename admin/src/app/dashboard/{NotificationsPage.tsx => configuration/NotificationsTab.tsx} (91%) rename admin/src/app/dashboard/{InstanceSettingsPage.tsx => configuration/SettingsTab.tsx} (94%) create mode 100644 admin/src/app/dashboard/fleet/CapacityTab.tsx create mode 100644 admin/src/app/dashboard/fleet/ConvertDedicatedDialog.tsx create mode 100644 admin/src/app/dashboard/fleet/DecisionsTab.tsx create mode 100644 admin/src/app/dashboard/fleet/DedicatedTab.tsx create mode 100644 admin/src/app/dashboard/fleet/OrgPicker.tsx create mode 100644 admin/src/app/dashboard/fleet/SearchPicker.tsx create mode 100644 admin/src/app/dashboard/fleet/format.ts create mode 100644 admin/src/app/dashboard/fleet/tones.tsx create mode 100644 admin/src/app/dashboard/health/FindingsTab.tsx rename admin/src/app/dashboard/{SystemStatusPage.tsx => health/ServicesTab.tsx} (64%) create mode 100644 admin/src/app/dashboard/jobs/ExpandableText.tsx create mode 100644 admin/src/app/dashboard/jobs/format.ts create mode 100644 admin/src/app/dashboard/sends/DeadLettersTab.tsx create mode 100644 admin/src/app/dashboard/sends/FailuresTab.tsx create mode 100644 admin/src/app/dashboard/sends/InFlightTab.tsx create mode 100644 admin/src/app/dashboard/sends/StatCard.tsx create mode 100644 admin/src/app/dashboard/sends/StatusSegments.tsx create mode 100644 admin/src/app/dashboard/sends/WebhooksTab.tsx create mode 100644 admin/src/app/dashboard/transfers/ExportDialog.tsx create mode 100644 admin/src/app/dashboard/transfers/GroupPicker.tsx create mode 100644 admin/src/app/dashboard/transfers/ImportArchiveDialog.tsx create mode 100644 admin/src/app/dashboard/transfers/OrgTransferTab.tsx create mode 100644 admin/src/app/dashboard/transfers/TransferPills.tsx create mode 100644 admin/src/components/ConfirmDialog.tsx create mode 100644 admin/src/components/layout/CommandPalette.tsx create mode 100644 admin/src/components/layout/MobileNav.tsx create mode 100644 admin/src/components/layout/PageTabs.tsx create mode 100644 admin/src/components/layout/RouteError.tsx create mode 100644 admin/src/components/ui/command.tsx create mode 100644 admin/src/components/ui/sheet.tsx create mode 100644 admin/src/hooks/useDocumentTitle.ts create mode 100644 admin/src/lib/api/client/admin/admins.ts create mode 100644 admin/src/lib/api/client/admin/fleet.ts create mode 100644 admin/src/lib/api/client/admin/jobs.ts delete mode 100644 admin/src/lib/api/client/admin/plans.ts create mode 100644 admin/src/lib/api/client/admin/sends.ts create mode 100644 admin/src/lib/api/client/admin/sync.ts create mode 100644 admin/src/lib/api/client/admin/transfers.ts create mode 100644 docs/content/docs/development/admin-panel.mdx diff --git a/admin/README.md b/admin/README.md index f1d5b660..972a58af 100644 --- a/admin/README.md +++ b/admin/README.md @@ -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 ├── client/ │ ├── auth/ # login, getMe, logout - │ └── admin/ # workers, audit, analytics, settings + │ └── admin/ # one module per backend area (workers, sync, sends, jobs, fleet, …) └── models/ ``` diff --git a/admin/package.json b/admin/package.json index 8380302a..e7a55c41 100644 --- a/admin/package.json +++ b/admin/package.json @@ -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", diff --git a/admin/pnpm-lock.yaml b/admin/pnpm-lock.yaml index df08fcda..0cedb0f0 100644 --- a/admin/pnpm-lock.yaml +++ b/admin/pnpm-lock.yaml @@ -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: {} diff --git a/admin/src/app/dashboard/AdminsPage.tsx b/admin/src/app/dashboard/AdminsPage.tsx new file mode 100644 index 00000000..b42ab8f1 --- /dev/null +++ b/admin/src/app/dashboard/AdminsPage.tsx @@ -0,0 +1,204 @@ +// 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; + const lastGranter = + hasBit(a.admin_permissions, AdminPerm.GrantAdminAccess) && granters.length <= 1 && !adminsQ.data?.pagination.has_more; + 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[] = [ + { + id: "admin", + header: "Admin", + cell: (a) => ( +
+
+ {userName(a)} + {a.id === me.data?.id && ( + you + )} +
+
{a.email}
+
+ ), + csv: (a) => a.email, + }, + { + id: "granted", + header: "Granted", + cell: (a) => ( +
+
{fmtDate(a.admin_granted_at)}
+ {a.granted_by_user ? ( +
by {a.granted_by_user.email}
+ ) : a.admin_granted_by ? ( +
by {a.admin_granted_by.slice(0, 8)}
+ ) : null} +
+ ), + csv: (a) => a.admin_granted_at || "", + }, + { + id: "permissions", + header: "Permissions", + cell: (a) => , + csv: (a) => catalog.filter((p) => hasBit(a.admin_permissions, p.permission)).map((p) => p.name).join(" "), + }, + { + id: "actions", + header: "", + align: "right", + cell: (a) => ( +
+ + +
+ ), + }, + ]; + + return ( +
+ + + + + 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." + /> + + setDialog((d) => ({ ...d, open: v }))} + catalog={catalog} + target={dialog.target} + selfId={me.data?.id} + /> +
+ ); +} diff --git a/admin/src/app/dashboard/AnalyticsPage.tsx b/admin/src/app/dashboard/AnalyticsPage.tsx deleted file mode 100644 index 484a732a..00000000 --- a/admin/src/app/dashboard/AnalyticsPage.tsx +++ /dev/null @@ -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 ( -
- - - - -
-

Email volume — last 30 days

- -
- -
- - -
- -
-

User growth — last 30 days

- -
-
- ); -} - -function TrendCards() { - const { data, isLoading } = useQuery({ - queryKey: ["admin", "analytics", "trends"], - queryFn: getAnalyticsTrends, - staleTime: 60_000, - }); - - if (isLoading) { - return ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ); - } - - return ( -
- } - label="Users" - pct={data?.users_growth_percent} - /> - } - label="Emails sent" - pct={data?.emails_growth_percent} - /> - } - label="Campaigns" - pct={data?.campaigns_growth_percent} - /> - } - label="Revenue" - pct={data?.revenue_growth_percent} - /> -
- ); -} - -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 ( -
-
- {icon} - {label} -
-
- {pct == null ? "—" : `${up && v > 0 ? "+" : ""}${v.toFixed(1)}%`} -
-
- {v > 0 ? : v < 0 ? : null} - vs. previous period -
-
- ); -} - -function DailyEmailChart() { - const { data, isLoading } = useQuery({ - queryKey: ["admin", "analytics", "emails", "daily"], - queryFn: () => getDailyEmailStats(30), - staleTime: 60_000, - }); - - if (isLoading) return ; - const rows = data?.data ?? []; - if (rows.length === 0) { - return ( -
- No email activity in the last 30 days. -
- ); - } - - const maxSent = Math.max(1, ...rows.map((r) => r.total_sent)); - return ( -
-
- {rows.map((r) => ( - - ))} -
- -
- ); -} - -function DayBar({ stat, maxSent }: { stat: DailyEmailStat; maxSent: number }) { - const h = (n: number) => `${Math.max(1, (n / maxSent) * 100)}%`; - return ( -
-
- {stat.total_bounced > 0 && ( -
- )} - {stat.total_replied > 0 && ( -
- )} - {stat.total_delivered > 0 && ( -
- )} -
-
- {new Date(stat.date).toLocaleDateString(undefined, { - month: "numeric", - day: "numeric", - })} -
-
- ); -} - -function Legend() { - return ( -
- - - -
- ); -} - -function LegendDot({ color, label }: { color: string; label: string }) { - return ( - - - {label} - - ); -} - -function HourlyChart() { - const { data, isLoading } = useQuery({ - queryKey: ["admin", "analytics", "emails", "hourly"], - queryFn: getHourlyEmailStats, - staleTime: 60_000, - }); - - if (isLoading) return ; - const rows = data?.data ?? []; - const max = Math.max(1, ...rows.map((r) => r.total_sent)); - - return ( -
-

Today by hour

- {rows.length === 0 ? ( -

No sends today.

- ) : ( -
- {rows.map((r) => ( -
-
-
- {r.hour} -
-
- ))} -
- )} -
- ); -} - -function WorkerLoadList() { - const { data, isLoading } = useQuery({ - queryKey: ["admin", "analytics", "workers", "load"], - queryFn: getWorkerLoadStats, - staleTime: 30_000, - }); - - if (isLoading) return ; - const rows = (data?.data ?? []).slice().sort( - (a, b) => b.emails_sent_today - a.emails_sent_today, - ); - - return ( -
-

Worker load today

- {rows.length === 0 ? ( -

No workers reporting.

- ) : ( -
    - {rows.slice(0, 10).map((w) => ( - - ))} -
- )} -
- ); -} - -function WorkerLoadRow({ w }: { w: WorkerLoadStat }) { - return ( -
  • - - {w.worker_name || w.worker_id.slice(0, 8)} - - - {w.emails_sent_today.toLocaleString()} sent - {w.queued_emails > 0 && ( - - · {w.queued_emails} queued - - )} - -
  • - ); -} - -function UserGrowthChart() { - const { data, isLoading } = useQuery({ - queryKey: ["admin", "analytics", "users", "growth"], - queryFn: () => getUserGrowthStats(30), - staleTime: 60_000, - }); - - if (isLoading) return ; - const rows = data?.data ?? []; - if (rows.length === 0) { - return ( -
    - No new users in the last 30 days. -
    - ); - } - const maxNew = Math.max(1, ...rows.map((r) => r.new_users)); - return ( -
    -
    - {rows.map((r) => ( -
    -
    -
    - ))} -
    -
    - New users per day (hover for totals). -
    -
    - ); -} diff --git a/admin/src/app/dashboard/ConfigurationPage.tsx b/admin/src/app/dashboard/ConfigurationPage.tsx index d959dc8b..48b7f203 100644 --- a/admin/src/app/dashboard/ConfigurationPage.tsx +++ b/admin/src/app/dashboard/ConfigurationPage.tsx @@ -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 = { - 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 = { - 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 = { - "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(); - 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 (
    - - + 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." + /> -
    -
    - - setSearch(e.target.value)} - placeholder="Search variables, groups or effects" - className="h-8 pl-8 text-[12.5px]" - /> -
    - {entries.length > 0 && ( - - {filtered.length} of {entries.length} variables - - )} -
    + void setTab(id)} + /> - {configQ.isLoading && ( -
    - - -
    + {tab === "settings" && ( + void setTab(t)} /> )} - - {configQ.isError && ( - configQ.refetch()} - /> - )} - - {configQ.data && entries.length === 0 && ( -
    - The backend returned no configuration entries. -
    - )} - - {configQ.data && entries.length > 0 && filtered.length === 0 && ( -
    - No variable matches "{search.trim()}". -
    - )} - -
    - {groups.map(([group, groupEntries]) => ( -
    -
    -
    - {groupLabel(group)} -
    -
    - {groupEntries.length} -
    -
    -
    - {groupEntries.map((entry) => ( - - ))} -
    -
    - ))} -
    + {tab === "notifications" && } + {tab === "environment" && } + {tab === "limits" && }
    ); } - -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. -
    -
    - {entry.key} - - {source.label} - - - {restart.label} - -
    - -
    - {entry.sensitive ? ( - - ) : ( - - )} -
    - - {entry.effect && ( -

    - {entry.effect} -

    - )} - - {entry.docs && ( - - Documentation - - - )} -
    - ); -} - -// 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 Not set; - } - return ( - - {entry.value} - - ); -} - -// 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 Not set; - } - return ( - - - Set, value hidden - - {entry.fingerprint && ( - - fingerprint {entry.fingerprint} - - )} - - ); -} diff --git a/admin/src/app/dashboard/FleetPage.tsx b/admin/src/app/dashboard/FleetPage.tsx new file mode 100644 index 00000000..a7c285cd --- /dev/null +++ b/admin/src/app/dashboard/FleetPage.tsx @@ -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 ( +
    + + + {tab === "capacity" && } + {tab === "decisions" && } + {tab === "dedicated" && } +
    + ); +} diff --git a/admin/src/app/dashboard/HealthPage.tsx b/admin/src/app/dashboard/HealthPage.tsx index 0a4aef45..724f98d6 100644 --- a/admin/src/app/dashboard/HealthPage.tsx +++ b/admin/src/app/dashboard/HealthPage.tsx @@ -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 (
    - {healthQ.dataUpdatedAt > 0 && ( - - Last checked {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()} - - )} - - - - {healthQ.isLoading && ( -
    - - - -
    - )} - - {healthQ.isError && ( - healthQ.refetch()} - /> - )} - - {healthQ.data && ( - <> - {checks.length === 0 ? ( -
    -
    - -
    -
    - No problems found -
    -

    - 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. -

    -
    -
    -
    - ) : ( - <> - - - - )} - - )} -
    - ); -} - -// 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 ( -
    - {updating ? ( - - ) : available ? ( - - ) : ( - - )} -
    - - {updating - ? "Updating this instance" - : available - ? `${state.latest?.tag && state.reason === "release" ? state.latest.tag : "A newer version"} is available` - : "Up to date"} - - - {" "} - 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()}` - : ""} - -
    - - -
    - ); -} - -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 ( -
    - 0 ? "border-red-200 bg-red-50/60" : ""} - iconClass={errors > 0 ? "text-red-600" : "text-muted-foreground"} + 0 ? findings : undefined, + }, + { id: "services", label: "Services", icon: Activity }, + ]} + value={tab} + onChange={setTab} /> - 0 ? "border-amber-200 bg-amber-50/60" : ""} - iconClass={warnings > 0 ? "text-amber-600" : "text-muted-foreground"} - /> - 0 ? "border-sky-200 bg-sky-50/50" : ""} - iconClass={info > 0 ? "text-sky-600" : "text-muted-foreground"} - /> -
    - ); -} -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 ( -
    -
    - - {label} -
    -
    {value}
    -
    {sub}
    + {tab === "findings" ? : }
    ); } diff --git a/admin/src/app/dashboard/JobsPage.tsx b/admin/src/app/dashboard/JobsPage.tsx new file mode 100644 index 00000000..fc4d4438 --- /dev/null +++ b/admin/src/app/dashboard/JobsPage.tsx @@ -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 = { + 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(); + 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[] = [ + { + id: "name", + header: "Job", + cell: (j) => {j.name}, + csv: (j) => j.name, + }, + { + id: "interval", + header: "Interval", + cell: (j) => {humanInterval(j.interval_seconds)}, + csv: (j) => j.interval_seconds, + }, + { + id: "last_run", + header: "Last run", + cell: (j) => + j.last_started_at ? ( +
    +
    + {relative(j.last_started_at)} +
    +
    + {j.last_status === "running" ? "still running" : `took ${humanDuration(j.last_duration_ms)}`} +
    +
    + ) : ( + never + ), + csv: (j) => j.last_started_at || "", + }, + { + id: "status", + header: "Status", + cell: (j) => ( + + {j.last_status === "running" && } + {j.last_status || "idle"} + + ), + csv: (j) => j.last_status, + }, + { + id: "next_run", + header: "Next run", + cell: (j) => ( +
    + + {relative(j.next_run_at, "—")} + + {j.run_requested_at && ( + + requested + + )} +
    + ), + csv: (j) => j.next_run_at || "", + }, + { + id: "counts", + header: "Runs / errors", + align: "right", + cell: (j) => ( + + {j.run_count.toLocaleString()} + / + 0 ? "text-red-700" : "text-muted-foreground"}>{j.error_count.toLocaleString()} + + ), + csv: (j) => `${j.run_count}/${j.error_count}`, + }, + { + id: "last_error", + header: "Last error", + className: "max-w-md", + cell: (j) => , + csv: (j) => j.last_error, + }, + { + id: "actions", + header: "", + align: "right", + cell: (j) => { + const pending = run.isPending && run.variables === j.name; + return ( + + ); + }, + }, + ]; + + const empty = !isLoading && !error && groups.length === 0; + + return ( +
    + + + {error ? ( + refetch()} /> + ) : empty ? ( +
    +
    No jobs have reported yet
    +
    + Rows appear as soon as a service (the backend or the consumer) has booted on this build and registered its loops. +
    +
    + ) : isLoading ? ( + j.name} loading storageKey="admin.jobs" noun="jobs" /> + ) : ( + groups.map(([service, jobs]) => ( +
    +
    +

    {service}

    + + {jobs.length} {jobs.length === 1 ? "loop" : "loops"} + +
    + j.name} + storageKey="admin.jobs" + csvName={`warmbly-jobs-${service}`} + noun="jobs" + /> +
    + )) + )} +
    + ); +} diff --git a/admin/src/app/dashboard/MailStatusCard.tsx b/admin/src/app/dashboard/MailStatusCard.tsx index e75f591b..4fab7ef2 100644 --- a/admin/src/app/dashboard/MailStatusCard.tsx +++ b/admin/src/app/dashboard/MailStatusCard.tsx @@ -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"; diff --git a/admin/src/app/dashboard/MailboxesPage.tsx b/admin/src/app/dashboard/MailboxesPage.tsx index 165fce08..625cd409 100644 --- a/admin/src/app/dashboard/MailboxesPage.tsx +++ b/admin/src/app/dashboard/MailboxesPage.tsx @@ -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("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(params.get("q") ? "all" : "active"); const [provider, setProvider] = useState(""); const [warmup, setWarmup] = useState("all"); const [workerId, setWorkerId] = useState(workerParam); @@ -304,6 +306,7 @@ export default function MailboxesPage() { next.delete("org"); next.delete("user"); next.delete("worker"); + next.delete("q"); setParams(next, { replace: true }); } diff --git a/admin/src/app/dashboard/NotFoundPage.tsx b/admin/src/app/dashboard/NotFoundPage.tsx new file mode 100644 index 00000000..ec72975e --- /dev/null +++ b/admin/src/app/dashboard/NotFoundPage.tsx @@ -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 ( +
    + + + Back to overview + +
    + ); +} diff --git a/admin/src/app/dashboard/OrganizationDetailPage.tsx b/admin/src/app/dashboard/OrganizationDetailPage.tsx index c21c2448..d324c412 100644 --- a/admin/src/app/dashboard/OrganizationDetailPage.tsx +++ b/admin/src/app/dashboard/OrganizationDetailPage.tsx @@ -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() { + + + {tab === "api-keys" && } + {tab === "webhooks" && } + {tab === "transfer" && } + + {tab === "overview" && ( + <>
    @@ -187,6 +252,8 @@ export default function OrganizationDetailPage() { )} + + )}
    ); } @@ -448,3 +515,252 @@ function DetailSkeleton() {
    ); } + +// ---- API keys ---- + +const KEY_STATUS_TONE: Record = { + 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(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 ( +
    +

    + 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. +

    + {keysQ.isLoading ? ( + + ) : keysQ.error ? ( + keysQ.refetch()} /> + ) : keys.length === 0 ? ( +
    + This workspace has not created any API keys. +
    + ) : ( +
    +
    + + + + + + + + + + + + + + + {keys.map((k) => ( + + + + + + + + + + + + ))} + +
    NameKeyStatusUserLast usedRequests 7dExpiresCreated +
    {k.name || untitled} + {k.key_prefix}…{k.key_suffix} + + + {k.status} + + {k.user_email || k.user_id.slice(0, 8)} + {k.last_used_at ? fmtAgo(k.last_used_at) : "never"} + {k.requests_last_7d.toLocaleString()}{k.expires_at ? fmtDate(k.expires_at) : "never"}{fmtDate(k.created_at)} + {k.status === "active" && ( + + )} +
    +
    +
    + )} + + { + if (!v && !revoke.isPending) setRevoking(null); + }} + > + + + Revoke this API key? + + {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. + + +
    + + setReason(e.target.value)} + placeholder="e.g. leaked in a public repository" + className="h-8 text-[12.5px]" + autoFocus + /> +
    + + + + +
    +
    +
    + ); +} + +// ---- webhooks ---- + +function WebhooksTab({ orgId }: { orgId: string }) { + const hooksQ = useQuery({ + queryKey: ["admin", "organizations", orgId, "webhooks"], + queryFn: () => listOrganizationWebhooks(orgId), + }); + const hooks = hooksQ.data?.data ?? []; + + return ( +
    +

    + 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. +

    + {hooksQ.isLoading ? ( + + ) : hooksQ.error ? ( + hooksQ.refetch()} /> + ) : hooks.length === 0 ? ( +
    + This workspace has no webhook endpoints. +
    + ) : ( +
    +
    + + + + + + + + + + + + + + {hooks.map((h: AdminWebhookEndpointRow) => ( + + + + + + + + + + ))} + +
    EndpointEnabledEventsFailures in a rowLast successLast failure7d delivered / failed / drops
    +
    + {h.url} +
    + {h.description &&
    {h.description}
    } +
    + + {h.enabled ? "enabled" : "disabled"} + + +
    + {(h.event_types ?? []).length === 0 ? ( + all + ) : ( + (h.event_types ?? []).map((t) => ( + + {t} + + )) + )} +
    +
    0 ? "font-medium text-red-600" : "text-muted-foreground"}`}> + {h.consecutive_failures} + + {h.last_success_at ? fmtAgo(h.last_success_at) : "never"} + +
    + {h.last_failure_at ? fmtAgo(h.last_failure_at) : "never"} +
    + {h.last_failure_reason && ( +
    + {h.last_failure_reason} +
    + )} +
    + {h.deliveries_last_7d.toLocaleString()} + / + 0 ? "text-red-600" : ""}>{h.failed_last_7d.toLocaleString()} + / + 0 ? "text-amber-700" : ""}>{h.drops_last_7d.toLocaleString()} +
    +
    +
    + )} +
    + ); +} diff --git a/admin/src/app/dashboard/OrganizationsPage.tsx b/admin/src/app/dashboard/OrganizationsPage.tsx index 9ac7b951..7a316fb8 100644 --- a/admin/src/app/dashboard/OrganizationsPage.tsx +++ b/admin/src/app/dashboard/OrganizationsPage.tsx @@ -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("active"); - const [planId, setPlanId] = useState(""); const [visibility, setVisibility] = useState(""); 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() { ]} /> - - setPlanId(v === "any" ? "" : v)} - options={planOptions} - placeholder="Any plan" - /> - (30); + + return ( +
    + + + + + + + {!canView ? ( +
    + Platform counters and trends need the View analytics permission. +
    + ) : ( + <> + + + +
    +

    + Email volume, last {days} days +

    + +
    + +
    + +
    +

    + User growth, last {days} days +

    + +
    +
    + +
    + +
    + + )} +
    + ); } -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 ( +
    + {RANGES.map((d) => { + const active = d === value; + return ( + + ); + })} +
    + ); +} + +// 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 ( -
    - - - - -
    + <> +
    - 0 && (ov.active_workers ?? 0) === 0 + ? "warn" + : "neutral" + } /> +
    +
    + + + + + 0} + /> + 0} + /> +
    + + ); +} -
    - - - Worker fleet - - Live snapshot from /admin/workers/managed. Click any row in the - Workers page for SSH actions + logs. - - - -
      - {workersQ.isLoading && ( -
    • - -
    • - )} - {!workersQ.isLoading && workers.length === 0 && ( -
    • - No workers registered yet. -
    • - )} - {workers.slice(0, 6).map((w) => ( -
    • - - - {w.name || w.id.slice(0, 8)} - - - {w.worker_type} - {w.worker_type === "shared" && - (w.free_tier ? " · free" : " · premium")} - - - {w.account_count} mailboxes - -
    • - ))} -
    -
    -
    +function TrendCards() { + const { data, isLoading } = useQuery({ + queryKey: ["admin", "analytics", "trends"], + queryFn: getAnalyticsTrends, + staleTime: 60_000, + }); - - - Activity - Quick-glance counters. - - - - - - - + if (isLoading) { + return ( +
    + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
    + ); + } + + return ( +
    + } label="Users" pct={data?.users_growth_percent} /> + } + label="Emails sent" + pct={data?.emails_growth_percent} + /> + } + label="Campaigns" + pct={data?.campaigns_growth_percent} + /> + } + label="Revenue" + pct={data?.revenue_growth_percent} + /> +
    + ); +} + +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 ( +
    +
    + {icon} + {label} +
    +
    + {pct == null ? "—" : `${v > 0 ? "+" : ""}${v.toFixed(1)}%`} +
    +
    + {v > 0 ? ( + + ) : v < 0 ? ( + + ) : null} + vs. previous period
    ); } -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 ; + const rows = data ?? []; + if (rows.length === 0) { + return ( +
    + No email activity in the last {days} days. Bars appear here once a campaign or + warmup send goes out. +
    + ); + } + + const maxSent = Math.max(1, ...rows.map((r) => r.total_sent)); + return ( +
    +
    + {rows.map((r) => ( + 45} /> + ))} +
    + +
    + ); } -// 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 ( +
    +
    + {stat.total_bounced > 0 && ( +
    + )} + {stat.total_replied > 0 && ( +
    + )} + {other > 0 && ( +
    + )} +
    + {/* Ninety labels do not fit; keep every third so the axis stays readable. */} +
    + {new Date(stat.date).toLocaleDateString(undefined, { + month: "numeric", + day: "numeric", + })} +
    +
    + ); +} + +function Legend() { + return ( +
    + + + +
    + ); +} + +function LegendDot({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ); +} + +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 ( +
    +

    Today by hour

    + {isLoading ? ( + + ) : ( +
    + {rows.length === 0 ? ( +

    + No sends today yet. The hourly profile fills in as workers send. +

    + ) : ( +
    + {rows.map((r) => ( +
    +
    +
    {r.hour}
    +
    + ))} +
    + )} +
    + )} +
    + ); +} + +function UserGrowthChart({ days }: { days: number }) { + const { data, isLoading } = useQuery({ + queryKey: ["admin", "analytics", "users", "growth", days], + queryFn: () => getUserGrowthStats(days), + staleTime: 60_000, + }); + + if (isLoading) return ; + const rows = data ?? []; + if (rows.length === 0) { + return ( +
    + No new users in the last {days} days. +
    + ); + } + const maxNew = Math.max(1, ...rows.map((r) => r.new_users)); + return ( +
    +
    + {rows.map((r) => ( +
    +
    +
    + ))} +
    +
    + New users per day (hover for totals). +
    +
    + ); +} + +// 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 ( + + + Acquisition + + 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. + + + + {acqQ.isLoading && } + {acqQ.isError && ( +

    + Acquisition data is unavailable on this backend. +

    + )} + {a && ( + <> +
    + + + + 0} + /> +
    +
    + ({ + 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, + }))} + /> + ({ + key: r.host, + label: r.host, + value: r.signups, + pct: (r.signups / maxRef) * 100, + }))} + /> +
    + + )} +
    +
    + ); +} + +function RankedList({ + title, + empty, + rows, +}: { + title: string; + empty: string; + rows: { key: string; label: string; value: number; note?: string; pct: number }[]; +}) { + return ( +
    +
    + {title} +
    + {rows.length === 0 ? ( +

    {empty}

    + ) : ( +
      + {rows.map((r) => ( +
    • +
      + {r.label} + + {formatNum(r.value)} + {r.note && · {r.note}} + +
      +
      +
      +
      +
    • + ))} +
    + )} +
    + ); } interface StatCardProps { @@ -208,28 +585,34 @@ function StatCard({ icon: Icon, label, value, sub, loading, tone = "neutral" }:
    {loading ? : value}
    - {sub &&
    {sub}
    } + {sub &&
    {sub}
    } ); } -function StatRow({ +function MiniStat({ icon: Icon, label, value, + warn, }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string; + warn?: boolean; }) { return ( -
    - - +
    +
    + {label} - - {value} +
    +
    {value}
    ); } diff --git a/admin/src/app/dashboard/SendsPage.tsx b/admin/src/app/dashboard/SendsPage.tsx new file mode 100644 index 00000000..dc73c391 --- /dev/null +++ b/admin/src/app/dashboard/SendsPage.tsx @@ -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 ( +
    + +
    + +
    + {tab === "in-flight" && } + {tab === "dead-letters" && } + {tab === "failures" && } + {tab === "webhooks" && } +
    + ); +} diff --git a/admin/src/app/dashboard/StubPages.tsx b/admin/src/app/dashboard/StubPages.tsx deleted file mode 100644 index 49100b6a..00000000 --- a/admin/src/app/dashboard/StubPages.tsx +++ /dev/null @@ -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 ( -
    - - -
    - ); -} - -export function NotFoundPage() { - return ( - - ); -} diff --git a/admin/src/app/dashboard/SyncPage.tsx b/admin/src/app/dashboard/SyncPage.tsx new file mode 100644 index 00000000..8d9fb039 --- /dev/null +++ b/admin/src/app/dashboard/SyncPage.tsx @@ -0,0 +1,386 @@ +// 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 = { + 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); + if (value && value !== "all") 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[] = [ + { + id: "mailbox", + header: "Mailbox", + cell: (m) => ( +
    +
    + {m.email} + {m.account_status && m.account_status !== "active" && ( + + {m.account_status} + + )} +
    +
    {m.provider}
    +
    + ), + csv: (m) => m.email, + }, + { + id: "workspace", + header: "Workspace", + cell: (m) => + m.organization_id ? ( + + {m.organization_name || m.organization_id} + + ) : ( + + ), + csv: (m) => m.organization_name || "", + }, + { + id: "backfill", + header: "Backfill", + cell: (m) => ( +
    +
    + + {m.backfill_status || "unknown"} + + {m.stalled && ( + + stalled + + )} +
    +
    + {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)}` + : ""} +
    +
    + ), + csv: (m) => `${m.backfill_status}${m.stalled ? " (stalled)" : ""}: ${m.backfill_synced}`, + }, + { + id: "throttle", + header: "Throttle", + cell: (m) => { + const throttled = isThrottled(m); + return ( +
    + {throttled ? ( +
    + + throttled + + {m.throttle_reason || "over budget"} +
    + ) : ( + + )} +
    + {throttled && until {relative(m.throttled_until)}} + {throttled && m.deferred > 0 && " · "} + {m.deferred > 0 && `${m.deferred.toLocaleString()} deferred`} +
    +
    + ); + }, + csv: (m) => (isThrottled(m) ? `${m.throttle_reason} until ${m.throttled_until}` : ""), + }, + { + id: "synced", + header: "Last synced", + cell: (m) => ( + + {relative(m.last_synced_at)} + + ), + csv: (m) => m.last_synced_at || "", + }, + { + id: "updated", + header: "Updated", + cell: (m) => ( + + {relative(m.updated_at)} + + ), + csv: (m) => m.updated_at, + }, + { + id: "worker", + header: "Worker", + defaultHidden: true, + cell: (m) => + m.worker_id ? ( + + {m.worker_id.slice(0, 8)} + + ) : ( + + ), + csv: (m) => m.worker_id || "", + }, + { + id: "actions", + header: "", + align: "right", + cell: (m) => ( + clear.mutate(m)} + onRestart={() => onRestart(m)} + /> + ), + }, + ]; + + return ( +
    + + +
    + + + + + + + +
    + + { + const next = new URLSearchParams(params); + next.delete("state"); + next.delete("q"); + setParams(next, { replace: true }); + }} + filters={ + <> + + setParam("q", v)} placeholder="Mailbox or workspace…" /> + + + 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" }, + ]} + /> + + + } + > + 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, + }} + /> + +
    + ); +} + +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 ( + + + + + + {throttled && ( + + Clear throttle + + )} + + Restart backfill + + + + ); +} diff --git a/admin/src/app/dashboard/TransfersPage.tsx b/admin/src/app/dashboard/TransfersPage.tsx new file mode 100644 index 00000000..ef5fcd1a --- /dev/null +++ b/admin/src/app/dashboard/TransfersPage.tsx @@ -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(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[] = [ + { id: "kind", header: "Kind", cell: (j) => , csv: (j) => j.kind }, + { + id: "org", + header: "Workspace", + cell: (j) => ( +
    + e.stopPropagation()} + className="font-medium text-[var(--admin-accent-strong)] hover:underline" + > + {j.organization_name || shortId(j.organization_id)} + +
    {shortId(j.id)}
    +
    + ), + csv: (j) => j.organization_name, + }, + { + id: "by", + header: "Requested by", + cell: (j) => {j.requested_by_email || system}, + csv: (j) => j.requested_by_email, + }, + { + id: "status", + header: "Status", + cell: (j) => ( +
    + + {j.error_message &&
    {j.error_message}
    } +
    + ), + csv: (j) => j.status, + }, + { id: "size", header: "Size", align: "right", cell: (j) => {formatBytes(j.archive_bytes)}, csv: (j) => j.archive_bytes ?? "" }, + { id: "groups", header: "Groups", align: "right", cell: (j) => {j.groups?.length ?? 0}, csv: (j) => j.groups?.length ?? 0 }, + { id: "secrets", header: "Secrets", cell: (j) => {j.include_secrets ? "yes" : "no"}, csv: (j) => (j.include_secrets ? "yes" : "no") }, + { id: "started", header: "Started", cell: (j) => {fmtDateTime(j.started_at ?? j.created_at)}, csv: (j) => j.started_at ?? j.created_at }, + { id: "completed", header: "Completed", cell: (j) => {fmtDateTime(j.completed_at)}, csv: (j) => j.completed_at ?? "" }, + { id: "expires", header: "Expires", cell: (j) => {fmtDateTime(j.expires_at)}, csv: (j) => j.expires_at ?? "", defaultHidden: true }, + { + id: "actions", + header: "", + align: "right", + cell: (j) => + j.kind === "export" ? ( +
    + + +
    + ) : null, + }, + ]; + + return ( +
    + + + + +
    + + + These move one workspace at a time. A whole-instance backup, including every workspace, the + encryption keys and blob storage, is warmblyctl backup and warmblyctl restore{" "} + from a shell on the host.{" "} + + warmblyctl docs + + {active > 0 && · {active} running, refreshing every 15s} + +
    + + `${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." + /> + + +
    + ); +} diff --git a/admin/src/app/dashboard/UsersPage.tsx b/admin/src/app/dashboard/UsersPage.tsx index b2228668..3615e73b 100644 --- a/admin/src/app/dashboard/UsersPage.tsx +++ b/admin/src/app/dashboard/UsersPage.tsx @@ -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("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); @@ -274,14 +263,6 @@ export default function UsersPage() { ]} /> - - setPlanId(v === "any" ? "" : v)} - options={planOptions} - placeholder="Any plan" - /> - 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, }); diff --git a/admin/src/app/dashboard/WarmupPage.tsx b/admin/src/app/dashboard/WarmupPage.tsx index 2f233950..0f8e3d89 100644 --- a/admin/src/app/dashboard/WarmupPage.tsx +++ b/admin/src/app/dashboard/WarmupPage.tsx @@ -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 (
    + + + {tab === "abuse" && } + {tab === "actions" && } + {tab === "overview" && } +
    + ); +} + +function Overview() { + return ( +
    @@ -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 ; @@ -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 ; @@ -567,3 +611,188 @@ function ReviewAppealDialog({ ); } + +// ---- abuse signals ---- + +const HEALTH_TONE: Record = Object.fromEntries( + MAILBOX_HEALTH_LEGEND.map((e) => [e.term, e.tone ?? ""]), +); + +function AbuseTab() { + const [win, setWin] = useState("7d"); + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["admin", "warmup", "abuse", win], + queryFn: () => listWarmupAbuse(win, 200), + }); + const rows = data?.data ?? []; + + return ( +
    +
    +

    + 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. +

    +
    + + value={win} + onChange={setWin} + options={[ + { value: "24h", label: "24h" }, + { value: "7d", label: "7d" }, + { value: "30d", label: "30d" }, + ]} + /> +
    +
    + + {isLoading ? ( + + ) : error ? ( + refetch()} /> + ) : rows.length === 0 ? ( +
    + No invalid warmup-token attempts in the last {win}. Rows appear when a pool mailbox receives + warmup mail whose token does not verify. +
    + ) : ( +
    +
    + + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + +
    MailboxWorkspaceInvalid attemptsLast attemptBlockedSpam scoreHealth
    {r.email} + {r.organization_id ? ( + + {r.organization_name || r.organization_id.slice(0, 8)} + + ) : ( + + )} + = 3 ? "font-medium text-red-600" : "" + }`} + > + {r.attempts} + + {new Date(r.last_attempt_at).toLocaleString()} + + {r.blocked ? ( + + blocked + + ) : ( + no + )} + 50 ? "font-medium text-red-600" : r.spam_score > 25 ? "text-amber-700" : "" + }`} + > + {r.spam_score} + + {r.health_state ? ( + + {r.health_state} + + ) : ( + + )} +
    +
    +
    + )} +
    + ); +} + +// ---- admin action history ---- + +function ActionsTab() { + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["admin", "warmup", "actions"], + queryFn: () => listWarmupActions(200), + }); + const rows = data?.data ?? []; + + return ( +
    +

    + Every manual block, unblock and appeal decision an admin made on a warmup mailbox, newest first. +

    + {isLoading ? ( + + ) : error ? ( + refetch()} /> + ) : rows.length === 0 ? ( +
    + No admin actions recorded yet. Blocking or unblocking a mailbox, or reviewing an appeal, writes a row + here. +
    + ) : ( +
    +
    + + + + + + + + + + + + {rows.map((a) => ( + + + + + + + + ))} + +
    WhenAdminMailboxActionReason
    + {new Date(a.created_at).toLocaleString()} + {a.admin_email || a.admin_user_id.slice(0, 8)}{a.email || a.email_account_id.slice(0, 8)} + + {a.action} + + {a.reason || }
    +
    +
    + )} +
    + ); +} diff --git a/admin/src/app/dashboard/WorkerDetailPage.tsx b/admin/src/app/dashboard/WorkerDetailPage.tsx index 7ab133e4..d996bfd4 100644 --- a/admin/src/app/dashboard/WorkerDetailPage.tsx +++ b/admin/src/app/dashboard/WorkerDetailPage.tsx @@ -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 = { @@ -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>(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() {
    )} -
    +
    SSH target @@ -498,14 +542,31 @@ export default function WorkerDetailPage() { )} + +
    - Mailboxes + + Mailboxes + + 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. @@ -524,6 +585,13 @@ export default function WorkerDetailPage() { + @@ -535,7 +603,18 @@ export default function WorkerDetailPage() { {(emailsQ.data.data ?? []).map((m) => ( - + toggleOne(m.id)} + className={`border-t border-border cursor-pointer ${selected.has(m.id) ? "bg-[var(--admin-accent-soft)]" : "hover:bg-muted/40"}`} + > + + + + + + + + + + {open && hasDiff && ( + + + + )} + + ); +} + +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 ( +
    +
    +
    +
    Kind
    + setKind(v === "any" ? "" : v)} + options={[{ value: "any", label: "Any kind" }, ...kinds.map((k) => ({ value: k, label: k }))]} + /> +
    +
    +
    Worker
    + setWorkerId(v === "any" ? "" : v)} + options={[ + { value: "any", label: "Any worker" }, + ...workers.map((w) => ({ value: w.id, label: w.name || w.id.slice(0, 8) })), + ]} + /> +
    +
    + {isLoading ? "Loading…" : `${rows.length} decision${rows.length === 1 ? "" : "s"}`} + {rows.length >= LIMIT && " (newest " + LIMIT + ")"} +
    +
    + + {error ? ( + refetch()} /> + ) : isLoading ? ( + + ) : rows.length === 0 ? ( +
    + 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. +
    + ) : ( +
    +
    +
    + + Mailbox Provider Status
    e.stopPropagation()}> + toggleOne(m.id)} + aria-label={`Select ${m.email}`} + /> + {m.email} {m.provider} @@ -591,6 +670,23 @@ export default function WorkerDetailPage() { + setReassignOpen(true)} + onClear={() => setSelected(new Set())} + /> + + selected.has(m.id))} + onDone={() => { + setSelected(new Set()); + invalidate(); + }} + /> + @@ -778,3 +874,208 @@ function KV({ ); } + +function CapacityCard({ + stats, + loading, + error, +}: { + stats: WorkerStats | undefined; + loading: boolean; + error: boolean; +}) { + return ( + + + + + Capacity + + Send throughput and queue depth for this worker. + + + {loading && } + {error &&
    Stats unavailable.
    } + {stats && ( + <> + + + + 0 ? "text-amber-700" : ""}> + {stats.success_rate.toFixed(1)}% + + } + /> + + 0 ? "font-medium" : ""}>{stats.queue_depth.toLocaleString()}} + /> + + )} +
    +
    + ); +} + +// 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 ( +
    +
    + + {count} selected +
    + + +
    + ); +} + +const HEALTH_LABEL: Record = { + 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 ( + { + if (!v && mutation.isPending) return; + if (!v) setTarget(""); + onOpenChange(v); + }} + > + + + Move {mailboxes.length} mailbox{mailboxes.length === 1 ? "" : "es"} to another worker + + 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. + + + +
    +
    + {mailboxes.map((m) => ( +
    + {m.email} +
    + ))} +
    + +
    +
    Target worker
    + + {chosen && chosen.worker_type === "dedicated" && ( +

    + That worker is dedicated to one workspace; only move mailboxes that belong to it. +

    + )} + {chosen && chosen.health_state !== "healthy" && ( +

    + That worker is {chosen.health_state}; the assignment loop would not place new mailboxes there. +

    + )} +
    +
    + + + + + +
    +
    + ); +} diff --git a/admin/src/app/dashboard/WorkersPage.tsx b/admin/src/app/dashboard/WorkersPage.tsx index 57e86153..308eb083 100644 --- a/admin/src/app/dashboard/WorkersPage.tsx +++ b/admin/src/app/dashboard/WorkersPage.tsx @@ -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(""); diff --git a/admin/src/app/dashboard/admins/GrantAdminDialog.tsx b/admin/src/app/dashboard/admins/GrantAdminDialog.tsx new file mode 100644 index 00000000..f3d53f6c --- /dev/null +++ b/admin/src/app/dashboard/admins/GrantAdminDialog.tsx @@ -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(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 ( + { + if (!v && mutation.isPending) return; + onOpenChange(v); + }} + > + { + if (document.querySelector("[data-floating]")) e.preventDefault(); + }} + > + + {edit ? "Edit admin permissions" : "Grant admin access"} + + These bits gate the operator panel only. They are separate from any role the user holds inside a + workspace. + + + +
    +
    +
    User
    + {edit && user ? ( +
    + {userName(user)} + {user.email} +
    + ) : ( + + )} +
    + +
    +
    Preset
    +
    + {PRESETS.map((p) => ( + + ))} + +
    +

    + {active ? PRESETS.find((p) => p.id === active)?.hint : "Custom selection."} +

    +
    + +
    + {groupByCategory(catalog).map((g) => ( +
    +
    + {g.category} +
    +
    + {g.items.map((p) => { + const on = hasBit(shown, p.permission); + return ( + + ); + })} +
    +
    + ))} + {catalog.length === 0 && ( +
    The permission catalog is empty.
    + )} +
    + + {dropsOwnGrant && ( +
    + You are removing your own ability to grant admin access. You will not be able to undo this + from the panel. +
    + )} +
    + + + + {count} of {catalog.length} bits · mask {shown} + +
    + + +
    +
    +
    +
    + ); +} diff --git a/admin/src/app/dashboard/admins/PermissionChips.tsx b/admin/src/app/dashboard/admins/PermissionChips.tsx new file mode 100644 index 00000000..c3dc125d --- /dev/null +++ b/admin/src/app/dashboard/admins/PermissionChips.tsx @@ -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 No live permissions; + } + return ( +
    + {groups.map((g) => ( +
    + {g.category} + {g.items.map((p) => ( + + {humanize(p.name)} + + ))} +
    + ))} +
    + ); +} diff --git a/admin/src/app/dashboard/admins/UserPicker.tsx b/admin/src/app/dashboard/admins/UserPicker.tsx new file mode 100644 index 00000000..6b482a6d --- /dev/null +++ b/admin/src/app/dashboard/admins/UserPicker.tsx @@ -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; + +export function UserPicker({ + value, + onChange, + autoFocus, +}: { + value: PickedUser | null; + onChange: (v: PickedUser | null) => void; + autoFocus?: boolean; +}) { + return ( + + 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) => ( +
    +
    +
    {userName(u)}
    +
    {u.email}
    +
    + {u.admin_permissions > 0 && ( + admin + )} +
    + )} + renderSelected={(u) => ( +
    + {userName(u)} + {u.email} +
    + )} + /> + ); +} diff --git a/admin/src/app/dashboard/admins/permissions.ts b/admin/src/app/dashboard/admins/permissions.ts new file mode 100644 index 00000000..a4207f4a --- /dev/null +++ b/admin/src/app/dashboard/admins/permissions.ts @@ -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, " "); +} diff --git a/admin/src/app/dashboard/configuration/EnvironmentTab.tsx b/admin/src/app/dashboard/configuration/EnvironmentTab.tsx new file mode 100644 index 00000000..7f173b1c --- /dev/null +++ b/admin/src/app/dashboard/configuration/EnvironmentTab.tsx @@ -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 = { + 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 = { + 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 = { + "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(); + 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 ( +
    +
    +

    + 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. +

    + +
    + +
    +
    + + setSearch(e.target.value)} + placeholder="Search variables, groups or effects" + className="h-8 pl-8 text-[12.5px]" + /> +
    + {entries.length > 0 && ( + + {filtered.length} of {entries.length} variables + + )} +
    + + {configQ.isLoading && ( +
    + + +
    + )} + + {configQ.isError && ( + configQ.refetch()} + /> + )} + + {configQ.data && entries.length === 0 && ( +
    + The backend returned no configuration entries. +
    + )} + + {configQ.data && entries.length > 0 && filtered.length === 0 && ( +
    + No variable matches "{search.trim()}". +
    + )} + +
    + {groups.map(([group, groupEntries]) => ( +
    +
    +
    + {groupLabel(group)} +
    +
    + {groupEntries.length} +
    +
    +
    + {groupEntries.map((entry) => ( + + ))} +
    +
    + ))} +
    +
    + ); +} + +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. +
    +
    + {entry.key} + + {source.label} + + + {restart.label} + +
    + +
    + {entry.sensitive ? ( + + ) : ( + + )} +
    + + {entry.effect && ( +

    + {entry.effect} +

    + )} + + {entry.docs && ( + + Documentation + + + )} +
    + ); +} + +// 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 Not set; + } + return ( + + {entry.value} + + ); +} + +// 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 Not set; + } + return ( + + + Set, value hidden + + {entry.fingerprint && ( + + fingerprint {entry.fingerprint} + + )} + + ); +} diff --git a/admin/src/app/dashboard/LimitsPage.tsx b/admin/src/app/dashboard/configuration/LimitsTab.tsx similarity index 81% rename from admin/src/app/dashboard/LimitsPage.tsx rename to admin/src/app/dashboard/configuration/LimitsTab.tsx index 2b4a39b2..76ae031b 100644 --- a/admin/src/app/dashboard/LimitsPage.tsx +++ b/admin/src/app/dashboard/configuration/LimitsTab.tsx @@ -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 (
    - - - +
    {limitsQ.isLoading && (
    diff --git a/admin/src/app/dashboard/NotificationsPage.tsx b/admin/src/app/dashboard/configuration/NotificationsTab.tsx similarity index 91% rename from admin/src/app/dashboard/NotificationsPage.tsx rename to admin/src/app/dashboard/configuration/NotificationsTab.tsx index 89b9bee9..7ea940b8 100644 --- a/admin/src/app/dashboard/NotificationsPage.tsx +++ b/admin/src/app/dashboard/configuration/NotificationsTab.tsx @@ -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 settings.refetch()} />; - } - function update(id: string, patch: Partial) { 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 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 (
    - - - - +
    +

    + Where this instance tells you something happened. Add a Discord or Slack + webhook, a signed endpoint, or an address. +

    +
    + + +
    +
    {settings.isLoading ? ( diff --git a/admin/src/app/dashboard/InstanceSettingsPage.tsx b/admin/src/app/dashboard/configuration/SettingsTab.tsx similarity index 94% rename from admin/src/app/dashboard/InstanceSettingsPage.tsx rename to admin/src/app/dashboard/configuration/SettingsTab.tsx index 71004f2c..dd566905 100644 --- a/admin/src/app/dashboard/InstanceSettingsPage.tsx +++ b/admin/src/app/dashboard/configuration/SettingsTab.tsx @@ -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(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 (
    - - +
    +

    + Stored in the database and never read from the environment. Everything the + environment owns is on the Environment tab. +

    - +
    {settingsQ.isLoading && (
    @@ -372,7 +377,11 @@ export default function InstanceSettingsPage() { Access 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{" "} + onSwitchTab?.("environment")}> + Environment + + . @@ -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{" "} + onSwitchTab?.("limits")}>Limits. @@ -615,3 +625,16 @@ export default function InstanceSettingsPage() {
    ); } + +// An inline link inside descriptive copy that switches tabs instead of leaving the page. +function TabLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} diff --git a/admin/src/app/dashboard/fleet/CapacityTab.tsx b/admin/src/app/dashboard/fleet/CapacityTab.tsx new file mode 100644 index 00000000..57aec1de --- /dev/null +++ b/admin/src/app/dashboard/fleet/CapacityTab.tsx @@ -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 ( +
    +
    + + {row.load_score.toFixed(2)} + / {row.effective_capacity.toFixed(2)} + + + {pct}% + +
    +
    +
    +
    +
    + ); +} + +function Pair({ a, b, tone }: { a: number; b: number; tone?: string }) { + return ( + + {a.toLocaleString()} + / {b.toLocaleString()} + + ); +} + +function Count({ n, warnAbove = 0 }: { n: number; warnAbove?: number }) { + return ( + warnAbove ? "font-medium text-red-600" : "text-muted-foreground")}> + {n.toLocaleString()} + + ); +} + +const columns: Column[] = [ + { + id: "name", + header: "Worker", + sortable: true, + cell: (w) => ( +
    + e.stopPropagation()} + className="font-medium text-[var(--admin-accent-strong)] hover:underline" + > + {w.name || w.worker_id.slice(0, 8)} + +
    {w.ip_addr}
    +
    + ), + csv: (w) => w.name || w.worker_id, + }, + { id: "tier", header: "Tier", cell: (w) => , csv: (w) => (w.free_tier ? "free" : "premium") }, + { id: "type", header: "Type", cell: (w) => , csv: (w) => w.worker_type }, + { id: "pool", header: "Risk pool", cell: (w) => , csv: (w) => w.risk_pool }, + { + id: "egress", + header: "Egress", + cell: (w) => {w.egress_kind}, + csv: (w) => w.egress_kind, + defaultHidden: true, + }, + { id: "health", header: "Health", cell: (w) => , csv: (w) => w.health_state }, + { + id: "live", + header: "Live", + cell: (w) => , + csv: (w) => (w.live ? "live" : "offline"), + }, + { + id: "accounts", + header: "Accounts", + align: "right", + sortable: true, + cell: (w) => {w.account_count}, + csv: (w) => w.account_count, + }, + { + id: "utilization", + header: "Load / capacity", + sortable: true, + cell: (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) => , + csv: (w) => `${w.sends_succeeded_1h}/${w.sends_attempted_1h}`, + }, + { + id: "bounces", + header: "Bounces 1h", + align: "right", + cell: (w) => 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) => , csv: (w) => w.complaints_1h }, + { id: "auth", header: "Auth errors 1h", align: "right", cell: (w) => , csv: (w) => w.auth_errors_1h }, + { + id: "tags", + header: "Tags", + cell: (w) => + w.tags && w.tags.length ? ( +
    + {w.tags.map((t) => ( + + {t} + + ))} +
    + ) : ( + + ), + 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 ( +
    +
    + + The rebalancer drains a worker above 80% utilization + onto peers below 50% in the same tier, and never + across tiers. Counters are the last hour; this view refreshes every 30s. + {rows.length > 0 && ( + + ({hot} hot, {cold} cold of {rows.length}) + + )} + + + + + +
    + 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." + /> +
    + ); +} diff --git a/admin/src/app/dashboard/fleet/ConvertDedicatedDialog.tsx b/admin/src/app/dashboard/fleet/ConvertDedicatedDialog.tsx new file mode 100644 index 00000000..6032a40e --- /dev/null +++ b/admin/src/app/dashboard/fleet/ConvertDedicatedDialog.tsx @@ -0,0 +1,197 @@ +// 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(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 ( + { + if (!v && mutation.isPending) return; + if (!v) reset(); + onOpenChange(v); + }} + > + { + // A picker popover owns Escape while it is open. + if (document.querySelector("[data-floating]")) e.preventDefault(); + }} + > + + Convert a worker to dedicated + + 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. + + + +
    +
    + + +
    + +
    + + +
    + +
    + + setSubscriptionId(e.target.value)} + placeholder="00000000-0000-0000-0000-000000000000" + className="h-8 font-mono text-[12px]" + /> +

    + The workspace's subscription row (a UUID). The organization page shows only the plan and + status, so read the id from the subscriptions table for this workspace, or from + the Stripe subscription's metadata. + {subscriptionId && !subOk && Not a UUID.} +

    +
    + +
    + + +
    +
    + + + + + +
    +
    + ); +} diff --git a/admin/src/app/dashboard/fleet/DecisionsTab.tsx b/admin/src/app/dashboard/fleet/DecisionsTab.tsx new file mode 100644 index 00000000..bff99dc3 --- /dev/null +++ b/admin/src/app/dashboard/fleet/DecisionsTab.tsx @@ -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 ( + <> +
    + {fmtAgo(d.created_at)} + + + {d.kind} + + + {d.worker_id ? ( + + {d.worker_name || shortId(d.worker_id)} + + ) : ( + + )} + {shortId(d.mailbox_id)}{d.reason || }{d.triggered_by || "—"} + {hasDiff && ( + + )} +
    +
    +
    +
    Before
    +
    +                                    {pretty(d.before) || "(none)"}
    +                                
    +
    +
    +
    After
    +
    +                                    {pretty(d.after) || "(none)"}
    +                                
    +
    +
    +
    + + + + + + + + + + + + {rows.map((d) => ( + + ))} + +
    WhenKindWorkerMailboxReasonTriggered by +
    +
    +
    + )} +
    + ); +} diff --git a/admin/src/app/dashboard/fleet/DedicatedTab.tsx b/admin/src/app/dashboard/fleet/DedicatedTab.tsx new file mode 100644 index 00000000..0bc6f95d --- /dev/null +++ b/admin/src/app/dashboard/fleet/DedicatedTab.tsx @@ -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[] = [ + { + id: "worker", + header: "Worker", + cell: (a) => ( +
    + + {a.worker_name || shortId(a.worker_id)} + +
    {a.worker_id}
    +
    + ), + csv: (a) => a.worker_name || a.worker_id, + }, + { id: "live", header: "Live", cell: (a) => , csv: (a) => (a.worker_live ? "live" : "offline") }, + { + id: "org", + header: "Workspace", + cell: (a) => ( + + {a.organization_name || shortId(a.organization_id)} + + ), + csv: (a) => a.organization_name, + }, + { + id: "sub", + header: "Subscription", + cell: (a) => ( + + {shortId(a.subscription_id)} + + ), + csv: (a) => a.subscription_id, + }, + { id: "assigned", header: "Assigned", cell: (a) => {fmtDate(a.assigned_at)}, csv: (a) => a.assigned_at }, + { id: "accounts", header: "Accounts", align: "right", cell: (a) => {a.account_count}, csv: (a) => a.account_count }, + { + id: "actions", + header: "", + align: "right", + cell: (a) => ( + + ), + }, + ]; + + return ( +
    +
    +

    + 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. +

    + +
    + 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." + /> + +
    + ); +} diff --git a/admin/src/app/dashboard/fleet/OrgPicker.tsx b/admin/src/app/dashboard/fleet/OrgPicker.tsx new file mode 100644 index 00000000..dfcb040b --- /dev/null +++ b/admin/src/app/dashboard/fleet/OrgPicker.tsx @@ -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; + +export function OrgPicker({ + value, + onChange, + autoFocus, + disabled, +}: { + value: PickedOrg | null; + onChange: (v: PickedOrg | null) => void; + autoFocus?: boolean; + disabled?: boolean; +}) { + return ( + + 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) => ( +
    +
    {o.name}
    +
    {o.owner_email}
    +
    + )} + renderSelected={(o) => ( +
    + {o.name} + {o.owner_email} +
    + )} + /> + ); +} diff --git a/admin/src/app/dashboard/fleet/SearchPicker.tsx b/admin/src/app/dashboard/fleet/SearchPicker.tsx new file mode 100644 index 00000000..cf0580ed --- /dev/null +++ b/admin/src/app/dashboard/fleet/SearchPicker.tsx @@ -0,0 +1,172 @@ +// 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, 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 { + value: T | null; + onChange: (v: T | null) => void; + search: (q: string) => Promise; + /** 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({ + value, + onChange, + search, + queryKey, + getKey, + renderItem, + renderSelected, + placeholder = "Search…", + minChars = 2, + emptyText = "No matches.", + autoFocus, + disabled, +}: SearchPickerProps) { + const [term, setTerm] = useState(""); + const [debounced, setDebounced] = useState(""); + const [open, setOpen] = useState(false); + const [highlight, setHighlight] = useState(0); + const rootRef = useRef(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) { + 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 ( +
    +
    {renderSelected(value)}
    + {!disabled && ( + + )} +
    + ); + } + + return ( +
    + + { + 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} + /> + {isFetching && ( + + )} + {open && term.trim().length > 0 && ( +
    + {!enabled ? ( +
    + Type at least {minChars} characters. +
    + ) : items.length === 0 && !isFetching ? ( +
    {emptyText}
    + ) : ( + items.map((t, i) => ( + + )) + )} +
    + )} +
    + ); +} diff --git a/admin/src/app/dashboard/fleet/format.ts b/admin/src/app/dashboard/fleet/format.ts new file mode 100644 index 00000000..edd11965 --- /dev/null +++ b/admin/src/app/dashboard/fleet/format.ts @@ -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; +} diff --git a/admin/src/app/dashboard/fleet/tones.tsx b/admin/src/app/dashboard/fleet/tones.tsx new file mode 100644 index 00000000..0aeb1742 --- /dev/null +++ b/admin/src/app/dashboard/fleet/tones.tsx @@ -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 ( + + {state || "unknown"} + + ); +} + +export function RiskPoolPill({ pool }: { pool: string }) { + return ( + + {pool || "—"} + + ); +} + +export function TierPill({ freeTier }: { freeTier: boolean }) { + return ( + + {freeTier ? "free" : "premium"} + + ); +} + +export function TypePill({ type }: { type: string }) { + return ( + + {type} + + ); +} + +export function LiveDot({ live, title }: { live: boolean; title?: string }) { + return ( + + + + {live ? "live" : "offline"} + + + ); +} diff --git a/admin/src/app/dashboard/health/FindingsTab.tsx b/admin/src/app/dashboard/health/FindingsTab.tsx new file mode 100644 index 00000000..e9abd411 --- /dev/null +++ b/admin/src/app/dashboard/health/FindingsTab.tsx @@ -0,0 +1,226 @@ +// 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 { 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 ( +
    +
    +

    + Checks the backend runs against this instance: secrets, addresses, platform + mail, accounts, workers and storage. Only findings that need a decision are + listed. +

    +
    + {healthQ.dataUpdatedAt > 0 && ( + + Last checked {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()} + + )} + +
    +
    + + + + {healthQ.isLoading && ( +
    + + + +
    + )} + + {healthQ.isError && ( + healthQ.refetch()} + /> + )} + + {healthQ.data && ( + <> + {checks.length === 0 ? ( +
    +
    + +
    +
    + No problems found +
    +

    + 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. +

    +
    +
    +
    + ) : ( + <> + + + + )} + + )} +
    + ); +} + +// 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 ( +
    + {updating ? ( + + ) : available ? ( + + ) : ( + + )} +
    + + {updating + ? "Updating this instance" + : available + ? `${state.latest?.tag && state.reason === "release" ? state.latest.tag : "A newer version"} is available` + : "Up to date"} + + + {" "} + 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()}` + : ""} + +
    + + +
    + ); +} + +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 ( +
    + 0 ? "border-red-200 bg-red-50/60" : ""} + iconClass={errors > 0 ? "text-red-600" : "text-muted-foreground"} + /> + 0 ? "border-amber-200 bg-amber-50/60" : ""} + iconClass={warnings > 0 ? "text-amber-600" : "text-muted-foreground"} + /> + 0 ? "border-sky-200 bg-sky-50/50" : ""} + iconClass={info > 0 ? "text-sky-600" : "text-muted-foreground"} + /> +
    + ); +} + +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 ( +
    +
    + + {label} +
    +
    {value}
    +
    {sub}
    +
    + ); +} diff --git a/admin/src/app/dashboard/SystemStatusPage.tsx b/admin/src/app/dashboard/health/ServicesTab.tsx similarity index 64% rename from admin/src/app/dashboard/SystemStatusPage.tsx rename to admin/src/app/dashboard/health/ServicesTab.tsx index 63c08810..2f55fe1d 100644 --- a/admin/src/app/dashboard/SystemStatusPage.tsx +++ b/admin/src/app/dashboard/health/ServicesTab.tsx @@ -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 = { 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 = { 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 (
    - - {statusQ.data && ( - - Last checked {new Date(statusQ.data.checked_at).toLocaleTimeString()} - - )} - - +
    +

    + Live health probes against the platform's backing services and its own + mail transport, run by the backend on each refresh. +

    +
    + {statusQ.data && ( + + Last checked {new Date(statusQ.data.checked_at).toLocaleTimeString()} + + )} + +
    +
    {statusQ.isLoading && (
    -
    +
    @@ -100,12 +96,12 @@ export default function SystemStatusPage() { {statusQ.data && ( <> {failing.length === 0 ? ( -
    +
    All systems operational
    ) : ( -
    +
    @@ -119,12 +115,12 @@ export default function SystemStatusPage() { )} {components.length === 0 && ( -
    +
    The status endpoint returned no components.
    )} -
    +
    {components.map((c) => ( @@ -154,7 +150,7 @@ function ComponentCard({ component: c }: { component: SystemComponentStatus }) { - +

    {COMPONENT_BLURBS[c.name] ?? GENERIC_BLURB}

    @@ -163,7 +159,7 @@ function ComponentCard({ component: c }: { component: SystemComponentStatus }) { {c.latency_ms} ms
    {c.error && ( -
    +
    {c.error}
    )} diff --git a/admin/src/app/dashboard/jobs/ExpandableText.tsx b/admin/src/app/dashboard/jobs/ExpandableText.tsx new file mode 100644 index 00000000..fb31d951 --- /dev/null +++ b/admin/src/app/dashboard/jobs/ExpandableText.tsx @@ -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 ; + const long = text.length > max; + const shown = open || !long ? text : `${text.slice(0, max).trimEnd()}…`; + return ( + + + {shown} + + {long && ( + + )} + + ); +} diff --git a/admin/src/app/dashboard/jobs/format.ts b/admin/src/app/dashboard/jobs/format.ts new file mode 100644 index 00000000..576ee250 --- /dev/null +++ b/admin/src/app/dashboard/jobs/format.ts @@ -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) : ""; +} diff --git a/admin/src/app/dashboard/sends/DeadLettersTab.tsx b/admin/src/app/dashboard/sends/DeadLettersTab.tsx new file mode 100644 index 00000000..83aa8b12 --- /dev/null +++ b/admin/src/app/dashboard/sends/DeadLettersTab.tsx @@ -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 = { + 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("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[] = [ + { + id: "type", + header: "Task", + cell: (r) => ( +
    +
    {r.task_type}
    +
    {shortId(r.task_id)}
    +
    + ), + csv: (r) => r.task_type, + }, + { + id: "workspace", + header: "Workspace", + cell: (r) => + r.organization_id ? ( + + {r.organization_name || r.organization_id} + + ) : ( + + ), + csv: (r) => r.organization_name || "", + }, + { + id: "error", + header: "Last error", + className: "max-w-md", + cell: (r) => , + csv: (r) => r.last_error, + }, + { + id: "attempts", + header: "Attempts", + align: "right", + cell: (r) => ( + + {r.attempts} / {r.max_attempts} + + ), + csv: (r) => `${r.attempts}/${r.max_attempts}`, + }, + { + id: "status", + header: "Status", + cell: (r) => ( + + {r.status} + + ), + csv: (r) => r.status, + }, + { + id: "next_retry", + header: "Next retry", + cell: (r) => ( + + {relative(r.next_retry_at, "—")} + + ), + csv: (r) => r.next_retry_at || "", + }, + { + id: "created", + header: "Created", + cell: (r) => ( + + {relative(r.created_at)} + + ), + csv: (r) => r.created_at, + }, + { + id: "replayed_at", + header: "Replayed", + defaultHidden: true, + cell: (r) => {relative(r.replayed_at, "—")}, + csv: (r) => r.replayed_at || "", + }, + { + id: "actions", + header: "", + align: "right", + cell: (r) => + r.status === "pending" ? ( + + ) : null, + }, + ]; + + return ( +
    +
    +

    + 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. +

    + +
    + + 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, + }} + /> +
    + ); +} diff --git a/admin/src/app/dashboard/sends/FailuresTab.tsx b/admin/src/app/dashboard/sends/FailuresTab.tsx new file mode 100644 index 00000000..80931fc1 --- /dev/null +++ b/admin/src/app/dashboard/sends/FailuresTab.tsx @@ -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 = { + 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[] = [ + { + id: "title", + header: "Failure", + className: "max-w-md", + cell: (r) => ( +
    +
    {r.title || "Task failed"}
    + +
    + ), + csv: (r) => `${r.title}: ${r.message}`, + }, + { + id: "task", + header: "Task", + cell: (r) => ( +
    + {r.task_type} + + {r.task_status} + +
    + ), + csv: (r) => `${r.task_type} (${r.task_status})`, + }, + { + id: "mailbox", + header: "Mailbox", + cell: (r) => ( +
    +
    {r.mailbox_email || "—"}
    +
    {shortId(r.email_account_id)}
    +
    + ), + csv: (r) => r.mailbox_email, + }, + { + id: "workspace", + header: "Workspace", + cell: (r) => + r.organization_id ? ( + + {r.organization_name || r.organization_id} + + ) : ( + + ), + csv: (r) => r.organization_name || "", + }, + { + id: "occurred", + header: "Occurred", + align: "right", + cell: (r) => ( + + {relative(r.occurred_at)} + + ), + 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 ( +
    +

    + The most recent failures tasks recorded about themselves: auth errors, provider refusals, send exceptions. Newest first. +

    + `${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." + /> +
    + ); +} diff --git a/admin/src/app/dashboard/sends/InFlightTab.tsx b/admin/src/app/dashboard/sends/InFlightTab.tsx new file mode 100644 index 00000000..c634b99d --- /dev/null +++ b/admin/src/app/dashboard/sends/InFlightTab.tsx @@ -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 = { + 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[] = [ + { + id: "campaign", + header: "Campaign", + cell: (r) => ( +
    + + {r.campaign_name || shortId(r.campaign_id)} + +
    {shortId(r.campaign_id)}
    +
    + ), + csv: (r) => r.campaign_name, + }, + { + id: "workspace", + header: "Workspace", + cell: (r) => + r.organization_id ? ( + + {r.organization_name || r.organization_id} + + ) : ( + + ), + csv: (r) => r.organization_name || "", + }, + { id: "contact", header: "Contact", cell: (r) => {r.contact_email}, csv: (r) => r.contact_email }, + { + id: "mailbox", + header: "Mailbox", + cell: (r) => ( +
    +
    {r.mailbox_email || "—"}
    + {r.worker_id && ( + + worker {shortId(r.worker_id)} + + )} +
    + ), + csv: (r) => r.mailbox_email, + }, + { + id: "task", + header: "Task", + cell: (r) => ( +
    + + {r.task_status || "unknown"} + + {r.task_id && {shortId(r.task_id)}} +
    + ), + csv: (r) => r.task_status, + }, + { + id: "message_id", + header: "Message ID", + cell: (r) => + r.has_message_id ? ( + + on the wire + + ) : ( + none + ), + 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 ( + + {humanSeconds(r.age_seconds)} ago + + ); + }, + csv: (r) => r.dispatched_at, + }, + ]; + + return ( +
    +
    +

    + 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. + )} +

    + +
    + +
    + + + + + +
    + + `${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." + /> +
    + ); +} + +function fmt(n: number | undefined): string { + return n === undefined ? "—" : n.toLocaleString(); +} diff --git a/admin/src/app/dashboard/sends/StatCard.tsx b/admin/src/app/dashboard/sends/StatCard.tsx new file mode 100644 index 00000000..553c7100 --- /dev/null +++ b/admin/src/app/dashboard/sends/StatCard.tsx @@ -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 ( + + +
    + + {label} +
    +
    + {loading ? : value} +
    + {sub &&
    {sub}
    } +
    +
    + ); +} diff --git a/admin/src/app/dashboard/sends/StatusSegments.tsx b/admin/src/app/dashboard/sends/StatusSegments.tsx new file mode 100644 index 00000000..af376777 --- /dev/null +++ b/admin/src/app/dashboard/sends/StatusSegments.tsx @@ -0,0 +1,48 @@ +// 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({ + value, + onChange, + options, +}: { + value: T; + onChange: (v: T) => void; + options: { value: T; label: string; count?: number }[]; +}) { + return ( +
    + {options.map((o) => { + const active = value === o.value; + return ( + + ); + })} +
    + ); +} diff --git a/admin/src/app/dashboard/sends/WebhooksTab.tsx b/admin/src/app/dashboard/sends/WebhooksTab.tsx new file mode 100644 index 00000000..99de0de4 --- /dev/null +++ b/admin/src/app/dashboard/sends/WebhooksTab.tsx @@ -0,0 +1,175 @@ +// 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[] = [ + { + id: "workspace", + header: "Workspace", + cell: (r) => ( + + {r.organization_name || r.organization_id} + + ), + csv: (r) => r.organization_name, + }, + { + id: "url", + header: "Endpoint", + className: "max-w-sm", + cell: (r) => ( +
    +
    + {r.url} +
    + {r.description &&
    {r.description}
    } +
    + ), + csv: (r) => r.url, + }, + { + id: "consecutive", + header: "Consecutive failures", + align: "right", + cell: (r) => ( + 0 ? "font-medium text-red-700" : "text-muted-foreground"}`}> + {r.consecutive_failures.toLocaleString()} + + ), + csv: (r) => r.consecutive_failures, + }, + { + id: "last_failure", + header: "Last failure", + className: "max-w-md", + cell: (r) => ( +
    + + {r.last_failure_at && ( +
    + {relative(r.last_failure_at)} + {r.last_success_at && ` · last success ${relative(r.last_success_at)}`} +
    + )} +
    + ), + csv: (r) => r.last_failure_reason, + }, + { + id: "week", + header: "Last 7 days", + align: "right", + cell: (r) => ( + + {r.deliveries_last_7d.toLocaleString()} + {" / "} + 0 ? "text-red-700" : undefined}>{r.failed_last_7d.toLocaleString()} + {" / "} + 0 ? "text-amber-700" : undefined}>{r.drops_last_7d.toLocaleString()} + + ), + csv: (r) => `${r.deliveries_last_7d}/${r.failed_last_7d}/${r.drops_last_7d}`, + }, + { + id: "enabled", + header: "Enabled", + cell: (r) => + r.enabled ? ( + enabled + ) : ( + + disabled + + ), + 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: `Deliveries claimed longer ago than the ${data?.lease_minutes ?? ""} minute lease 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 ( +
    +
    +

    + 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.} +

    + +
    + +
    + + + + + + +
    + +
    Failing endpoints
    + 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." + /> +
    + ); +} + +function fmt(n: number | undefined): string { + return n === undefined ? "—" : n.toLocaleString(); +} diff --git a/admin/src/app/dashboard/transfers/ExportDialog.tsx b/admin/src/app/dashboard/transfers/ExportDialog.tsx new file mode 100644 index 00000000..81cffebd --- /dev/null +++ b/admin/src/app/dashboard/transfers/ExportDialog.tsx @@ -0,0 +1,185 @@ +// 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 { + 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(null); + const [groups, setGroups] = useState>(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 ( + { + if (!v && mutation.isPending) return; + onOpenChange(v); + }} + > + { + if (document.querySelector("[data-floating]")) e.preventDefault(); + }} + > + + Export a workspace + + Builds the same archive an owner gets from Settings > Data. It stays downloadable for 7 days. + + + +
    + {!org && ( +
    + + +
    + )} + +
    + + + {heavyOn.length > 0 && ( +

    + {heavyOn.map((g) => g.label).join(", ")} can multiply the archive size on a busy workspace. +

    + )} +
    + +
    + + {secrets && ( +
    +
    + + setPassphrase(e.target.value)} + className="h-8 text-[12.5px]" + /> +
    +
    + + setConfirmPass(e.target.value)} + className="h-8 text-[12.5px]" + /> +
    +

    + 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 && ( + Too short. + )} + {passphrase.length >= MIN_EXPORT_PASSPHRASE && confirmPass && passphrase !== confirmPass && ( + Passphrases differ. + )} +

    +
    + )} +
    +
    + + + + + +
    +
    + ); +} diff --git a/admin/src/app/dashboard/transfers/GroupPicker.tsx b/admin/src/app/dashboard/transfers/GroupPicker.tsx new file mode 100644 index 00000000..ca36eee4 --- /dev/null +++ b/admin/src/app/dashboard/transfers/GroupPicker.tsx @@ -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; + onChange: (next: Set) => 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 ( +
    + {groups.map((g) => { + const on = g.required || selected.has(g.key); + const deps = dependentsOf(g.key, selected); + return ( + + ); + })} +
    + ); +} diff --git a/admin/src/app/dashboard/transfers/ImportArchiveDialog.tsx b/admin/src/app/dashboard/transfers/ImportArchiveDialog.tsx new file mode 100644 index 00000000..390a7504 --- /dev/null +++ b/admin/src/app/dashboard/transfers/ImportArchiveDialog.tsx @@ -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(null); + const [file, setFile] = useState(null); + const [passphrase, setPassphrase] = useState(""); + const [report, setReport] = useState(null); + const [groups, setGroups] = useState>(new Set()); + const [conflict, setConflict] = useState("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 ( + { + if (!v && busy) return; + onOpenChange(v); + }} + > + + + Import an archive into {orgName} + + Preflight reads the archive and reports what would land, what already exists, and who has no + account here, without writing anything. + + + +
    +
    + +
    +
    {file ? file.name : "Choose an archive"}
    +
    + {file ? formatBytes(file.size) : "A .warmbly.zip exported from this or another instance."} +
    +
    + pickFile(e.target.files?.[0] ?? null)} + /> + +
    + + {file && ( +
    +
    + + { + setPassphrase(e.target.value); + setReport(null); + }} + className="h-8 text-[12.5px]" + /> +
    + +
    + )} + + {report && ( +
    +
    +
    + + + + + + + + +
    + {(report.unknown_members?.length ?? 0) > 0 && ( +

    + {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(", ")}. +

    + )} + {(report.skipped_tables?.length ?? 0) > 0 && ( +

    + Skipped (unknown here): {report.skipped_tables!.join(", ")}. +

    + )} + {(report.warnings?.length ?? 0) > 0 && ( +
      + {report.warnings!.map((w, i) => ( +
    • + + {w} +
    • + ))} +
    + )} +
    + +
    + + +
    + +
    + + +
    +
    + )} +
    + + + + + +
    +
    + ); +} + +function Row({ k, v }: { k: string; v: string }) { + return ( +
    + {k} + {v} +
    + ); +} diff --git a/admin/src/app/dashboard/transfers/OrgTransferTab.tsx b/admin/src/app/dashboard/transfers/OrgTransferTab.tsx new file mode 100644 index 00000000..e4ae7de7 --- /dev/null +++ b/admin/src/app/dashboard/transfers/OrgTransferTab.tsx @@ -0,0 +1,243 @@ +// 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(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 ( +
    +

    + The same archives the owner builds from Settings > Data, started here on their behalf. Finished + exports stay downloadable for 7 days. +

    + +
    +
    +

    + Exports{exports.length > 0 && ({exports.length})} +

    + +
    + {exportsQ.isLoading ? ( + + ) : exportsQ.error ? ( + exportsQ.refetch()} /> + ) : exports.length === 0 ? ( + No exports yet. Start one above to build a portable archive of this workspace. + ) : ( +
    +
    + + + + + + + + + + + + + + {exports.map((j) => ( + + + + + + + + + + + ))} + +
    StatusGroupsSecretsRowsSizeStartedExpires +
    + + {j.error_message &&
    {j.error_message}
    } +
    {j.groups?.length ?? 0}{j.include_secrets ? "yes" : "no"}{totalRows(j.row_counts).toLocaleString()}{formatBytes(j.archive_bytes)}{fmtDateTime(j.started_at ?? j.created_at)}{fmtDateTime(j.expires_at)} +
    + + +
    +
    +
    +
    + )} +
    + +
    +
    +

    + Imports{imports.length > 0 && ({imports.length})} +

    + +
    + {importsQ.isLoading ? ( + + ) : importsQ.error ? ( + importsQ.refetch()} /> + ) : imports.length === 0 ? ( + No imports yet. Apply an archive from another instance (or an older export) above. + ) : ( +
    +
    + + + + + + + + + + + + + + + {imports.map((j: OrgImportJob) => ( + + + + + + + + + + + ))} + +
    StatusSourceGroupsConflictsRowsSizeStartedCompleted
    + + {j.error_message &&
    {j.error_message}
    } + {(j.warnings?.length ?? 0) > 0 && ( +
    {j.warnings!.join(" · ")}
    + )} +
    + {j.source_manifest ? ( + <> +
    {j.source_manifest.organization_name}
    +
    {j.source_manifest.source_instance || "unknown instance"}
    + + ) : ( + "—" + )} +
    {j.groups?.length ?? 0}{j.conflict_strategy}{totalRows(j.row_counts).toLocaleString()}{formatBytes(j.archive_bytes)}{fmtDateTime(j.started_at ?? j.created_at)}{fmtDateTime(j.completed_at)}
    +
    +
    + )} +
    + + + +
    + ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return
    {children}
    ; +} diff --git a/admin/src/app/dashboard/transfers/TransferPills.tsx b/admin/src/app/dashboard/transfers/TransferPills.tsx new file mode 100644 index 00000000..51b86ecf --- /dev/null +++ b/admin/src/app/dashboard/transfers/TransferPills.tsx @@ -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 = { + 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 ( +
    + + {running && } + {status} + {running && progress != null && {progress}%} + + {running && stage && {stage}} +
    + ); +} + +export function KindPill({ kind }: { kind: "export" | "import" }) { + return ( + + {kind} + + ); +} diff --git a/admin/src/components/ConfirmDialog.tsx b/admin/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..5efd343f --- /dev/null +++ b/admin/src/components/ConfirmDialog.tsx @@ -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; + +const ConfirmContext = createContext(null); + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [options, setOptions] = useState(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( + (next) => { + // A second call while one is open answers the first with "no". + resolver.current?.(false); + setOptions(next); + return new Promise((resolve) => { + resolver.current = resolve; + }); + }, + [], + ); + + return ( + + {children} + !open && settle(false)}> + + + 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 && ( + <> +
    + {options.destructive && ( + + + + )} +
    + + {options.title} + + + {options.description ?? "This action cannot be undone."} + +
    +
    +
    + + +
    + + )} +
    +
    +
    +
    + ); +} + +// 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; +} diff --git a/admin/src/components/layout/AppShell.tsx b/admin/src/components/layout/AppShell.tsx index 9ab48021..6fcc06b4 100644 --- a/admin/src/components/layout/AppShell.tsx +++ b/admin/src/components/layout/AppShell.tsx @@ -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 ( -
    - {/* The 3px stripe across the very top of the viewport. Cheap, - always-visible signal that the user is in the admin surface. */} -
    + useDocumentTitle(); -
    - -
    - -
    -
    - -
    -
    + return ( + +
    + {/* The 3px stripe across the very top of the viewport. Cheap, + always-visible signal that the user is in the admin surface. */} +
    + +
    + +
    + +
    +
    + +
    +
    +
    -
    + +
    ); } diff --git a/admin/src/components/layout/CommandPalette.tsx b/admin/src/components/layout/CommandPalette.tsx new file mode 100644 index 00000000..6b0304b1 --- /dev/null +++ b/admin/src/components/layout/CommandPalette.tsx @@ -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 ( + + + + {nothing && ( + + {loading + ? "Searching" + : searching + ? "Nothing matches that. Try an email, a name or a worker." + : "No page matches that."} + + )} + {pages.length > 0 && ( + + {pages.map((item) => ( + go(item.to)}> + + {item.label} + + {item.to} + + + ))} + + )} + {users.length > 0 && ( + + {users.map((u) => ( + go(`/users/${u.id}`)}> + + {u.email} + + {[u.first_name, u.last_name].filter(Boolean).join(" ")} + + + ))} + + )} + {orgs.length > 0 && ( + + {orgs.map((o) => ( + go(`/organizations/${o.id}`)} + > + + {o.name} + + {o.owner_email} + + + ))} + + )} + {mailboxes.length > 0 && ( + + {mailboxes.map((m) => ( + go(`/mailboxes?q=${encodeURIComponent(m.email)}`)} + > + + {m.email} + + {m.org_name || m.owner_email} + + + ))} + + )} + {workers.length > 0 && ( + + {workers.map((w) => ( + go(`/workers/${w.id}`)}> + + {w.name} + + {w.ssh_host || w.ip_addr} + + + ))} + + )} + {actions.length > 0 && ( + + {actions.map((a) => ( + + + {a.label} + + ))} + + )} + + + ); +} diff --git a/admin/src/components/layout/MobileNav.tsx b/admin/src/components/layout/MobileNav.tsx new file mode 100644 index 00000000..978c6fa1 --- /dev/null +++ b/admin/src/components/layout/MobileNav.tsx @@ -0,0 +1,47 @@ +// 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]); + + return ( + + + Navigation + Admin panel sections +
    + + +
    + +
    +
    + ); +} diff --git a/admin/src/components/layout/PageHeader.tsx b/admin/src/components/layout/PageHeader.tsx index 204b4cec..579c7445 100644 --- a/admin/src/components/layout/PageHeader.tsx +++ b/admin/src/components/layout/PageHeader.tsx @@ -33,17 +33,3 @@ export function PageHeader({ title, description, children, className }: PageHead
    ); } - -// 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 ( -
    -
    {label}
    -
    - This surface is part of the admin app, but the page implementation - will land in a follow-up iteration. -
    -
    - ); -} diff --git a/admin/src/components/layout/PageTabs.tsx b/admin/src/components/layout/PageTabs.tsx new file mode 100644 index 00000000..51065e75 --- /dev/null +++ b/admin/src/components/layout/PageTabs.tsx @@ -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) { + 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(`[data-tab="${next.id}"]`); + el?.focus(); + } + + return ( +
    + {tabs.map(({ id, label, icon: Icon, badge }) => { + const active = id === value; + return ( + + ); + })} +
    + ); +} diff --git a/admin/src/components/layout/RouteError.tsx b/admin/src/components/layout/RouteError.tsx new file mode 100644 index 00000000..c796b9e5 --- /dev/null +++ b/admin/src/components/layout/RouteError.tsx @@ -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 ( +
    +
    + + + +
    +
    {title}
    +

    {message}

    + {meta.length > 0 && ( +
    + {meta.map((m, i) => ( + + {i > 0 && ·} + {m} + + ))} +
    + )} +
    + + +
    +
    +
    +
    + ); +} diff --git a/admin/src/components/layout/Sidebar.tsx b/admin/src/components/layout/Sidebar.tsx index eb38a919..655a50e4 100644 --- a/admin/src/components/layout/Sidebar.tsx +++ b/admin/src/components/layout/Sidebar.tsx @@ -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 (