Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ interface QuotaCardProps {
providerLabel: string;
onRefresh: () => void;
onOpenCutoff: () => void;
onToggleActive: (nextActive: boolean) => void;
togglingActive: boolean;
}

export default function QuotaCard({
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -81,6 +86,8 @@ export default function QuotaCard({
onRefresh={onRefresh}
onOpenCutoff={onOpenCutoff}
hasCutoffOverrides={hasOverrides}
onToggleActive={onToggleActive}
togglingActive={togglingActive}
/>
<QuotaCardExpanded
quotas={quotas}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -24,6 +26,8 @@ export default function QuotaCardGrid({
providerLabels,
onRefresh,
onOpenCutoff,
onToggleActive,
togglingActiveId,
}: Props) {
if (connections.length === 0) return null;

Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>>({});
Expand Down Expand Up @@ -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> = {};
Expand Down Expand Up @@ -985,6 +1018,8 @@ export default function ProviderLimits({
setCutoffModalWindows(windows);
setCutoffModalConn(conn);
}}
onToggleActive={handleToggleActive}
togglingActiveId={togglingActiveId}
/>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -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">
Expand Down Expand Up @@ -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) => {
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}%",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}%",
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/quota-deactivate-toggle.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
}
});