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