mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user