feat(admin-ui): risk pool toggle + Pool column on workers list

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.
This commit is contained in:
Matthew Meszaros
2026-05-19 05:41:41 +00:00
parent 7e02bb2a5a
commit d0ff189fcd
7 changed files with 102 additions and 1 deletions
+27
View File
@@ -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 {
+1
View File
@@ -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).
+2
View File
@@ -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) {
@@ -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() {
<pre className="mt-3 bg-slate-100 text-xs p-2 rounded whitespace-pre-wrap min-h-[60px]">{liveStatus || "(click refresh)"}</pre>
</Section>
{w.worker_type === "shared" && (
<Section title="Risk pool">
<p className="text-slate-500 text-sm mb-2">
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.
</p>
<div className="flex gap-2 flex-wrap">
{(["clean", "risky", "quarantine"] as const).map((p) => (
<button
key={p}
onClick={() => {
if (p === w.risk_pool) return;
if (!confirm(`Move this worker to the ${p} pool? The rebalancer will redistribute mailboxes on the next tick.`)) return;
setPool.mutate(p);
}}
disabled={setPool.isPending}
className={`px-3 py-1.5 rounded text-sm border disabled:opacity-50 ${
p === w.risk_pool
? p === "clean"
? "bg-green-600 text-white border-green-600"
: p === "risky"
? "bg-amber-600 text-white border-amber-600"
: "bg-red-600 text-white border-red-600"
: "bg-white text-slate-700 hover:bg-slate-50"
}`}
>
{p}
</button>
))}
</div>
<p className="text-slate-400 text-xs mt-2">
Currently: <span className="font-mono">{w.risk_pool}</span>
</p>
</Section>
)}
<Section title="Move accounts to another worker">
<p className="text-slate-500 text-sm mb-2">
Useful when this worker is down or overloaded. Eligible targets are workers of
+18 -1
View File
@@ -191,6 +191,7 @@ export default function AdminWorkersPage() {
<th className="text-left px-3 py-2">Host</th>
<th className="text-left px-3 py-2">Tier</th>
<th className="text-left px-3 py-2">Install</th>
<th className="text-left px-3 py-2">Pool</th>
<th className="text-left px-3 py-2">Version</th>
<th className="text-left px-3 py-2">Live</th>
<th className="text-left px-3 py-2">Accounts</th>
@@ -222,6 +223,13 @@ export default function AdminWorkersPage() {
{w.install_state}
</span>
</td>
<td className="px-3 py-2">
{w.worker_type === "dedicated" ? (
<span className="text-slate-400 text-xs">n/a</span>
) : (
<RiskPoolBadge pool={w.risk_pool} />
)}
</td>
<td className="px-3 py-2 text-xs font-mono">
{w.image_version || <span className="text-slate-400"></span>}
</td>
@@ -239,7 +247,7 @@ export default function AdminWorkersPage() {
})}
{filteredWorkers.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-slate-400 text-sm">
<td colSpan={9} className="px-3 py-8 text-center text-slate-400 text-sm">
{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 <span className="ml-1 font-semibold tabular-nums">{value}</span>;
}
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 <span className={`px-2 py-0.5 rounded text-xs ${cls}`}>{pool}</span>;
}
@@ -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 },
@@ -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;