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:
@@ -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()
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -246,8 +246,6 @@ export interface CreateApplicationDto {
|
||||
memoryLimit?: string;
|
||||
replicas?: number;
|
||||
port?: number;
|
||||
clusterId?: string;
|
||||
poolId?: string;
|
||||
customDomain?: string;
|
||||
// Optional services
|
||||
enableRedis?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user