91a66d5645
Replace the default react-toastify look with project-styled toast cards (icon chip, rounded shell, RTL-aware container, type-colored progress bar) via a new notify helper and globals.css overrides. Add a central error layer (src/lib/errors.ts): classify any caught error by HTTP status / network condition, log the full technical detail (including the raw backend message) to the console only, and surface a friendly, localized message to the user. Raw backend messages are no longer shown. All ~190 toast call sites across 22 files move to notify, routing backend errors through notify.error(err, fallback); dead apiErrorMessage/formatApiError helpers removed. Adds an `errors` section to the fa/en dictionaries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
'use client';
|
||
|
||
import { useRef, useState } from 'react';
|
||
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
|
||
import { notify } from '@/lib/notify';
|
||
import { useT } from '@/i18n/I18nProvider';
|
||
import { Select } from '@/components/ui/select';
|
||
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
|
||
|
||
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
|
||
|
||
export interface ManagedDatabaseFormState {
|
||
databaseType: DatabaseEngine;
|
||
dbVersion: string;
|
||
dbUsername: string;
|
||
dbPassword: string;
|
||
dbStorageSize: string;
|
||
}
|
||
|
||
const DB_OPTIONS: { value: DatabaseEngine; label: string; versions: string[] }[] = [
|
||
{ value: 'postgresql', label: 'PostgreSQL', versions: ['17', '16', '15', '14'] },
|
||
{ value: 'mysql', label: 'MySQL', versions: ['9.0', '8.4', '8.0', '5.7'] },
|
||
{ value: 'mariadb', label: 'MariaDB', versions: ['11.4', '11.3', '10.11', '10.6'] },
|
||
{ value: 'mongodb', label: 'MongoDB', versions: ['7.0', '6.0', '5.0'] },
|
||
];
|
||
|
||
function versionOptions(databaseType: DatabaseEngine) {
|
||
const opt = DB_OPTIONS.find((o) => o.value === databaseType);
|
||
return opt?.versions ?? ['16'];
|
||
}
|
||
|
||
function versionLabel(databaseType: DatabaseEngine, v: string) {
|
||
if (databaseType === 'postgresql') return `PostgreSQL ${v}`;
|
||
if (databaseType === 'mysql') return `MySQL ${v}`;
|
||
if (databaseType === 'mariadb') return `MariaDB ${v}`;
|
||
return `MongoDB ${v}`;
|
||
}
|
||
|
||
export function ManagedDatabaseConfig({
|
||
form,
|
||
onChange,
|
||
dbDumpFile,
|
||
onDbDumpFileChange,
|
||
}: {
|
||
form: ManagedDatabaseFormState;
|
||
onChange: (patch: Partial<ManagedDatabaseFormState>) => void;
|
||
dbDumpFile: File | null;
|
||
onDbDumpFileChange: (file: File | null) => void;
|
||
}) {
|
||
const c = useT().components.dbConfig;
|
||
const dbDumpInputRef = useRef<HTMLInputElement>(null);
|
||
const [showDbPassword, setShowDbPassword] = useState(false);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
|
||
const minDbGiFromRestoreDump = dbDumpFile ? minGiToFitFileBytes(dbDumpFile.size) : 1;
|
||
const dbGi = parseInt(form.dbStorageSize || '1', 10) || 1;
|
||
|
||
const acceptDump = (f: File) => {
|
||
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
||
notify.error(c.allowedFormats);
|
||
return;
|
||
}
|
||
if (f.size > 500 * 1024 * 1024) {
|
||
notify.error(c.maxSize);
|
||
return;
|
||
}
|
||
onDbDumpFileChange(f);
|
||
const suggested = Math.max(minGiToFitFileBytes(f.size), Math.ceil((f.size / ONE_GIB) * 3));
|
||
const cur = parseInt(form.dbStorageSize || '1', 10) || 1;
|
||
onChange({ dbStorageSize: String(Math.max(cur, suggested)) });
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">{c.engine}</label>
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||
{DB_OPTIONS.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() =>
|
||
onChange({
|
||
databaseType: opt.value,
|
||
dbVersion: opt.versions[0],
|
||
})
|
||
}
|
||
className={`p-3 rounded-xl border-2 text-center transition-colors ${
|
||
form.databaseType === opt.value
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<Database
|
||
className={`w-6 h-6 mx-auto ${form.databaseType === opt.value ? 'text-primary-600' : 'text-gray-400'}`}
|
||
/>
|
||
<p className="mt-1 font-semibold text-sm text-gray-900">{opt.label}</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">{c.version}</label>
|
||
<Select
|
||
size="md"
|
||
className="max-w-xs"
|
||
ariaLabel={c.version}
|
||
value={form.dbVersion}
|
||
onChange={(v) => onChange({ dbVersion: v })}
|
||
options={versionOptions(form.databaseType).map((v) => ({
|
||
value: v,
|
||
label: versionLabel(form.databaseType, v),
|
||
}))}
|
||
/>
|
||
</div>
|
||
|
||
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<Database className="w-5 h-5 text-blue-500" />
|
||
<h3 className="text-sm font-semibold text-gray-800">{c.credentials}</h3>
|
||
<span className="text-xs text-gray-400">{c.credentialsOptional}</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">{c.username}</label>
|
||
<input
|
||
className="input-field"
|
||
placeholder={c.usernamePlaceholder}
|
||
value={form.dbUsername}
|
||
onChange={(e) => onChange({ dbUsername: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">{c.password}</label>
|
||
<div className="relative">
|
||
<input
|
||
className="input-field pr-20 rtl:pr-3 rtl:pl-20"
|
||
type={showDbPassword ? 'text' : 'password'}
|
||
placeholder={c.passwordPlaceholder}
|
||
value={form.dbPassword}
|
||
onChange={(e) => onChange({ dbPassword: e.target.value })}
|
||
/>
|
||
<div className="absolute inset-y-0 right-0 rtl:right-auto rtl:left-0 flex items-center gap-1 pr-2 rtl:pr-0 rtl:pl-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||
let pass = '';
|
||
for (let i = 0; i < 20; i++) pass += chars.charAt(Math.floor(Math.random() * chars.length));
|
||
onChange({ dbPassword: pass });
|
||
setShowDbPassword(true);
|
||
}}
|
||
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
|
||
title={c.generatePassword}
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowDbPassword(!showDbPassword)}
|
||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||
>
|
||
{showDbPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-400">{c.credentialsNote}</p>
|
||
|
||
<div className="pt-2">
|
||
<label className="block text-xs text-gray-500 mb-2">{c.uploadDumpLabel}</label>
|
||
<div
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
const f = e.dataTransfer.files[0];
|
||
if (f) acceptDump(f);
|
||
}}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setIsDragging(true);
|
||
}}
|
||
onDragLeave={() => setIsDragging(false)}
|
||
onClick={() => dbDumpInputRef.current?.click()}
|
||
className={`border-2 border-dashed rounded-xl p-3 text-center cursor-pointer transition-colors ${
|
||
dbDumpFile
|
||
? 'border-blue-400 bg-blue-50'
|
||
: isDragging
|
||
? 'border-blue-400 bg-blue-50'
|
||
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<input
|
||
ref={dbDumpInputRef}
|
||
type="file"
|
||
accept=".sql,.gz,.dump"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) acceptDump(f);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
{dbDumpFile ? (
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm text-left rtl:text-right">
|
||
<p className="font-medium text-gray-800">{dbDumpFile.name}</p>
|
||
<p className="text-xs text-gray-500">{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onDbDumpFileChange(null);
|
||
}}
|
||
className="text-sm text-red-500"
|
||
>
|
||
{c.remove}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<p className="text-sm text-gray-700">{c.uploadHint}</p>
|
||
<p className="text-xs text-gray-400">{c.uploadConstraints}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-2">
|
||
<label className="block text-xs text-gray-500 mb-2">{c.storageSize}</label>
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const current = parseInt(form.dbStorageSize || '1', 10);
|
||
if (current > minDbGiFromRestoreDump) {
|
||
onChange({ dbStorageSize: String(current - 1) });
|
||
}
|
||
}}
|
||
disabled={dbGi <= minDbGiFromRestoreDump}
|
||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
−
|
||
</button>
|
||
<input
|
||
type="number"
|
||
min={minDbGiFromRestoreDump}
|
||
max={100}
|
||
value={form.dbStorageSize || '1'}
|
||
onChange={(e) => {
|
||
const val = Math.max(
|
||
minDbGiFromRestoreDump,
|
||
Math.min(100, parseInt(e.target.value, 10) || minDbGiFromRestoreDump),
|
||
);
|
||
onChange({ dbStorageSize: String(val) });
|
||
}}
|
||
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const current = parseInt(form.dbStorageSize || '1', 10);
|
||
if (current < 100) onChange({ dbStorageSize: String(current + 1) });
|
||
}}
|
||
disabled={dbGi >= 100}
|
||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||
{dbDumpFile && (
|
||
<span className="text-xs text-blue-500">
|
||
{c.suggestedFromDump.replace('{gi}', (dbDumpFile.size / ONE_GIB).toFixed(2))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-1 text-xs text-gray-400">
|
||
{c.minimumGb.replace('{n}', String(minDbGiFromRestoreDump))}
|
||
{dbDumpFile ? c.mustFitDump : ''}{c.expansionOnly}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Validates that the chosen DB storage fits the uploaded dump. Pass the localized
|
||
* message template (components.dbConfig.storageTooSmallDetail) with {dumpGi}/{need}/{selected}.
|
||
*/
|
||
export function validateDbDumpStorage(
|
||
dbDumpFile: File | null,
|
||
dbStorageSizeGi: number,
|
||
detailTemplate: string,
|
||
): string | null {
|
||
if (!dbDumpFile) return null;
|
||
const need = minGiToFitFileBytes(dbDumpFile.size);
|
||
if (dbStorageSizeGi < need) {
|
||
return detailTemplate
|
||
.replace('{dumpGi}', (dbDumpFile.size / ONE_GIB).toFixed(2))
|
||
.replace('{need}', String(need))
|
||
.replace('{selected}', String(dbStorageSizeGi));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function RestoreStorageErrorModal({
|
||
open,
|
||
message,
|
||
onClose,
|
||
}: {
|
||
open: boolean;
|
||
message: string;
|
||
onClose: () => void;
|
||
}) {
|
||
const c = useT().components.dbConfig;
|
||
if (!open) return null;
|
||
return (
|
||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||
<div
|
||
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-modal-backdrop"
|
||
onClick={onClose}
|
||
role="presentation"
|
||
/>
|
||
<div className="relative bg-white rounded-2xl shadow-2xl max-w-md w-full animate-modal-enter">
|
||
<div className="p-6 pb-0">
|
||
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mb-4">
|
||
<AlertCircle className="w-6 h-6 text-red-600" />
|
||
</div>
|
||
<h3 className="text-lg font-bold text-gray-900 mb-2">{c.storageTooSmallTitle}</h3>
|
||
<p className="text-sm text-gray-600 leading-relaxed">{message}</p>
|
||
</div>
|
||
<div className="flex items-center justify-end gap-3 p-6">
|
||
<button type="button" onClick={onClose} className="btn-primary">
|
||
{c.ok}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|