feat(dashboard): deactivate/activate accounts from the quota overview (#3675)

Integrated into release/v3.8.22
This commit is contained in:
Lenine Júnior
2026-06-11 15:41:10 -04:00
committed by GitHub
parent ef82f10a0d
commit 592554dbcd
7 changed files with 172 additions and 5 deletions

View File

@@ -30,6 +30,8 @@ interface QuotaCardProps {
providerLabel: string;
onRefresh: () => void;
onOpenCutoff: () => void;
onToggleActive: (nextActive: boolean) => void;
togglingActive: boolean;
}
export default function QuotaCard({
@@ -42,7 +44,10 @@ export default function QuotaCard({
providerLabel,
onRefresh,
onOpenCutoff,
onToggleActive,
togglingActive,
}: QuotaCardProps) {
const isActive = connection.isActive ?? true;
const quotas = quota?.quotas ?? [];
const cardStatus = useMemo<CardStatus>(() => worstStatus(quotas), [quotas]);
const tierMeta = useMemo(
@@ -66,7 +71,7 @@ export default function QuotaCard({
return (
<Card
padding="none"
className="flex flex-col overflow-hidden"
className={`flex flex-col overflow-hidden transition-opacity ${isActive ? "" : "opacity-60"}`}
style={{ borderLeft: `3px solid ${STATUS_BORDER[cardStatus]}` }}
>
<QuotaCardHeader
@@ -81,6 +86,8 @@ export default function QuotaCard({
onRefresh={onRefresh}
onOpenCutoff={onOpenCutoff}
hasCutoffOverrides={hasOverrides}
onToggleActive={onToggleActive}
togglingActive={togglingActive}
/>
<QuotaCardExpanded
quotas={quotas}

View File

@@ -12,6 +12,8 @@ interface Props {
providerLabels: Record<string, string>;
onRefresh: (id: string, provider: string) => void;
onOpenCutoff: (connection: any) => void;
onToggleActive: (id: string, nextActive: boolean) => void;
togglingActiveId: string | null;
}
export default function QuotaCardGrid({
@@ -24,6 +26,8 @@ export default function QuotaCardGrid({
providerLabels,
onRefresh,
onOpenCutoff,
onToggleActive,
togglingActiveId,
}: Props) {
if (connections.length === 0) return null;
@@ -58,6 +62,8 @@ export default function QuotaCardGrid({
providerLabel={providerLabels[conn.provider] || conn.provider}
onRefresh={() => onRefresh(conn.id, conn.provider)}
onOpenCutoff={() => onOpenCutoff(conn)}
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
togglingActive={togglingActiveId === conn.id}
/>
))}
</div>

View File

@@ -16,6 +16,7 @@ import { CardSkeleton } from "@/shared/components/Loading";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import QuotaCutoffModal from "./QuotaCutoffModal";
import QuotaCardGrid from "./QuotaCardGrid";
@@ -233,7 +234,9 @@ export default function ProviderLimits({
[t]
);
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
const notify = useNotificationStore();
const [connections, setConnections] = useState<any[]>([]);
const [togglingActiveId, setTogglingActiveId] = useState<string | null>(null);
const [quotaData, setQuotaData] = useState<Record<string, any>>({});
const [loading, setLoading] = useState<Record<string, boolean>>({});
const [errors, setErrors] = useState<Record<string, string | null>>({});
@@ -321,6 +324,36 @@ export default function ProviderLimits({
}
}, []);
// Toggle a connection's active state straight from the quota overview, so an
// operator can park an account that is being routed to despite low quota.
// Mirrors saveQuotaWindowThresholds: PUT /api/providers/[id] + optimistic state.
const handleToggleActive = useCallback(
async (connectionId: string, nextActive: boolean) => {
setTogglingActiveId(connectionId);
try {
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: nextActive }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setConnections((prev) =>
prev.map((c) => (c.id === connectionId ? { ...c, isActive: nextActive } : c))
);
notify.success(
nextActive
? tr("accountActivated", "Account activated")
: tr("accountDeactivated", "Account deactivated")
);
} catch {
notify.error(tr("toggleActiveFailed", "Failed to update account status"));
} finally {
setTogglingActiveId(null);
}
},
[notify, tr]
);
const applyCachedQuotaState = useCallback(
(connectionList: any[], caches: Record<string, any>) => {
const nextQuotaData: Record<string, any> = {};
@@ -985,6 +1018,8 @@ export default function ProviderLimits({
setCutoffModalWindows(windows);
setCutoffModalConn(conn);
}}
onToggleActive={handleToggleActive}
togglingActiveId={togglingActiveId}
/>
</div>

View File

@@ -20,6 +20,10 @@ interface Props {
onRefresh: () => void;
onOpenCutoff: () => void;
hasCutoffOverrides: boolean;
/** Toggle the connection's active state (routing on/off). */
onToggleActive: (nextActive: boolean) => void;
/** True while the active-state PUT is in flight. */
togglingActive: boolean;
}
export default function QuotaCardHeader({
@@ -34,8 +38,14 @@ export default function QuotaCardHeader({
onRefresh,
onOpenCutoff,
hasCutoffOverrides,
onToggleActive,
togglingActive,
}: Props) {
const t = useTranslations("usage");
const isActive = connection.isActive ?? true;
const toggleActiveLabel = isActive
? translateUsageOrFallback(t, "deactivateAccount", "Deactivate account (stop routing)")
: translateUsageOrFallback(t, "activateAccount", "Activate account (resume routing)");
const accountName = pickDisplayValue(
[connection.name, connection.displayName, connection.email],
emailsVisible,
@@ -59,9 +69,7 @@ export default function QuotaCardHeader({
time: tokenCountdown,
})
: translateUsageOrFallback(t, "tokenExpired", "Token expired");
const tokenExpiryTitle = hasTokenExpiry
? new Date(tokenExpiryMs).toLocaleString()
: undefined;
const tokenExpiryTitle = hasTokenExpiry ? new Date(tokenExpiryMs).toLocaleString() : undefined;
return (
<div className="flex items-start justify-between gap-2 px-3 pt-2.5 pb-1.5">
@@ -118,6 +126,24 @@ export default function QuotaCardHeader({
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
disabled={togglingActive}
onClick={(e) => {
e.stopPropagation();
if (togglingActive) return;
onToggleActive(!isActive);
}}
title={toggleActiveLabel}
aria-label={toggleActiveLabel}
className={`p-1 rounded-md cursor-pointer transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.04] disabled:opacity-40 disabled:cursor-not-allowed ${
isActive ? "text-text-muted" : "text-rose-500"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{isActive ? "toggle_on" : "toggle_off"}
</span>
</button>
<button
type="button"
onClick={(e) => {

View File

@@ -1325,7 +1325,7 @@
"settings.update_failed": "Settings Update Failed",
"sync.token.created": "Sync Token Created",
"sync.token.revoked": "Sync Token Revoked"
}
}
},
"themesPage": {
"title": "Themes",
@@ -6585,6 +6585,11 @@
"quotaCutoffsButtonDefault": "Default",
"quotaCutoffsButtonHelp": "Edit minimum remaining quota cutoffs for this account.",
"quotaCutoffsButtonDisabled": "No quota windows are available for this account yet.",
"deactivateAccount": "Deactivate account (stop routing)",
"activateAccount": "Activate account (resume routing)",
"accountActivated": "Account activated",
"accountDeactivated": "Account deactivated",
"toggleActiveFailed": "Failed to update account status",
"quotaCutoffsTitle": "Quota cutoffs for {name} ({provider})",
"quotaCutoffsExplainer": "Override the minimum remaining quota percentage where this account stops being selected for each quota window. Leave blank to inherit the provider default.",
"quotaCutoffsDefaultHint": "Default min remaining: {default}%",

View File

@@ -7846,6 +7846,11 @@
"quotaCutoffsButtonDefault": "Padrão",
"quotaCutoffsButtonHelp": "Edite os limites mínimos de cota restantes para esta conta.",
"quotaCutoffsButtonDisabled": "Ainda não há janelas de cota disponíveis para esta conta.",
"deactivateAccount": "Desativar conta (parar roteamento)",
"activateAccount": "Ativar conta (retomar roteamento)",
"accountActivated": "Conta ativada",
"accountDeactivated": "Conta desativada",
"toggleActiveFailed": "Falha ao atualizar o status da conta",
"quotaCutoffsTitle": "Limites de cota para {name} ({provider})",
"quotaCutoffsExplainer": "Substitua a porcentagem mínima de cota restante onde esta conta deixa de ser selecionada para cada janela de cota. Deixe em branco para herdar o padrão do provedor.",
"quotaCutoffsDefaultHint": "Minuto restante padrão: {default}%",

View File

@@ -0,0 +1,83 @@
/**
* Quota overview gains a per-account deactivate/activate toggle, so an operator
* can park an account that is still being routed to despite low quota. The
* toggle reuses PUT /api/providers/[id] with { isActive } and dims the card when
* the account is inactive. Validated by asserting the wiring in the component
* sources (same lightweight approach as quota-token-expiry-display.test.ts).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const base = "src/app/(dashboard)/dashboard/usage/components/ProviderLimits";
const read = (rel: string) => fs.readFileSync(path.join(repoRoot, base, rel), "utf8");
const header = read("parts/QuotaCardHeader.tsx");
const card = read("QuotaCard.tsx");
const grid = read("QuotaCardGrid.tsx");
const index = read("index.tsx");
test("QuotaCardHeader renders a toggle that flips the current active state", () => {
assert.match(header, /onToggleActive:\s*\(nextActive: boolean\)\s*=>\s*void/);
assert.match(header, /const isActive = connection\.isActive \?\? true/);
assert.match(header, /onToggleActive\(!isActive\)/, "click must flip the current state");
assert.match(header, /toggle_on/, "active state icon");
assert.match(header, /toggle_off/, "inactive state icon");
assert.match(header, /disabled=\{togglingActive\}/, "disabled while the PUT is in flight");
});
test("QuotaCard dims the card and threads the toggle props through", () => {
assert.match(card, /const isActive = connection\.isActive \?\? true/);
assert.match(card, /opacity-60/, "inactive accounts are visually dimmed");
assert.match(card, /onToggleActive=\{onToggleActive\}/);
assert.match(card, /togglingActive=\{togglingActive\}/);
});
test("QuotaCardGrid forwards the per-connection toggle handler and busy id", () => {
assert.match(grid, /onToggleActive:\s*\(id: string, nextActive: boolean\)\s*=>\s*void/);
assert.match(
grid,
/onToggleActive=\{\(nextActive\)\s*=>\s*onToggleActive\(conn\.id, nextActive\)\}/
);
assert.match(grid, /togglingActive=\{togglingActiveId === conn\.id\}/);
});
test("index handleToggleActive PUTs isActive, updates state and notifies", () => {
assert.match(index, /const handleToggleActive = useCallback\(/);
assert.match(
index,
/fetch\(`\/api\/providers\/\$\{connectionId\}`,\s*\{[\s\S]*method:\s*"PUT"[\s\S]*isActive: nextActive/,
"must PUT { isActive } to the per-connection route"
);
assert.match(
index,
/c\.id === connectionId \? \{ \.\.\.c, isActive: nextActive \} : c/,
"must optimistically update local connection state"
);
assert.match(
index,
/onToggleActive=\{handleToggleActive\}/,
"must wire the handler into the grid"
);
assert.match(index, /togglingActiveId=\{togglingActiveId\}/);
});
test("toggle i18n keys exist in en and pt-BR", () => {
for (const locale of ["en", "pt-BR"]) {
const msgs = JSON.parse(
fs.readFileSync(path.join(repoRoot, `src/i18n/messages/${locale}.json`), "utf8")
);
for (const key of [
"deactivateAccount",
"activateAccount",
"accountActivated",
"accountDeactivated",
"toggleActiveFailed",
]) {
assert.ok(msgs.usage?.[key], `${locale}: usage.${key} must exist`);
}
}
});