feat(deploy): remove cluster allocation section from app creation

Drop the admin-only "cluster assignment" UI from the resources &
config step of the deploy wizard and the related backend override.
App placement is now always decided automatically by the allocator.

- frontend: remove cluster/pool selection block, review-step cluster
  row, clusterMode state, public cluster/pool queries, and clusterId/
  poolId from CreateApplicationDto
- backend: drop clusterId/poolId override from the create DTO and
  simplify ApplicationsService.create to always auto-allocate; widen
  selectClusterForApplication param to keep the fallback path working

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-19 11:58:30 +03:30
parent 5fd2a9883a
commit dbc169206f
6 changed files with 10 additions and 260 deletions
@@ -63,7 +63,7 @@ export class ApplicationsController {
@Post()
@ApiOperation({ summary: 'Create a new application' })
async create(@Request() req: any, @Body() dto: CreateApplicationDto) {
return this.applicationsService.create(req.user.id, dto, req.user.role);
return this.applicationsService.create(req.user.id, dto);
}
@Post(':id/upload')
@@ -9,7 +9,6 @@ import { Application } from './entities/application.entity';
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
import { ClustersService } from '../clusters/clusters.service';
import {
UserRole,
DatabaseType,
CustomDomainStatus,
AppRuntime,
@@ -57,39 +56,17 @@ export class ApplicationsService {
throw new BadRequestException('Failed to generate a unique subdomain. Please try again.');
}
async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise<Application> {
async create(userId: string, dto: CreateApplicationDto): Promise<Application> {
dto = normalizeCreateApplicationDto(dto);
const productType = dto.productType ?? ProductType.APPLICATION;
// End users and technical staff cannot influence placement; only admins may manually assign.
const isAdmin = userRole === UserRole.ADMIN;
if (!isAdmin) {
if (dto.clusterId || dto.poolId) {
this.logger.warn(`Non-admin user ${userId} attempted manual cluster/pool selection - ignoring`);
}
dto.clusterId = undefined;
dto.poolId = undefined;
}
let clusterId = dto.clusterId;
let poolId = dto.poolId;
let allocationLogId: string | undefined;
if (isAdmin && clusterId) {
await this.clustersService.findOne(clusterId);
this.logger.log(`Manual cluster assignment for app "${dto.name}" -> cluster ${clusterId}`);
} else {
const allocationDto = { ...dto };
if (poolId) {
allocationDto.poolId = poolId;
}
const allocation = await this.clustersService.selectClusterForApplication(allocationDto, userId);
clusterId = allocation.cluster.id;
poolId = allocation.pool?.id || (isAdmin ? poolId : undefined);
allocationLogId = allocation.allocationLogId;
if (!clusterId) {
throw new BadRequestException('No eligible cluster available for this application');
}
// Placement is always decided automatically by the allocator.
const allocation = await this.clustersService.selectClusterForApplication(dto, userId);
const clusterId = allocation.cluster.id;
const poolId = allocation.pool?.id;
const allocationLogId = allocation.allocationLogId;
if (!clusterId) {
throw new BadRequestException('No eligible cluster available for this application');
}
// Generate database credentials if a database is requested
@@ -198,16 +198,6 @@ export class CreateApplicationDto {
@IsNumber()
port?: number;
@ApiPropertyOptional({ description: 'Admin-only manual cluster override. Ignored for non-admin users.' })
@IsOptional()
@IsString()
clusterId?: string;
@ApiPropertyOptional({ description: 'Admin-only pool override. Ignored for non-admin users.' })
@IsOptional()
@IsString()
poolId?: string;
@ApiPropertyOptional({ example: 'www.example.com', description: 'Custom domain for the application (requires additional fee)' })
@IsOptional()
@IsString()
+1 -1
View File
@@ -266,7 +266,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
}
async selectClusterForApplication(
dto: CreateApplicationDto,
dto: CreateApplicationDto & { poolId?: string },
userId: string,
options: {
excludeClusterIds?: string[];
@@ -13,8 +13,6 @@ import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
import { notify } from '@/lib/notify';
import type {
CreateApplicationDto,
ClusterPublic,
ClusterPoolPublic,
DeployCostPreview,
BillingCycle,
PricingCatalog,
@@ -251,7 +249,6 @@ export default function DeployPage() {
useDeployProgressStore.getState().stopTracking(appId);
}
};
const isAdmin = user?.role === 'admin';
const [step, setStep] = useState(0);
const [form, setForm] = useState<CreateApplicationDto>({
name: '',
@@ -289,7 +286,6 @@ export default function DeployPage() {
const [zipFile, setZipFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [clusterMode, setClusterMode] = useState<'default' | 'manual' | 'pool'>('default');
const [showDbPassword, setShowDbPassword] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [dbDumpFile, setDbDumpFile] = useState<File | null>(null);
@@ -336,18 +332,6 @@ export default function DeployPage() {
};
};
const { data: clusters = [] } = useQuery<ClusterPublic[]>({
queryKey: ['clusters-public'],
queryFn: () => api.get('/clusters/public').then((r) => r.data),
enabled: isAdmin,
});
const { data: pools = [] } = useQuery<ClusterPoolPublic[]>({
queryKey: ['pools-public'],
queryFn: () => api.get('/clusters/pools/public').then((r) => r.data),
enabled: isAdmin,
});
const { data: pricingCatalog } = useQuery<PricingCatalog>({
queryKey: ['pricing-catalog'],
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
@@ -441,10 +425,6 @@ export default function DeployPage() {
: {}),
...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}),
});
if (!isAdmin) {
delete payload.clusterId;
delete payload.poolId;
}
const res = await api.post('/applications', payload);
const appId = res.data.id;
@@ -524,10 +504,6 @@ export default function DeployPage() {
: {}),
...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}),
});
if (!isAdmin) {
delete payload.clusterId;
delete payload.poolId;
}
const res = await api.post('/applications', payload);
const appId = res.data.id;
@@ -684,10 +660,6 @@ export default function DeployPage() {
if (enableCustomDomain && customDomainInput.trim()) {
payload.customDomain = customDomainInput.trim();
}
if (!isAdmin) {
delete payload.clusterId;
delete payload.poolId;
}
createMutation.mutate(sanitizePayloadForWordPressRuntime(payload));
};
@@ -2101,181 +2073,6 @@ export default function DeployPage() {
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">{dw.resourcesConfig}</h2>
{/* Cluster Assignment Mode — Super Admin only */}
{isAdmin && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{dw.clusterAssignment}</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
<button
type="button"
onClick={() => {
setClusterMode('default');
setForm({ ...form, clusterId: undefined, poolId: undefined });
}}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
clusterMode === 'default'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Home className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">{dw.default}</p>
<p className="text-xs text-gray-500">{dw.useDefaultCluster}</p>
</button>
<button
type="button"
onClick={() => {
setClusterMode('manual');
setForm({ ...form, poolId: undefined });
}}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
clusterMode === 'manual'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Target className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">{dw.manual}</p>
<p className="text-xs text-gray-500">{dw.pickSpecificCluster}</p>
</button>
<button
type="button"
onClick={() => {
setClusterMode('pool');
setForm({ ...form, clusterId: undefined });
}}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
clusterMode === 'pool'
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Scale className="w-5 h-5 mx-auto text-gray-500" />
<p className="mt-1 font-semibold text-sm text-gray-900">{dw.loadBalanced}</p>
<p className="text-xs text-gray-500">{dw.pickClusterPool}</p>
</button>
</div>
{/* Manual: show cluster list */}
{clusterMode === 'manual' && (
<div className="space-y-2">
{clusters.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">{dw.noClustersAvailable}</p>
) : (
clusters.map((cluster) => (
<button
key={cluster.id}
type="button"
onClick={() => setForm({ ...form, clusterId: cluster.id })}
className={`w-full p-3 rounded-xl border-2 text-left transition-colors ${
form.clusterId === cluster.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Server className="w-5 h-5 text-gray-400" />
<div>
<p className="font-semibold text-sm text-gray-900">
{cluster.name}
{cluster.isDefault && (
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">{dw.default}</span>
)}
</p>
<p className="text-xs text-gray-500">
{[cluster.provider, cluster.region].filter(Boolean).join(' · ') || 'No region info'}
</p>
</div>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
cluster.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
{cluster.status}
</span>
</div>
</button>
))
)}
</div>
)}
{/* Pool: show pool list */}
{clusterMode === 'pool' && (
<div className="space-y-2">
{pools.length === 0 ? (
<div className="text-center py-4">
<p className="text-sm text-gray-400">{dw.noClusterPools}</p>
<p className="text-xs text-gray-400 mt-1">{dw.askAdminPool}</p>
</div>
) : (
pools.map((pool) => (
<button
key={pool.id}
type="button"
onClick={() => setForm({ ...form, poolId: pool.id })}
className={`w-full p-3 rounded-xl border-2 text-left transition-colors ${
form.poolId === pool.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Scale className="w-5 h-5 text-gray-400" />
<div>
<p className="font-semibold text-sm text-gray-900">{pool.name}</p>
{pool.description && (
<p className="text-xs text-gray-500">{pool.description}</p>
)}
<div className="flex items-center space-x-2 mt-1">
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded flex items-center gap-1">
{pool.strategy === 'weighted-resource'
? <><BarChart3 className="w-3 h-3" />{dw.stratWeightedResource}</>
: pool.strategy === 'least-loaded'
? <><BarChart3 className="w-3 h-3" />{dw.stratLeastLoaded}</>
: pool.strategy === 'weighted-round-robin'
? <><RotateCw className="w-3 h-3" />{dw.stratWeightedRR}</>
: pool.strategy === 'region-based'
? <><BarChart3 className="w-3 h-3" />{dw.stratRegionBased}</>
: pool.strategy === 'least-apps'
? <><BarChart3 className="w-3 h-3" />{dw.stratLeastApps}</>
: <><RotateCw className="w-3 h-3" />{dw.stratRoundRobin}</>}
</span>
<span className="text-xs text-gray-400">
{pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}:
{' '}{pool.clusters.map((c) => c.name).join(', ')}
</span>
</div>
</div>
</div>
</div>
</button>
))
)}
</div>
)}
{/* Default: info text */}
{clusterMode === 'default' && (
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center space-x-3">
<Home className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-700">{dw.defaultClusterWillBeUsed}</p>
<p className="text-xs text-gray-500">
Your app will be deployed to the platform&apos;s default cluster
{clusters.find((c) => c.isDefault) && (
<> <strong>{clusters.find((c) => c.isDefault)?.name}</strong></>
)}
</p>
</div>
</div>
</div>
)}
</div>
)}
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-4">
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">{dw.applicationWorkload}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -2609,18 +2406,6 @@ export default function DeployPage() {
<span className="text-sm font-medium text-green-600">{dw.tokenProvided}</span>
</div>
)}
{isAdmin && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">{dw.cluster}</span>
<span className="text-sm font-medium">
{clusterMode === 'manual' && form.clusterId
? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}`
: clusterMode === 'pool' && form.poolId
? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)`
: dw.automaticAllocator}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-sm text-gray-500">{dw.cpu}</span>
<span className="text-sm font-medium">{form.cpuRequest} / {form.cpuLimit}</span>
-2
View File
@@ -246,8 +246,6 @@ export interface CreateApplicationDto {
memoryLimit?: string;
replicas?: number;
port?: number;
clusterId?: string;
poolId?: string;
customDomain?: string;
// Optional services
enableRedis?: boolean;