Improve logging recovery, resource scaling, and app deploy logging.
Auto-reconnect Elasticsearch port-forward after cluster or API restarts, poll log status in the UI, and apply storage changes through billing upgrade for all workloads. Add Redis/RabbitMQ PVC resize, Helm ES credentials for Fluent Bit, and fix deploy progress overlay behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,6 +19,30 @@ import { useAuthStore } from '@/lib/store';
|
||||
/** Matches backend multipart limit for POST /applications/:id/upload */
|
||||
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
|
||||
|
||||
type UpgradePayload = {
|
||||
cpuRequest?: string;
|
||||
cpuLimit?: string;
|
||||
memoryRequest?: string;
|
||||
memoryLimit?: string;
|
||||
replicas?: number;
|
||||
dbStorageSize?: string;
|
||||
appStorageSize?: string;
|
||||
redisResources?: {
|
||||
cpuRequest?: string;
|
||||
cpuLimit?: string;
|
||||
memoryRequest?: string;
|
||||
memoryLimit?: string;
|
||||
storageGi?: number;
|
||||
};
|
||||
rabbitmqResources?: {
|
||||
cpuRequest?: string;
|
||||
cpuLimit?: string;
|
||||
memoryRequest?: string;
|
||||
memoryLimit?: string;
|
||||
storageGi?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
pending: 'badge-yellow',
|
||||
@@ -80,8 +104,9 @@ export default function AppDetailPage() {
|
||||
});
|
||||
const [scaleWorkload, setScaleWorkload] = useState<'app' | 'database' | 'redis' | 'rabbitmq'>('app');
|
||||
const [resourceFormDirty, setResourceFormDirty] = useState(false);
|
||||
const [showDbDiskExpand, setShowDbDiskExpand] = useState(false);
|
||||
const [dbStorageSize, setDbStorageSize] = useState('1');
|
||||
const [redisStorageSize, setRedisStorageSize] = useState('1');
|
||||
const [rabbitmqStorageSize, setRabbitmqStorageSize] = useState('2');
|
||||
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||
const [downloadingArtifact, setDownloadingArtifact] = useState<'source' | 'wp-content' | 'database' | null>(null);
|
||||
@@ -95,6 +120,7 @@ export default function AppDetailPage() {
|
||||
currentCost: { hourly: number };
|
||||
newCost: { hourly: number };
|
||||
} | null>(null);
|
||||
const [pendingUpgradePayload, setPendingUpgradePayload] = useState<UpgradePayload | null>(null);
|
||||
|
||||
// ── Custom Domain ──────────────────────────────────
|
||||
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
||||
@@ -192,8 +218,6 @@ export default function AppDetailPage() {
|
||||
|
||||
// App storage expansion state
|
||||
const [appStorageSize, setAppStorageSize] = useState('2');
|
||||
const [showAppStorageExpand, setShowAppStorageExpand] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (app?.appStorageSize) {
|
||||
const sizeNum = parseInt(app.appStorageSize.replace('Gi', ''), 10) || 2;
|
||||
@@ -201,38 +225,17 @@ export default function AppDetailPage() {
|
||||
}
|
||||
}, [app?.appStorageSize]);
|
||||
|
||||
const resizeAppStorageMutation = useMutation({
|
||||
mutationFn: (size: string) => api.patch(`/applications/${appId}/app-storage`, { size }),
|
||||
onSuccess: (res) => {
|
||||
if (res.data.success) {
|
||||
toast.success(res.data.message || 'App storage expanded!');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||
setShowAppStorageExpand(false);
|
||||
} else {
|
||||
toast.error(res.data.message || 'Failed to expand storage');
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to resize app storage');
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
if (storageUsage?.redisStorage) {
|
||||
setRedisStorageSize(String(Math.max(1, Math.round(storageUsage.redisStorage.allocatedGi))));
|
||||
}
|
||||
}, [storageUsage?.redisStorage?.allocatedGi]);
|
||||
|
||||
const resizeDbMutation = useMutation({
|
||||
mutationFn: (size: string) => api.patch(`/applications/${appId}/db-storage`, { size }),
|
||||
onSuccess: (res) => {
|
||||
if (res.data.success) {
|
||||
toast.success(res.data.message || 'Database storage expanded!');
|
||||
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||
} else {
|
||||
toast.error(res.data.message || 'Failed to expand storage');
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to resize database storage');
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
if (storageUsage?.rabbitmqStorage) {
|
||||
setRabbitmqStorageSize(String(Math.max(2, Math.round(storageUsage.rabbitmqStorage.allocatedGi))));
|
||||
}
|
||||
}, [storageUsage?.rabbitmqStorage?.allocatedGi]);
|
||||
|
||||
// ─── Billing & Renewal ──────────────────────────────
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
@@ -599,15 +602,17 @@ export default function AppDetailPage() {
|
||||
});
|
||||
|
||||
const scaleMutation = useMutation({
|
||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||
api.post(`/billing/applications/${appId}/upgrade`, data),
|
||||
mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade`, data),
|
||||
onSuccess: (res) => {
|
||||
setResourceFormDirty(false);
|
||||
invalidateAll();
|
||||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
||||
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`);
|
||||
@@ -638,8 +643,7 @@ export default function AppDetailPage() {
|
||||
|
||||
// Calculate upgrade cost before applying
|
||||
const calculateUpgradeCostMutation = useMutation({
|
||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||
api.post(`/billing/applications/${appId}/upgrade/calculate`, data),
|
||||
mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade/calculate`, data),
|
||||
onSuccess: (res) => {
|
||||
setUpgradeCostData(res.data);
|
||||
setShowUpgradeConfirm(true);
|
||||
@@ -648,35 +652,201 @@ export default function AppDetailPage() {
|
||||
});
|
||||
|
||||
const createUpgradeInvoiceMutation = useMutation({
|
||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||
mutationFn: (data: UpgradePayload) =>
|
||||
api.post<Invoice>(`/billing/applications/${appId}/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: any) => toast.error(err.response?.data?.message || 'Failed to create upgrade invoice'),
|
||||
});
|
||||
|
||||
// Handler: app uses billing upgrade path when subscribed; other workloads patch directly.
|
||||
const buildWorkloadUpgradePayload = useCallback((): UpgradePayload => {
|
||||
if (!app) return {};
|
||||
switch (scaleWorkload) {
|
||||
case 'app': {
|
||||
const payload: UpgradePayload = {
|
||||
cpuRequest: resourceForm.cpuRequest || undefined,
|
||||
cpuLimit: resourceForm.cpuLimit || undefined,
|
||||
memoryRequest: resourceForm.memoryRequest || undefined,
|
||||
memoryLimit: resourceForm.memoryLimit || undefined,
|
||||
replicas: resourceForm.replicas,
|
||||
};
|
||||
const minAppGi = parseInt((app.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2;
|
||||
const newAppGi = parseInt(appStorageSize, 10);
|
||||
if (newAppGi > minAppGi) payload.appStorageSize = `${newAppGi}Gi`;
|
||||
return payload;
|
||||
}
|
||||
case 'database': {
|
||||
const minDbGi =
|
||||
parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1;
|
||||
const newDbGi = parseInt(dbStorageSize, 10);
|
||||
if (newDbGi > minDbGi) return { dbStorageSize: `${newDbGi}Gi` };
|
||||
return {};
|
||||
}
|
||||
case 'redis': {
|
||||
const prev = app.optionalServiceResources?.redis;
|
||||
const minGi = Math.max(1, Math.round(storageUsage?.redisStorage?.allocatedGi ?? prev?.storageGi ?? 1));
|
||||
const newGi = parseInt(redisStorageSize, 10);
|
||||
return {
|
||||
redisResources: {
|
||||
cpuRequest: resourceForm.cpuRequest || prev?.cpuRequest,
|
||||
cpuLimit: resourceForm.cpuLimit || prev?.cpuLimit,
|
||||
memoryRequest: resourceForm.memoryRequest || prev?.memoryRequest,
|
||||
memoryLimit: resourceForm.memoryLimit || prev?.memoryLimit,
|
||||
storageGi: newGi > minGi ? newGi : (prev?.storageGi ?? minGi),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'rabbitmq': {
|
||||
const prev = app.optionalServiceResources?.rabbitmq;
|
||||
const minGi = Math.max(2, Math.round(storageUsage?.rabbitmqStorage?.allocatedGi ?? prev?.storageGi ?? 2));
|
||||
const newGi = parseInt(rabbitmqStorageSize, 10);
|
||||
return {
|
||||
rabbitmqResources: {
|
||||
cpuRequest: resourceForm.cpuRequest || prev?.cpuRequest,
|
||||
cpuLimit: resourceForm.cpuLimit || prev?.cpuLimit,
|
||||
memoryRequest: resourceForm.memoryRequest || prev?.memoryRequest,
|
||||
memoryLimit: resourceForm.memoryLimit || prev?.memoryLimit,
|
||||
storageGi: newGi > minGi ? newGi : (prev?.storageGi ?? minGi),
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [
|
||||
app,
|
||||
scaleWorkload,
|
||||
resourceForm,
|
||||
appStorageSize,
|
||||
dbStorageSize,
|
||||
redisStorageSize,
|
||||
rabbitmqStorageSize,
|
||||
dbStorageData?.currentSize,
|
||||
storageUsage?.redisStorage?.allocatedGi,
|
||||
storageUsage?.rabbitmqStorage?.allocatedGi,
|
||||
]);
|
||||
|
||||
const patchDatabaseCpuIfNeeded = () => {
|
||||
if (scaleWorkload !== 'database') return;
|
||||
directPatchResourcesMutation.mutate({
|
||||
workload: 'database',
|
||||
cpuRequest: resourceForm.cpuRequest || undefined,
|
||||
cpuLimit: resourceForm.cpuLimit || undefined,
|
||||
memoryRequest: resourceForm.memoryRequest || undefined,
|
||||
memoryLimit: resourceForm.memoryLimit || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleScaleResources = () => {
|
||||
if (scaleWorkload !== 'app') {
|
||||
directPatchResourcesMutation.mutate({
|
||||
workload: scaleWorkload,
|
||||
cpuRequest: resourceForm.cpuRequest || undefined,
|
||||
cpuLimit: resourceForm.cpuLimit || undefined,
|
||||
memoryRequest: resourceForm.memoryRequest || undefined,
|
||||
memoryLimit: resourceForm.memoryLimit || undefined,
|
||||
if (!app) return;
|
||||
if (app.lifecycleStatus && app.lifecycleStatus !== 'active') {
|
||||
if (scaleWorkload === 'database') {
|
||||
patchDatabaseCpuIfNeeded();
|
||||
return;
|
||||
}
|
||||
toast.warn('Renew the application before changing resources');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildWorkloadUpgradePayload();
|
||||
const hasBillingPayload = Object.keys(payload).length > 0;
|
||||
const needsDbCpuPatch = scaleWorkload === 'database';
|
||||
|
||||
if (!hasBillingPayload && !needsDbCpuPatch) {
|
||||
toast.warn('No changes to apply');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!app.billingCycle) {
|
||||
if (hasBillingPayload) {
|
||||
scaleMutation.mutate(payload, {
|
||||
onSuccess: () => patchDatabaseCpuIfNeeded(),
|
||||
});
|
||||
} else {
|
||||
patchDatabaseCpuIfNeeded();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasBillingPayload) {
|
||||
patchDatabaseCpuIfNeeded();
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingUpgradePayload(payload);
|
||||
calculateUpgradeCostMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const confirmUpgradeApply = () => {
|
||||
if (!pendingUpgradePayload || !upgradeCostData) return;
|
||||
if (upgradeCostData.proratedAmount > 0) {
|
||||
createUpgradeInvoiceMutation.mutate(pendingUpgradePayload);
|
||||
} else {
|
||||
scaleMutation.mutate(pendingUpgradePayload, {
|
||||
onSuccess: () => patchDatabaseCpuIfNeeded(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!app?.billingCycle) {
|
||||
scaleMutation.mutate(resourceForm);
|
||||
return;
|
||||
};
|
||||
|
||||
const workloadStorageConfig = (): {
|
||||
label: string;
|
||||
value: string;
|
||||
setValue: (v: string) => void;
|
||||
minGi: number;
|
||||
maxGi: number;
|
||||
currentGi: number;
|
||||
} | null => {
|
||||
if (!app) return null;
|
||||
switch (scaleWorkload) {
|
||||
case 'app':
|
||||
if (!storageUsage?.appStorage) return null;
|
||||
return {
|
||||
label: 'Application volume',
|
||||
value: appStorageSize,
|
||||
setValue: setAppStorageSize,
|
||||
minGi: parseInt((app.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2,
|
||||
maxGi: 100,
|
||||
currentGi: storageUsage.appStorage.allocatedGi,
|
||||
};
|
||||
case 'database':
|
||||
if (app.databaseType === 'none' || !storageUsage?.database) return null;
|
||||
return {
|
||||
label: 'Database volume',
|
||||
value: dbStorageSize,
|
||||
setValue: setDbStorageSize,
|
||||
minGi: parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1,
|
||||
maxGi: 500,
|
||||
currentGi: storageUsage.database.allocatedGi,
|
||||
};
|
||||
case 'redis':
|
||||
if (!app.enableRedis || !storageUsage?.redisStorage) return null;
|
||||
return {
|
||||
label: 'Redis volume',
|
||||
value: redisStorageSize,
|
||||
setValue: setRedisStorageSize,
|
||||
minGi: Math.max(1, Math.round(storageUsage.redisStorage.allocatedGi)),
|
||||
maxGi: 100,
|
||||
currentGi: storageUsage.redisStorage.allocatedGi,
|
||||
};
|
||||
case 'rabbitmq':
|
||||
if (!app.enableRabbitmq || !storageUsage?.rabbitmqStorage) return null;
|
||||
return {
|
||||
label: 'RabbitMQ volume',
|
||||
value: rabbitmqStorageSize,
|
||||
setValue: setRabbitmqStorageSize,
|
||||
minGi: Math.max(2, Math.round(storageUsage.rabbitmqStorage.allocatedGi)),
|
||||
maxGi: 100,
|
||||
currentGi: storageUsage.rabbitmqStorage.allocatedGi,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
calculateUpgradeCostMutation.mutate(resourceForm);
|
||||
};
|
||||
|
||||
const previewMutation = useMutation({
|
||||
@@ -1187,19 +1357,14 @@ export default function AppDetailPage() {
|
||||
onClick={() => {
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
setPendingUpgradePayload(null);
|
||||
}}
|
||||
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
|
||||
onClick={() => {
|
||||
if (upgradeCostData.proratedAmount > 0) {
|
||||
createUpgradeInvoiceMutation.mutate(resourceForm);
|
||||
} else {
|
||||
scaleMutation.mutate(resourceForm);
|
||||
}
|
||||
}}
|
||||
onClick={confirmUpgradeApply}
|
||||
disabled={scaleMutation.isPending || createUpgradeInvoiceMutation.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"
|
||||
>
|
||||
@@ -1639,71 +1804,6 @@ export default function AppDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Database Storage Management */}
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Database Storage</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-500">Current Size:</span>
|
||||
<span className="text-sm font-semibold text-gray-800">{dbStorageData?.currentSize || app.dbStorageSize || '1Gi'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(dbStorageSize, 10);
|
||||
const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
|
||||
if (current > min + 1) setDbStorageSize(String(current - 1));
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={dbStorageSize}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1));
|
||||
setDbStorageSize(String(val));
|
||||
}}
|
||||
className="w-14 text-center py-1.5 border-x border-gray-300 text-sm font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(dbStorageSize, 10);
|
||||
if (current < 100) setDbStorageSize(String(current + 1));
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">GB</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newSize = `${parseInt(dbStorageSize, 10)}Gi`;
|
||||
resizeDbMutation.mutate(newSize);
|
||||
}}
|
||||
disabled={
|
||||
resizeDbMutation.isPending ||
|
||||
parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
|
||||
}
|
||||
className="btn-primary text-xs px-3 py-1.5 disabled:opacity-50"
|
||||
>
|
||||
{resizeDbMutation.isPending ? 'Expanding...' : 'Expand'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">Only expansion is allowed (shrinking is not possible)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB Dump Upload */}
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Restore Database Dump</h3>
|
||||
<div
|
||||
@@ -2055,70 +2155,7 @@ export default function AppDetailPage() {
|
||||
<span>Used {storageUsage.database.usedGi.toFixed(2)} GiB</span>
|
||||
<span>Free ~{storageUsage.database.availableGi.toFixed(2)} GiB</span>
|
||||
</div>
|
||||
{app?.databaseType !== 'none' && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
{showDbDiskExpand ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(dbStorageSize, 10);
|
||||
const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
|
||||
if (current > min + 1) setDbStorageSize(String(current - 1));
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={dbStorageSize}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(500, parseInt(e.target.value, 10) || 1));
|
||||
setDbStorageSize(String(val));
|
||||
}}
|
||||
className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(dbStorageSize, 10);
|
||||
if (current < 500) setDbStorageSize(String(current + 1));
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">GiB</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resizeDbMutation.mutate(`${parseInt(dbStorageSize, 10)}Gi`)}
|
||||
disabled={
|
||||
resizeDbMutation.isPending ||
|
||||
parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
|
||||
}
|
||||
className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
{resizeDbMutation.isPending ? 'Expanding…' : 'Expand DB disk'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowDbDiskExpand(false)} className="btn-secondary text-xs px-2 py-1">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDbDiskExpand(true)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
<Scale className="w-3 h-3" /> Expand database disk
|
||||
</button>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-400 mt-1">PVC can only grow. Size from API: {storageUsage.database.allocatedRaw}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-400 mt-2">Expand disk in Adjust CPU / memory & storage below.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2148,79 +2185,7 @@ export default function AppDetailPage() {
|
||||
<span>Used {storageUsage.appStorage.usedGi.toFixed(2)} GiB</span>
|
||||
<span>Free ~{storageUsage.appStorage.availableGi.toFixed(2)} GiB</span>
|
||||
</div>
|
||||
|
||||
{/* Expand App Storage (all app types) */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
{showAppStorageExpand ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(appStorageSize, 10);
|
||||
const min = parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2;
|
||||
if (current > min + 1) setAppStorageSize(String(current - 1));
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
value={appStorageSize}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(2, Math.min(100, parseInt(e.target.value, 10) || 2));
|
||||
setAppStorageSize(String(val));
|
||||
}}
|
||||
className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(appStorageSize, 10);
|
||||
if (current < 100) setAppStorageSize(String(current + 1));
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">GB</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newSize = `${parseInt(appStorageSize, 10)}Gi`;
|
||||
resizeAppStorageMutation.mutate(newSize);
|
||||
}}
|
||||
disabled={
|
||||
resizeAppStorageMutation.isPending ||
|
||||
parseInt(appStorageSize, 10) <= (parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2)
|
||||
}
|
||||
className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
{resizeAppStorageMutation.isPending ? 'Expanding...' : 'Expand'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAppStorageExpand(false)}
|
||||
className="btn-secondary text-xs px-2 py-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAppStorageExpand(true)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
<Scale className="w-3 h-3" /> Expand Storage
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1">Only expansion is allowed</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 mt-2">Expand disk in Adjust CPU / memory & storage below.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2238,7 +2203,9 @@ export default function AppDetailPage() {
|
||||
style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.redisStorage.allocatedRaw}</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">
|
||||
Allocated {storageUsage.redisStorage.allocatedRaw} — expand in Adjust CPU / memory & storage.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2256,7 +2223,9 @@ export default function AppDetailPage() {
|
||||
style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.rabbitmqStorage.allocatedRaw}</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">
|
||||
Allocated {storageUsage.rabbitmqStorage.allocatedRaw} — expand in Adjust CPU / memory & storage.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2271,9 +2240,12 @@ export default function AppDetailPage() {
|
||||
|
||||
{/* Scaling Controls */}
|
||||
<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>
|
||||
<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 & storage
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Pick which component to update. The main application may use billing if your plan charges for upgrades; database and optional services apply directly in the cluster.
|
||||
Pick which component to update. CPU and memory apply per workload; storage can only grow (expand). The main
|
||||
application may use billing for paid upgrades; database and optional services apply directly in the cluster.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-gray-500 mb-1">Workload</label>
|
||||
@@ -2351,6 +2323,65 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const storageCfg = workloadStorageConfig();
|
||||
if (!storageCfg) return null;
|
||||
return (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
Storage — {storageCfg.label}
|
||||
</label>
|
||||
<p className="text-[11px] text-gray-500 mb-2">
|
||||
Current: {storageCfg.currentGi.toFixed(1)} GiB allocated (expand only, no shrink). Applied with
|
||||
Apply changes.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const c = parseInt(storageCfg.value, 10);
|
||||
if (c > storageCfg.minGi + 1) storageCfg.setValue(String(c - 1));
|
||||
}}
|
||||
className="px-2 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={storageCfg.minGi + 1}
|
||||
max={storageCfg.maxGi}
|
||||
value={storageCfg.value}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(
|
||||
storageCfg.minGi + 1,
|
||||
Math.min(storageCfg.maxGi, parseInt(e.target.value, 10) || storageCfg.minGi + 1),
|
||||
);
|
||||
storageCfg.setValue(String(val));
|
||||
}}
|
||||
className="w-14 text-center py-1.5 border-x border-gray-300 text-xs font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const c = parseInt(storageCfg.value, 10);
|
||||
if (c < storageCfg.maxGi) storageCfg.setValue(String(c + 1));
|
||||
}}
|
||||
className="px-2 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">GiB</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="sm:col-span-2" />
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -67,9 +67,25 @@ function LogsPageContent() {
|
||||
if (initialAppId) setAppId(initialAppId);
|
||||
}, [initialAppId]);
|
||||
|
||||
const { data: loggingStatus } = useQuery({
|
||||
const { data: loggingStatus, isFetching: statusFetching } = useQuery({
|
||||
queryKey: ['logs-status'],
|
||||
queryFn: () => api.get('/logs/status').then((r) => r.data as { available: boolean }),
|
||||
queryFn: () =>
|
||||
api
|
||||
.get('/logs/status')
|
||||
.then((r) =>
|
||||
r.data as {
|
||||
available: boolean;
|
||||
deployed?: boolean;
|
||||
recovering?: boolean;
|
||||
message?: string;
|
||||
},
|
||||
),
|
||||
refetchInterval: (query) => {
|
||||
const s = query.state.data;
|
||||
if (s?.available) return false;
|
||||
if (s?.deployed === false) return false;
|
||||
return 8_000;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: applications = [] } = useQuery<Application[]>({
|
||||
@@ -150,7 +166,7 @@ function LogsPageContent() {
|
||||
params.set('limit', '100');
|
||||
return api.get(`/logs?${params.toString()}`).then((r) => r.data);
|
||||
},
|
||||
enabled: loggingStatus?.available !== false,
|
||||
enabled: loggingStatus?.available === true,
|
||||
refetchInterval: autoRefresh ? 5000 : false,
|
||||
});
|
||||
|
||||
@@ -163,20 +179,32 @@ function LogsPageContent() {
|
||||
params.set('period', timeRange);
|
||||
return api.get(`/logs/stats?${params.toString()}`).then((r) => r.data);
|
||||
},
|
||||
enabled: loggingStatus?.available !== false,
|
||||
enabled: loggingStatus?.available === true,
|
||||
});
|
||||
|
||||
const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1;
|
||||
|
||||
if (loggingStatus && !loggingStatus.available) {
|
||||
const isRecovering = loggingStatus.recovering || loggingStatus.deployed === true;
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto card p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">Logging not available</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app,
|
||||
and ask an administrator to deploy the logging stack.
|
||||
{isRecovering ? (
|
||||
<Loader2 className="w-12 h-12 text-primary-500 mx-auto mb-4 animate-spin" />
|
||||
) : (
|
||||
<AlertCircle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||
)}
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{isRecovering ? 'Reconnecting to logging…' : 'Logging not available'}
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm whitespace-pre-wrap">
|
||||
{loggingStatus.message ||
|
||||
'Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app, and ask an administrator to deploy the logging stack.'}
|
||||
</p>
|
||||
{isRecovering && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
{statusFetching ? 'Checking connection…' : 'Retrying automatically every few seconds.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DeletingTableRowCell({
|
||||
message?: string;
|
||||
}) {
|
||||
return (
|
||||
<td colSpan={colSpan} className="px-6 py-4 bg-white/95" aria-live="polite" aria-busy="true">
|
||||
<td colSpan={colSpan} className="px-6 py-4 bg-white/80 backdrop-blur-sm" aria-live="polite" aria-busy="true">
|
||||
<div className="flex min-h-[52px] w-full items-center justify-center gap-2 text-sm font-semibold text-gray-800">
|
||||
<Clock className="w-4 h-4 shrink-0 animate-spin text-red-600" />
|
||||
<span>{message}</span>
|
||||
@@ -34,7 +34,7 @@ export function DeletingTableRowCell({
|
||||
export function DeletingCardOverlay({ message = 'Deleting…' }: { message?: string }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center gap-2 rounded-xl bg-white/92 backdrop-blur-[2px] text-sm font-semibold text-gray-800"
|
||||
className="absolute inset-0 z-20 flex items-center justify-center gap-2 rounded-xl bg-white/75 backdrop-blur-sm text-sm font-semibold text-gray-800"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import { useQuery, useQueries, keepPreviousData } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { Application } from '@/types';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
@@ -49,16 +49,15 @@ export function DeploymentProgressManager() {
|
||||
.get<{ progress: BuildProgress | null }>(`/deployments/applications/${app.id}/build-progress`)
|
||||
.then((r) => r.data.progress),
|
||||
refetchInterval: 1500,
|
||||
placeholderData: keepPreviousData,
|
||||
})),
|
||||
});
|
||||
|
||||
const activeItems = useMemo(() => {
|
||||
return deployingApps
|
||||
.map((app, i) => ({
|
||||
app,
|
||||
progress: progressQueries[i]?.data ?? null,
|
||||
}))
|
||||
.filter(({ progress }) => isActiveBuildProgress(progress));
|
||||
return deployingApps.map((app, i) => ({
|
||||
app,
|
||||
progress: progressQueries[i]?.data ?? null,
|
||||
}));
|
||||
}, [deployingApps, progressQueries]);
|
||||
|
||||
const routeAppId =
|
||||
@@ -66,10 +65,10 @@ export function DeploymentProgressManager() {
|
||||
pathname.match(/\/dashboard\/services\/([^/]+)/)?.[1];
|
||||
|
||||
useEffect(() => {
|
||||
if (activeItems.length === 0) {
|
||||
if (deployingApps.length === 0) {
|
||||
useDeployProgressStore.setState({ minimized: false, focusedAppId: null });
|
||||
}
|
||||
}, [activeItems.length]);
|
||||
}, [deployingApps.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeItems.length === 0) return;
|
||||
@@ -84,12 +83,15 @@ export function DeploymentProgressManager() {
|
||||
const focusedItem =
|
||||
activeItems.find((item) => item.app.id === focusedAppId) ?? activeItems[0];
|
||||
|
||||
const focusedProgress = focusedItem?.progress;
|
||||
const focusedProgress =
|
||||
focusedItem?.progress ??
|
||||
({ phase: 'building', percent: 0, message: 'Loading progress…' } satisfies BuildProgress);
|
||||
const showModal =
|
||||
!minimized &&
|
||||
!!focusedItem &&
|
||||
!!focusedProgress &&
|
||||
isActiveBuildProgress(focusedProgress);
|
||||
(!focusedItem.progress ||
|
||||
isActiveBuildProgress(focusedItem.progress) ||
|
||||
focusedItem.progress.phase === 'done');
|
||||
|
||||
const handleMinimize = () => {
|
||||
if (focusedItem) minimize(focusedItem.app.id);
|
||||
@@ -99,7 +101,7 @@ export function DeploymentProgressManager() {
|
||||
expand(appId);
|
||||
};
|
||||
|
||||
if (activeItems.length === 0) return null;
|
||||
if (deployingApps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -109,7 +111,7 @@ export function DeploymentProgressManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && focusedProgress && (
|
||||
{showModal && (
|
||||
<BuildProgressModal
|
||||
appId={focusedItem.app.id}
|
||||
appName={focusedItem.app.name}
|
||||
|
||||
@@ -226,23 +226,6 @@ export function ManagedServiceResourcesPanel({
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -280,9 +263,15 @@ export function ManagedServiceResourcesPanel({
|
||||
[dbUploadMutation],
|
||||
);
|
||||
|
||||
const currentDbGi =
|
||||
parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1;
|
||||
|
||||
const buildUpgradePayload = useCallback((): UpgradePayload => {
|
||||
if (app.productType === 'managed_database') {
|
||||
return { ...dbResources };
|
||||
const payload: UpgradePayload = { ...dbResources };
|
||||
const newGi = parseInt(dbStorageSize, 10);
|
||||
if (newGi > currentDbGi) payload.dbStorageSize = `${newGi}Gi`;
|
||||
return payload;
|
||||
}
|
||||
if (app.productType === 'managed_redis') {
|
||||
return {
|
||||
@@ -304,7 +293,7 @@ export function ManagedServiceResourcesPanel({
|
||||
storageGi: rabbitResources.storageGi ?? app.optionalServiceResources?.rabbitmq?.storageGi ?? 2,
|
||||
},
|
||||
};
|
||||
}, [app, dbResources, redisResources, rabbitResources]);
|
||||
}, [app, dbResources, redisResources, rabbitResources, dbStorageSize, currentDbGi]);
|
||||
|
||||
const applyResources = () => {
|
||||
if (needsRenewal) {
|
||||
@@ -316,6 +305,10 @@ export function ManagedServiceResourcesPanel({
|
||||
const w = workloadKey(app);
|
||||
|
||||
if (!app.billingCycle) {
|
||||
if (Object.keys(payload).length > 0 && (payload.dbStorageSize || payload.redisResources || payload.rabbitmqResources)) {
|
||||
scaleMutation.mutate(payload);
|
||||
return;
|
||||
}
|
||||
if (w === 'database') {
|
||||
directPatchResourcesMutation.mutate({ workload: 'database', ...dbResources });
|
||||
} else if (w === 'redis') {
|
||||
@@ -351,37 +344,12 @@ export function ManagedServiceResourcesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
@@ -558,22 +526,8 @@ export function ManagedServiceResourcesPanel({
|
||||
</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.
|
||||
Only expansion is allowed. Use Apply changes below.
|
||||
{app.billingCycle
|
||||
? ' Additional storage is charged for the remaining billing period.'
|
||||
: ''}
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface Application {
|
||||
enableElasticsearch?: boolean;
|
||||
elasticsearchVersion?: string;
|
||||
logPaths?: string[];
|
||||
optionalServiceResources?: OptionalServiceResourcesMap;
|
||||
// Billing & Lifecycle
|
||||
planId?: string;
|
||||
billingCycle?: BillingCycle;
|
||||
|
||||
Reference in New Issue
Block a user