Add automatic cluster pool allocation.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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" /> },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user