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:
keyhan
2026-05-23 19:00:09 +03:30
parent 736509708b
commit 695e05f948
55 changed files with 5575 additions and 600 deletions
@@ -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>
);
}