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