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:
keyhan
2026-05-25 21:41:00 +03:30
parent bf9e827f85
commit dc9830383b
18 changed files with 1068 additions and 414 deletions
+296 -265
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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"
+37 -9
View File
@@ -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>
);
}
+2 -2
View File
@@ -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.'
: ''}
+1
View File
@@ -58,6 +58,7 @@ export interface Application {
enableElasticsearch?: boolean;
elasticsearchVersion?: string;
logPaths?: string[];
optionalServiceResources?: OptionalServiceResourcesMap;
// Billing & Lifecycle
planId?: string;
billingCycle?: BillingCycle;