From fa1d4f511bd3e7bb18b70492a562e85f3268bf44 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Mon, 9 Nov 2026 14:34:22 +0000 Subject: [PATCH] feat: Add import config functionality Signed-off-by: Gabriel Costa --- .../settings/ConfigurationSettings.test.tsx | 78 ++++++++++++ .../settings/ConfigurationSettings.tsx | 111 ++++++++++++++++++ src/i18n/locales/en-US/common.json | 5 +- src/i18n/locales/en-US/settings.json | 17 ++- src/i18n/locales/es-ES/common.json | 5 +- src/i18n/locales/es-ES/settings.json | 19 ++- src/i18n/locales/pt-BR/common.json | 5 +- src/i18n/locales/pt-BR/settings.json | 17 ++- src/index.css | 3 + src/pages/Settings.test.tsx | 2 + src/pages/Settings.tsx | 18 ++- 11 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 src/components/settings/ConfigurationSettings.test.tsx create mode 100644 src/components/settings/ConfigurationSettings.tsx diff --git a/src/components/settings/ConfigurationSettings.test.tsx b/src/components/settings/ConfigurationSettings.test.tsx new file mode 100644 index 00000000..4ef521e6 --- /dev/null +++ b/src/components/settings/ConfigurationSettings.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import { toast } from "sonner"; + +import { importExportApi, type ImportResult } from "@/api/importExport"; +import enMessages from "@/i18n/locales/en-US"; + +import { ConfigurationSettings } from "./ConfigurationSettings"; + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } })); +vi.mock("@/auth/useAuth", () => ({ useAuth: () => ({ hasPermission: () => true }) })); +vi.mock("@/api/importExport", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, importExportApi: { importConfig: vi.fn() } }; +}); + +const RESULT: ImportResult = { + import_id: "imp-1", + status: "completed", + progress: { total: 2, processed: 2, created: 1, updated: 1, skipped: 0, failed: 0 }, + errors: [], + warnings: [], + started_at: "2026-11-02T10:00:00Z", + completed_at: "2026-11-02T10:00:01Z", +}; + +const EXPORT = { + version: "1", + exported_at: "2026-11-02T10:00:00Z", + entities: { gateways: [{ name: "g1" }], servers: [{ name: "s1" }, { name: "s2" }] }, +}; + +// jsdom's File has no .text(); parseImportFile only needs that. +const FILE = { text: async () => JSON.stringify(EXPORT) } as unknown as File; + +function pickVia(buttonName: string) { + const { getByRole, getByTestId } = render( + + + , + ); + fireEvent.click(getByRole("button", { name: buttonName })); + fireEvent.change(getByTestId("import-config-input"), { target: { files: [FILE] } }); +} + +describe("ConfigurationSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(importExportApi.importConfig).mockResolvedValue(RESULT); + }); + + it("imports the picked file and toasts the outcome", async () => { + pickVia("Import configuration"); + + await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1)); + expect(vi.mocked(toast.success).mock.calls[0][0]).toBe( + "Imported 1 MCP server and 2 virtual servers: 1 created, 1 updated, 0 skipped.", + ); + expect(importExportApi.importConfig).toHaveBeenCalledTimes(1); + expect(vi.mocked(importExportApi.importConfig).mock.calls[0][0]).toMatchObject({ + data: EXPORT, + dryRun: false, + }); + }); + + it("dry run summarises the file by type and says nothing changed", async () => { + pickVia("Dry run"); + + await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1)); + expect(vi.mocked(importExportApi.importConfig).mock.calls[0][0]).toMatchObject({ + dryRun: true, + }); + expect(vi.mocked(toast.success).mock.calls[0][0]).toBe( + "Dry run: would import 1 MCP server and 2 virtual servers. Nothing was changed.", + ); + }); +}); diff --git a/src/components/settings/ConfigurationSettings.tsx b/src/components/settings/ConfigurationSettings.tsx new file mode 100644 index 00000000..0f0ad40f --- /dev/null +++ b/src/components/settings/ConfigurationSettings.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef } from "react"; +import { useIntl, type IntlShape } from "react-intl"; +import { toast } from "sonner"; +import { ArrowDownFromLine } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { ConfigExport, ExportEntityType } from "@/api/importExport"; +import { importOutcome, useImportConfig } from "@/hooks/useImportConfig"; + +const OUTCOME_TOAST = { + success: { fn: toast.success, id: "settings.configuration.import.success" }, + partial: { fn: toast.warning, id: "settings.configuration.import.partial" }, + failed: { fn: toast.error, id: "settings.configuration.import.allFailed" }, +} as const; + +const TYPE_ORDER: ExportEntityType[] = [ + "gateways", + "servers", + "tools", + "prompts", + "resources", + "roots", +]; + +// Backend reports only aggregate counts (and none on dry run), so the per-type +// split comes from the file; it matches what's processed since we never send selected_entities. +function typeSummary(data: ConfigExport, intl: IntlShape): string | null { + const items = TYPE_ORDER.filter((t) => data.entities[t]?.length).map((t) => + intl.formatMessage( + { id: `settings.configuration.type.${t}` }, + { count: data.entities[t]!.length }, + ), + ); + return items.length ? intl.formatList(items, { type: "conjunction" }) : null; +} + +export function ConfigurationSettings() { + const intl = useIntl(); + const title = intl.formatMessage({ id: "settings.configuration.import.title" }); + const { loadFile, runImport, dryRun, reset, data, isLoading, error, canImport } = + useImportConfig(); + const inputRef = useRef(null); + const dryRunRef = useRef(false); // which button opened the picker + + // Submit as soon as a file parses; reset after so data can't re-trigger it. + useEffect(() => { + if (!data) return; + const isDry = dryRunRef.current; + const items = typeSummary(data, intl); + void (isDry ? dryRun : runImport)().then((result) => { + if (!result) return; + const { fn, id } = OUTCOME_TOAST[importOutcome(result)]; + fn( + !items + ? intl.formatMessage({ id: "settings.configuration.empty" }) + : intl.formatMessage( + { id: isDry ? "settings.configuration.dryRun.result" : id }, + { ...result.progress, items }, + ), + ); + reset(); + }); + }, [data, runImport, dryRun, reset, intl]); + + const pick = (isDry: boolean) => { + dryRunRef.current = isDry; + inputRef.current?.click(); + }; + + useEffect(() => { + if (error) toast.error(error); + }, [error]); + + return ( +
+

+ + + + {title} +

+

+ {intl.formatMessage({ id: "settings.configuration.import.description" })} +

+ { + const file = e.target.files?.[0]; + e.target.value = ""; // allow re-picking the same file + if (file) void loadFile(file); + }} + /> +
+ + +
+
+ ); +} diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json index d9ca5b4b..77683a02 100644 --- a/src/i18n/locales/en-US/common.json +++ b/src/i18n/locales/en-US/common.json @@ -69,14 +69,11 @@ "common.team.label": "Team", "common.team.placeholder": "Select a team", "importExport.export.failed": "Export failed", - "importExport.import.success": "Import complete: {created} created, {updated} updated, {skipped} skipped, {failed} failed", "importExport.import.failed": "Import failed", "importExport.import.invalidJson": "The selected file is not valid JSON", "importExport.import.invalidShape": "The selected file is not a ContextForge export", "importExport.import.tooLarge": "The file is too large to import", "importExport.import.conflict": "Import stopped: some items already exist", "importExport.import.validationFailed": "The file failed import validation", - "importExport.import.forbidden": "You don't have permission to import this configuration", - "importExport.import.partial": "Import partially applied: {created} created, {updated} updated, {skipped} skipped, {failed} failed", - "importExport.import.allFailed": "Import failed: none of the {failed} items could be applied" + "importExport.import.forbidden": "You don't have permission to import this configuration" } diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index 0eda0a49..b2216683 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -4,9 +4,24 @@ "settings.tabs.users": "Users", "settings.tabs.teams": "Teams", "settings.tabs.sso": "SSO", + "settings.tabs.configuration": "Configuration", "settings.sso.statusLabel": "Status", "settings.sso.statusEnabled": "Enabled", "settings.sso.statusDisabled": "Disabled", "settings.sso.providerLabel": "Provider", - "settings.sso.hint": "SSO is configured via environment variables on the server. This page is read-only." + "settings.sso.hint": "SSO is configured via environment variables on the server. This page is read-only.", + "settings.configuration.import.title": "Import configuration", + "settings.configuration.import.description": "Restore servers, tools, prompts and other gateway entities from a previously exported configuration file.", + "settings.configuration.dryRun.title": "Dry run", + "settings.configuration.dryRun.result": "Dry run: would import {items}. Nothing was changed.", + "settings.configuration.import.success": "Imported {items}: {created} created, {updated} updated, {skipped} skipped.", + "settings.configuration.import.partial": "Import partially applied to {items}: {created} created, {updated} updated, {skipped} skipped, {failed} failed.", + "settings.configuration.import.allFailed": "Import failed: none of {items} could be applied.", + "settings.configuration.empty": "Nothing to import.", + "settings.configuration.type.gateways": "{count, plural, one {# MCP server} other {# MCP servers}}", + "settings.configuration.type.servers": "{count, plural, one {# virtual server} other {# virtual servers}}", + "settings.configuration.type.tools": "{count, plural, one {# tool} other {# tools}}", + "settings.configuration.type.prompts": "{count, plural, one {# prompt} other {# prompts}}", + "settings.configuration.type.resources": "{count, plural, one {# resource} other {# resources}}", + "settings.configuration.type.roots": "{count, plural, one {# root} other {# roots}}" } diff --git a/src/i18n/locales/es-ES/common.json b/src/i18n/locales/es-ES/common.json index 5cb6eac6..b5e44254 100644 --- a/src/i18n/locales/es-ES/common.json +++ b/src/i18n/locales/es-ES/common.json @@ -69,14 +69,11 @@ "common.team.label": "Equipo", "common.team.placeholder": "Seleccione un equipo", "importExport.export.failed": "Error al exportar", - "importExport.import.success": "Importación completada: {created} creados, {updated} actualizados, {skipped} omitidos, {failed} con error", "importExport.import.failed": "Error al importar", "importExport.import.invalidJson": "El archivo seleccionado no es un JSON válido", "importExport.import.invalidShape": "El archivo seleccionado no es una exportación de ContextForge", "importExport.import.tooLarge": "El archivo es demasiado grande para importarlo", "importExport.import.conflict": "Importación detenida: algunos elementos ya existen", "importExport.import.validationFailed": "El archivo no superó la validación de importación", - "importExport.import.forbidden": "No tiene permiso para importar esta configuración", - "importExport.import.partial": "Importación aplicada parcialmente: {created} creados, {updated} actualizados, {skipped} omitidos, {failed} con error", - "importExport.import.allFailed": "Error al importar: no se pudo aplicar ninguno de los {failed} elementos" + "importExport.import.forbidden": "No tiene permiso para importar esta configuración" } diff --git a/src/i18n/locales/es-ES/settings.json b/src/i18n/locales/es-ES/settings.json index a5f4e277..647e0943 100644 --- a/src/i18n/locales/es-ES/settings.json +++ b/src/i18n/locales/es-ES/settings.json @@ -1,12 +1,27 @@ { - "settings.title": "Configuración", + "settings.title": "Configuraciones", "settings.tabs.apiTokens": "Tokens de API", "settings.tabs.users": "Usuarios", "settings.tabs.teams": "Equipos", "settings.tabs.sso": "SSO", + "settings.tabs.configuration": "Configuraciones", "settings.sso.statusLabel": "Estado", "settings.sso.statusEnabled": "Activado", "settings.sso.statusDisabled": "Desactivado", "settings.sso.providerLabel": "Proveedor", - "settings.sso.hint": "El SSO se configura mediante variables de entorno en el servidor. Esta página es de solo lectura." + "settings.sso.hint": "El SSO se configura mediante variables de entorno en el servidor. Esta página es de solo lectura.", + "settings.configuration.import.title": "Importar configuraciones", + "settings.configuration.import.description": "Restaura servidores, herramientas, prompts y otras entidades del gateway desde un archivo de configuración exportado previamente.", + "settings.configuration.dryRun.title": "Simulación", + "settings.configuration.dryRun.result": "Simulación: se importarían {items}. No se cambió nada.", + "settings.configuration.import.success": "Importados {items}: {created} creados, {updated} actualizados, {skipped} omitidos.", + "settings.configuration.import.partial": "Importación parcial de {items}: {created} creados, {updated} actualizados, {skipped} omitidos, {failed} fallidos.", + "settings.configuration.import.allFailed": "La importación falló: no se pudo aplicar ninguno de {items}.", + "settings.configuration.empty": "No hay nada que importar.", + "settings.configuration.type.gateways": "{count, plural, one {# servidor MCP} other {# servidores MCP}}", + "settings.configuration.type.servers": "{count, plural, one {# servidor virtual} other {# servidores virtuales}}", + "settings.configuration.type.tools": "{count, plural, one {# herramienta} other {# herramientas}}", + "settings.configuration.type.prompts": "{count, plural, one {# prompt} other {# prompts}}", + "settings.configuration.type.resources": "{count, plural, one {# recurso} other {# recursos}}", + "settings.configuration.type.roots": "{count, plural, one {# raíz} other {# raíces}}" } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index abb5e097..3703fc40 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -69,14 +69,11 @@ "common.team.label": "Equipe", "common.team.placeholder": "Selecione uma equipe", "importExport.export.failed": "Falha ao exportar", - "importExport.import.success": "Importação concluída: {created} criados, {updated} atualizados, {skipped} ignorados, {failed} com falha", "importExport.import.failed": "Falha ao importar", "importExport.import.invalidJson": "O arquivo selecionado não é um JSON válido", "importExport.import.invalidShape": "O arquivo selecionado não é uma exportação do ContextForge", "importExport.import.tooLarge": "O arquivo é grande demais para ser importado", "importExport.import.conflict": "Importação interrompida: alguns itens já existem", "importExport.import.validationFailed": "O arquivo não passou na validação de importação", - "importExport.import.forbidden": "Você não tem permissão para importar esta configuração", - "importExport.import.partial": "Importação aplicada parcialmente: {created} criados, {updated} atualizados, {skipped} ignorados, {failed} com falha", - "importExport.import.allFailed": "Falha ao importar: nenhum dos {failed} itens pôde ser aplicado" + "importExport.import.forbidden": "Você não tem permissão para importar esta configuração" } diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index f6e96f66..238462aa 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -4,9 +4,24 @@ "settings.tabs.users": "Usuários", "settings.tabs.teams": "Equipes", "settings.tabs.sso": "SSO", + "settings.tabs.configuration": "Configurações", "settings.sso.statusLabel": "Status", "settings.sso.statusEnabled": "Ativado", "settings.sso.statusDisabled": "Desativado", "settings.sso.providerLabel": "Provedor", - "settings.sso.hint": "O SSO é configurado por variáveis de ambiente no servidor. Esta página é somente leitura." + "settings.sso.hint": "O SSO é configurado por variáveis de ambiente no servidor. Esta página é somente leitura.", + "settings.configuration.import.title": "Importar configurações", + "settings.configuration.import.description": "Restaure servidores, ferramentas, prompts e outras entidades do gateway a partir de um arquivo de configuração exportado anteriormente.", + "settings.configuration.dryRun.title": "Simulação", + "settings.configuration.dryRun.result": "Simulação: seriam importados {items}. Nada foi alterado.", + "settings.configuration.import.success": "Importados {items}: {created} criados, {updated} atualizados, {skipped} ignorados.", + "settings.configuration.import.partial": "Importação parcial de {items}: {created} criados, {updated} atualizados, {skipped} ignorados, {failed} com falha.", + "settings.configuration.import.allFailed": "Falha na importação: nenhum de {items} pôde ser aplicado.", + "settings.configuration.empty": "Nada a importar.", + "settings.configuration.type.gateways": "{count, plural, one {# servidor MCP} other {# servidores MCP}}", + "settings.configuration.type.servers": "{count, plural, one {# servidor virtual} other {# servidores virtuais}}", + "settings.configuration.type.tools": "{count, plural, one {# ferramenta} other {# ferramentas}}", + "settings.configuration.type.prompts": "{count, plural, one {# prompt} other {# prompts}}", + "settings.configuration.type.resources": "{count, plural, one {# recurso} other {# recursos}}", + "settings.configuration.type.roots": "{count, plural, one {# raiz} other {# raízes}}" } diff --git a/src/index.css b/src/index.css index 625ecb6c..6b43f310 100644 --- a/src/index.css +++ b/src/index.css @@ -73,6 +73,7 @@ --color-server-icon-bg: var(--server-icon-bg); --color-agent-icon-bg: var(--agent-icon-bg); --color-team-icon-bg: var(--team-icon-bg); + --color-config-icon-bg: var(--config-icon-bg); --color-catalog-icon-tile: var(--catalog-icon-tile); --color-catalog-icon-backing: var(--catalog-icon-backing); --color-tool-status-active: var(--tool-status-active); @@ -164,6 +165,8 @@ /* Same rationale again: a chip background carrying a black glyph, so no `.dark` override. Brand cyan, as on the status headline. */ --team-icon-bg: #70f9ff; + /* Chip background with black glyph; no .dark override. */ + --config-icon-bg: #ffa4ff; --catalog-icon-tile: oklch(1 0 0); --catalog-icon-backing: var(--color-white); /* emerald-400 falls under 3:1 on white; .dark restores it */ diff --git a/src/pages/Settings.test.tsx b/src/pages/Settings.test.tsx index e4ae7cbb..9b789591 100644 --- a/src/pages/Settings.test.tsx +++ b/src/pages/Settings.test.tsx @@ -70,6 +70,7 @@ describe("Settings", () => { expect(screen.getByRole("tab", { name: "Users" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Teams" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "SSO" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Configuration" })).toBeInTheDocument(); expect(screen.getByText("Tokens tab content")).toBeInTheDocument(); }); @@ -121,6 +122,7 @@ describe("Settings", () => { expect(screen.queryByRole("tab", { name: "Users" })).not.toBeInTheDocument(); expect(screen.queryByRole("tab", { name: "Teams" })).not.toBeInTheDocument(); expect(screen.queryByRole("tab", { name: "SSO" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Configuration" })).not.toBeInTheDocument(); expect(screen.getByText("Tokens tab content")).toBeInTheDocument(); }); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ec072156..1b658d9e 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,6 +5,7 @@ import { Redirect, useRouter } from "@/router"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { SettingsTabsProvider } from "@/components/settings/settings-toolbar"; import { SsoSettings } from "@/components/settings/SsoSettings"; +import { ConfigurationSettings } from "@/components/settings/ConfigurationSettings"; import { Tokens } from "@/pages/Tokens"; import { Users } from "@/pages/Users"; import { Teams } from "@/pages/Teams"; @@ -32,7 +33,12 @@ export function Settings({ tab }: SettingsProps) { const tabsContext = useMemo(() => ({ toolbar: toolbarEl, setTabsHidden }), [toolbarEl]); const availableTabs = useMemo( - () => ["tokens", ...(isAdmin ? ["users", "teams"] : []), ...(showSsoTab ? ["sso"] : [])], + () => [ + "tokens", + ...(isAdmin ? ["users", "teams"] : []), + ...(showSsoTab ? ["sso"] : []), + ...(isAdmin ? ["configuration"] : []), + ], [isAdmin, showSsoTab], ); @@ -67,6 +73,11 @@ export function Settings({ tab }: SettingsProps) { {intl.formatMessage({ id: "settings.tabs.sso" })} )} + {isAdmin && ( + + {intl.formatMessage({ id: "settings.tabs.configuration" })} + + )} {/* min-h-10 reserves the toolbar's populated height so the tab row keeps a constant height whether or not the active tab has filled @@ -93,6 +104,11 @@ export function Settings({ tab }: SettingsProps) { )} + {isAdmin && ( + + + + )}