diff --git a/packages/backend/src/public-stats/trust-stats.format.ts b/packages/backend/src/public-stats/trust-stats.format.ts index 8d5bbf92c..b56916cda 100644 --- a/packages/backend/src/public-stats/trust-stats.format.ts +++ b/packages/backend/src/public-stats/trust-stats.format.ts @@ -29,10 +29,10 @@ export interface TrustStatsDisplay { /** * The least each figure is shown as (Matteo, 8 Oct 2026): 200,000+ downloads - * across all distribution channels, 1M+ AI tool calls last month. Docker Hub alone undercounts downloads, so the floor applies even + * across all distribution channels, 2M+ AI tool calls last month (raised from 1M+ on 9 Nov 2026, 2.08M in the last 30 days). Docker Hub alone undercounts downloads, so the floor applies even * when a live number is missing or lower. */ -export const TRUST_FLOORS = { dockerPulls: 200_000, toolCalls30d: 1_000_000 } as const; +export const TRUST_FLOORS = { dockerPulls: 200_000, toolCalls30d: 2_000_000 } as const; const atLeast = (n: number | null | undefined, floor: number) => (usable(n) && n > floor ? n : floor); diff --git a/packages/backend/src/public-stats/trust-stats.spec.ts b/packages/backend/src/public-stats/trust-stats.spec.ts index e60df72c2..a4eb18daf 100644 --- a/packages/backend/src/public-stats/trust-stats.spec.ts +++ b/packages/backend/src/public-stats/trust-stats.spec.ts @@ -35,11 +35,11 @@ describe('trust number formatting (always rounded down)', () => { it('formats a whole set, never below the floors', () => { expect( formatTrustStats({ githubStars: 984, dockerPulls: 32_456, workspaces: 3_871, toolCalls30d: 654_321, updatedAt: null }), - ).toEqual({ downloads: '200,000+', workspaces: '3,800+', toolCalls: '1M+' }); + ).toEqual({ downloads: '200,000+', workspaces: '3,800+', toolCalls: '2M+' }); expect( formatTrustStats({ githubStars: 1_234, dockerPulls: 250_400, workspaces: null, toolCalls30d: 2_100_000, updatedAt: null }), ).toEqual({ downloads: '250,000+', workspaces: null, toolCalls: '2M+' }); - expect(formatTrustStats(null)).toEqual({ downloads: '200,000+', workspaces: null, toolCalls: '1M+' }); + expect(formatTrustStats(null)).toEqual({ downloads: '200,000+', workspaces: null, toolCalls: '2M+' }); }); }); diff --git a/packages/backend/src/settings/email-templates.spec.ts b/packages/backend/src/settings/email-templates.spec.ts index 972c5af8a..a45028632 100644 --- a/packages/backend/src/settings/email-templates.spec.ts +++ b/packages/backend/src/settings/email-templates.spec.ts @@ -139,7 +139,7 @@ describe('trust band', () => { it('shows the numbers, never below their floors', () => { const html = verify.render(cloud, { name: 'A', other: 'B' }).html; expect(html).toContain('200,000+'); - expect(html).toContain('1M+'); + expect(html).toContain('2M+'); expect(html).toContain('3,800+'); expect(html).toContain('workspaces on AnythingMCP Cloud'); expect(html).not.toContain('stars on GitHub'); diff --git a/packages/backend/src/settings/email.service.spec.ts b/packages/backend/src/settings/email.service.spec.ts index 9dadfd516..f6b86e48f 100644 --- a/packages/backend/src/settings/email.service.spec.ts +++ b/packages/backend/src/settings/email.service.spec.ts @@ -81,7 +81,7 @@ describe('EmailService', () => { // Downloads and tool calls at their floors; no GitHub star count. expect(mail.html).not.toContain('stars on GitHub'); expect(mail.html).toContain('200,000+'); - expect(mail.html).toContain('1M+'); + expect(mail.html).toContain('2M+'); }); describe('Reply-To of the system SMTP', () => { diff --git a/packages/frontend/messages/de.json b/packages/frontend/messages/de.json index 6e973c2e7..84721aafa 100644 --- a/packages/frontend/messages/de.json +++ b/packages/frontend/messages/de.json @@ -483,7 +483,11 @@ "title": "Tool-Leistung", "description": "Aufrufe, Fehler und Latenz nach Tool, Connector und MCP-Server. Für diese Tabelle gilt ein eigener Zeitraum.", "range": "Zeitraum der Tool-Leistung", - "ranges": { "24": "Letzte 24 Stunden", "168": "Letzte 7 Tage", "720": "Letzte 30 Tage" }, + "ranges": { + "24": "Letzte 24 Stunden", + "168": "Letzte 7 Tage", + "720": "Letzte 30 Tage" + }, "connector": "Tool-Leistung nach Connector filtern", "server": "Tool-Leistung nach MCP-Server filtern", "allConnectors": "Alle Connectors", @@ -3605,5 +3609,82 @@ "directory": "Gelistet im Claude Directory", "directoryChatgpt": "Gelistet im ChatGPT-Plugin-Verzeichnis", "directoryBoth": "Gelistet in den Verzeichnissen von Claude und ChatGPT" + }, + "planFit": { + "title": "Was sich mit {plan} ändert", + "fits": "Alles, was Sie heute nutzen, passt in {plan}.", + "chooseTeam": "Stattdessen Team wählen", + "showDetails": "Welche genau?", + "hideDetails": "Ausblenden", + "keep": "Bleiben aktiv", + "pause": "Pausiert", + "groups": { + "connectors": "Connectors", + "mcpServers": "MCP-Server", + "fileSearch": "File-Search-Ordner" + }, + "unnamed": "Ohne Namen", + "lines": { + "connectors": "{paused} von {total} Connectors pausiert (am wenigsten genutzt). Welche, wählen Sie selbst.", + "mcpServers": "{paused} von {total} MCP-Servern pausiert (am wenigsten genutzt).", + "mcpServersChoose": "{paused} von {total} MCP-Servern pausiert (am wenigsten genutzt). Welche, wählen Sie selbst.", + "users": "{max, plural, one {# Nutzer} other {# Nutzer}} enthalten, Sie haben {current}. Niemand verliert den Zugang.", + "fileSearch": "File Search: bis zu {maxFolders, plural, one {# Ordner} other {# Ordner}} und {maxFiles, number} Dateien.", + "fileSearchStorage": "File Search: bis zu {maxSize} Speicher.", + "codeTools": "{max} gespeicherte Code-Tools (Sie haben {current}).", + "inline": "Kein Inline-Code (code_execute)." + } + }, + "planSwap": { + "badge": "Pausiert: Tarif-Limit", + "activate": "Aktivieren", + "title": { + "connectors": "Ihr Tarif enthält {max, plural, one {# aktiven Connector} other {# aktive Connectors}}", + "mcpServers": "Ihr Tarif enthält {max, plural, one {# aktiven MCP-Server} other {# aktive MCP-Server}}", + "fileSearchConnectors": "Ihr Tarif enthält {max, plural, one {# File-Search-Ordner} other {# File-Search-Ordner}}" + }, + "body": "Pausieren Sie einen, um {name} zu aktivieren.", + "bodyCreate": "Pausieren Sie einen, um einen neuen hinzuzufügen, oder wechseln Sie den Tarif.", + "listLabel": "Jetzt aktiv, meistgenutzte zuerst", + "listLabelPlain": "Jetzt aktiv", + "calls": "{count, plural, =0 {keine Aufrufe in 30 Tagen} one {# Aufruf in 30 Tagen} other {# Aufrufe in 30 Tagen}}", + "pauseAndActivate": "Pausieren und aktivieren", + "pauseOnly": "Pausieren", + "working": "Wird umgestellt…", + "cancel": "Abbrechen", + "upgradeTeam": "Auf Team upgraden", + "askEnterprise": "Enterprise anfragen", + "loading": "Wird geladen…", + "empty": "Nichts Aktives zum Pausieren.", + "error": "Umstellen nicht möglich: {message}", + "pausedDone": "{name} ist pausiert. Sie können den neuen jetzt hinzufügen.", + "notice": { + "connectors": "Ihr Tarif enthält {max, plural, one {# aktiven Connector} other {# aktive Connectors}}. Pausieren Sie einen oder wechseln Sie den Tarif.", + "mcpServers": "Ihr Tarif enthält {max, plural, one {# aktiven MCP-Server} other {# aktive MCP-Server}}. Pausieren Sie einen oder wechseln Sie den Tarif.", + "fileSearchConnectors": "Ihr Tarif enthält {max, plural, one {# File-Search-Ordner} other {# File-Search-Ordner}}. Pausieren Sie einen oder wechseln Sie den Tarif.", + "users": "Ihr Tarif enthält {max, plural, one {# Benutzer} other {# Benutzer}}. Für weitere Einladungen wechseln Sie den Tarif." + }, + "pauseOne": "Einen pausieren" + }, + "planUsage": { + "title": "Nutzung im Tarif {plan}", + "titleTrial": "Nutzung in Ihrer Testphase (Team-Limits)", + "connectors": "Connectors", + "mcpServers": "MCP-Server", + "users": "Benutzer", + "fileSearchFolders": "File-Search-Ordner", + "fileSearchFiles": "File-Search-Dateien", + "fileSearchStorage": "File-Search-Speicher", + "codeTools": "Gespeicherte Code-Tools", + "codeRuns": "Code-Ausführungen diesen Monat", + "inlineCode": "Inline-Code", + "included": "Enthalten", + "notIncluded": "Nicht enthalten", + "inUseNotIncluded": "In Nutzung, nicht enthalten", + "unlimited": "{current} · ohne Limit", + "paused": "{count} durch Ihren Tarif pausiert", + "starterTitle": "Mit Starter", + "seeStarter": "Was sich mit Starter ändert", + "hideStarter": "Ausblenden" } } diff --git a/packages/frontend/messages/en.json b/packages/frontend/messages/en.json index 6f5d672c8..dad71b395 100644 --- a/packages/frontend/messages/en.json +++ b/packages/frontend/messages/en.json @@ -483,7 +483,11 @@ "title": "Tool performance", "description": "Calls, errors and latency by tool, connector and MCP server. This table has its own time range.", "range": "Tool performance time range", - "ranges": { "24": "Last 24 hours", "168": "Last 7 days", "720": "Last 30 days" }, + "ranges": { + "24": "Last 24 hours", + "168": "Last 7 days", + "720": "Last 30 days" + }, "connector": "Filter tool performance by connector", "server": "Filter tool performance by MCP server", "allConnectors": "All connectors", @@ -3605,5 +3609,82 @@ "directory": "Listed in the Claude Directory", "directoryChatgpt": "Listed in the ChatGPT Plugin Directory", "directoryBoth": "Listed in the Claude and ChatGPT directories" + }, + "planFit": { + "title": "What changes with {plan}", + "fits": "Everything you use today fits {plan}.", + "chooseTeam": "Choose Team instead", + "showDetails": "See which ones", + "hideDetails": "Hide", + "keep": "Stay active", + "pause": "Paused", + "groups": { + "connectors": "Connectors", + "mcpServers": "MCP servers", + "fileSearch": "File Search folders" + }, + "unnamed": "Unnamed", + "lines": { + "connectors": "{paused} of {total} connectors paused (least used). You choose which.", + "mcpServers": "{paused} of {total} MCP servers paused (least used).", + "mcpServersChoose": "{paused} of {total} MCP servers paused (least used). You choose which.", + "users": "{max, plural, one {# user} other {# users}} included, you have {current}. Nobody loses access.", + "fileSearch": "File Search: up to {maxFolders, plural, one {# folder} other {# folders}} and {maxFiles, number} files.", + "fileSearchStorage": "File Search: up to {maxSize} of storage.", + "codeTools": "{max} saved code tools (you have {current}).", + "inline": "No inline code (code_execute)." + } + }, + "planSwap": { + "badge": "Paused: plan limit", + "activate": "Activate", + "title": { + "connectors": "Your plan includes {max, plural, one {# active connector} other {# active connectors}}", + "mcpServers": "Your plan includes {max, plural, one {# active MCP server} other {# active MCP servers}}", + "fileSearchConnectors": "Your plan includes {max, plural, one {# File Search folder} other {# File Search folders}}" + }, + "body": "Pause one to activate {name}.", + "bodyCreate": "Pause one to add a new one, or upgrade.", + "listLabel": "Active now, most used first", + "listLabelPlain": "Active now", + "calls": "{count, plural, =0 {no calls in 30 days} one {# call in 30 days} other {# calls in 30 days}}", + "pauseAndActivate": "Pause and activate", + "pauseOnly": "Pause", + "working": "Switching…", + "cancel": "Cancel", + "upgradeTeam": "Upgrade to Team", + "askEnterprise": "Ask about Enterprise", + "loading": "Loading…", + "empty": "Nothing active to pause.", + "error": "Could not switch: {message}", + "pausedDone": "{name} is paused. You can add the new one now.", + "notice": { + "connectors": "Your plan includes {max, plural, one {# active connector} other {# active connectors}}. Pause one or upgrade.", + "mcpServers": "Your plan includes {max, plural, one {# active MCP server} other {# active MCP servers}}. Pause one or upgrade.", + "fileSearchConnectors": "Your plan includes {max, plural, one {# File Search folder} other {# File Search folders}}. Pause one or upgrade.", + "users": "Your plan includes {max, plural, one {# user} other {# users}}. Upgrade to invite more." + }, + "pauseOne": "Pause one" + }, + "planUsage": { + "title": "Usage on {plan}", + "titleTrial": "Usage in your trial (Team limits)", + "connectors": "Connectors", + "mcpServers": "MCP servers", + "users": "Users", + "fileSearchFolders": "File Search folders", + "fileSearchFiles": "File Search files", + "fileSearchStorage": "File Search storage", + "codeTools": "Saved code tools", + "codeRuns": "Code runs this month", + "inlineCode": "Inline code", + "included": "Included", + "notIncluded": "Not included", + "inUseNotIncluded": "In use, not included", + "unlimited": "{current} · no limit", + "paused": "{count} paused by your plan", + "starterTitle": "On Starter", + "seeStarter": "See what changes with Starter", + "hideStarter": "Hide" } } diff --git a/packages/frontend/messages/it.json b/packages/frontend/messages/it.json index b3352e0af..d6813a674 100644 --- a/packages/frontend/messages/it.json +++ b/packages/frontend/messages/it.json @@ -483,7 +483,11 @@ "title": "Prestazioni degli strumenti", "description": "Chiamate, errori e latenza per strumento, connettore e server MCP. Questa tabella ha un proprio intervallo di tempo.", "range": "Periodo delle prestazioni degli strumenti", - "ranges": { "24": "Ultime 24 ore", "168": "Ultimi 7 giorni", "720": "Ultimi 30 giorni" }, + "ranges": { + "24": "Ultime 24 ore", + "168": "Ultimi 7 giorni", + "720": "Ultimi 30 giorni" + }, "connector": "Filtra le prestazioni per connettore", "server": "Filtra le prestazioni per server MCP", "allConnectors": "Tutti i connettori", @@ -3605,5 +3609,82 @@ "directory": "Presente nella Claude Directory", "directoryChatgpt": "Presente nella directory dei plugin di ChatGPT", "directoryBoth": "Presente nelle directory di Claude e ChatGPT" + }, + "planFit": { + "title": "Cosa cambia con {plan}", + "fits": "Tutto ciò che usi oggi rientra in {plan}.", + "chooseTeam": "Scegli Team", + "showDetails": "Vedi quali", + "hideDetails": "Nascondi", + "keep": "Restano attivi", + "pause": "In pausa", + "groups": { + "connectors": "Connettori", + "mcpServers": "Server MCP", + "fileSearch": "Cartelle File Search" + }, + "unnamed": "Senza nome", + "lines": { + "connectors": "{paused} connettori su {total} in pausa (i meno usati). Scegli tu quali.", + "mcpServers": "{paused} server MCP su {total} in pausa (i meno usati).", + "mcpServersChoose": "{paused} server MCP su {total} in pausa (i meno usati). Scegli tu quali.", + "users": "{max, plural, one {# utente incluso} other {# utenti inclusi}}, ne hai {current}. Nessuno perde l’accesso.", + "fileSearch": "File Search: fino a {maxFolders, plural, one {# cartella} other {# cartelle}} e {maxFiles, number} file.", + "fileSearchStorage": "File Search: fino a {maxSize} di spazio.", + "codeTools": "{max} code tool salvati (ne hai {current}).", + "inline": "Niente codice inline (code_execute)." + } + }, + "planSwap": { + "badge": "In pausa: limite del piano", + "activate": "Attiva", + "title": { + "connectors": "Il tuo piano include {max, plural, one {# connettore attivo} other {# connettori attivi}}", + "mcpServers": "Il tuo piano include {max, plural, one {# server MCP attivo} other {# server MCP attivi}}", + "fileSearchConnectors": "Il tuo piano include {max, plural, one {# cartella File Search} other {# cartelle File Search}}" + }, + "body": "Mettine uno in pausa per attivare {name}.", + "bodyCreate": "Mettine uno in pausa per aggiungerne uno nuovo, oppure cambia piano.", + "listLabel": "Attivi ora, i più usati per primi", + "listLabelPlain": "Attivi ora", + "calls": "{count, plural, =0 {nessuna chiamata in 30 giorni} one {# chiamata in 30 giorni} other {# chiamate in 30 giorni}}", + "pauseAndActivate": "Metti in pausa e attiva", + "pauseOnly": "Metti in pausa", + "working": "Cambio in corso…", + "cancel": "Annulla", + "upgradeTeam": "Passa a Team", + "askEnterprise": "Chiedi di Enterprise", + "loading": "Caricamento…", + "empty": "Niente di attivo da mettere in pausa.", + "error": "Impossibile cambiare: {message}", + "pausedDone": "{name} è in pausa. Ora puoi aggiungere quello nuovo.", + "notice": { + "connectors": "Il tuo piano include {max, plural, one {# connettore attivo} other {# connettori attivi}}. Mettine uno in pausa o cambia piano.", + "mcpServers": "Il tuo piano include {max, plural, one {# server MCP attivo} other {# server MCP attivi}}. Mettine uno in pausa o cambia piano.", + "fileSearchConnectors": "Il tuo piano include {max, plural, one {# cartella File Search} other {# cartelle File Search}}. Mettine una in pausa o cambia piano.", + "users": "Il tuo piano include {max, plural, one {# utente} other {# utenti}}. Cambia piano per invitarne altri." + }, + "pauseOne": "Mettine uno in pausa" + }, + "planUsage": { + "title": "Utilizzo con {plan}", + "titleTrial": "Utilizzo nella prova (limiti di Team)", + "connectors": "Connettori", + "mcpServers": "Server MCP", + "users": "Utenti", + "fileSearchFolders": "Cartelle File Search", + "fileSearchFiles": "File in File Search", + "fileSearchStorage": "Spazio File Search", + "codeTools": "Code tool salvati", + "codeRuns": "Esecuzioni di codice questo mese", + "inlineCode": "Codice inline", + "included": "Incluso", + "notIncluded": "Non incluso", + "inUseNotIncluded": "In uso, non incluso", + "unlimited": "{current} · nessun limite", + "paused": "{count} in pausa per il piano", + "starterTitle": "Con Starter", + "seeStarter": "Cosa cambia con Starter", + "hideStarter": "Nascondi" } } diff --git a/packages/frontend/src/app/connectors/[id]/page.tsx b/packages/frontend/src/app/connectors/[id]/page.tsx index d6f3870a4..f65d74748 100644 --- a/packages/frontend/src/app/connectors/[id]/page.tsx +++ b/packages/frontend/src/app/connectors/[id]/page.tsx @@ -3,7 +3,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; -import { connectors, tools, withSecretsNotice, withImportNotice, isMachineGrant, type ToolTestResult, type CatalogDiff, type OAuthGrant } from '@/lib/api'; +import { connectors, tools, withSecretsNotice, withImportNotice, isMachineGrant, planLimitOf, type ToolTestResult, type CatalogDiff, type OAuthGrant } from '@/lib/api'; +import { atPlanCap, pausedByPlanSets, usePlanUsage } from '@/lib/use-plan-fit'; +import { PausedByPlanBadge, PlanSwapDialog, type SwapAxis } from '@/components/plan-swap-dialog'; import { findDemoByTool } from '@/lib/demo-connectors'; import { ToolEditor } from '@/components/tool-editor'; import { McpAssignModal } from '@/components/mcp-assign-modal'; @@ -186,6 +188,12 @@ export default function ConnectorDetailPage() { const router = useRouter(); const searchParams = useSearchParams(); const id = params.id as string; + // Cloud: whether the last automatic downgrade paused this connector, and the + // swap dialog when activating it would go past the plan. + const { usage: planUsage, reload: reloadPlanUsage } = usePlanUsage(); + const pausedByPlan = pausedByPlanSets(planUsage).connectors.has(id); + const tSwap = useTranslations('planSwap'); + const [swap, setSwap] = useState<{ axis: SwapAxis; max: number | null; activate?: () => Promise } | null>(null); const [connector, setConnector] = useState(null); const [toolList, setToolList] = useState([]); @@ -518,8 +526,39 @@ export default function ConnectorDetailPage() { } }; - const handleSave = async () => { + /** The plan-limit axis of this connector: File Search folders have their own. */ + const swapAxis = (): SwapAxis => (connector?.type === 'FILE_SEARCH' ? 'fileSearchConnectors' : 'connectors'); + + /** Activate from the header (a connector the plan paused); the swap at the cap. */ + const handleActivateNow = async () => { + if (!token || !connector) return; + if (swapAxis() === 'connectors' && atPlanCap(planUsage, 'connectors')) { + setSwap({ axis: 'connectors', max: planUsage?.connectors.max ?? null }); + return; + } + try { + await connectors.update(id, { isActive: true }, token); + fetchConnector(); + reloadPlanUsage(); + } catch (err: any) { + const limit = planLimitOf(err); + if (limit && limit.axis !== 'users') setSwap({ axis: limit.axis, max: limit.max || null }); + else setMsg(t('msg.error', { message: err.message })); + } + }; + + /** + * Save the form. Turning the connector on at the plan's cap opens the swap + * dialog first; its "Pause and activate" then saves again (`rethrow`, so a + * failure puts the paused one back). + */ + const handleSave = async (opts?: { rethrow?: boolean }) => { if (!token) return; + const activating = editActive && !connector.isActive; + if (!opts?.rethrow && activating && swapAxis() === 'connectors' && atPlanCap(planUsage, 'connectors')) { + setSwap({ axis: 'connectors', max: planUsage?.connectors.max ?? null, activate: () => handleSave({ rethrow: true }) }); + return; + } try { if ( editAuthType === 'OAUTH2' && @@ -627,7 +666,14 @@ export default function ConnectorDetailPage() { setMsg(withSecretsNotice(t('msg.updated'), saved)); setEditing(false); fetchConnector(); + if (activating) reloadPlanUsage(); } catch (err: any) { + if (opts?.rethrow) throw err; + const limit = planLimitOf(err); + if (limit && limit.axis !== 'users') { + setSwap({ axis: limit.axis, max: limit.max || null, activate: () => handleSave({ rethrow: true }) }); + return; + } setMsg(t('msg.error', { message: err.message })); } }; @@ -1108,6 +1154,23 @@ export default function ConnectorDetailPage() { } > + {swap && ( + { + if (!open) setSwap(null); + }} + axis={swap.axis} + target={{ id, name: connector.name }} + max={swap.max} + activate={swap.activate} + onDone={() => { + fetchConnector(); + reloadPlanUsage(); + }} + /> + )} + { if (!open && !deleting) setDeleteTarget(null); }}> @@ -1144,12 +1207,21 @@ export default function ConnectorDetailPage() {
{connector.name} {connector.type} - - {connector.isActive ? t('active') : t('inactive')} - + {!connector.isActive && pausedByPlan ? ( + <> + + + + ) : ( + + {connector.isActive ? t('active') : t('inactive')} + + )}
@@ -1777,7 +1849,7 @@ export default function ConnectorDetailPage() { {t('details.instructionsHint')}

- diff --git a/packages/frontend/src/app/connectors/new/page.tsx b/packages/frontend/src/app/connectors/new/page.tsx index f165b693e..1e5f765fd 100644 --- a/packages/frontend/src/app/connectors/new/page.tsx +++ b/packages/frontend/src/app/connectors/new/page.tsx @@ -5,7 +5,8 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/auth-context'; -import { adapters, connectors, isMachineGrant, type CatalogUrlMatch, type OAuthGrant } from '@/lib/api'; +import { adapters, connectors, isMachineGrant, planLimitOf, type CatalogUrlMatch, type OAuthGrant, type PlanLimitError } from '@/lib/api'; +import { PlanLimitNotice } from '@/components/plan-swap-dialog'; import { ConnectorLogo } from '@/components/connector-logo'; import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; @@ -111,6 +112,8 @@ export default function NewConnectorPage() { // EMAIL: servers, login and sender (IMAP + SMTP). const [emailForm, setEmailForm] = useState(EMPTY_EMAIL_FORM); const [error, setError] = useState(''); + // A create refused at the plan's limit (Cloud): "Pause one or upgrade". + const [planLimit, setPlanLimit] = useState(null); const [loading, setLoading] = useState(false); const [testResult, setTestResult] = useState<{ ok: boolean; @@ -235,6 +238,7 @@ export default function NewConnectorPage() { e.preventDefault(); if (!token || !selectedType) return; setError(''); + setPlanLimit(null); if ( authType === 'OAUTH2' && selectedType !== 'MCP' && @@ -299,6 +303,7 @@ export default function NewConnectorPage() { setCreatedConnector({ id: created.id, name: name || created.name }); } } catch (err: any) { + setPlanLimit(planLimitOf(err)); setError(err.message); } finally { setLoading(false); @@ -428,7 +433,11 @@ export default function NewConnectorPage() { {t('configure', { type: t(`types.${selectedType}.name`) })} - {error && isTrialLimitMessage(error) ? ( + {planLimit ? ( +
+ +
+ ) : error && isTrialLimitMessage(error) ? (
diff --git a/packages/frontend/src/app/connectors/page.tsx b/packages/frontend/src/app/connectors/page.tsx index aa361430f..c3063b671 100644 --- a/packages/frontend/src/app/connectors/page.tsx +++ b/packages/frontend/src/app/connectors/page.tsx @@ -3,7 +3,9 @@ import Link from 'next/link'; import { useEffect, useState, useCallback } from 'react'; import { useAuth } from '@/lib/auth-context'; -import { connectors, withSecretsNotice, withImportNotice } from '@/lib/api'; +import { connectors, planLimitOf, withSecretsNotice, withImportNotice } from '@/lib/api'; +import { atPlanCap, pausedByPlanSets, usePlanUsage } from '@/lib/use-plan-fit'; +import { PausedByPlanBadge, PlanSwapDialog, type SwapAxis } from '@/components/plan-swap-dialog'; import * as Dialog from '@radix-ui/react-dialog'; import { useTranslations } from 'next-intl'; import { AppSelect } from '@/components/ui/select'; @@ -114,12 +116,49 @@ export default function ConnectorsPage() { const [checkingHealth, setCheckingHealth] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); const [deleting, setDeleting] = useState(false); + // Cloud: which connectors the last automatic downgrade paused, and the swap + // dialog when activating one would go past the plan. + const { usage, reload: reloadUsage } = usePlanUsage(); + const pausedByPlan = pausedByPlanSets(usage).connectors; + const tSwap = useTranslations('planSwap'); + const [swap, setSwap] = useState<{ id: string; name: string; axis: SwapAxis; max: number | null } | null>(null); + const [activating, setActivating] = useState(null); useEffect(() => { if (!token) return; connectors.list(token).then(setList).catch(() => {}).finally(() => setLoading(false)); }, [token]); + const refreshAfterSwap = useCallback(() => { + if (!token) return; + connectors.list(token).then(setList).catch(() => {}); + reloadUsage(); + }, [token, reloadUsage]); + + /** Activate a connector the plan paused; at the plan's cap, offer the swap. */ + const handleActivate = async (c: { id: string; name: string; type: string }) => { + if (!token) return; + const axis: SwapAxis = c.type === 'FILE_SEARCH' ? 'fileSearchConnectors' : 'connectors'; + if (axis === 'connectors' && atPlanCap(usage, 'connectors')) { + setSwap({ id: c.id, name: c.name, axis, max: usage?.connectors.max ?? null }); + return; + } + setActivating(c.id); + try { + await connectors.update(c.id, { isActive: true }, token); + refreshAfterSwap(); + } catch (err) { + const limit = planLimitOf(err); + if (limit && limit.axis !== 'users') setSwap({ id: c.id, name: c.name, axis: limit.axis, max: limit.max || null }); + else { + setMsg((err as Error)?.message || String(err)); + setTimeout(() => setMsg(''), 5000); + } + } finally { + setActivating(null); + } + }; + const handleDelete = useCallback(async () => { if (!token || !deleteConfirm) return; setDeleting(true); @@ -327,6 +366,19 @@ export default function ConnectorsPage() {
{/* Delete Confirmation Dialog */} + {swap && ( + { + if (!open) setSwap(null); + }} + axis={swap.axis} + target={{ id: swap.id, name: swap.name }} + max={swap.max} + onDone={refreshAfterSwap} + /> + )} + { if (!open) setDeleteConfirm(null); }}> @@ -509,17 +561,26 @@ export default function ConnectorsPage() {
{authTypeLabel(c.authType)}
- - {c.isActive ? t('card.active') : t('card.inactive')} - + {!c.isActive && pausedByPlan.has(c.id) ? ( + + ) : ( + + {c.isActive ? t('card.active') : t('card.inactive')} + + )} {/* Hover actions (above the full-card link) */}
+ {!c.isActive && pausedByPlan.has(c.id) && ( + + )} {(c.type === 'REST' || c.type === 'GRAPHQL' || c.type === 'SOAP') && (
)} {error && ( - isTrialLimitMessage(error) ? ( + planLimit && planLimit.message === error ? ( + + ) : isTrialLimitMessage(error) ? ( ) : (

{error}

diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx index 6a39dc2de..03db8f71f 100644 --- a/packages/frontend/src/app/mcp-server/[id]/page.tsx +++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx @@ -5,7 +5,9 @@ import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/auth-context'; -import { mcpServers, connectors as connectorsApi, mcpKeys, productEvents, server as serverApi } from '@/lib/api'; +import { mcpServers, connectors as connectorsApi, mcpKeys, productEvents, server as serverApi, planLimitOf } from '@/lib/api'; +import { atPlanCap, pausedByPlanSets, usePlanUsage } from '@/lib/use-plan-fit'; +import { PausedByPlanBadge, PlanSwapDialog } from '@/components/plan-swap-dialog'; import { AppShell } from '@/components/app-shell'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -33,6 +35,9 @@ export default function McpServerDetailPage() { const isCloud = deploymentMode === 'cloud'; const router = useRouter(); const t = useTranslations('mcpServer'); + // Cloud: paused by the last automatic downgrade, and the swap at the cap. + const { usage: planUsage, reload: reloadPlanUsage } = usePlanUsage(); + const [swapMax, setSwapMax] = useState(undefined); const [server, setServer] = useState(null); // Bumped a moment after the first request arrives, so the card-trial offer @@ -144,10 +149,20 @@ export default function McpServerDetailPage() { const handleToggleActive = async () => { if (!token || !id || !server) return; + const activating = !server.isActive; + // Turning one on at the plan's cap: pause another first (swap dialog). + if (activating && atPlanCap(planUsage, 'mcpServers')) { + setSwapMax(planUsage?.mcpServers.max ?? null); + return; + } try { - const updated = await mcpServers.update(id, { isActive: !server.isActive }, token); + const updated = await mcpServers.update(id, { isActive: activating }, token); setServer((prev: any) => ({ ...prev, ...updated })); - } catch {} + reloadPlanUsage(); + } catch (err) { + const limit = planLimitOf(err); + if (limit?.axis === 'mcpServers') setSwapMax(limit.max || null); + } }; const saveAssignments = (next: Set) => { @@ -733,6 +748,22 @@ Auth: OAuth`; } > + {swapMax !== undefined && ( + { + if (!open) setSwapMax(undefined); + }} + axis="mcpServers" + target={{ id: server.id, name: server.name }} + max={swapMax} + onDone={() => { + setServer((prev: any) => ({ ...prev, isActive: true })); + reloadPlanUsage(); + }} + /> + )} + {/* Server identity header */}
@@ -745,9 +776,13 @@ Auth: OAuth`;
{server.name} - - {server.isActive ? t('active') : t('inactive')} - + {!server.isActive && pausedByPlanSets(planUsage).mcpServers.has(server.id) ? ( + + ) : ( + + {server.isActive ? t('active') : t('inactive')} + + )}
{endpointUrl}
diff --git a/packages/frontend/src/app/mcp-server/page.tsx b/packages/frontend/src/app/mcp-server/page.tsx index 0ab2790cb..86b15826d 100644 --- a/packages/frontend/src/app/mcp-server/page.tsx +++ b/packages/frontend/src/app/mcp-server/page.tsx @@ -4,7 +4,9 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/auth-context'; -import { mcpServers } from '@/lib/api'; +import { mcpServers, planLimitOf, type PlanLimitError } from '@/lib/api'; +import { pausedByPlanSets, usePlanUsage } from '@/lib/use-plan-fit'; +import { PausedByPlanBadge, PlanLimitNotice } from '@/components/plan-swap-dialog'; import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -29,6 +31,10 @@ export default function McpServerListPage() { const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [copiedId, setCopiedId] = useState(null); + // A create refused at the plan's limit (Cloud): "Pause one or upgrade". + const [createLimit, setCreateLimit] = useState(null); + const { usage, reload: reloadUsage } = usePlanUsage(); + const pausedByPlan = pausedByPlanSets(usage).mcpServers; useEffect(() => { if (!token) return; @@ -39,6 +45,7 @@ export default function McpServerListPage() { if (!token || !newName.trim()) return; setCreating(true); setCreateError(''); + setCreateLimit(null); try { const server = await mcpServers.create( { name: newName.trim(), description: newDescription.trim() || undefined }, @@ -51,6 +58,7 @@ export default function McpServerListPage() { router.push(`/mcp-server/${server.id}`); } catch (err: any) { // Used to be swallowed: a failed create left the button doing nothing. + setCreateLimit(planLimitOf(err)); setCreateError(err?.message || t('createError')); } finally { setCreating(false); @@ -110,7 +118,15 @@ export default function McpServerListPage() { className="w-full rounded-[9px] border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--brand)]" />
- {createError && isTrialLimitMessage(createError) ? ( + {createLimit ? ( + { + mcpServers.list(token!).then(setServers).catch(() => {}); + reloadUsage(); + }} + /> + ) : createError && isTrialLimitMessage(createError) ? ( ) : createError ? (
@@ -212,13 +228,17 @@ export default function McpServerListPage() {
- - {active ? t('active') : t('inactive')} - + {!active && pausedByPlan.has(s.id) ? ( + + ) : ( + + {active ? t('active') : t('inactive')} + + )} {s.description && ( diff --git a/packages/frontend/src/app/reactivate/[token]/copy.ts b/packages/frontend/src/app/reactivate/[token]/copy.ts index 4f367b298..a690af99f 100644 --- a/packages/frontend/src/app/reactivate/[token]/copy.ts +++ b/packages/frontend/src/app/reactivate/[token]/copy.ts @@ -666,3 +666,134 @@ export function splitTag(text: string, tag: string): [string, string, string] | const m = new RegExp(`^([\\s\\S]*)<${tag}>([\\s\\S]*)([\\s\\S]*)$`).exec(text); return m ? [m[1], m[2], m[3]] : null; } + +/** + * The "What changes with Starter" block on this page, in ICU message syntax + * (the PlanFitNotice component renders it through next-intl, under its own + * provider in the page's language). Same keys as `planFit` in the dashboard + * messages, worded for a visitor who is not signed in: counts only, and the + * choice of which ones is made after signing in. + */ +export interface PlanFitCopy { + title: string; + fits: string; + chooseTeam: string; + lines: { + connectors: string; + mcpServers: string; + mcpServersChoose: string; + users: string; + fileSearch: string; + fileSearchStorage: string; + codeTools: string; + inline: string; + }; +} + +export const PLAN_FIT_COPY: Record = { + en: { + title: 'What changes with {plan}', + fits: 'Everything you use today fits {plan}.', + chooseTeam: 'Choose Team instead', + lines: { + connectors: '{paused} of {total} connectors paused (least used). You choose which after signing in.', + mcpServers: '{paused} of {total} MCP servers paused (least used).', + mcpServersChoose: '{paused} of {total} MCP servers paused (least used). You choose which after signing in.', + users: '{max, plural, one {# user} other {# users}} included, you have {current}. Nobody loses access.', + fileSearch: 'File Search: up to {maxFolders, plural, one {# folder} other {# folders}} and {maxFiles, number} files.', + fileSearchStorage: 'File Search: up to {maxSize} of storage.', + codeTools: '{max} saved code tools (you have {current}).', + inline: 'No inline code (code_execute).', + }, + }, + de: { + title: 'Was sich mit {plan} ändert', + fits: 'Alles, was Sie heute nutzen, passt in {plan}.', + chooseTeam: 'Stattdessen Team wählen', + lines: { + connectors: '{paused} von {total} Connectors pausiert (am wenigsten genutzt). Welche, wählen Sie nach der Anmeldung.', + mcpServers: '{paused} von {total} MCP-Servern pausiert (am wenigsten genutzt).', + mcpServersChoose: '{paused} von {total} MCP-Servern pausiert (am wenigsten genutzt). Welche, wählen Sie nach der Anmeldung.', + users: '{max, plural, one {# Nutzer} other {# Nutzer}} enthalten, Sie haben {current}. Niemand verliert den Zugang.', + fileSearch: 'File Search: bis zu {maxFolders, plural, one {# Ordner} other {# Ordner}} und {maxFiles, number} Dateien.', + fileSearchStorage: 'File Search: bis zu {maxSize} Speicher.', + codeTools: '{max} gespeicherte Code-Tools (Sie haben {current}).', + inline: 'Kein Inline-Code (code_execute).', + }, + }, + it: { + title: 'Cosa cambia con {plan}', + fits: 'Tutto ciò che usi oggi rientra in {plan}.', + chooseTeam: 'Scegli Team', + lines: { + connectors: '{paused} connettori su {total} in pausa (i meno usati). Scegli tu quali dopo l’accesso.', + mcpServers: '{paused} server MCP su {total} in pausa (i meno usati).', + mcpServersChoose: '{paused} server MCP su {total} in pausa (i meno usati). Scegli tu quali dopo l’accesso.', + users: '{max, plural, one {# utente incluso} other {# utenti inclusi}}, ne hai {current}. Nessuno perde l’accesso.', + fileSearch: 'File Search: fino a {maxFolders, plural, one {# cartella} other {# cartelle}} e {maxFiles, number} file.', + fileSearchStorage: 'File Search: fino a {maxSize} di spazio.', + codeTools: '{max} code tool salvati (ne hai {current}).', + inline: 'Niente codice inline (code_execute).', + }, + }, + es: { + title: 'Qué cambia con {plan}', + fits: 'Todo lo que usas hoy cabe en {plan}.', + chooseTeam: 'Elegir Team', + lines: { + connectors: '{paused} de {total} conectores en pausa (los menos usados). Eliges cuáles al iniciar sesión.', + mcpServers: '{paused} de {total} servidores MCP en pausa (los menos usados).', + mcpServersChoose: '{paused} de {total} servidores MCP en pausa (los menos usados). Eliges cuáles al iniciar sesión.', + users: '{max, plural, one {# usuario incluido} other {# usuarios incluidos}}, tienes {current}. Nadie pierde el acceso.', + fileSearch: 'File Search: hasta {maxFolders, plural, one {# carpeta} other {# carpetas}} y {maxFiles, number} archivos.', + fileSearchStorage: 'File Search: hasta {maxSize} de almacenamiento.', + codeTools: '{max} code tools guardados (tienes {current}).', + inline: 'Sin código inline (code_execute).', + }, + }, + zh: { + title: '{plan} 方案的变化', + fits: '你目前的使用量完全符合 {plan}。', + chooseTeam: '改选 Team', + lines: { + connectors: '{total} 个连接器中有 {paused} 个暂停(使用最少的)。登录后可自行选择。', + mcpServers: '{total} 个 MCP 服务器中有 {paused} 个暂停(使用最少的)。', + mcpServersChoose: '{total} 个 MCP 服务器中有 {paused} 个暂停(使用最少的)。登录后可自行选择。', + users: '包含 {max} 个用户,你有 {current} 个。没有人会失去访问权限。', + fileSearch: 'File Search:最多 {maxFolders} 个文件夹和 {maxFiles, number} 个文件。', + fileSearchStorage: 'File Search:最多 {maxSize} 存储空间。', + codeTools: '{max} 个已保存的代码工具(你有 {current} 个)。', + inline: '不含内联代码(code_execute)。', + }, + }, + ja: { + title: '{plan} で変わること', + fits: '現在の利用内容はすべて {plan} に収まります。', + chooseTeam: 'Team を選ぶ', + lines: { + connectors: '{total} 件中 {paused} 件のコネクタが一時停止(利用の少ないもの)。どれにするかはサインイン後に選べます。', + mcpServers: '{total} 件中 {paused} 件の MCP サーバーが一時停止(利用の少ないもの)。', + mcpServersChoose: '{total} 件中 {paused} 件の MCP サーバーが一時停止(利用の少ないもの)。どれにするかはサインイン後に選べます。', + users: 'ユーザー {max} 人まで(現在 {current} 人)。アクセスを失う人はいません。', + fileSearch: 'File Search:最大 {maxFolders} フォルダ、{maxFiles, number} ファイル。', + fileSearchStorage: 'File Search:最大 {maxSize} のストレージ。', + codeTools: '保存済みコードツール {max} 件(現在 {current} 件)。', + inline: 'インラインコード(code_execute)は含まれません。', + }, + }, + ru: { + title: 'Что изменится на {plan}', + fits: 'Всё, чем вы пользуетесь, помещается в {plan}.', + chooseTeam: 'Выбрать Team', + lines: { + connectors: '{paused} из {total} коннекторов на паузе (наименее используемые). Какие, выберете после входа.', + mcpServers: '{paused} из {total} MCP-серверов на паузе (наименее используемые).', + mcpServersChoose: '{paused} из {total} MCP-серверов на паузе (наименее используемые). Какие, выберете после входа.', + users: 'Пользователей: {max} (у вас {current}). Никто не теряет доступ.', + fileSearch: 'File Search: до {maxFolders} папок и {maxFiles, number} файлов.', + fileSearchStorage: 'File Search: до {maxSize} хранилища.', + codeTools: 'Сохранённых code tools: {max} (у вас {current}).', + inline: 'Без inline-кода (code_execute).', + }, + }, +}; diff --git a/packages/frontend/src/app/reactivate/[token]/page.tsx b/packages/frontend/src/app/reactivate/[token]/page.tsx index a389e334b..3219de016 100644 --- a/packages/frontend/src/app/reactivate/[token]/page.tsx +++ b/packages/frontend/src/app/reactivate/[token]/page.tsx @@ -3,6 +3,7 @@ import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { useParams, useSearchParams } from 'next/navigation'; +import { NextIntlClientProvider } from 'next-intl'; import { ApiError, reactivation, type ReactivationView } from '@/lib/api'; import { useAuth } from '@/lib/auth-context'; import { useTrustStats } from '@/lib/trust-stats'; @@ -11,18 +12,30 @@ import { CHATGPT_DIRECTORY_URL, CLAUDE_DIRECTORY_URL, buildEnterpriseContactUrl, import { CLOUD_PLANS, DEFAULT_SELECTION, + fitFor, formatEur, limitsExceeded, planById, + selectionForFit, selectionForUsage, yearlyPerMonth, type PlanSelection, } from '@/lib/card-trial'; import { LogoIcon } from '@/components/logo-icon'; +import { PlanFitNotice } from '@/components/plan-fit-notice'; import { buttonVariants } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { deviceKind, usePaywallLifecycle, type PaywallAction, type PaywallActionDetail } from '@/lib/paywall-tracking'; -import { COPY, DATE_LOCALE, fmt, pickPageLang, splitTag, type PageLang, type ReactivateCopy } from './copy'; +import { + COPY, + DATE_LOCALE, + PLAN_FIT_COPY, + fmt, + pickPageLang, + splitTag, + type PageLang, + type ReactivateCopy, +} from './copy'; /** How often the page asks whether the payment reached the workspace, and for how long. */ const PAID_POLL_MS = 4000; @@ -95,7 +108,9 @@ function ReactivateContent() { .then((v) => { if (cancelled) return; setView(v); - if (!picked.current) setSelection(selectionForUsage(DEFAULT_SELECTION, v.usage)); + if (!picked.current) { + setSelection(selectionForFit(selectionForUsage(DEFAULT_SELECTION, v.usage), v.planFit ?? null)); + } }) .catch((e) => { if (cancelled) return; @@ -289,6 +304,22 @@ function ReactivateContent() { ))} + {/* What the chosen plan pauses or leaves out, before checkout. + Counts only: the page has no sign-in, so no names. */} + + track(action, { plan })} + onChooseTeam={ + !opening && fitFor(view.planFit, 'team')?.fits + ? () => pickPlan({ ...selection, plan: 'team' }) + : undefined + } + /> +