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,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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user