chore: implement NPM/JSON import routes and fix SMTP persistence
Phase 3 of skipped tests remediation - enables 7 previously skipped E2E tests Backend: Add NPM import handler with session-based upload/commit/cancel Add JSON import handler with Charon/NPM format support Fix SMTP SaveSMTPConfig using transaction-based upsert Add comprehensive unit tests for new handlers Frontend: Add ImportNPM page component following ImportCaddy pattern Add ImportJSON page component with format detection Add useNPMImport and useJSONImport React Query hooks Add API clients for npm/json import endpoints Register routes in App.tsx and navigation in Layout.tsx Add i18n keys for new import pages Tests: 7 E2E tests now enabled and passing Backend coverage: 86.8% Reduced total skipped tests from 98 to 91 Closes: Phase 3 of skipped-tests-remediation plan
This commit is contained in:
@@ -16,6 +16,8 @@ const RemoteServers = lazy(() => import('./pages/RemoteServers'))
|
||||
const DNS = lazy(() => import('./pages/DNS'))
|
||||
const ImportCaddy = lazy(() => import('./pages/ImportCaddy'))
|
||||
const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec'))
|
||||
const ImportNPM = lazy(() => import('./pages/ImportNPM'))
|
||||
const ImportJSON = lazy(() => import('./pages/ImportJSON'))
|
||||
const Certificates = lazy(() => import('./pages/Certificates'))
|
||||
const DNSProviders = lazy(() => import('./pages/DNSProviders'))
|
||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
|
||||
@@ -109,6 +111,8 @@ export default function App() {
|
||||
<Route path="import">
|
||||
<Route path="caddyfile" element={<ImportCaddy />} />
|
||||
<Route path="crowdsec" element={<ImportCrowdSec />} />
|
||||
<Route path="npm" element={<ImportNPM />} />
|
||||
<Route path="json" element={<ImportJSON />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
90
frontend/src/api/jsonImport.ts
Normal file
90
frontend/src/api/jsonImport.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import client from './client';
|
||||
|
||||
/** Represents a host parsed from a JSON export. */
|
||||
export interface JSONHost {
|
||||
domain_names: string;
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
ssl_forced: boolean;
|
||||
websocket_support: boolean;
|
||||
}
|
||||
|
||||
/** Preview of a JSON import with hosts and conflicts. */
|
||||
export interface JSONImportPreview {
|
||||
session: {
|
||||
id: string;
|
||||
state: string;
|
||||
source: string;
|
||||
};
|
||||
preview: {
|
||||
hosts: JSONHost[];
|
||||
conflicts: string[];
|
||||
errors: string[];
|
||||
};
|
||||
conflict_details: Record<string, {
|
||||
existing: {
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
ssl_forced: boolean;
|
||||
websocket: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
imported: {
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
ssl_forced: boolean;
|
||||
websocket: boolean;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Result of committing a JSON import operation. */
|
||||
export interface JSONImportCommitResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads JSON export content for import preview.
|
||||
* @param content - The JSON export content as a string
|
||||
* @returns Promise resolving to JSONImportPreview with parsed hosts
|
||||
* @throws {AxiosError} If parsing fails or content is invalid
|
||||
*/
|
||||
export const uploadJSONExport = async (content: string): Promise<JSONImportPreview> => {
|
||||
const { data } = await client.post<JSONImportPreview>('/import/json/upload', { content });
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Commits the JSON import, creating/updating proxy hosts.
|
||||
* @param sessionUuid - The import session UUID
|
||||
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
|
||||
* @param names - Map of custom names for imported hosts
|
||||
* @returns Promise resolving to JSONImportCommitResult with counts
|
||||
* @throws {AxiosError} If commit fails
|
||||
*/
|
||||
export const commitJSONImport = async (
|
||||
sessionUuid: string,
|
||||
resolutions: Record<string, string>,
|
||||
names: Record<string, string>
|
||||
): Promise<JSONImportCommitResult> => {
|
||||
const { data } = await client.post<JSONImportCommitResult>('/import/json/commit', {
|
||||
session_uuid: sessionUuid,
|
||||
resolutions,
|
||||
names,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancels the current JSON import session.
|
||||
* @throws {AxiosError} If cancellation fails
|
||||
*/
|
||||
export const cancelJSONImport = async (): Promise<void> => {
|
||||
await client.post('/import/json/cancel');
|
||||
};
|
||||
90
frontend/src/api/npmImport.ts
Normal file
90
frontend/src/api/npmImport.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import client from './client';
|
||||
|
||||
/** Represents a host parsed from an NPM export. */
|
||||
export interface NPMHost {
|
||||
domain_names: string;
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
ssl_forced: boolean;
|
||||
websocket_support: boolean;
|
||||
}
|
||||
|
||||
/** Preview of an NPM import with hosts and conflicts. */
|
||||
export interface NPMImportPreview {
|
||||
session: {
|
||||
id: string;
|
||||
state: string;
|
||||
source: string;
|
||||
};
|
||||
preview: {
|
||||
hosts: NPMHost[];
|
||||
conflicts: string[];
|
||||
errors: string[];
|
||||
};
|
||||
conflict_details: Record<string, {
|
||||
existing: {
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
ssl_forced: boolean;
|
||||
websocket: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
imported: {
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
ssl_forced: boolean;
|
||||
websocket: boolean;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Result of committing an NPM import operation. */
|
||||
export interface NPMImportCommitResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads NPM export content for import preview.
|
||||
* @param content - The NPM export JSON content as a string
|
||||
* @returns Promise resolving to NPMImportPreview with parsed hosts
|
||||
* @throws {AxiosError} If parsing fails or content is invalid
|
||||
*/
|
||||
export const uploadNPMExport = async (content: string): Promise<NPMImportPreview> => {
|
||||
const { data } = await client.post<NPMImportPreview>('/import/npm/upload', { content });
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Commits the NPM import, creating/updating proxy hosts.
|
||||
* @param sessionUuid - The import session UUID
|
||||
* @param resolutions - Map of conflict resolutions (domain -> 'keep'|'replace'|'skip')
|
||||
* @param names - Map of custom names for imported hosts
|
||||
* @returns Promise resolving to NPMImportCommitResult with counts
|
||||
* @throws {AxiosError} If commit fails
|
||||
*/
|
||||
export const commitNPMImport = async (
|
||||
sessionUuid: string,
|
||||
resolutions: Record<string, string>,
|
||||
names: Record<string, string>
|
||||
): Promise<NPMImportCommitResult> => {
|
||||
const { data } = await client.post<NPMImportCommitResult>('/import/npm/commit', {
|
||||
session_uuid: sessionUuid,
|
||||
resolutions,
|
||||
names,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancels the current NPM import session.
|
||||
* @throws {AxiosError} If cancellation fails
|
||||
*/
|
||||
export const cancelNPMImport = async (): Promise<void> => {
|
||||
await client.post('/import/npm/cancel');
|
||||
};
|
||||
@@ -100,6 +100,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
children: [
|
||||
{ name: t('navigation.caddyfile'), path: '/tasks/import/caddyfile', icon: '📥' },
|
||||
{ name: t('navigation.crowdsec'), path: '/tasks/import/crowdsec', icon: '🛡️' },
|
||||
{ name: t('navigation.importNPM'), path: '/tasks/import/npm', icon: '📦' },
|
||||
{ name: t('navigation.importJSON'), path: '/tasks/import/json', icon: '📄' },
|
||||
]
|
||||
},
|
||||
{ name: t('navigation.backups'), path: '/tasks/backups', icon: '💾' },
|
||||
|
||||
84
frontend/src/hooks/useJSONImport.ts
Normal file
84
frontend/src/hooks/useJSONImport.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
uploadJSONExport,
|
||||
commitJSONImport,
|
||||
cancelJSONImport,
|
||||
JSONImportPreview,
|
||||
JSONImportCommitResult,
|
||||
} from '../api/jsonImport';
|
||||
|
||||
/**
|
||||
* Hook for managing JSON import workflow.
|
||||
* Provides upload, commit, and cancel functionality with state management.
|
||||
*/
|
||||
export function useJSONImport() {
|
||||
const queryClient = useQueryClient();
|
||||
const [preview, setPreview] = useState<JSONImportPreview | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [commitResult, setCommitResult] = useState<JSONImportCommitResult | null>(null);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: uploadJSONExport,
|
||||
onSuccess: (data) => {
|
||||
setPreview(data);
|
||||
setSessionId(data.session.id);
|
||||
},
|
||||
});
|
||||
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: ({
|
||||
resolutions,
|
||||
names,
|
||||
}: {
|
||||
resolutions: Record<string, string>;
|
||||
names: Record<string, string>;
|
||||
}) => {
|
||||
if (!sessionId) throw new Error('No active session');
|
||||
return commitJSONImport(sessionId, resolutions, names);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setCommitResult(data);
|
||||
setPreview(null);
|
||||
setSessionId(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelJSONImport,
|
||||
onSuccess: () => {
|
||||
setPreview(null);
|
||||
setSessionId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const clearCommitResult = () => {
|
||||
setCommitResult(null);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setPreview(null);
|
||||
setSessionId(null);
|
||||
setCommitResult(null);
|
||||
};
|
||||
|
||||
return {
|
||||
preview,
|
||||
sessionId,
|
||||
loading: uploadMutation.isPending,
|
||||
error: uploadMutation.error,
|
||||
upload: uploadMutation.mutateAsync,
|
||||
commit: (resolutions: Record<string, string>, names: Record<string, string>) =>
|
||||
commitMutation.mutateAsync({ resolutions, names }),
|
||||
committing: commitMutation.isPending,
|
||||
commitError: commitMutation.error,
|
||||
commitResult,
|
||||
clearCommitResult,
|
||||
cancel: cancelMutation.mutateAsync,
|
||||
cancelling: cancelMutation.isPending,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export type { JSONImportPreview, JSONImportCommitResult };
|
||||
84
frontend/src/hooks/useNPMImport.ts
Normal file
84
frontend/src/hooks/useNPMImport.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
uploadNPMExport,
|
||||
commitNPMImport,
|
||||
cancelNPMImport,
|
||||
NPMImportPreview,
|
||||
NPMImportCommitResult,
|
||||
} from '../api/npmImport';
|
||||
|
||||
/**
|
||||
* Hook for managing NPM import workflow.
|
||||
* Provides upload, commit, and cancel functionality with state management.
|
||||
*/
|
||||
export function useNPMImport() {
|
||||
const queryClient = useQueryClient();
|
||||
const [preview, setPreview] = useState<NPMImportPreview | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [commitResult, setCommitResult] = useState<NPMImportCommitResult | null>(null);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: uploadNPMExport,
|
||||
onSuccess: (data) => {
|
||||
setPreview(data);
|
||||
setSessionId(data.session.id);
|
||||
},
|
||||
});
|
||||
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: ({
|
||||
resolutions,
|
||||
names,
|
||||
}: {
|
||||
resolutions: Record<string, string>;
|
||||
names: Record<string, string>;
|
||||
}) => {
|
||||
if (!sessionId) throw new Error('No active session');
|
||||
return commitNPMImport(sessionId, resolutions, names);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setCommitResult(data);
|
||||
setPreview(null);
|
||||
setSessionId(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelNPMImport,
|
||||
onSuccess: () => {
|
||||
setPreview(null);
|
||||
setSessionId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const clearCommitResult = () => {
|
||||
setCommitResult(null);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setPreview(null);
|
||||
setSessionId(null);
|
||||
setCommitResult(null);
|
||||
};
|
||||
|
||||
return {
|
||||
preview,
|
||||
sessionId,
|
||||
loading: uploadMutation.isPending,
|
||||
error: uploadMutation.error,
|
||||
upload: uploadMutation.mutateAsync,
|
||||
commit: (resolutions: Record<string, string>, names: Record<string, string>) =>
|
||||
commitMutation.mutateAsync({ resolutions, names }),
|
||||
committing: commitMutation.isPending,
|
||||
commitError: commitMutation.error,
|
||||
commitResult,
|
||||
clearCommitResult,
|
||||
cancel: cancelMutation.mutateAsync,
|
||||
cancelling: cancelMutation.isPending,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export type { NPMImportPreview, NPMImportCommitResult };
|
||||
@@ -69,6 +69,8 @@
|
||||
"accountManagement": "Account Management",
|
||||
"import": "Import",
|
||||
"caddyfile": "Caddyfile",
|
||||
"importNPM": "Import NPM",
|
||||
"importJSON": "Import JSON",
|
||||
"backups": "Backups",
|
||||
"logs": "Logs",
|
||||
"securityHeaders": "Security Headers",
|
||||
@@ -761,6 +763,38 @@
|
||||
"creatingBackup": "Creating backup...",
|
||||
"importing": "Importing CrowdSec..."
|
||||
},
|
||||
"importNPM": {
|
||||
"title": "Import from NPM",
|
||||
"description": "Import proxy hosts from Nginx Proxy Manager export",
|
||||
"enterContent": "Please paste NPM export JSON",
|
||||
"invalidJSON": "Invalid JSON format",
|
||||
"upload": "Upload & Preview",
|
||||
"import": "Import",
|
||||
"success": "Import completed successfully",
|
||||
"previewTitle": "Preview Import",
|
||||
"conflict": "Conflict",
|
||||
"new": "New",
|
||||
"skip": "Skip",
|
||||
"keep": "Keep Existing",
|
||||
"replace": "Replace",
|
||||
"cancelConfirm": "Are you sure you want to cancel this import?"
|
||||
},
|
||||
"importJSON": {
|
||||
"title": "Import from JSON",
|
||||
"description": "Import configuration from JSON export",
|
||||
"enterContent": "Please paste JSON configuration",
|
||||
"invalidJSON": "Invalid JSON format",
|
||||
"upload": "Upload & Preview",
|
||||
"import": "Import",
|
||||
"success": "Import completed successfully",
|
||||
"previewTitle": "Preview Import",
|
||||
"conflict": "Conflict",
|
||||
"new": "New",
|
||||
"skip": "Skip",
|
||||
"keep": "Keep Existing",
|
||||
"replace": "Replace",
|
||||
"cancelConfirm": "Are you sure you want to cancel this import?"
|
||||
},
|
||||
"systemSettings": {
|
||||
"title": "System Settings",
|
||||
"settingsSaved": "System settings saved",
|
||||
|
||||
312
frontend/src/pages/ImportJSON.tsx
Normal file
312
frontend/src/pages/ImportJSON.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { useJSONImport } from '../hooks/useJSONImport'
|
||||
import ImportSuccessModal from '../components/dialogs/ImportSuccessModal'
|
||||
|
||||
export default function ImportJSON() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
preview,
|
||||
loading,
|
||||
error,
|
||||
upload,
|
||||
commit,
|
||||
committing,
|
||||
commitResult,
|
||||
clearCommitResult,
|
||||
cancel,
|
||||
reset,
|
||||
} = useJSONImport()
|
||||
const [content, setContent] = useState('')
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false)
|
||||
const [resolutions, setResolutions] = useState<Record<string, string>>({})
|
||||
const [names] = useState<Record<string, string>>({})
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!content.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(content)
|
||||
} catch {
|
||||
alert(t('importJSON.invalidJSON'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await upload(content)
|
||||
setShowReview(true)
|
||||
} catch {
|
||||
// Error is handled by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const text = await file.text()
|
||||
setContent(text)
|
||||
}
|
||||
|
||||
const handleCommit = async () => {
|
||||
try {
|
||||
await createBackup()
|
||||
await commit(resolutions, names)
|
||||
setContent('')
|
||||
setShowReview(false)
|
||||
setShowSuccessModal(true)
|
||||
} catch {
|
||||
// Error is handled by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseSuccessModal = () => {
|
||||
setShowSuccessModal(false)
|
||||
clearCommitResult()
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (confirm(t('importJSON.cancelConfirm'))) {
|
||||
try {
|
||||
await cancel()
|
||||
setShowReview(false)
|
||||
reset()
|
||||
} catch {
|
||||
// Error is handled by hook
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolutionChange = (domain: string, resolution: string) => {
|
||||
setResolutions((prev) => ({ ...prev, [domain]: resolution }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">{t('importJSON.title')}</h1>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6"
|
||||
role="alert"
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showReview && (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">
|
||||
{t('importJSON.title')}
|
||||
</h2>
|
||||
<p className="text-gray-400 text-sm">{t('importJSON.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="json-file-upload"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
{t('common.upload')}
|
||||
</label>
|
||||
<input
|
||||
id="json-file-upload"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-active file:text-white hover:file:bg-blue-hover file:cursor-pointer cursor-pointer"
|
||||
data-testid="json-import-dropzone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
<span className="text-gray-500 text-sm">
|
||||
{t('importCaddy.orPasteContent')}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="json-content"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
{t('importJSON.enterContent')}
|
||||
</label>
|
||||
<textarea
|
||||
id="json-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`{
|
||||
"proxy_hosts": [
|
||||
{
|
||||
"domain_names": ["example.com"],
|
||||
"forward_host": "192.168.1.100",
|
||||
"forward_port": 8080,
|
||||
"forward_scheme": "http"
|
||||
}
|
||||
]
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={loading || !content.trim()}
|
||||
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? t('common.loading') : t('importJSON.upload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReview && preview?.preview && (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">
|
||||
{t('importJSON.previewTitle')}
|
||||
</h2>
|
||||
|
||||
{preview.preview.errors.length > 0 && (
|
||||
<div
|
||||
className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-4"
|
||||
role="alert"
|
||||
>
|
||||
<ul className="list-disc list-inside">
|
||||
{preview.preview.errors.map((err, idx) => (
|
||||
<li key={idx}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm text-left text-gray-300">
|
||||
<caption className="sr-only">JSON Import Preview</caption>
|
||||
<thead className="text-xs uppercase bg-gray-800 text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('proxyHosts.domainNames')}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('proxyHosts.forwardHost')}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('proxyHosts.forwardPort')}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('proxyHosts.sslForced')}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('common.status')}
|
||||
</th>
|
||||
{preview.preview.conflicts.length > 0 && (
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('common.actions')}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.preview.hosts.map((host, idx) => {
|
||||
const isConflict = preview.preview.conflicts.includes(
|
||||
host.domain_names
|
||||
)
|
||||
return (
|
||||
<tr key={idx} className="border-b border-gray-700">
|
||||
<td className="px-4 py-3">{host.domain_names}</td>
|
||||
<td className="px-4 py-3">
|
||||
{host.forward_scheme}://{host.forward_host}
|
||||
</td>
|
||||
<td className="px-4 py-3">{host.forward_port}</td>
|
||||
<td className="px-4 py-3">
|
||||
{host.ssl_forced ? t('common.yes') : t('common.no')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{isConflict ? (
|
||||
<span className="text-yellow-400">
|
||||
{t('importJSON.conflict')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-green-400">
|
||||
{t('importJSON.new')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{preview.preview.conflicts.length > 0 && (
|
||||
<td className="px-4 py-3">
|
||||
{isConflict && (
|
||||
<select
|
||||
value={resolutions[host.domain_names] || 'skip'}
|
||||
onChange={(e) =>
|
||||
handleResolutionChange(
|
||||
host.domain_names,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className="bg-gray-800 border border-gray-600 text-white rounded px-2 py-1 text-sm"
|
||||
aria-label={`Resolution for ${host.domain_names}`}
|
||||
>
|
||||
<option value="skip">{t('importJSON.skip')}</option>
|
||||
<option value="keep">{t('importJSON.keep')}</option>
|
||||
<option value="replace">
|
||||
{t('importJSON.replace')}
|
||||
</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCommit}
|
||||
disabled={committing}
|
||||
className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{committing ? t('common.loading') : t('importJSON.import')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImportSuccessModal
|
||||
visible={showSuccessModal}
|
||||
onClose={handleCloseSuccessModal}
|
||||
onNavigateDashboard={() => {
|
||||
handleCloseSuccessModal()
|
||||
navigate('/')
|
||||
}}
|
||||
onNavigateHosts={() => {
|
||||
handleCloseSuccessModal()
|
||||
navigate('/proxy-hosts')
|
||||
}}
|
||||
results={commitResult}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
312
frontend/src/pages/ImportNPM.tsx
Normal file
312
frontend/src/pages/ImportNPM.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { useNPMImport } from '../hooks/useNPMImport'
|
||||
import ImportSuccessModal from '../components/dialogs/ImportSuccessModal'
|
||||
|
||||
export default function ImportNPM() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
preview,
|
||||
loading,
|
||||
error,
|
||||
upload,
|
||||
commit,
|
||||
committing,
|
||||
commitResult,
|
||||
clearCommitResult,
|
||||
cancel,
|
||||
reset,
|
||||
} = useNPMImport()
|
||||
const [content, setContent] = useState('')
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false)
|
||||
const [resolutions, setResolutions] = useState<Record<string, string>>({})
|
||||
const [names] = useState<Record<string, string>>({})
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!content.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(content)
|
||||
} catch {
|
||||
alert(t('importNPM.invalidJSON'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await upload(content)
|
||||
setShowReview(true)
|
||||
} catch {
|
||||
// Error is handled by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const text = await file.text()
|
||||
setContent(text)
|
||||
}
|
||||
|
||||
const handleCommit = async () => {
|
||||
try {
|
||||
await createBackup()
|
||||
await commit(resolutions, names)
|
||||
setContent('')
|
||||
setShowReview(false)
|
||||
setShowSuccessModal(true)
|
||||
} catch {
|
||||
// Error is handled by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseSuccessModal = () => {
|
||||
setShowSuccessModal(false)
|
||||
clearCommitResult()
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (confirm(t('importNPM.cancelConfirm'))) {
|
||||
try {
|
||||
await cancel()
|
||||
setShowReview(false)
|
||||
reset()
|
||||
} catch {
|
||||
// Error is handled by hook
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolutionChange = (domain: string, resolution: string) => {
|
||||
setResolutions((prev) => ({ ...prev, [domain]: resolution }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">{t('importNPM.title')}</h1>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6"
|
||||
role="alert"
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showReview && (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">
|
||||
{t('importNPM.title')}
|
||||
</h2>
|
||||
<p className="text-gray-400 text-sm">{t('importNPM.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="npm-file-upload"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
{t('common.upload')}
|
||||
</label>
|
||||
<input
|
||||
id="npm-file-upload"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-active file:text-white hover:file:bg-blue-hover file:cursor-pointer cursor-pointer"
|
||||
data-testid="npm-import-dropzone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
<span className="text-gray-500 text-sm">
|
||||
{t('importCaddy.orPasteContent')}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="npm-content"
|
||||
className="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
{t('importNPM.enterContent')}
|
||||
</label>
|
||||
<textarea
|
||||
id="npm-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`{
|
||||
"proxy_hosts": [
|
||||
{
|
||||
"domain_names": ["example.com"],
|
||||
"forward_host": "192.168.1.100",
|
||||
"forward_port": 8080,
|
||||
"forward_scheme": "http"
|
||||
}
|
||||
]
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={loading || !content.trim()}
|
||||
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? t('common.loading') : t('importNPM.upload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReview && preview?.preview && (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">
|
||||
{t('importNPM.previewTitle')}
|
||||
</h2>
|
||||
|
||||
{preview.preview.errors.length > 0 && (
|
||||
<div
|
||||
className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-4"
|
||||
role="alert"
|
||||
>
|
||||
<ul className="list-disc list-inside">
|
||||
{preview.preview.errors.map((err, idx) => (
|
||||
<li key={idx}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm text-left text-gray-300">
|
||||
<caption className="sr-only">NPM Import Preview</caption>
|
||||
<thead className="text-xs uppercase bg-gray-800 text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('proxyHosts.domainNames')}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('proxyHosts.forwardHost')}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('proxyHosts.forwardPort')}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('proxyHosts.sslForced')}
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('common.status')}
|
||||
</th>
|
||||
{preview.preview.conflicts.length > 0 && (
|
||||
<th scope="col" className="px-4 py-3">
|
||||
{t('common.actions')}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.preview.hosts.map((host, idx) => {
|
||||
const isConflict = preview.preview.conflicts.includes(
|
||||
host.domain_names
|
||||
)
|
||||
return (
|
||||
<tr key={idx} className="border-b border-gray-700">
|
||||
<td className="px-4 py-3">{host.domain_names}</td>
|
||||
<td className="px-4 py-3">
|
||||
{host.forward_scheme}://{host.forward_host}
|
||||
</td>
|
||||
<td className="px-4 py-3">{host.forward_port}</td>
|
||||
<td className="px-4 py-3">
|
||||
{host.ssl_forced ? t('common.yes') : t('common.no')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{isConflict ? (
|
||||
<span className="text-yellow-400">
|
||||
{t('importNPM.conflict')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-green-400">
|
||||
{t('importNPM.new')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{preview.preview.conflicts.length > 0 && (
|
||||
<td className="px-4 py-3">
|
||||
{isConflict && (
|
||||
<select
|
||||
value={resolutions[host.domain_names] || 'skip'}
|
||||
onChange={(e) =>
|
||||
handleResolutionChange(
|
||||
host.domain_names,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className="bg-gray-800 border border-gray-600 text-white rounded px-2 py-1 text-sm"
|
||||
aria-label={`Resolution for ${host.domain_names}`}
|
||||
>
|
||||
<option value="skip">{t('importNPM.skip')}</option>
|
||||
<option value="keep">{t('importNPM.keep')}</option>
|
||||
<option value="replace">
|
||||
{t('importNPM.replace')}
|
||||
</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCommit}
|
||||
disabled={committing}
|
||||
className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{committing ? t('common.loading') : t('importNPM.import')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImportSuccessModal
|
||||
visible={showSuccessModal}
|
||||
onClose={handleCloseSuccessModal}
|
||||
onNavigateDashboard={() => {
|
||||
handleCloseSuccessModal()
|
||||
navigate('/')
|
||||
}}
|
||||
onNavigateHosts={() => {
|
||||
handleCloseSuccessModal()
|
||||
navigate('/proxy-hosts')
|
||||
}}
|
||||
results={commitResult}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user