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..b60c562a --- /dev/null +++ b/admin/src/app/dashboard/AdminsPage.tsx @@ -0,0 +1,207 @@ +// Admins: who holds operator-panel bits. Gated on GrantAdminAccess by the +// route. Grant and edit share one dialog; revoke confirms and warns when the +// target is you or the last admin who can still grant access. No poll: the +// list invalidates after each mutation. + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Pencil, ShieldOff, UserPlus } from "lucide-react"; +import { PageHeader } from "@/components/layout/PageHeader"; +import { Button } from "@/components/ui/button"; +import { DataTable, type Column } from "@/components/data/DataTable"; +import { useConfirm } from "@/components/ConfirmDialog"; +import { useMe } from "@/hooks/useMe"; +import { useCursorPager } from "@/lib/useCursorPager"; +import { AdminPerm } from "@/lib/auth/permissions"; +import { + listAdminPermissions, + listAdmins, + revokeAdminPermissions, + type AdminInfo, +} from "@/lib/api/client/admin/admins"; +import { GrantAdminDialog, type GrantTarget } from "./admins/GrantAdminDialog"; +import { PermissionChips } from "./admins/PermissionChips"; +import { hasBit } from "./admins/permissions"; +import { fmtDate, userName } from "./fleet/format"; + +export default function AdminsPage() { + const qc = useQueryClient(); + const confirm = useConfirm(); + const me = useMe(); + const pager = useCursorPager(); + + const adminsQ = useQuery({ + queryKey: ["admin", "admins", pager.cursor], + queryFn: () => listAdmins(pager.cursor), + }); + const catalogQ = useQuery({ + queryKey: ["admin", "permissions"], + queryFn: listAdminPermissions, + staleTime: 5 * 60_000, + }); + const catalog = catalogQ.data ?? []; + const rows = adminsQ.data?.data ?? []; + + const [dialog, setDialog] = useState<{ open: boolean; target: GrantTarget | null }>({ open: false, target: null }); + + const revoke = useMutation({ + mutationFn: (userId: string) => revokeAdminPermissions(userId), + onSuccess: (_res, userId) => { + toast.success("Admin access revoked"); + qc.invalidateQueries({ queryKey: ["admin", "admins"] }); + qc.invalidateQueries({ queryKey: ["admin", "users"] }); + if (userId === me.data?.id) qc.invalidateQueries({ queryKey: ["me"] }); + }, + onError: (e: Error) => toast.error(e.message || "Revoke failed"), + }); + + const granters = rows.filter((a) => hasBit(a.admin_permissions, AdminPerm.GrantAdminAccess)); + + async function onRevoke(a: AdminInfo) { + const isSelf = a.id === me.data?.id; + // `granters` only covers the current page, so the claim is only safe + // when the whole list is on screen. + const fullListShown = !pager.canPrev && !adminsQ.data?.pagination.has_more; + const lastGranter = + hasBit(a.admin_permissions, AdminPerm.GrantAdminAccess) && granters.length <= 1 && fullListShown; + const warnings: string[] = []; + if (isSelf) warnings.push("This is your own account: you will lose access to this panel immediately."); + if (lastGranter) + warnings.push( + "This is the only admin who can grant admin access. After this, nobody can add or edit admins from the panel; it would take a database change to recover.", + ); + const ok = await confirm({ + title: `Revoke all admin permissions from ${a.email}?`, + description: [ + "Every operator-panel bit is removed. Their workspace roles are untouched.", + ...warnings, + ].join(" "), + confirmLabel: isSelf ? "Revoke my access" : "Revoke", + destructive: true, + }); + if (ok) revoke.mutate(a.id); + } + + const columns: Column[] = [ + { + 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..0569ebd0 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); @@ -196,6 +198,14 @@ export default function MailboxesPage() { setWorkerId(workerParam); }, [workerParam]); + // The page stays mounted when the palette lands here again with another + // ?q=, so the search box follows the URL rather than only its first value. + const qParam = params.get("q") ?? ""; + useEffect(() => { + setQuery(qParam); + if (qParam) setStatus("all"); + }, [qParam]); + const { data: workersData } = useQuery({ queryKey: ["admin", "workers", "managed"], queryFn: listManagedWorkers, staleTime: 60_000 }); const workerOptions = [ { value: "any", label: "Any worker" }, @@ -304,6 +314,7 @@ export default function MailboxesPage() { next.delete("org"); next.delete("user"); next.delete("worker"); + next.delete("q"); setParams(next, { replace: true }); } 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..34f77363 --- /dev/null +++ b/admin/src/app/dashboard/SyncPage.tsx @@ -0,0 +1,388 @@ +// Mailbox sync governor. The platform copy of every mailbox's sync state: +// backfill progress and the fair-use throttle the worker last reported via +// SYNC_STATE. No polling here: the realtime spine invalidates +// ["admin","sync"] on SYNC_STATE and account events. + +import { useEffect } from "react"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; +import { + AlertTriangle, + CheckCircle2, + Clock, + Database, + Gauge, + Hourglass, + MoreHorizontal, + PauseCircle, + RotateCcw, +} from "lucide-react"; +import { PageHeader } from "@/components/layout/PageHeader"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Explorer, FilterGroup, SearchFilter, SegmentedFilter } from "@/components/data/Explorer"; +import { DataTable, type Column } from "@/components/data/DataTable"; +import { useConfirm } from "@/components/ConfirmDialog"; +import { useCursorPager } from "@/lib/useCursorPager"; +import { + ADMIN_SYNC_STATES, + clearSyncThrottle, + restartSyncBackfill, + searchSync, + type AdminSyncActionResult, + type AdminSyncRow, + type AdminSyncStateFilter, +} from "@/lib/api/client/admin/sync"; +import { StatCard } from "@/app/dashboard/sends/StatCard"; +import { absolute, relative } from "@/app/dashboard/jobs/format"; + +const BACKFILL_TONE: Record = { + pending: "border-zinc-300 bg-zinc-50 text-zinc-600", + running: "border-amber-300 bg-amber-50 text-amber-700", + complete: "border-emerald-300 bg-emerald-50 text-emerald-700", +}; + +function isThrottled(row: AdminSyncRow): boolean { + return !!row.throttled_until && new Date(row.throttled_until).getTime() > Date.now(); +} + +function parseState(raw: string | null): AdminSyncStateFilter { + return ADMIN_SYNC_STATES.includes(raw as AdminSyncStateFilter) ? (raw as AdminSyncStateFilter) : "all"; +} + +export default function SyncPage() { + const [params, setParams] = useSearchParams(); + const state = parseState(params.get("state")); + const query = params.get("q") ?? ""; + const pager = useCursorPager(); + const { reset } = pager; + const qc = useQueryClient(); + const confirm = useConfirm(); + + function setParam(key: "state" | "q", value: string) { + const next = new URLSearchParams(params); + // "all" is the state filter's default, not a search term. + const isDefault = key === "state" && value === "all"; + if (value && !isDefault) next.set(key, value); + else next.delete(key); + setParams(next, { replace: true }); + } + + const filters = { state, q: query.trim() }; + const filterKey = JSON.stringify(filters); + + useEffect(() => { + reset(); + }, [filterKey, reset]); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["admin", "sync", filters, pager.cursor], + queryFn: () => + searchSync({ + state: state === "all" ? undefined : state, + q: filters.q || undefined, + cursor: pager.cursor, + limit: 50, + }), + staleTime: 30_000, + placeholderData: keepPreviousData, + }); + + // The worker is re-shipped the mailbox after the write; when that fails + // the platform copy is right and the worker's live copy is not, which is + // worth a warning rather than a success. + function report(verb: string, res: AdminSyncActionResult) { + if (res.reloaded) { + toast.success(`${verb}; the worker reloaded the mailbox`); + } else { + toast.warning(`${verb}, but the worker was not reloaded: ${res.reload_error || "unknown error"}`); + } + qc.invalidateQueries({ queryKey: ["admin", "sync"] }); + } + + const clear = useMutation({ + mutationFn: (row: AdminSyncRow) => clearSyncThrottle(row.email_id), + onSuccess: (res) => report("Throttle cleared", res), + onError: (err: Error) => toast.error(err.message || "Failed to clear throttle"), + }); + + const restart = useMutation({ + mutationFn: (row: AdminSyncRow) => restartSyncBackfill(row.email_id), + onSuccess: (res) => report("Backfill restarted", res), + onError: (err: Error) => toast.error(err.message || "Failed to restart backfill"), + }); + + async function onRestart(row: AdminSyncRow) { + const ok = await confirm({ + title: "Restart backfill?", + description: `${row.email} will re-import its history from scratch: the backfill cursor is dropped and the worker starts again from the newest message, within the configured window. Mail already imported is kept; the pass counts against the mailbox's daily budget.`, + confirmLabel: "Restart backfill", + destructive: true, + }); + if (!ok) return; + restart.mutate(row); + } + + const rows = data?.data ?? []; + const summary = data?.summary; + const activeCount = (state !== "all" ? 1 : 0) + (query ? 1 : 0); + + const columns: Column[] = [ + { + 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..a8f89ef4 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); @@ -254,7 +243,7 @@ export default function UsersPage() { return (
    - + - - 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..fc3fa583 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,33 @@ 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 +587,13 @@ export default function WorkerDetailPage() { + @@ -535,7 +605,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 +672,23 @@ export default function WorkerDetailPage() { + setReassignOpen(true)} + onClear={() => setSelected(new Set())} + /> + + selected.has(m.id))} + onDone={() => { + setSelected(new Set()); + invalidate(); + }} + /> + @@ -778,3 +876,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..32887621 --- /dev/null +++ b/admin/src/app/dashboard/fleet/ConvertDedicatedDialog.tsx @@ -0,0 +1,204 @@ +// Convert a shared worker into a dedicated one bound to a workspace. The +// backend refuses a worker that still holds mailboxes unless a drain target +// is named, so the dialog requires one whenever the chosen worker is loaded. + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { convertWorkerToDedicated } from "@/lib/api/client/admin/fleet"; +import { listManagedWorkers } from "@/lib/api/client/admin/workers"; +import type { ManagedWorker } from "@/lib/api/models/admin"; +import { OrgPicker, type PickedOrg } from "./OrgPicker"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function workerLabel(w: ManagedWorker): string { + return `${w.name || w.id.slice(0, 8)} · ${w.free_tier ? "free" : "premium"} · ${w.health_state} · ${w.account_count} mailbox${w.account_count === 1 ? "" : "es"}`; +} + +export function ConvertDedicatedDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const qc = useQueryClient(); + const [workerId, setWorkerId] = useState(""); + const [org, setOrg] = useState(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..fd963c5c --- /dev/null +++ b/admin/src/app/dashboard/fleet/SearchPicker.tsx @@ -0,0 +1,181 @@ +// Debounced type-to-search picker for records too numerous for a Select +// (organizations, users). Closes on click-away (capture phase, so a dialog's +// mousedown stopPropagation cannot swallow it) and on Escape, which it stops +// from reaching the surrounding dialog so only the innermost layer closes. + +import { useEffect, useId, useRef, useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Loader2, Search, X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +export interface SearchPickerProps { + 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 listId = useId(); + 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} + aria-controls={listId} + aria-autocomplete="list" + aria-activedescendant={open && items[highlight] ? `${listId}-${highlight}` : undefined} + /> + {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..96dfd114 --- /dev/null +++ b/admin/src/app/dashboard/health/FindingsTab.tsx @@ -0,0 +1,227 @@ +// Setup and health, findings: everything this deployment is currently +// getting wrong, as decided by the running backend. The endpoint returns +// only checks that are not ok, so an empty response is a real all-clear. + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { useSearchParams } from "react-router-dom"; +import { + AlertTriangle, + ArrowUpCircle, + CheckCircle2, + Info, + Loader2, + RefreshCw, + XCircle, +} from "lucide-react"; +import { ErrorState } from "@/components/ErrorState"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { InstanceFindings } from "../InstanceHealthPanel"; +import { UpdateDialog } from "@/components/layout/UpdateDialog"; +import { useInstanceHealth } from "@/hooks/useInstanceHealth"; +import { buildLabel, isUpdating, useUpdateState } from "@/hooks/useUpdateState"; +import type { InstanceHealthSummary } from "@/lib/api/client/admin/instance"; + +export function FindingsTab() { + const healthQ = useInstanceHealth(); + + const checks = healthQ.data?.checks ?? []; + const summary = healthQ.data?.summary; + + return ( +
    +
    +

    + 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..316edfca --- /dev/null +++ b/admin/src/app/dashboard/sends/StatusSegments.tsx @@ -0,0 +1,49 @@ +// Segmented status filter with a count per option. Explorer's SegmentedFilter +// is a fixed three-column grid with no badges, so status rails that need +// counts (dead letters) use this one; same visual language. + +import { cn } from "@/lib/utils"; + +export function StatusSegments({ + 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..0767f946 --- /dev/null +++ b/admin/src/app/dashboard/sends/WebhooksTab.tsx @@ -0,0 +1,179 @@ +// Customer webhook delivery health across the instance, and the endpoints +// that are currently failing. Polls at 30s; no realtime event covers the +// delivery queue. + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; +import { AlertTriangle, CheckCircle2, Clock, RotateCcw, Trash2, XCircle, Ban } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataTable, type Column } from "@/components/data/DataTable"; +import { useConfirm } from "@/components/ConfirmDialog"; +import { getWebhookHealth, reclaimWebhookDeliveries, type AdminWebhookEndpointRow } from "@/lib/api/client/admin/sends"; +import { StatCard } from "@/app/dashboard/sends/StatCard"; +import { ExpandableText } from "@/app/dashboard/jobs/ExpandableText"; +import { absolute, relative } from "@/app/dashboard/jobs/format"; + +const columns: Column[] = [ + { + 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: `${ + data + ? `Deliveries claimed longer ago than the ${data.lease_minutes} minute lease` + : "Deliveries claimed longer ago than the lease window" + } are handed back to the queue so a delivery worker can pick them up again. An endpoint may receive a duplicate if the original attempt did complete after its lease expired.`, + confirmLabel: "Reclaim", + }); + if (!ok) return; + reclaim.mutate(); + } + + const rows = data?.failing_endpoints ?? []; + + return ( +
    +
    +

    + 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..08ade395 --- /dev/null +++ b/admin/src/app/dashboard/transfers/ExportDialog.tsx @@ -0,0 +1,189 @@ +// Start an archive build for a workspace. With `org` fixed (organization +// detail page) the picker is hidden; without it the operator searches first. +// Secrets only travel under a passphrase, which is used once and never stored. + +import { useEffect, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + createOrgExport, + expandGroups, + MIN_EXPORT_PASSPHRASE, + ORG_DATA_GROUP_CATALOG, + type OrgDataGroup, +} from "@/lib/api/client/admin/transfers"; +import { OrgPicker, type PickedOrg } from "../fleet/OrgPicker"; +import { GroupPicker } from "./GroupPicker"; + +function defaultGroups(): Set { + 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..f6d1b05d --- /dev/null +++ b/admin/src/app/dashboard/transfers/OrgTransferTab.tsx @@ -0,0 +1,244 @@ +// One workspace's exports and imports, with the export dialog and the +// import flow. Polls at 15s only while a job is queued or running. + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Download, PackageOpen, Trash2, Upload } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ErrorState } from "@/components/ErrorState"; +import { useConfirm } from "@/components/ConfirmDialog"; +import { + deleteOrgExport, + downloadOrgExport, + formatBytes, + isTransferActive, + listOrgExports, + listOrgImports, + saveBlob, + totalRows, + type OrgExportJob, + type OrgImportJob, +} from "@/lib/api/client/admin/transfers"; +import { ExportDialog } from "./ExportDialog"; +import { ImportArchiveDialog } from "./ImportArchiveDialog"; +import { StatusPill } from "./TransferPills"; +import { fmtDateTime } from "../fleet/format"; + +const POLL = 15_000; + +export function OrgTransferTab({ orgId, orgName }: { orgId: string; orgName: string }) { + const qc = useQueryClient(); + const confirm = useConfirm(); + const [exportOpen, setExportOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + + const exportsQ = useQuery({ + queryKey: ["admin", "transfers", "org", orgId, "exports"], + queryFn: () => listOrgExports(orgId), + refetchInterval: (q) => ((q.state.data?.data ?? []).some((j) => isTransferActive(j.status)) ? POLL : false), + }); + const importsQ = useQuery({ + queryKey: ["admin", "transfers", "org", orgId, "imports"], + queryFn: () => listOrgImports(orgId), + refetchInterval: (q) => ((q.state.data?.data ?? []).some((j) => isTransferActive(j.status)) ? POLL : false), + }); + + const invalidate = () => qc.invalidateQueries({ queryKey: ["admin", "transfers"] }); + + const [downloading, setDownloading] = useState(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..353fbcd6 --- /dev/null +++ b/admin/src/components/layout/MobileNav.tsx @@ -0,0 +1,59 @@ +// Mobile navigation drawer. Below `md` the sidebar is hidden, so the +// Topbar's hamburger opens this left sheet with the same nav model. +// Closes on a tap (onNavigate), on Escape and on the backdrop (Radix), and +// on any route change so a browser back button never leaves it open. + +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"; +import { AdminBadge } from "./AdminBadge"; +import { NavList, SidebarBrand } from "./Sidebar"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function MobileNav({ open, onOpenChange }: Props) { + const location = useLocation(); + const routeKey = location.pathname + location.search; + const lastRoute = useRef(routeKey); + + useEffect(() => { + if (lastRoute.current === routeKey) return; + lastRoute.current = routeKey; + onOpenChange(false); + }, [routeKey, onOpenChange]); + + // The sheet body is hidden at md but its overlay is not, so a resize to + // desktop with the drawer open would leave an invisible click shield. + useEffect(() => { + const media = window.matchMedia("(min-width: 768px)"); + const closeOnDesktop = () => { + if (media.matches) onOpenChange(false); + }; + closeOnDesktop(); + media.addEventListener("change", closeOnDesktop); + return () => media.removeEventListener("change", closeOnDesktop); + }, [onOpenChange]); + + return ( + + + 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 (