mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
feat(dashboard): deactivate/activate accounts from the quota overview (#3675)
Integrated into release/v3.8.22
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
83
tests/unit/quota-deactivate-toggle.test.ts
Normal file
83
tests/unit/quota-deactivate-toggle.test.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user