Files
cloud-host/frontend/src/components/managed-database-config.tsx
T
keyhan 91a66d5645 Restyle toasts and centralize friendly error handling.
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>
2026-06-13 11:45:26 +03:30

347 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}