Add managed databases and services with billing-aligned upgrades.
Introduce product types for managed PostgreSQL, Redis, and RabbitMQ with a dedicated dashboard, Helm-only deploy pipeline, external access, snapshots with progress, and prorated resource or storage upgrades matching application billing rules. PVCs use an expandable StorageClass with automatic migration when legacy disks cannot resize in place. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
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 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')) {
|
||||
toast.error('Allowed: .sql, .gz, .dump');
|
||||
return;
|
||||
}
|
||||
if (f.size > 500 * 1024 * 1024) {
|
||||
toast.error('Max 500MB');
|
||||
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">Database 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">Version</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
value={form.dbVersion}
|
||||
onChange={(e) => onChange({ dbVersion: e.target.value })}
|
||||
>
|
||||
{versionOptions(form.databaseType).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{versionLabel(form.databaseType, v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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">Database credentials</h3>
|
||||
<span className="text-xs text-gray-400">(optional — auto-generated if empty)</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">Username</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="appuser"
|
||||
value={form.dbUsername}
|
||||
onChange={(e) => onChange({ dbUsername: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="input-field pr-20"
|
||||
type={showDbPassword ? 'text' : 'password'}
|
||||
placeholder="Auto-generated"
|
||||
value={form.dbPassword}
|
||||
onChange={(e) => onChange({ dbPassword: e.target.value })}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-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="Generate random password"
|
||||
>
|
||||
<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">
|
||||
Used for internal cluster access. Use temporary or permanent external access below after deploy.
|
||||
</p>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="block text-xs text-gray-500 mb-2">Optional: upload DB dump to restore at creation</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">
|
||||
<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"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">Upload a SQL dump to be restored after the database is created</p>
|
||||
<p className="text-xs text-gray-400">Optional • Max 500MB • .sql, .gz, .dump</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="block text-xs text-gray-500 mb-2">Database storage size</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">
|
||||
Suggested from dump ({(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB min fit)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Minimum {minDbGiFromRestoreDump} GB
|
||||
{dbDumpFile ? ' (must fit the uploaded dump)' : ''} • Only expansion allowed after creation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function validateDbDumpStorage(
|
||||
dbDumpFile: File | null,
|
||||
dbStorageSizeGi: number,
|
||||
): string | null {
|
||||
if (!dbDumpFile) return null;
|
||||
const need = minGiToFitFileBytes(dbDumpFile.size);
|
||||
if (dbStorageSizeGi < need) {
|
||||
return `Your database dump is about ${(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB. Database storage must be at least ${need} GiB (you selected ${dbStorageSizeGi} GiB). Increase database storage, then try again.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RestoreStorageErrorModal({
|
||||
open,
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
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">Storage too small</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">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user