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,314 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { AppSnapshot } from '@/types';
import { formatBytes } from '@/lib/format-utils';
import {
Camera,
ChevronDown,
Clock,
Database,
Download,
History,
Trash2,
XCircle,
} from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
const statusColors: Record<string, string> = {
running: 'badge-green',
pending: 'badge-yellow',
building: 'badge-blue',
deploying: 'badge-blue',
failed: 'badge-red',
build_failed: 'badge-red',
cancelled: 'badge-gray',
stopped: 'badge-gray',
};
function BackupProgressBar({ progress, label }: { progress: number; label?: string }) {
const pct = Math.min(100, Math.max(0, progress));
return (
<div className="mt-3 space-y-1.5">
<div className="flex justify-between text-xs text-blue-700">
<span>{label || 'Creating database dump…'}</span>
<span className="font-semibold tabular-nums">{pct}%</span>
</div>
<div className="w-full bg-blue-100 rounded-full h-2.5 overflow-hidden">
<div
className="h-2.5 rounded-full bg-blue-600 transition-all duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
export function DatabaseSnapshotsPanel({
serviceId,
isDeployed,
}: {
serviceId: string;
isDeployed: boolean;
}) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [showPanel, setShowPanel] = useState(false);
const prevInProgressRef = useRef<Set<string>>(new Set());
const { data: snapshots = [], isLoading } = useQuery<AppSnapshot[]>({
queryKey: ['snapshots', serviceId],
queryFn: () => api.get(`/snapshots/applications/${serviceId}`).then((r) => r.data),
enabled: showPanel,
refetchInterval: (query) => {
if (!showPanel) return false;
const list = query.state.data;
const hasInProgress = list?.some((s) => s.status === 'in_progress');
return hasInProgress ? 2000 : 10000;
},
});
const hasInProgress = snapshots.some((s) => s.status === 'in_progress');
useEffect(() => {
const inProgressIds = new Set(snapshots.filter((s) => s.status === 'in_progress').map((s) => s.id));
for (const snap of snapshots) {
if (
prevInProgressRef.current.has(snap.id) &&
!inProgressIds.has(snap.id) &&
snap.status === 'completed' &&
snap.dbDumpPath
) {
toast.success('Backup ready — you can download the dump now');
}
if (prevInProgressRef.current.has(snap.id) && snap.status === 'failed') {
toast.error(snap.errorMessage || 'Backup failed');
}
}
prevInProgressRef.current = inProgressIds;
}, [snapshots]);
const createMutation = useMutation({
mutationFn: () =>
api.post(
`/snapshots/applications/${serviceId}?label=${encodeURIComponent('Database backup')}`,
),
onSuccess: () => {
setShowPanel(true);
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
toast.info('Backup started — dump in progress');
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data
?.message;
const text = Array.isArray(msg) ? msg.join(', ') : msg;
toast.error(text || 'Failed to create backup');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/snapshots/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
toast.success('Backup deleted');
},
onError: () => toast.error('Failed to delete backup'),
});
const downloadSnapshotDb = (snapshotId: string) => {
const url = `${api.defaults.baseURL}/snapshots/${snapshotId}/download/database`;
const token = localStorage.getItem('accessToken');
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => {
if (!r.ok) throw new Error('download failed');
return r.blob();
})
.then((blob) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`;
link.click();
URL.revokeObjectURL(link.href);
toast.success('Download started');
})
.catch(() => toast.error('Failed to download database dump'));
};
const handleDelete = async (snap: AppSnapshot) => {
const ok = await confirm({
title: 'Delete backup?',
message: `Delete "${snap.label || 'backup'}"? The dump file will be permanently removed.`,
confirmText: 'Delete',
variant: 'danger',
});
if (ok) deleteMutation.mutate(snap.id);
};
const handleNewBackup = () => {
setShowPanel(true);
createMutation.mutate();
};
return (
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<History className="w-5 h-5" /> Snapshots
</h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleNewBackup}
disabled={!isDeployed || createMutation.isPending || hasInProgress}
className="btn-secondary text-sm disabled:opacity-50"
title={
!isDeployed
? 'Deploy the service first'
: hasInProgress
? 'Wait for the current backup to finish'
: undefined
}
>
{createMutation.isPending || hasInProgress ? (
<>
<Clock className="w-3 h-3 inline animate-spin" /> Creating
</>
) : (
<>
<Camera className="w-3 h-3 inline" /> New Snapshot
</>
)}
</button>
<button type="button" onClick={() => setShowPanel(!showPanel)} className="btn-secondary text-sm">
{showPanel ? (
<>
<ChevronDown className="w-4 h-4 inline" /> Hide
</>
) : (
<>
<History className="w-4 h-4 inline" /> Show
</>
)}
</button>
</div>
</div>
{showPanel && (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
<p className="text-xs text-blue-700">
<Camera className="w-3 h-3 inline" /> <strong>Database snapshots</strong> store a SQL dump you can
download later. When progress reaches 100%, use the download button. Up to 10 snapshots are kept;
oldest are removed automatically.
</p>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">Loading backups</div>
) : snapshots.length === 0 ? (
<div className="text-center py-8">
<Camera className="w-8 h-8 mx-auto text-gray-300 mb-2" />
<p className="text-gray-500 text-sm">No backups yet</p>
<p className="text-gray-400 text-xs mt-1">Click New backup to create your first database dump.</p>
</div>
) : (
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{snapshots.map((snap) => (
<div
key={snap.id}
className={`border rounded-xl p-4 ${
snap.status === 'completed'
? 'border-gray-200 bg-white'
: snap.status === 'in_progress'
? 'border-blue-200 bg-blue-50'
: 'border-red-200 bg-red-50'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium text-gray-900 truncate">{snap.label || 'Database backup'}</p>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
snap.type === 'pre_deploy' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
}`}
>
{snap.type === 'pre_deploy' ? 'Auto' : 'Manual'}
</span>
<span className={`badge ${statusColors[snap.status] || 'badge-gray'} text-xs`}>
{snap.status === 'in_progress' ? 'Dumping…' : snap.status}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">{new Date(snap.createdAt).toLocaleString()}</p>
{snap.status === 'in_progress' && (
<BackupProgressBar
progress={snap.progress ?? 0}
label={
(snap.progress ?? 0) < 15
? 'Preparing dump…'
: (snap.progress ?? 0) < 90
? 'Exporting database…'
: 'Finalizing…'
}
/>
)}
{snap.status === 'completed' && snap.dbDumpPath && (
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
<Database className="w-3 h-3" /> Dump: {formatBytes(snap.dbDumpSize)}
</p>
)}
{snap.errorMessage && (
<p className="text-xs text-red-500 mt-1 truncate" title={snap.errorMessage}>
<XCircle className="w-3 h-3 inline" /> {snap.errorMessage}
</p>
)}
</div>
{snap.status === 'completed' && snap.dbDumpPath && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => downloadSnapshotDb(snap.id)}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Download database dump"
>
<Download className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleDelete(snap)}
disabled={deleteMutation.isPending}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg disabled:opacity-50"
title="Delete backup"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
{snap.status === 'failed' && (
<button
type="button"
onClick={() => handleDelete(snap)}
disabled={deleteMutation.isPending}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg shrink-0"
title="Remove failed backup"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 text-center">Maximum 10 backups are kept.</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,81 @@
'use client';
import { Database } from 'lucide-react';
export interface DatabaseWorkloadValues {
cpuRequest: string;
cpuLimit: string;
memoryRequest: string;
memoryLimit: string;
}
export function DatabaseWorkloadResources({
values,
onChange,
}: {
values: DatabaseWorkloadValues;
onChange: (patch: Partial<DatabaseWorkloadValues>) => void;
}) {
return (
<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 resources</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">CPU request</label>
<select
className="input-field text-sm"
value={values.cpuRequest}
onChange={(e) => onChange({ cpuRequest: e.target.value })}
>
<option value="50m">50m</option>
<option value="100m">100m</option>
<option value="250m">250m</option>
<option value="500m">500m</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">CPU limit</label>
<select
className="input-field text-sm"
value={values.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })}
>
<option value="250m">250m</option>
<option value="500m">500m</option>
<option value="1">1 core</option>
<option value="2">2 cores</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Memory request</label>
<select
className="input-field text-sm"
value={values.memoryRequest}
onChange={(e) => onChange({ memoryRequest: e.target.value })}
>
<option value="64Mi">64 Mi</option>
<option value="128Mi">128 Mi</option>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Memory limit</label>
<select
className="input-field text-sm"
value={values.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })}
>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
</div>
</div>
</div>
);
}
@@ -8,6 +8,7 @@ import type { Application } from '@/types';
import { useAuthStore } from '@/lib/store';
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
import { getAppsInProgress, isActiveBuildProgress } from '@/lib/deployment-progress';
import { filterApplications, filterManagedServices } from '@/lib/product-type';
import { BuildProgressModal, type BuildProgress } from '@/components/build-progress-modal';
import { DeploymentProgressBar } from '@/components/deployment-progress-bar';
@@ -16,17 +17,29 @@ export function DeploymentProgressManager() {
const pathname = usePathname();
const { minimized, focusedAppId, minimize, expand } = useDeployProgressStore();
const { data: apps = [] } = useQuery<Application[]>({
queryKey: ['applications'],
queryFn: () => api.get('/applications').then((r) => r.data),
const refetchWhileDeploying = (list: Application[] | undefined) =>
getAppsInProgress(list ?? []).length > 0 ? 3000 : false;
const { data: appsList = [] } = useQuery<Application[]>({
queryKey: ['applications', 'application'],
queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data),
enabled: isAuthenticated,
refetchInterval: (query) => {
const list = query.state.data ?? [];
return getAppsInProgress(list).length > 0 ? 3000 : false;
},
refetchInterval: (query) => refetchWhileDeploying(query.state.data),
});
const deployingApps = useMemo(() => getAppsInProgress(apps), [apps]);
const { data: servicesList = [] } = useQuery<Application[]>({
queryKey: ['applications', 'managed'],
queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data),
enabled: isAuthenticated,
refetchInterval: (query) => refetchWhileDeploying(query.state.data),
});
const allResources = useMemo(
() => [...filterApplications(appsList), ...filterManagedServices(servicesList)],
[appsList, servicesList],
);
const deployingApps = useMemo(() => getAppsInProgress(allResources), [allResources]);
const progressQueries = useQueries({
queries: deployingApps.map((app) => ({
@@ -48,7 +61,9 @@ export function DeploymentProgressManager() {
.filter(({ progress }) => isActiveBuildProgress(progress));
}, [deployingApps, progressQueries]);
const routeAppId = pathname.match(/\/dashboard\/apps\/([^/]+)/)?.[1];
const routeAppId =
pathname.match(/\/dashboard\/apps\/([^/]+)/)?.[1] ??
pathname.match(/\/dashboard\/services\/([^/]+)/)?.[1];
useEffect(() => {
if (activeItems.length === 0) {
@@ -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>
);
}
@@ -0,0 +1,707 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, Invoice, OptionalServiceResourcesMap, ResourceUsage } from '@/types';
import {
ResourceUpgradeConfirmModal,
type UpgradeCostSummary,
} from '@/components/resource-upgrade-confirm-modal';
import { optionalDefaultsFromCatalog } from '@/lib/optional-service-defaults';
import { parseCpuToMillicores, parseMemoryToMi } from '@/lib/format-utils';
import { DatabaseWorkloadResources } from '@/components/database-workload-resources';
import { OptionalServiceResourceFields } from '@/components/optional-service-resource-fields';
import {
BarChart3,
CheckCircle,
ChevronDown,
Clock,
Database,
RefreshCw,
Scale,
Settings,
} from 'lucide-react';
type AppWithOptional = Application & { optionalServiceResources?: OptionalServiceResourcesMap };
interface StorageUsageSlice {
allocatedRaw: string;
allocatedGi: number;
usedGi: number;
availableGi: number;
usedPercent: number;
}
interface StorageUsageData {
database: StorageUsageSlice | null;
redisStorage?: StorageUsageSlice | null;
rabbitmqStorage?: StorageUsageSlice | null;
}
function workloadKey(app: Application): 'database' | 'redis' | 'rabbitmq' {
if (app.productType === 'managed_redis') return 'redis';
if (app.productType === 'managed_rabbitmq') return 'rabbitmq';
return 'database';
}
function workloadTitle(app: Application): string {
if (app.productType === 'managed_redis') return 'Redis';
if (app.productType === 'managed_rabbitmq') return 'RabbitMQ';
return 'Database';
}
type UpgradePayload = {
cpuRequest?: string;
cpuLimit?: string;
memoryRequest?: string;
memoryLimit?: string;
dbStorageSize?: string;
redisResources?: {
cpuRequest?: string;
cpuLimit: string;
memoryRequest?: string;
memoryLimit: string;
storageGi: number;
};
rabbitmqResources?: {
cpuRequest?: string;
cpuLimit: string;
memoryRequest?: string;
memoryLimit: string;
storageGi: number;
};
};
export function ManagedServiceResourcesPanel({
serviceId,
app,
isDeployed,
isStopped,
needsRenewal = false,
}: {
serviceId: string;
app: AppWithOptional;
isDeployed: boolean;
isStopped: boolean;
needsRenewal?: boolean;
}) {
const router = useRouter();
const queryClient = useQueryClient();
const dbFileInputRef = useRef<HTMLInputElement>(null);
const [showResources, setShowResources] = useState(false);
const [dbStorageSize, setDbStorageSize] = useState('1');
const [isDraggingDb, setIsDraggingDb] = useState(false);
const [dbRestoreLogs, setDbRestoreLogs] = useState<string | null>(null);
const [dbResources, setDbResources] = useState({
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '128Mi',
memoryLimit: '512Mi',
});
const [redisResources, setRedisResources] = useState(optionalDefaultsFromCatalog(undefined, 'redis'));
const [rabbitResources, setRabbitResources] = useState(optionalDefaultsFromCatalog(undefined, 'rabbitmq'));
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false);
const [upgradeCostData, setUpgradeCostData] = useState<UpgradeCostSummary | null>(null);
const [pendingUpgradePayload, setPendingUpgradePayload] = useState<UpgradePayload | null>(null);
const isDatabase = app.productType === 'managed_database';
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet'],
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
const { data: dbStorageData } = useQuery<{ currentSize: string }>({
queryKey: ['db-storage', serviceId],
queryFn: () => api.get(`/applications/${serviceId}/db-storage`).then((r) => r.data),
enabled: isDatabase && app.databaseType !== 'none',
});
const { data: resourceUsage, isLoading: resourcesLoading } = useQuery<ResourceUsage>({
queryKey: ['resources', serviceId],
queryFn: () => api.get(`/applications/${serviceId}/resources`).then((r) => r.data),
enabled: showResources && isDeployed,
refetchInterval: showResources && isDeployed ? 5000 : false,
});
const { data: storageUsage, isLoading: storageUsageLoading } = useQuery<StorageUsageData>({
queryKey: ['storage-usage', serviceId],
queryFn: () => api.get(`/applications/${serviceId}/storage`).then((r) => r.data),
enabled: showResources && isDeployed,
refetchInterval: showResources && isDeployed ? 15000 : false,
});
useEffect(() => {
if (dbStorageData?.currentSize) {
const n = parseInt(dbStorageData.currentSize.replace('Gi', ''), 10) || 1;
setDbStorageSize(String(n));
}
}, [dbStorageData]);
useEffect(() => {
setDbResources({
cpuRequest: app.cpuRequest || '100m',
cpuLimit: app.cpuLimit || '500m',
memoryRequest: app.memoryRequest || '128Mi',
memoryLimit: app.memoryLimit || '512Mi',
});
if (app.optionalServiceResources?.redis) setRedisResources(app.optionalServiceResources.redis);
if (app.optionalServiceResources?.rabbitmq) setRabbitResources(app.optionalServiceResources.rabbitmq);
}, [app]);
const directPatchResourcesMutation = useMutation({
mutationFn: (data: {
workload: 'database' | 'redis' | 'rabbitmq';
cpuRequest?: string;
cpuLimit?: string;
memoryRequest?: string;
memoryLimit?: string;
}) => api.patch(`/applications/${serviceId}/resources`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
toast.success('Resources updated');
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to update resources');
},
});
const scaleMutation = useMutation({
mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${serviceId}/upgrade`, data),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
setPendingUpgradePayload(null);
const paidAmount = res.data.paidAmount || 0;
if (paidAmount > 0) {
toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`);
} else {
toast.success('Resources updated successfully');
}
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to upgrade resources');
},
});
const calculateUpgradeCostMutation = useMutation({
mutationFn: (data: UpgradePayload) =>
api.post(`/billing/applications/${serviceId}/upgrade/calculate`, data),
onSuccess: (res) => {
setUpgradeCostData(res.data);
setShowUpgradeConfirm(true);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to calculate upgrade cost');
},
});
const createUpgradeInvoiceMutation = useMutation({
mutationFn: (data: UpgradePayload) =>
api.post<Invoice>(`/billing/applications/${serviceId}/upgrade/invoice`, data).then((r) => r.data),
onSuccess: (invoice) => {
toast.success('Invoice created. Choose how you want to pay.');
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
setPendingUpgradePayload(null);
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to create upgrade invoice');
},
});
const resizeDbMutation = useMutation({
mutationFn: (size: string) => api.patch(`/applications/${serviceId}/db-storage`, { size }),
onSuccess: (res) => {
if (res.data.success) {
toast.success(res.data.message || 'Storage expanded');
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
} else {
toast.error(res.data.message || 'Failed to expand storage');
}
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to resize storage');
},
});
const dbUploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post(`/applications/${serviceId}/db-upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
onSuccess: (res) => {
const data = res.data;
setDbRestoreLogs(data.logs || null);
if (data.success) toast.success('Database restored successfully');
else toast.error(data.message || 'Database restore failed');
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to upload database dump');
setDbRestoreLogs(null);
},
});
const handleDbFileUpload = useCallback(
(file: File) => {
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) {
toast.error('Please upload a .sql, .dump, or .gz file');
return;
}
if (file.size > 500 * 1024 * 1024) {
toast.error('File size must be less than 500MB');
return;
}
setDbRestoreLogs(null);
dbUploadMutation.mutate(file);
},
[dbUploadMutation],
);
const buildUpgradePayload = useCallback((): UpgradePayload => {
if (app.productType === 'managed_database') {
return { ...dbResources };
}
if (app.productType === 'managed_redis') {
return {
redisResources: {
cpuRequest: redisResources.cpuRequest,
cpuLimit: redisResources.cpuLimit,
memoryRequest: redisResources.memoryRequest,
memoryLimit: redisResources.memoryLimit,
storageGi: redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
},
};
}
return {
rabbitmqResources: {
cpuRequest: rabbitResources.cpuRequest,
cpuLimit: rabbitResources.cpuLimit,
memoryRequest: rabbitResources.memoryRequest,
memoryLimit: rabbitResources.memoryLimit,
storageGi: rabbitResources.storageGi ?? app.optionalServiceResources?.rabbitmq?.storageGi ?? 2,
},
};
}, [app, dbResources, redisResources, rabbitResources]);
const applyResources = () => {
if (needsRenewal) {
toast.warn('Renew the service before changing resources');
return;
}
const payload = buildUpgradePayload();
const w = workloadKey(app);
if (!app.billingCycle) {
if (w === 'database') {
directPatchResourcesMutation.mutate({ workload: 'database', ...dbResources });
} else if (w === 'redis') {
directPatchResourcesMutation.mutate({
workload: 'redis',
cpuRequest: redisResources.cpuRequest,
cpuLimit: redisResources.cpuLimit,
memoryRequest: redisResources.memoryRequest,
memoryLimit: redisResources.memoryLimit,
});
} else {
directPatchResourcesMutation.mutate({
workload: 'rabbitmq',
cpuRequest: rabbitResources.cpuRequest,
cpuLimit: rabbitResources.cpuLimit,
memoryRequest: rabbitResources.memoryRequest,
memoryLimit: rabbitResources.memoryLimit,
});
}
return;
}
setPendingUpgradePayload(payload);
calculateUpgradeCostMutation.mutate(payload);
};
const confirmUpgrade = () => {
if (!pendingUpgradePayload || !upgradeCostData) return;
if (upgradeCostData.proratedAmount > 0) {
createUpgradeInvoiceMutation.mutate(pendingUpgradePayload);
} else {
scaleMutation.mutate(pendingUpgradePayload);
}
};
const handleExpandStorage = () => {
if (needsRenewal) {
toast.warn('Renew the service before expanding storage');
return;
}
const newGi = parseInt(dbStorageSize, 10);
if (newGi <= currentDbGi) {
toast.warn('New size must be larger than current allocation');
return;
}
const newSize = `${newGi}Gi`;
const payload: UpgradePayload = { dbStorageSize: newSize };
if (!app.billingCycle) {
resizeDbMutation.mutate(newSize);
return;
}
setPendingUpgradePayload(payload);
calculateUpgradeCostMutation.mutate(payload);
};
const resourcesPending =
directPatchResourcesMutation.isPending ||
scaleMutation.isPending ||
calculateUpgradeCostMutation.isPending ||
createUpgradeInvoiceMutation.isPending;
const currentDbGi =
parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1;
const metricsWorkload =
resourceUsage?.workloads?.find((w) => w.key === workloadKey(app)) ||
(resourceUsage?.configured
? {
key: workloadKey(app),
title: workloadTitle(app),
deploymentName: `${app.name}-${workloadKey(app) === 'database' ? 'db' : workloadKey(app)}`,
configured: resourceUsage.configured,
pods: resourceUsage.pods,
metrics: resourceUsage.metrics,
}
: null);
const storageSlice =
isDatabase
? storageUsage?.database
: app.productType === 'managed_redis'
? storageUsage?.redisStorage
: storageUsage?.rabbitmqStorage;
return (
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<BarChart3 className="w-5 h-5" /> Resources &amp; Scaling
</h2>
<button type="button" onClick={() => setShowResources(!showResources)} className="btn-secondary text-sm">
{showResources ? (
<>
<ChevronDown className="w-4 h-4 inline" /> Hide
</>
) : (
<>
<BarChart3 className="w-4 h-4 inline" /> Monitor
</>
)}
</button>
</div>
{showResources && (
<div className="space-y-6">
{!isDeployed ? (
<p className="text-sm text-gray-500 text-center py-6">Deploy the service to view metrics and adjust resources.</p>
) : resourcesLoading ? (
<div className="text-center py-6 text-gray-400 text-sm">Loading metrics</div>
) : metricsWorkload ? (
<div className="border border-gray-200 rounded-xl p-4 space-y-4 bg-slate-50/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">{metricsWorkload.title}</h3>
<span className="text-[11px] text-gray-400 font-mono">{metricsWorkload.deploymentName}</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-blue-50 rounded-lg p-3">
<p className="text-[10px] text-blue-600 font-medium">Replicas</p>
<p className="text-lg font-bold text-blue-800">
{metricsWorkload.configured.readyReplicas}/{metricsWorkload.configured.replicas}
</p>
</div>
<div className="bg-green-50 rounded-lg p-3">
<p className="text-[10px] text-green-600 font-medium">Pods</p>
<p className="text-lg font-bold text-green-800">{metricsWorkload.pods.length}</p>
</div>
<div className="bg-purple-50 rounded-lg p-3">
<p className="text-[10px] text-purple-600 font-medium">Metrics</p>
<p className="text-lg font-bold text-purple-800 flex justify-center">
{metricsWorkload.metrics.length > 0 ? (
<CheckCircle className="w-5 h-5 text-purple-700" />
) : (
<Clock className="w-5 h-5 text-purple-400" />
)}
</p>
</div>
</div>
{metricsWorkload.metrics.length > 0 && (
<div className="space-y-2">
{metricsWorkload.metrics.map((metric) => {
const cpuUsed = parseCpuToMillicores(metric.cpu);
const cpuLimit = parseCpuToMillicores(metricsWorkload.configured.cpuLimit);
const cpuPercent = cpuLimit > 0 ? Math.min((cpuUsed / cpuLimit) * 100, 100) : 0;
const memUsed = parseMemoryToMi(metric.memory);
const memLimit = parseMemoryToMi(metricsWorkload.configured.memoryLimit);
const memPercent = memLimit > 0 ? Math.min((memUsed / memLimit) * 100, 100) : 0;
return (
<div key={metric.name} className="bg-white rounded-lg p-3 border border-gray-100 text-xs">
<p className="font-mono text-gray-600 truncate mb-2">{metric.name}</p>
<div className="space-y-1">
<div className="flex justify-between text-gray-500">
<span>CPU</span>
<span>
{cpuPercent.toFixed(0)}% ({cpuUsed.toFixed(0)}m / {cpuLimit.toFixed(0)}m)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${cpuPercent > 80 ? 'bg-red-500' : 'bg-green-500'}`}
style={{ width: `${cpuPercent}%` }}
/>
</div>
<div className="flex justify-between text-gray-500">
<span>Memory</span>
<span>
{memPercent.toFixed(0)}% ({memUsed.toFixed(0)} / {memLimit.toFixed(0)} Mi)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${memPercent > 80 ? 'bg-red-500' : 'bg-green-500'}`}
style={{ width: `${memPercent}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
) : (
<p className="text-sm text-gray-400 text-center py-4">
{isStopped ? 'Service is stopped.' : 'No resource metrics yet.'}
</p>
)}
<div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
<Database className="w-4 h-4" /> Storage
</h3>
{storageUsageLoading ? (
<p className="text-sm text-gray-400">Loading storage</p>
) : storageSlice ? (
<div className="bg-gray-50 rounded-xl p-4 space-y-3">
<div className="flex justify-between text-xs text-gray-600">
<span>Used {storageSlice.usedGi.toFixed(2)} GiB</span>
<span>Allocated {storageSlice.allocatedGi.toFixed(1)} GiB</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full ${storageSlice.usedPercent > 80 ? 'bg-red-500' : storageSlice.usedPercent > 50 ? 'bg-yellow-500' : 'bg-blue-500'}`}
style={{ width: `${Math.min(storageSlice.usedPercent, 100)}%` }}
/>
</div>
{isDatabase && (
<div className="flex flex-wrap items-center gap-3 pt-2 border-t border-gray-200">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
<button
type="button"
onClick={() => {
const c = parseInt(dbStorageSize, 10);
if (c > currentDbGi + 1) setDbStorageSize(String(c - 1));
}}
className="px-3 py-1.5 bg-gray-100 font-bold text-sm"
>
</button>
<input
type="number"
min={1}
max={100}
value={dbStorageSize}
onChange={(e) =>
setDbStorageSize(String(Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1))))
}
className="w-14 text-center py-1.5 border-x border-gray-300 text-sm font-semibold"
/>
<button
type="button"
onClick={() => {
const c = parseInt(dbStorageSize, 10);
if (c < 100) setDbStorageSize(String(c + 1));
}}
className="px-3 py-1.5 bg-gray-100 font-bold text-sm"
>
+
</button>
</div>
<span className="text-sm text-gray-600">GB</span>
<button
type="button"
className="btn-primary text-xs px-3 py-1.5 disabled:opacity-50"
disabled={
resourcesPending ||
!isDeployed ||
parseInt(dbStorageSize, 10) <= currentDbGi
}
onClick={handleExpandStorage}
>
{resizeDbMutation.isPending || calculateUpgradeCostMutation.isPending
? 'Expanding…'
: 'Expand'}
</button>
<p className="text-xs text-gray-400 w-full">
Only expansion is allowed.
{app.billingCycle
? ' Additional storage is charged for the remaining billing period.'
: ''}
</p>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-400">Storage metrics unavailable.</p>
)}
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
<Settings className="w-4 h-4" /> Adjust CPU / memory
</h3>
{app.productType === 'managed_database' && (
<DatabaseWorkloadResources values={dbResources} onChange={(p) => setDbResources((v) => ({ ...v, ...p }))} />
)}
{app.productType === 'managed_redis' && (
<OptionalServiceResourceFields
title="Redis resources"
accentClass="text-red-500"
borderClass="border-red-200"
bgClass="bg-red-50/30"
config={redisResources}
onChange={(p) => setRedisResources((c) => ({ ...c, ...p }))}
/>
)}
{app.productType === 'managed_rabbitmq' && (
<OptionalServiceResourceFields
title="RabbitMQ resources"
accentClass="text-orange-500"
borderClass="border-orange-200"
bgClass="bg-orange-50/30"
config={rabbitResources}
onChange={(p) => setRabbitResources((c) => ({ ...c, ...p }))}
/>
)}
{app.billingCycle && (
<p className="text-xs text-gray-500 mt-3">
Upgrades that increase cost are charged for the remaining billing period (wallet or invoice), same as
applications.
</p>
)}
<button
type="button"
className="btn-primary text-sm mt-4 disabled:opacity-50"
disabled={resourcesPending || needsRenewal || !isDeployed}
onClick={applyResources}
>
{resourcesPending ? (
<>
<Clock className="w-3 h-3 inline animate-spin" /> Applying
</>
) : (
<>
<RefreshCw className="w-3 h-3 inline" /> Apply changes
</>
)}
</button>
</div>
{isDatabase && (
<div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
<Scale className="w-4 h-4" /> Restore database dump
</h3>
<div
onDrop={(e) => {
e.preventDefault();
setIsDraggingDb(false);
const file = e.dataTransfer.files[0];
if (file) handleDbFileUpload(file);
}}
onDragOver={(e) => {
e.preventDefault();
setIsDraggingDb(true);
}}
onDragLeave={() => setIsDraggingDb(false)}
onClick={() => dbFileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${
isDraggingDb ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
} ${dbUploadMutation.isPending ? 'pointer-events-none opacity-60' : ''}`}
>
<input
ref={dbFileInputRef}
type="file"
accept=".sql,.gz,.dump"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleDbFileUpload(file);
e.target.value = '';
}}
/>
{dbUploadMutation.isPending ? (
<p className="text-sm text-gray-700">Restoring database</p>
) : (
<>
<Database className="w-8 h-8 mx-auto text-gray-400 mb-2" />
<p className="text-sm font-medium text-gray-700">Upload SQL dump to restore</p>
<p className="text-xs text-gray-500 mt-1">.sql, .gz, or .dump max 500MB</p>
</>
)}
</div>
{dbRestoreLogs && (
<pre className="mt-3 bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono max-h-[240px] overflow-auto whitespace-pre-wrap">
{dbRestoreLogs}
</pre>
)}
</div>
)}
</div>
)}
<ResourceUpgradeConfirmModal
open={showUpgradeConfirm}
upgradeCostData={upgradeCostData}
walletBalance={walletData?.balance}
isPending={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
onCancel={() => {
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
setPendingUpgradePayload(null);
}}
onConfirm={confirmUpgrade}
/>
</div>
);
}
@@ -0,0 +1,95 @@
'use client';
import { Server } from 'lucide-react';
import type { OptionalServiceResourceConfig } from '@/types';
export function OptionalServiceResourceFields({
title,
accentClass,
borderClass,
bgClass,
config,
readOnly,
onChange,
}: {
title: string;
accentClass: string;
borderClass: string;
bgClass: string;
config: OptionalServiceResourceConfig;
readOnly?: boolean;
onChange: (patch: Partial<OptionalServiceResourceConfig>) => void;
}) {
const storageStr = String(config.storageGi);
const setStorage = (gb: number) => onChange({ storageGi: Math.max(0, gb) });
return (
<div className={`rounded-xl p-5 border-2 ${borderClass} ${bgClass}`}>
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Server className={`w-5 h-5 ${accentClass}`} />
{title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">CPU limit</label>
<select
className="input-field text-sm"
disabled={readOnly}
value={config.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })}
>
<option value="200m">200m</option>
<option value="500m">500m</option>
<option value="1">1 core</option>
<option value="2">2 cores</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Memory limit</label>
<select
className="input-field text-sm"
disabled={readOnly}
value={config.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })}
>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="block text-xs text-gray-500 mb-1">Storage (GB)</label>
<div className="flex items-center gap-2">
<button
type="button"
disabled={readOnly}
onClick={() => setStorage(parseInt(storageStr, 10) - 1)}
className="px-3 py-1.5 bg-gray-100 rounded-lg font-bold text-sm disabled:opacity-30"
>
</button>
<input
type="number"
min={0}
max={100}
disabled={readOnly}
value={storageStr}
onChange={(e) => setStorage(parseInt(e.target.value, 10) || 0)}
className="w-16 text-center input-field py-1.5"
/>
<button
type="button"
disabled={readOnly}
onClick={() => setStorage(parseInt(storageStr, 10) + 1)}
className="px-3 py-1.5 bg-gray-100 rounded-lg font-bold text-sm disabled:opacity-30"
>
+
</button>
<span className="text-sm text-gray-600">GB</span>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,121 @@
'use client';
import { AlertTriangle, CheckCircle, Clock, CreditCard, Wallet } from 'lucide-react';
export type UpgradeCostSummary = {
proratedAmount: number;
remainingHours: number;
currentCost: { hourly: number; monthly: number; yearly: number };
newCost: { hourly: number; monthly: number; yearly: number };
};
export function ResourceUpgradeConfirmModal({
open,
upgradeCostData,
walletBalance,
isPending,
onCancel,
onConfirm,
}: {
open: boolean;
upgradeCostData: UpgradeCostSummary | null;
walletBalance?: number;
isPending: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
if (!open || !upgradeCostData) return null;
const needsPay = upgradeCostData.proratedAmount > 0;
const walletShort =
needsPay && walletBalance != null && upgradeCostData.proratedAmount > walletBalance;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirm Resource Upgrade</h2>
<p className="text-sm text-gray-500 mb-6">
{needsPay
? 'This upgrade requires payment for the remaining billing period.'
: 'No additional cost for this change.'}
</p>
<div className="bg-gray-50 rounded-xl p-4 mb-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Current hourly cost</span>
<span className="text-sm font-medium text-gray-900">
{upgradeCostData.currentCost.hourly.toLocaleString()} Toman/hour
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">New hourly cost</span>
<span className="text-sm font-medium text-gray-900">
{upgradeCostData.newCost.hourly.toLocaleString()} Toman/hour
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Remaining hours in period</span>
<span className="text-sm font-medium text-gray-900">{upgradeCostData.remainingHours} hours</span>
</div>
<div className="border-t pt-3 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Prorated amount to pay</span>
<span className="text-lg font-bold text-primary-600">
{upgradeCostData.proratedAmount.toLocaleString()} Toman
</span>
</div>
</div>
<div className="bg-blue-50 rounded-xl p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Wallet className="w-5 h-5 text-blue-500" />
<span className="text-sm text-blue-700">Wallet Balance</span>
</div>
<span className="text-lg font-bold text-blue-900">
{walletBalance?.toLocaleString() ?? 0} Toman
</span>
</div>
{walletShort && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<p className="text-sm text-amber-700">
<AlertTriangle className="w-4 h-4 inline mr-1" />
Wallet is short by{' '}
{(upgradeCostData.proratedAmount - (walletBalance ?? 0)).toLocaleString()} Toman. You can pay the
delta by gateway on the invoice page.
</p>
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isPending}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isPending ? (
<>
<Clock className="w-4 h-4 animate-spin" /> Applying
</>
) : needsPay ? (
<>
<CreditCard className="w-4 h-4" /> Create Invoice & Pay
</>
) : (
<>
<CheckCircle className="w-4 h-4" /> Apply Changes
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,290 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
import { ExternalLink, ShieldAlert, Copy, Check, Eye, EyeOff } from 'lucide-react';
export function ServiceExternalAccessPanel({
appId,
app,
}: {
appId: string;
app: Application;
}) {
const queryClient = useQueryClient();
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
const [accessDuration, setAccessDuration] = useState(60);
const [accessPersistent, setAccessPersistent] = useState(false);
const [showAccessSecret, setShowAccessSecret] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [accessNow, setAccessNow] = useState(() => Date.now());
const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = [];
const hasDb =
(app.databaseType && app.databaseType !== 'none') || app.productType === 'managed_database';
if (hasDb) accessTargetOptions.push({ value: 'database', label: 'Database' });
if (app.enableRedis || app.productType === 'managed_redis') {
accessTargetOptions.push({ value: 'redis', label: 'Redis' });
}
if (app.enableRabbitmq || app.productType === 'managed_rabbitmq') {
accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' });
accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' });
}
const hasAccessTargets = accessTargetOptions.length > 0;
useEffect(() => {
if (!hasAccessTargets) return;
if (!accessTargetOptions.some((o) => o.value === accessTarget)) {
setAccessTarget(accessTargetOptions[0].value);
}
}, [app.databaseType, app.enableRedis, app.enableRabbitmq, app.productType]);
const { data: accessGrants = [], refetch: refetchAccessGrants } = useQuery<ServiceAccessGrant[]>({
queryKey: ['access-grants', appId],
queryFn: () => api.get(`/applications/${appId}/access`).then((r) => r.data),
enabled: hasAccessTargets && !!app.latestImageTag,
refetchInterval: 30000,
});
useEffect(() => {
if (!accessGrants.some((g) => g.status === 'active' && !g.persistent)) return;
const t = setInterval(() => setAccessNow(Date.now()), 1000);
return () => clearInterval(t);
}, [accessGrants]);
const createAccessMutation = useMutation({
mutationFn: () =>
api
.post(`/applications/${appId}/access`, {
target: accessTarget,
persistent: accessPersistent,
...(accessPersistent ? {} : { durationMinutes: accessDuration }),
})
.then((r) => r.data),
onSuccess: () => {
refetchAccessGrants();
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
toast.success(accessPersistent ? 'Permanent external access enabled' : 'Temporary external access enabled');
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to enable access');
},
});
const revokeAccessMutation = useMutation({
mutationFn: (grantId: string) => api.delete(`/applications/${appId}/access/${grantId}`),
onSuccess: () => {
refetchAccessGrants();
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
toast.success('Access revoked');
},
onError: () => toast.error('Failed to revoke access'),
});
const copyToClipboard = (text: string, field: string) => {
navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
const accessTargetLabel = (target: ServiceAccessTarget) =>
accessTargetOptions.find((o) => o.value === target)?.label || target;
const formatAccessCountdown = (grant: ServiceAccessGrant) => {
if (grant.persistent) return 'Permanent (until revoked)';
const ms = new Date(grant.expiresAt).getTime() - accessNow;
if (ms <= 0) return 'Expired';
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
};
if (!hasAccessTargets) return null;
return (
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
<ExternalLink className="w-5 h-5" /> External access
</h2>
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-4 flex items-start gap-2">
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the session ends or you
revoke it. Use short durations for temporary access; permanent keeps the port open until revoked.
</p>
{!app.latestImageTag ? (
<p className="text-sm text-gray-500">Deploy the service first to enable external access.</p>
) : (
<>
<div className="bg-gray-50 rounded-xl p-4 mb-4 space-y-4">
<div className="flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-[160px]">
<label className="text-xs font-medium text-gray-600 block mb-1">Service</label>
<select
value={accessTarget}
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
className="input-field w-full text-sm"
>
{accessTargetOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="flex-1 min-w-[200px]">
<label className="text-xs font-medium text-gray-600 block mb-1">Access mode</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setAccessPersistent(false)}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border transition-colors ${
!accessPersistent
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
}`}
>
Temporary
</button>
<button
type="button"
onClick={() => setAccessPersistent(true)}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border transition-colors ${
accessPersistent
? 'bg-amber-600 text-white border-amber-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
}`}
>
Always open
</button>
</div>
</div>
</div>
{!accessPersistent && (
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Duration</label>
<div className="flex gap-2 flex-wrap">
{[30, 60, 240].map((mins) => (
<button
key={mins}
type="button"
onClick={() => setAccessDuration(mins)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
accessDuration === mins
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
}`}
>
{mins < 60 ? `${mins}m` : `${mins / 60}h`}
</button>
))}
</div>
</div>
)}
{accessPersistent && (
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
The port stays exposed until you click Revoke. Only use this when you need a stable external endpoint.
</p>
)}
<button
type="button"
onClick={() => createAccessMutation.mutate()}
disabled={createAccessMutation.isPending}
className="btn-primary text-sm"
>
{createAccessMutation.isPending ? 'Opening…' : accessPersistent ? 'Open port permanently' : 'Enable access'}
</button>
</div>
{accessGrants.filter((g) => g.status === 'active').length === 0 ? (
<p className="text-sm text-gray-500">No active external access sessions.</p>
) : (
<div className="space-y-3">
{accessGrants
.filter((g) => g.status === 'active')
.map((grant) => (
<div key={grant.id} className="border border-gray-200 rounded-xl p-4 bg-white">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<span className="text-sm font-semibold text-gray-800">{accessTargetLabel(grant.target)}</span>
{grant.persistent && (
<span className="ml-2 text-xs font-medium text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full">
Always open
</span>
)}
<span className="ml-2 text-xs text-gray-500">{formatAccessCountdown(grant)}</span>
</div>
<button
type="button"
onClick={() => revokeAccessMutation.mutate(grant.id)}
disabled={revokeAccessMutation.isPending}
className="btn-secondary text-xs text-red-600 border-red-200 hover:bg-red-50"
>
Revoke
</button>
</div>
<div className="space-y-1.5 text-sm font-mono">
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500 text-xs font-sans">Endpoint</span>
<div className="flex items-center gap-2">
<span className="text-gray-800">
{grant.host}:{grant.port}
</span>
<button
type="button"
onClick={() => copyToClipboard(`${grant.host}:${grant.port}`, `access-endpoint-${grant.id}`)}
className="p-1 text-gray-400 hover:text-gray-600"
>
{copiedField === `access-endpoint-${grant.id}` ? (
<Check className="w-3.5 h-3.5 text-green-500" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>
{grant.connection.url && (
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500 text-xs font-sans">URL</span>
<div className="flex items-center gap-2 max-w-[70%]">
<span className="text-gray-800 truncate text-xs" title={grant.connection.url}>
{showAccessSecret ? grant.connection.url : '••••••••••••'}
</span>
<button type="button" onClick={() => setShowAccessSecret(!showAccessSecret)} className="p-1 text-gray-400 hover:text-gray-600">
{showAccessSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
type="button"
onClick={() => copyToClipboard(grant.connection.url || '', `access-url-${grant.id}`)}
className="p-1 text-gray-400 hover:text-gray-600"
>
{copiedField === `access-url-${grant.id}` ? (
<Check className="w-3.5 h-3.5 text-green-500" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,165 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { FileText, ChevronDown, Monitor, Hammer, Pin } from 'lucide-react';
const statusColors: Record<string, string> = {
running: 'badge-green',
pending: 'badge-yellow',
building: 'badge-blue',
deploying: 'badge-blue',
failed: 'badge-red',
build_failed: 'badge-red',
cancelled: 'badge-gray',
stopped: 'badge-gray',
};
export function WorkloadLogsPanel({
appId,
showBuildLogs = true,
isRunning = false,
isStopped = false,
emptyPodMessage,
}: {
appId: string;
showBuildLogs?: boolean;
isRunning?: boolean;
isStopped?: boolean;
emptyPodMessage?: string;
}) {
const [showLogs, setShowLogs] = useState(false);
const [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
const logsEndRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (!showBuildLogs && logTab === 'build') {
setLogTab('pod');
}
}, [showBuildLogs, logTab]);
const { data: logsData } = useQuery<{ logs: string }>({
queryKey: ['logs', appId],
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
enabled: showLogs && logTab === 'pod',
refetchInterval: showLogs && logTab === 'pod' ? 3000 : false,
});
const { data: buildLogsData } = useQuery<{
buildLog: string | null;
status: string;
version: string | null;
}>({
queryKey: ['build-logs', appId],
queryFn: () => api.get(`/deployments/applications/${appId}/build-logs`).then((r) => r.data),
enabled: showBuildLogs && showLogs && logTab === 'build',
refetchInterval: showBuildLogs && showLogs && logTab === 'build' ? 5000 : false,
});
const podPlaceholder =
emptyPodMessage ||
(isRunning
? 'Loading logs...'
: isStopped
? 'Service is stopped. Start it to see logs.'
: 'Waiting for workload pods to be ready...');
return (
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<FileText className="w-5 h-5" /> Logs
</h2>
<div className="flex items-center space-x-3">
{showLogs && logTab === 'pod' && (
<span className="text-xs text-gray-400 flex items-center space-x-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span>Live (every 3s)</span>
</span>
)}
{showBuildLogs && showLogs && logTab === 'build' && (
<span className="text-xs text-gray-400 flex items-center space-x-1">
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span>Auto-refresh (every 5s)</span>
</span>
)}
<button type="button" onClick={() => setShowLogs(!showLogs)} className="btn-secondary text-sm">
{showLogs ? (
<>
<ChevronDown className="w-4 h-4 inline" /> Hide logs
</>
) : (
<>
<FileText className="w-4 h-4 inline" /> Show logs
</>
)}
</button>
</div>
</div>
{showLogs && (
<div className="space-y-3">
{showBuildLogs ? (
<div className="flex gap-1 bg-gray-100 rounded-xl p-1">
<button
type="button"
onClick={() => setLogTab('pod')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
logTab === 'pod' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
<Monitor className="w-4 h-4 inline" /> Pod logs
</button>
<button
type="button"
onClick={() => setLogTab('build')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
logTab === 'build' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
<Hammer className="w-4 h-4 inline" /> Build logs
</button>
</div>
) : (
<p className="text-xs text-gray-500">Workload pod output (no image build for this service).</p>
)}
{logTab === 'pod' && (
<pre
ref={logsEndRef}
className="bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
>
{logsData?.logs || podPlaceholder}
</pre>
)}
{showBuildLogs && logTab === 'build' && (
<div>
{buildLogsData?.version && (
<div className="flex items-center gap-3 mb-2 text-xs text-gray-500">
<span>
<Pin className="w-3 h-3 inline" /> {buildLogsData.version}
</span>
<span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}>
{buildLogsData.status}
</span>
</div>
)}
<pre className="bg-gray-900 text-blue-300 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
{buildLogsData?.buildLog ||
(buildLogsData?.status === 'building'
? 'Build in progress... Logs will appear when complete.'
: buildLogsData?.status === 'pending'
? 'Build is pending...'
: buildLogsData?.status === 'no_deployment'
? 'No deployments yet. Deploy your app to see build logs.'
: 'No build logs available for this deployment.')}
</pre>
</div>
)}
</div>
)}
</div>
);
}