From d0ff189fcd673aa48ff045598014242d4488287a Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 19 May 2026 05:41:41 +0000 Subject: [PATCH] feat(admin-ui): risk pool toggle + Pool column on workers list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker list grows a Pool column (clean=green, risky=amber, quarantine=red badge). Dedicated workers render "n/a" — risk pools are a shared-worker concept since dedicated workers don't share IPs across customers. Worker detail page (shared workers only) gets a "Risk pool" section with three big buttons. Clicking a non-current pool confirms, then calls PUT /admin/workers/:id/risk-pool. Action audited with the new pool value. Saving doesn't migrate accounts directly — the hourly rebalancer notices the mismatch and moves mailboxes to a matching-pool worker on its next tick. Documented in the section's helper text. Endpoint accepts {risk_pool: "clean"|"risky"|"quarantine"} and is gated by AdminPermManageWorkers. The worker detail row scan now includes risk_pool so the column actually has data. --- internal/api/handler/admin_workers_ssh.go | 27 ++++++++++++ internal/api/routes.go | 1 + internal/repository/pg_worker_ssh.go | 2 + web/src/app/app/admin/workers/[id]/page.tsx | 42 +++++++++++++++++++ web/src/app/app/admin/workers/page.tsx | 19 ++++++++- .../lib/api/client/app/admin/workers/index.ts | 9 ++++ web/src/lib/api/models/app/admin/Worker.ts | 3 ++ 7 files changed, 102 insertions(+), 1 deletion(-) diff --git a/internal/api/handler/admin_workers_ssh.go b/internal/api/handler/admin_workers_ssh.go index 1c9cd0a1..669f82be 100644 --- a/internal/api/handler/admin_workers_ssh.go +++ b/internal/api/handler/admin_workers_ssh.go @@ -458,6 +458,33 @@ func boolStr(b bool) string { return "false" } +type setRiskPoolBody struct { + RiskPool string `json:"risk_pool" binding:"required,oneof=clean risky quarantine"` +} + +// AdminSetWorkerRiskPool moves a shared worker into a different risk pool. +// The rebalancer will redistribute mailboxes on the next tick — admins +// don't need to migrate accounts manually after this. +func (h *Handler) AdminSetWorkerRiskPool(c *gin.Context) { + id, ok := h.parseID(c) + if !ok { + return + } + var body setRiskPoolBody + if err := c.ShouldBindJSON(&body); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid request body")) + return + } + if err := h.WorkerRepo.SetWorkerRiskPool(c.Request.Context(), id, models.WorkerRiskPool(body.RiskPool)); err != nil { + errx.JSON(c, errx.New(errx.Internal, err.Error())) + return + } + h.audit(c, models.AuditActionUpdate, models.AuditEntityWorker, &id, map[string]string{ + "risk_pool": body.RiskPool, + }) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + func (h *Handler) AdminDeleteSSHWorker(c *gin.Context) { id, ok := h.parseID(c) if !ok { diff --git a/internal/api/routes.go b/internal/api/routes.go index f3905209..3ae56ea7 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -389,6 +389,7 @@ func Run( adminRoutes.POST("/workers/:id/system-update", middleware.RequireAdminPermission(models.AdminPermManageWorkers), h.AdminSystemUpdate) adminRoutes.POST("/workers/:id/reboot", middleware.RequireAdminPermission(models.AdminPermManageWorkers), h.AdminRebootWorker) adminRoutes.POST("/workers/:id/convert-to-dedicated", middleware.RequireAdminPermission(models.AdminPermManageWorkers), h.AdminConvertWorkerToDedicated) + adminRoutes.PUT("/workers/:id/risk-pool", middleware.RequireAdminPermission(models.AdminPermManageWorkers), h.AdminSetWorkerRiskPool) // Reusable AWS credentials (gated under AdminPermManageSettings — these // hold real production secrets, not just worker assignments). diff --git a/internal/repository/pg_worker_ssh.go b/internal/repository/pg_worker_ssh.go index 2b235bc7..727f402b 100644 --- a/internal/repository/pg_worker_ssh.go +++ b/internal/repository/pg_worker_ssh.go @@ -66,6 +66,7 @@ const workerDetailColumns = ` COALESCE(ssh_public_key,''), COALESCE(ssh_host_fingerprint,''), install_state, last_seen_at, COALESCE(last_error,''), profile_id, config_applied_at, COALESCE(image_version,''), + risk_pool, created_at, updated_at ` @@ -78,6 +79,7 @@ func scanWorkerDetail(row pgx.Row) (*models.Worker, error) { &w.SSHPublicKey, &w.SSHHostFingerprint, &w.InstallState, &w.LastSeenAt, &w.LastError, &w.ProfileID, &w.ConfigAppliedAt, &w.ImageVersion, + &w.RiskPool, &w.CreatedAt, &w.UpdatedAt, ) if errors.Is(err, pgx.ErrNoRows) { diff --git a/web/src/app/app/admin/workers/[id]/page.tsx b/web/src/app/app/admin/workers/[id]/page.tsx index ab6e5337..2dcf7b6f 100644 --- a/web/src/app/app/admin/workers/[id]/page.tsx +++ b/web/src/app/app/admin/workers/[id]/page.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { convertWorkerToDedicated, deleteWorker, + setWorkerRiskPool, getManagedWorker, getWorkerLiveStatus, getWorkerLogs, @@ -73,6 +74,10 @@ export default function AdminWorkerDetailPage() { const upgrade = useMutation({ mutationFn: () => upgradeWorker(id), ...opts("upgrade") }); const uninstall = useMutation({ mutationFn: () => uninstallWorker(id), ...opts("uninstall") }); const apply = useMutation({ mutationFn: () => applyWorkerConfig(id), ...opts("apply") }); + const setPool = useMutation({ + mutationFn: (pool: "clean" | "risky" | "quarantine") => setWorkerRiskPool(id, pool), + ...opts("set risk pool"), + }); const profiles = useQuery({ queryKey: ["admin", "profiles"], queryFn: listWorkerProfiles }); const assignProfile = useMutation({ @@ -315,6 +320,43 @@ export default function AdminWorkerDetailPage() {
{liveStatus || "(click refresh)"}
+ {w.worker_type === "shared" && ( +
+

+ Buckets shared workers so high-risk mailboxes don't poison the reputation of + clean ones. The background rebalancer migrates mailboxes between pools + hourly based on their warmup health state. +

+
+ {(["clean", "risky", "quarantine"] as const).map((p) => ( + + ))} +
+

+ Currently: {w.risk_pool} +

+
+ )} +

Useful when this worker is down or overloaded. Eligible targets are workers of diff --git a/web/src/app/app/admin/workers/page.tsx b/web/src/app/app/admin/workers/page.tsx index 256ea167..61f53cac 100644 --- a/web/src/app/app/admin/workers/page.tsx +++ b/web/src/app/app/admin/workers/page.tsx @@ -191,6 +191,7 @@ export default function AdminWorkersPage() { Host Tier Install + Pool Version Live Accounts @@ -222,6 +223,13 @@ export default function AdminWorkersPage() { {w.install_state} + + {w.worker_type === "dedicated" ? ( + n/a + ) : ( + + )} + {w.image_version || } @@ -239,7 +247,7 @@ export default function AdminWorkersPage() { })} {filteredWorkers.length === 0 && ( - + {data?.data?.length === 0 ? "No workers yet. Add one to get started." : "No workers match this filter."} @@ -286,3 +294,12 @@ function Chip({ function Count({ value }: { value: number }) { return {value}; } + +function RiskPoolBadge({ pool }: { pool: "clean" | "risky" | "quarantine" }) { + const cls = { + clean: "bg-green-100 text-green-700", + risky: "bg-amber-100 text-amber-700", + quarantine: "bg-red-100 text-red-700", + }[pool]; + return {pool}; +} diff --git a/web/src/lib/api/client/app/admin/workers/index.ts b/web/src/lib/api/client/app/admin/workers/index.ts index 028a87d0..52942c3b 100644 --- a/web/src/lib/api/client/app/admin/workers/index.ts +++ b/web/src/lib/api/client/app/admin/workers/index.ts @@ -122,6 +122,15 @@ export function rebootWorker(id: string): Promise<{ ok: boolean }> { }); } +export function setWorkerRiskPool(workerID: string, pool: "clean" | "risky" | "quarantine"): Promise<{ ok: boolean }> { + return Request({ + method: "PUT", + url: `/admin/workers/${workerID}/risk-pool`, + data: { risk_pool: pool }, + authorization: true, + }); +} + export function convertWorkerToDedicated( workerID: string, body: { user_id: string; subscription_id: string; drain_to_worker_id?: string | null }, diff --git a/web/src/lib/api/models/app/admin/Worker.ts b/web/src/lib/api/models/app/admin/Worker.ts index 9c8911ee..8026819a 100644 --- a/web/src/lib/api/models/app/admin/Worker.ts +++ b/web/src/lib/api/models/app/admin/Worker.ts @@ -8,6 +8,8 @@ export type WorkerInstallState = export type WorkerType = "shared" | "dedicated"; +export type WorkerRiskPool = "clean" | "risky" | "quarantine"; + export interface ManagedWorker { id: string; name: string; @@ -17,6 +19,7 @@ export interface ManagedWorker { free_tier: boolean; worker_type: WorkerType; account_count: number; + risk_pool: WorkerRiskPool; ssh_host?: string; ssh_port?: number;