diff --git a/changelog.d/features/6836-import-providers-from-file.md b/changelog.d/features/6836-import-providers-from-file.md new file mode 100644 index 0000000000..fadbee0c6d --- /dev/null +++ b/changelog.d/features/6836-import-providers-from-file.md @@ -0,0 +1 @@ +- feat(dashboard): import multiple, possibly different providers from a CSV/JSON file — per-row validation, a checklist to pick which parsed rows to import, and a new `POST /api/providers/import` route with partial-failure results (#6836) diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 02bba19fa9..2733369e9a 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -435,6 +435,8 @@ Response example: | `/api/providers/[id]/test` | POST | Test provider connection | | `/api/providers/[id]/models` | GET | List provider models | | `/api/providers/validate` | POST | Validate provider config | +| `/api/providers/bulk` | POST | Bulk-add API keys for ONE provider | +| `/api/providers/import` | POST | Import a heterogeneous provider LIST from a parsed CSV/JSON file (#6836); per-row partial-failure results | | `/api/provider-nodes*` | Various | Provider node management | | `/api/provider-models` | GET/POST/PATCH/DELETE | Custom models (add, update, hide/show, delete) | diff --git a/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx b/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx new file mode 100644 index 0000000000..0363f205d8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Button, Modal } from "@/shared/components"; +import type { ParsedProviderImportEntry, ProviderImportParseError } from "./parseProviderImportFile"; +import { useImportProvidersFromFile } from "./useImportProvidersFromFile"; + +interface ImportProvidersFromFileModalProps { + isOpen: boolean; + onClose: () => void; + onImported: () => Promise; +} + +type Translator = (key: string, values?: Record) => string; + +const PARSE_ERROR_REASON_KEYS = [ + "importErrorMissingProvider", + "importErrorMissingName", + "importErrorMissingApiKey", + "importErrorInvalidPriority", + "importErrorMalformedRow", + "importErrorNotArray", +] as const; + +/** Per-row parse error list. Split out of the modal to keep it under the LOC ratchet. */ +function ParseErrorsList({ errors, t }: { errors: ProviderImportParseError[]; t: Translator }) { + if (errors.length === 0) return null; + return ( +
+ {errors.map((err, idx) => ( +
+ {t("importFromFileErrorLine", { + line: err.line, + reason: t( + PARSE_ERROR_REASON_KEYS.includes(err.reason as (typeof PARSE_ERROR_REASON_KEYS)[number]) + ? err.reason + : "importErrorMalformedRow" + ), + })} +
+ ))} +
+ ); +} + +interface EntriesTableProps { + entries: ParsedProviderImportEntry[]; + selected: Set; + onToggleRow: (idx: number) => void; + onToggleAll: (checked: boolean) => void; + t: Translator; +} + +/** Parsed-rows selection checklist (acceptance criterion #3 of #6836). */ +function EntriesTable({ entries, selected, onToggleRow, onToggleAll, t }: EntriesTableProps) { + if (entries.length === 0) return null; + return ( + <> +
+ {t("importFromFileSelectHint", { count: selected.size, total: entries.length })} +
+
+ + + + + + + + + + + {entries.map((entry, idx) => ( + + + + + + + ))} + +
+ onToggleAll(e.target.checked)} + /> + {t("importFromFileColProvider")}{t("importFromFileColName")}{t("importFromFileColBaseUrl")}
+ onToggleRow(idx)} /> + {entry.provider}{entry.name}{entry.baseUrl || "—"}
+
+ + ); +} + +interface FilePickerRowProps { + fileInputRef: React.RefObject; + fileName: string; + onFile: (file: File) => void; + t: Translator; +} + +/** File-input trigger + chosen filename display. */ +function FilePickerRow({ fileInputRef, fileName, onFile, t }: FilePickerRowProps) { + return ( +
+ { + const file = e.target.files?.[0]; + if (file) onFile(file); + }} + /> + + {fileName && {fileName}} +
+ ); +} + +/** + * Wizard step: upload a CSV/JSON file listing MULTIPLE, possibly different providers, + * pick which parsed rows to actually import, then submit them in one batch (#6836). + * Mirrors `ProxyBulkImportModal.tsx`'s parse → review → execute UX. State/handlers live + * in `useImportProvidersFromFile`; presentation-only pieces are split into the small + * components above so this component itself stays under the LOC ratchet. + */ +export function ImportProvidersFromFileModal({ + isOpen, + onClose, + onImported, +}: ImportProvidersFromFileModalProps) { + const t = useTranslations("providers"); + const s = useImportProvidersFromFile(onImported); + + return ( + s.handleClose(onClose)} title={t("importFromFileTitle")} maxWidth="xl"> +
+

{t("importFromFileDescription")}

+ + + + + + {s.result && ( +
+ {t("importFromFileResult", { success: s.result.success, failed: s.result.failed })} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx index e03c389ffe..a4be582732 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx @@ -42,6 +42,7 @@ interface ProviderSummaryCardProps { onCategoryChange(category: string | null, freeOnly: boolean): void; onDisplayModeChange(mode: ProviderDisplayMode): void; onNewProvider(): void; + onImportFromFile(): void; searchQuery: string; setModelSearchQuery(value: string): void; setSearchQuery(value: string): void; @@ -96,6 +97,7 @@ export default function ProviderSummaryCard({ onCategoryChange, onDisplayModeChange, onNewProvider, + onImportFromFile, searchQuery, setModelSearchQuery, setSearchQuery, @@ -200,6 +202,9 @@ export default function ProviderSummaryCard({ +