Add automatic cluster pool allocation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-18 23:08:06 +03:30
parent 97e4c865b6
commit 72a1519ea0
19 changed files with 983 additions and 151 deletions
@@ -227,6 +227,8 @@ export default function AdminClustersPage() {
kubeconfig: '',
region: '',
provider: '',
weight: 1,
tags: '',
isDefault: false,
});
@@ -236,12 +238,16 @@ export default function AdminClustersPage() {
});
const createMutation = useMutation({
mutationFn: (data: typeof form) => api.post('/clusters', data),
mutationFn: (data: typeof form) => api.post('/clusters', {
...data,
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
weight: Number(data.weight) || 1,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
toast.success('Cluster added & connection verified ✓');
setShowForm(false);
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', isDefault: false });
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false });
},
onError: (err: any) => {
const message = err?.response?.data?.message || 'Failed to add cluster';
@@ -333,6 +339,25 @@ export default function AdminClustersPage() {
<option value="bare-metal">Bare Metal</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Weight</label>
<input
type="number"
min={1}
className="input-field"
value={form.weight}
onChange={(e) => setForm({ ...form, weight: Number(e.target.value) || 1 })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tags</label>
<input
className="input-field"
placeholder="ssd, production, iran"
value={form.tags}
onChange={(e) => setForm({ ...form, tags: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
@@ -410,10 +435,39 @@ export default function AdminClustersPage() {
}`}>
{cluster.status}
</span>
<span className={`badge ${
cluster.healthStatus === 'healthy' ? 'badge-green'
: cluster.healthStatus === 'degraded' ? 'badge-yellow'
: cluster.healthStatus === 'unhealthy' ? 'badge-red'
: 'badge-gray'
}`}>
health: {cluster.healthStatus || 'unknown'}
</span>
<span className="badge badge-gray">weight {cluster.weight || 1}</span>
</div>
<p className="text-sm text-gray-500 truncate">
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
</p>
{cluster.healthMessage && (
<p className="text-xs text-gray-400 mt-1">
{cluster.healthMessage}
{cluster.lastHealthCheckedAt ? ` · ${new Date(cluster.lastHealthCheckedAt).toLocaleString()}` : ''}
</p>
)}
{cluster.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{cluster.tags.map((tag) => (
<span key={tag} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs">
{tag}
</span>
))}
</div>
)}
{cluster.availableResources && (
<p className="text-xs text-gray-500 mt-2">
CPU {cluster.availableResources.totalCpuAllocatable || 'n/a'} · Memory {cluster.availableResources.totalMemoryAllocatable || 'n/a'} · Pods {cluster.availableResources.podCount ?? 'n/a'} · Apps {cluster.availableResources.appCount ?? 'n/a'}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
@@ -16,8 +16,10 @@ export default function AdminPoolsPage() {
const [form, setForm] = useState({
name: '',
description: '',
strategy: 'least-apps' as 'least-apps' | 'round-robin',
strategy: 'weighted-resource' as 'least-apps' | 'round-robin' | 'weighted-resource',
clusterIds: [] as string[],
isDefault: false,
priority: 100,
});
const { data: pools = [], isLoading } = useQuery<ClusterPool[]>({
@@ -66,7 +68,7 @@ export default function AdminPoolsPage() {
const resetForm = () => {
setShowForm(false);
setEditingPool(null);
setForm({ name: '', description: '', strategy: 'least-apps', clusterIds: [] });
setForm({ name: '', description: '', strategy: 'weighted-resource', clusterIds: [], isDefault: false, priority: 100 });
};
const startEdit = (pool: ClusterPool) => {
@@ -76,6 +78,8 @@ export default function AdminPoolsPage() {
description: pool.description || '',
strategy: pool.strategy,
clusterIds: pool.clusterIds,
isDefault: pool.isDefault || false,
priority: pool.priority || 100,
});
setShowForm(true);
};
@@ -138,10 +142,21 @@ export default function AdminPoolsPage() {
value={form.strategy}
onChange={(e) => setForm({ ...form, strategy: e.target.value as any })}
>
<option value="weighted-resource">Weighted Resource prefer healthy capacity and higher weights</option>
<option value="least-apps">Least Apps deploy to cluster with fewest apps</option>
<option value="round-robin">Round Robin rotate across clusters evenly</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<input
type="number"
min={1}
className="input-field"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: Number(e.target.value) || 100 })}
/>
</div>
</div>
<div>
@@ -154,6 +169,15 @@ export default function AdminPoolsPage() {
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={form.isDefault}
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
/>
Use as default allocator pool
</label>
{/* Cluster selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
@@ -195,7 +219,7 @@ export default function AdminPoolsPage() {
)}
</p>
<p className="text-xs text-gray-500">
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'}
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · weight {cluster.weight || 1}
</p>
</div>
</div>
@@ -206,6 +230,17 @@ export default function AdminPoolsPage() {
}`}>
{cluster.status}
</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
cluster.healthStatus === 'healthy'
? 'bg-green-100 text-green-700'
: cluster.healthStatus === 'degraded'
? 'bg-yellow-100 text-yellow-700'
: cluster.healthStatus === 'unhealthy'
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-600'
}`}>
{cluster.healthStatus || 'unknown'}
</span>
</div>
</button>
);
@@ -278,8 +313,14 @@ export default function AdminPoolsPage() {
{pool.isActive ? 'Active' : 'Inactive'}
</span>
<span className="badge badge-purple flex items-center gap-1">
{pool.strategy === 'least-apps' ? <><BarChart3 className="w-3 h-3" /> Least Apps</> : <><RotateCw className="w-3 h-3" /> Round Robin</>}
{pool.strategy === 'weighted-resource'
? <><BarChart3 className="w-3 h-3" /> Weighted Resource</>
: pool.strategy === 'least-apps'
? <><BarChart3 className="w-3 h-3" /> Least Apps</>
: <><RotateCw className="w-3 h-3" /> Round Robin</>}
</span>
{pool.isDefault && <span className="badge badge-blue">Default Pool</span>}
<span className="badge badge-gray">Priority {pool.priority || 100}</span>
</div>
{pool.description && (
<p className="text-sm text-gray-500 mb-3">{pool.description}</p>
@@ -299,7 +340,7 @@ export default function AdminPoolsPage() {
<span>{cluster.status === 'active' ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}</span>
<span>{cluster.name}</span>
<span className="text-gray-400">
({cluster.provider || 'N/A'} · {cluster.region || 'N/A'})
({cluster.provider || 'N/A'} · {cluster.region || 'N/A'} · weight {cluster.weight || 1} · {cluster.healthStatus || 'unknown'})
</span>
</div>
)) : (
@@ -9,6 +9,7 @@ import { useState, useRef, useCallback, useEffect } from 'react';
import NextLink from 'next/link';
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
import { useAuthStore } from '@/lib/store';
import { BuildProgressModal } from '@/components/build-progress-modal';
/** Matches backend multipart limit for POST /applications/:id/upload */
@@ -55,6 +56,8 @@ export default function AppDetailPage() {
const queryClient = useQueryClient();
const confirm = useConfirm();
const appId = params.id as string;
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'admin';
const [showLogs, setShowLogs] = useState(false);
const [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -143,11 +146,13 @@ export default function AppDetailPage() {
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,
});
// Fetch DB storage size
@@ -1308,7 +1313,7 @@ export default function AppDetailPage() {
<dt className="text-sm text-gray-500">Port</dt>
<dd className="text-sm font-medium text-gray-900">{app.port}</dd>
</div>
{app.clusterId && (
{isAdmin && app.clusterId && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Cluster</dt>
<dd className="text-sm font-medium text-gray-900">
@@ -1316,7 +1321,7 @@ export default function AppDetailPage() {
</dd>
</div>
)}
{app.poolId && (
{isAdmin && app.poolId && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Pool</dt>
<dd className="text-sm font-medium text-gray-900">
+32 -26
View File
@@ -217,7 +217,7 @@ function minGiToFitFileBytes(bytes: number): number {
export default function DeployPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'admin' || user?.role === 'technical';
const isAdmin = user?.role === 'admin';
const [step, setStep] = useState(0);
const [form, setForm] = useState<CreateApplicationDto>({
name: '',
@@ -388,6 +388,10 @@ 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;
@@ -466,6 +470,10 @@ 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;
@@ -622,6 +630,10 @@ export default function DeployPage() {
if (enableCustomDomain && customDomainInput.trim()) {
payload.customDomain = customDomainInput.trim();
}
if (!isAdmin) {
delete payload.clusterId;
delete payload.poolId;
}
createMutation.mutate(sanitizePayloadForWordPressRuntime(payload));
};
@@ -2138,8 +2150,8 @@ export default function DeployPage() {
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Resources & Configuration</h2>
{/* Cluster Assignment Mode — Admin only */}
{isAdmin ? (
{/* Cluster Assignment Mode — Super Admin only */}
{isAdmin && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Cluster Assignment</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
@@ -2267,7 +2279,11 @@ export default function DeployPage() {
)}
<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 === 'least-apps' ? <><BarChart3 className="w-3 h-3" /> Least Apps</> : <><RotateCw className="w-3 h-3" /> Round Robin</>}
{pool.strategy === 'weighted-resource'
? <><BarChart3 className="w-3 h-3" /> Weighted Resource</>
: pool.strategy === 'least-apps'
? <><BarChart3 className="w-3 h-3" /> Least Apps</>
: <><RotateCw className="w-3 h-3" /> Round Robin</>}
</span>
<span className="text-xs text-gray-400">
{pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}:
@@ -2301,18 +2317,6 @@ export default function DeployPage() {
</div>
)}
</div>
) : (
<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">Cluster Assignment</p>
<p className="text-xs text-gray-500">
Your app will be automatically deployed to the platform&apos;s default cluster
</p>
</div>
</div>
</div>
)}
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-4">
@@ -2574,16 +2578,18 @@ export default function DeployPage() {
<span className="text-sm font-medium text-green-600">Token provided</span>
</div>
)}
<div className="flex justify-between">
<span className="text-sm text-gray-500">Cluster</span>
<span className="text-sm font-medium">
{isAdmin && clusterMode === 'manual' && form.clusterId
? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}`
: isAdmin && clusterMode === 'pool' && form.poolId
? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)`
: 'Default Cluster'}
</span>
</div>
{isAdmin && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">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)`
: 'Automatic allocator'}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-sm text-gray-500">CPU</span>
<span className="text-sm font-medium">{form.cpuRequest} / {form.cpuLimit}</span>
-2
View File
@@ -53,8 +53,6 @@ const adminNavItems: NavItem[] = [
const technicalNavItems: NavItem[] = [
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
{ href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: <Wrench className="w-4 h-4" /> },
];