c1cc70eeb9
Full rebrand: UI display text, package scope (@ai-tycoon/* -> @token-empire/*), localStorage keys, Docker/CI image paths, database names, and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1477 lines
76 KiB
TypeScript
1477 lines
76 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import {
|
|
Plus, Server, MapPin, Zap, HardDrive, Wrench,
|
|
ChevronRight, Thermometer, Shield,
|
|
Rocket, Clock, Lock, Cpu,
|
|
Activity, DollarSign, Globe, Building2, Layers,
|
|
Network, ArrowLeft, RefreshCw, ChevronDown,
|
|
X, CheckCircle,
|
|
} from 'lucide-react';
|
|
import { TutorialHint } from '@/components/game/TutorialHint';
|
|
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
|
import { useGameStore, type InfraNav, computeFillForDC } from '@/store';
|
|
import {
|
|
formatMoney, formatNumber, formatPercent,
|
|
LOCATION_CONFIGS, DC_TIER_CONFIGS, RACK_SKU_CONFIGS,
|
|
CAMPUS_TIER_COSTS, CLUSTER_COST_CONFIG, FIRST_CAMPUS_BUILD_TICKS,
|
|
estimateNetworkSlots, maxComputeRacks,
|
|
SWITCH_TIER_CONFIGS,
|
|
DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT,
|
|
skuTotalFlops,
|
|
} from '@token-empire/shared';
|
|
import type {
|
|
DCTier, RackSkuId, LocationId, PipelineStage, Era,
|
|
Cluster, Campus, DataCenter, DeploymentCohort,
|
|
} from '@token-empire/shared';
|
|
|
|
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
|
|
|
const STAGE_LABELS: Record<PipelineStage, string> = {
|
|
ordered: 'Ordered', manufacturing: 'Mfg', receiving: 'Recv',
|
|
installation: 'Install', testing: 'Testing', repair: 'Repair',
|
|
'network-down': 'Net Down', decommission: 'Decom',
|
|
};
|
|
|
|
const STAGE_COLORS: Record<PipelineStage, string> = {
|
|
ordered: 'bg-surface-600', manufacturing: 'bg-blue-500', receiving: 'bg-cyan-500',
|
|
installation: 'bg-violet-500', testing: 'bg-amber-500', repair: 'bg-danger',
|
|
'network-down': 'bg-red-600', decommission: 'bg-surface-500',
|
|
};
|
|
|
|
// ─── Shared Components ──────────────────────────────────────────
|
|
|
|
function FleetStat({ label, value, sub, icon: Icon }: {
|
|
label: string; value: string; sub?: string; icon: typeof Server;
|
|
}) {
|
|
return (
|
|
<div className="bg-surface-900 border border-surface-700 rounded-lg p-3">
|
|
<div className="flex items-center gap-1.5 mb-1">
|
|
<Icon size={14} className="text-surface-400" />
|
|
<span className="text-[11px] text-surface-400 uppercase">{label}</span>
|
|
</div>
|
|
<div className="text-lg font-bold font-mono">{value}</div>
|
|
{sub && <div className="text-[11px] text-surface-500">{sub}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CapacityBar({ label, used, total }: { label: string; used: number; total: number }) {
|
|
const pct = total > 0 ? (used / total) * 100 : 0;
|
|
const color = pct > 90 ? 'bg-danger' : pct > 70 ? 'bg-amber-500' : 'bg-green-500';
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<span className="text-surface-400">{label}</span>
|
|
<span className="font-mono">{formatNumber(used)} / {formatNumber(total)}</span>
|
|
</div>
|
|
<div className="h-2 bg-surface-700 rounded-full overflow-hidden">
|
|
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${Math.min(100, pct)}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Breadcrumb({ nav }: { nav: InfraNav }) {
|
|
const setNav = useGameStore((s) => s.setInfraNav);
|
|
const clusters = useGameStore((s) => s.infrastructure.clusters);
|
|
|
|
const crumbs: { label: string; nav: InfraNav }[] = [
|
|
{ label: 'Infrastructure', nav: { level: 'clusters' } },
|
|
];
|
|
|
|
if (nav.level !== 'clusters' && nav.clusterId) {
|
|
const cluster = clusters.find(c => c.id === nav.clusterId);
|
|
if (cluster) {
|
|
crumbs.push({ label: cluster.name, nav: { level: 'cluster', clusterId: nav.clusterId } });
|
|
}
|
|
if ((nav.level === 'campus' || nav.level === 'datacenter') && nav.campusId) {
|
|
const campus = cluster?.campuses.find(c => c.id === nav.campusId);
|
|
if (campus) {
|
|
crumbs.push({ label: campus.name, nav: { level: 'campus', clusterId: nav.clusterId, campusId: nav.campusId } });
|
|
}
|
|
if (nav.level === 'datacenter' && nav.datacenterId) {
|
|
const dc = campus?.dataCenters.find(d => d.id === nav.datacenterId);
|
|
if (dc) {
|
|
crumbs.push({ label: dc.name, nav: { level: 'datacenter', clusterId: nav.clusterId, campusId: nav.campusId, datacenterId: nav.datacenterId } });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 text-sm mb-4">
|
|
{crumbs.map((crumb, i) => (
|
|
<span key={i} className="flex items-center gap-1">
|
|
{i > 0 && <ChevronRight size={14} className="text-surface-500" />}
|
|
{i < crumbs.length - 1 ? (
|
|
<button onClick={() => setNav(crumb.nav)} className="text-accent hover:underline">{crumb.label}</button>
|
|
) : (
|
|
<span className="text-surface-200 font-medium">{crumb.label}</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DeploymentProgressBar({ dc }: { dc: DataCenter }) {
|
|
const tierConfig = DC_TIER_CONFIGS[dc.tier];
|
|
const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier);
|
|
const pipelineRacks = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((s, c) => s + c.count, 0);
|
|
const totalTarget = dc.computeRacksOnline + pipelineRacks;
|
|
const pct = totalTarget > 0 ? (dc.computeRacksOnline / totalTarget) * 100 : 0;
|
|
|
|
if (totalTarget === 0 && dc.status === 'operational') return null;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<span className="text-surface-400">Deployment</span>
|
|
<span className="font-mono">{dc.computeRacksOnline} / {totalTarget} online</span>
|
|
</div>
|
|
<div className="h-2.5 bg-surface-700 rounded-full overflow-hidden">
|
|
<div className="h-full bg-green-500 rounded-full transition-all" style={{ width: `${Math.min(100, pct)}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CohortStageBreakdown({ cohorts }: { cohorts: DeploymentCohort[] }) {
|
|
const stages: PipelineStage[] = ['ordered', 'manufacturing', 'receiving', 'installation', 'testing', 'repair'];
|
|
const counts: Record<string, number> = {};
|
|
for (const stage of stages) counts[stage] = 0;
|
|
for (const c of cohorts) {
|
|
if (c.stage in counts) counts[c.stage] += c.count;
|
|
}
|
|
|
|
const hasAny = stages.some(s => counts[s] > 0);
|
|
if (!hasAny) return null;
|
|
|
|
return (
|
|
<div className="flex gap-3 flex-wrap text-xs">
|
|
{stages.map(stage => counts[stage] > 0 && (
|
|
<div key={stage} className="flex items-center gap-1.5">
|
|
<span className={`w-2 h-2 rounded-full ${STAGE_COLORS[stage]}`} />
|
|
<span className="text-surface-400">{STAGE_LABELS[stage]}:</span>
|
|
<span className="font-mono">{counts[stage]}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NetworkHealthIndicator({ dc }: { dc: DataCenter }) {
|
|
const ns = dc.networkSummary;
|
|
const torTotal = ns.totalByTier?.tor ?? 0;
|
|
if (torTotal === 0) return null;
|
|
|
|
const hasDisconnected = ns.racksDisconnected > 0;
|
|
const hasDegraded = ns.racksDegraded > 0;
|
|
const coreDown = (ns.healthyByTier?.t3 ?? 0) < (ns.totalByTier?.t3 ?? 0);
|
|
|
|
const color = coreDown ? 'text-danger'
|
|
: hasDisconnected ? 'text-danger'
|
|
: hasDegraded ? 'text-amber-400'
|
|
: 'text-green-400';
|
|
|
|
const bwPct = Math.round(ns.averageBandwidth * 100);
|
|
|
|
return (
|
|
<div className={`flex items-center gap-1 text-xs ${color}`}>
|
|
<Network size={12} />
|
|
<span>
|
|
{coreDown ? 'Core Down'
|
|
: hasDisconnected ? `${ns.racksDisconnected} disconnected`
|
|
: hasDegraded ? `${bwPct}% bandwidth`
|
|
: 'Healthy'}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Clusters List View ─────────────────────────────────────────
|
|
|
|
function ClustersListView() {
|
|
const clusters = useGameStore((s) => s.infrastructure.clusters);
|
|
const totalFlops = useGameStore((s) => s.infrastructure.totalFlops);
|
|
const totalRacks = useGameStore((s) => s.infrastructure.totalRackCount);
|
|
const totalUptime = useGameStore((s) => s.infrastructure.totalUptime);
|
|
const totalDCs = useGameStore((s) => s.infrastructure.totalDataCenterCount);
|
|
const setNav = useGameStore((s) => s.setInfraNav);
|
|
|
|
const [showBuild, setShowBuild] = useState(false);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold">Infrastructure</h2>
|
|
<button
|
|
onClick={() => setShowBuild(true)}
|
|
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium"
|
|
>
|
|
<Plus size={16} /> New Cluster
|
|
</button>
|
|
</div>
|
|
|
|
{clusters.length === 0 && (
|
|
<TutorialHint id="first-cluster">
|
|
Build your first cluster to establish a regional presence. Your first cluster is free!
|
|
</TutorialHint>
|
|
)}
|
|
|
|
{clusters.length > 0 && (
|
|
<div className="grid grid-cols-5 gap-3">
|
|
<FleetStat icon={Globe} label="Clusters" value={clusters.length.toString()} />
|
|
<FleetStat icon={Server} label="Data Centers" value={totalDCs.toString()} />
|
|
<FleetStat icon={Cpu} label="Racks" value={formatNumber(totalRacks)} />
|
|
<FleetStat icon={Zap} label="FLOPS" value={formatNumber(totalFlops)} />
|
|
<FleetStat icon={Activity} label="Uptime" value={formatPercent(totalUptime)} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{clusters.map(cluster => (
|
|
<button
|
|
key={cluster.id}
|
|
onClick={() => cluster.status === 'operational' && setNav({ level: 'cluster', clusterId: cluster.id })}
|
|
className={`bg-surface-800 border border-surface-700 rounded-xl p-5 text-left transition-colors ${
|
|
cluster.status === 'operational' ? 'hover:border-accent/50 cursor-pointer' : 'cursor-default'
|
|
}`}
|
|
title={cluster.status === 'constructing' ? 'Available when construction completes' : undefined}
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Globe size={18} className="text-accent" />
|
|
<h3 className="font-bold text-lg">{cluster.name}</h3>
|
|
{cluster.status === 'constructing' && (
|
|
<span className="text-xs bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded">Building...</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 text-surface-400">
|
|
<MapPin size={14} />
|
|
<span className="text-sm">{LOCATION_CONFIGS[cluster.locationId].name}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{cluster.status === 'constructing' ? (
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<span className="text-surface-400">Construction</span>
|
|
<span className="font-mono">{Math.floor((cluster.constructionProgress / cluster.constructionTotal) * 100)}%</span>
|
|
</div>
|
|
<div className="h-2 bg-surface-700 rounded-full overflow-hidden">
|
|
<div className="h-full bg-amber-500 rounded-full transition-all" style={{ width: `${(cluster.constructionProgress / cluster.constructionTotal) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-4 gap-4 text-sm">
|
|
<div><span className="text-surface-400">Campuses:</span> <span className="font-mono">{cluster.campuses.length}</span></div>
|
|
<div><span className="text-surface-400">DCs:</span> <span className="font-mono">{cluster.campuses.reduce((s, c) => s + c.dataCenters.length, 0)}</span></div>
|
|
<div><span className="text-surface-400">Racks:</span> <span className="font-mono">{
|
|
formatNumber(cluster.campuses.reduce((s, c) => s + c.dataCenters.reduce((s2, d) => s2 + d.computeRacksOnline + d.computeRacksFailed, 0), 0))
|
|
}</span></div>
|
|
<div className="flex items-center justify-end gap-1 text-surface-400">
|
|
<ChevronRight size={16} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{showBuild && <BuildClusterModal onClose={() => setShowBuild(false)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Build Cluster Modal ────────────────────────────────────────
|
|
|
|
function BuildClusterModal({ onClose }: { onClose: () => void }) {
|
|
const era = useGameStore((s) => s.meta.currentEra);
|
|
const money = useGameStore((s) => s.economy.money);
|
|
const existingClusters = useGameStore((s) => s.infrastructure.clusters);
|
|
const buildCluster = useGameStore((s) => s.buildCluster);
|
|
|
|
const [name, setName] = useState('');
|
|
const [location, setLocation] = useState<LocationId>('us-west');
|
|
|
|
const isFirst = existingClusters.length === 0;
|
|
const cost = isFirst ? 0 : CLUSTER_COST_CONFIG.baseCost;
|
|
const alreadyExists = existingClusters.some(c => c.locationId === location);
|
|
const locationConfig = LOCATION_CONFIGS[location];
|
|
const eraUnlocked = ERA_ORDER.indexOf(era) >= ERA_ORDER.indexOf(locationConfig.availableAt);
|
|
const canBuild = name.trim() !== '' && !alreadyExists && eraUnlocked && money >= cost;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-bold mb-4">Build New Cluster</h3>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-1 block">Cluster Name</label>
|
|
<input value={name} onChange={e => setName(e.target.value)} placeholder="e.g., US West Cluster"
|
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-1 block">Region</label>
|
|
<select value={location} onChange={e => setLocation(e.target.value as LocationId)}
|
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-sm">
|
|
{Object.values(LOCATION_CONFIGS).map(loc => {
|
|
const locked = ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(loc.availableAt);
|
|
const taken = existingClusters.some(c => c.locationId === loc.id);
|
|
return (
|
|
<option key={loc.id} value={loc.id} disabled={locked || taken}>
|
|
{loc.name} ({loc.energyCostMultiplier}x energy) {locked ? '🔒' : ''} {taken ? '(exists)' : ''}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</div>
|
|
|
|
{alreadyExists && <p className="text-xs text-amber-400">You already have a cluster in this region.</p>}
|
|
|
|
<div className="bg-surface-800 rounded-lg p-3 text-sm space-y-1">
|
|
<div className="flex justify-between"><span className="text-surface-400">Cost:</span><span className="font-mono">{isFirst ? 'Free' : formatMoney(cost)}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">Build Time:</span><span className="font-mono">{isFirst ? 'Instant' : `${CLUSTER_COST_CONFIG.buildTimeTicks}s`}</span></div>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button onClick={onClose} className="flex-1 bg-surface-700 hover:bg-surface-600 rounded-lg py-2 text-sm">Cancel</button>
|
|
<button onClick={() => { buildCluster(name.trim(), location); onClose(); }} disabled={!canBuild}
|
|
className="flex-1 bg-accent hover:bg-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
|
{isFirst ? 'Create Cluster' : `Build (${formatMoney(cost)})`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Cluster Fill All Modal ─────────────────────────────────────
|
|
|
|
function ClusterFillAllModal({ cluster, money, era, research, onConfirm, onClose }: {
|
|
cluster: Cluster;
|
|
money: number;
|
|
era: Era;
|
|
research: string[];
|
|
onConfirm: (skuId: RackSkuId) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const availableSkus = Object.values(RACK_SKU_CONFIGS).filter(s => {
|
|
if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(s.era)) return false;
|
|
if (s.requiredResearch.length > 0 && !s.requiredResearch.every(r => research.includes(r))) return false;
|
|
return true;
|
|
});
|
|
|
|
const allDCs = cluster.campuses.flatMap(c => c.dataCenters);
|
|
const mostCommonSku = useMemo(() => {
|
|
const counts: Record<string, number> = {};
|
|
for (const dc of allDCs) {
|
|
if (dc.rackSkuId) counts[dc.rackSkuId] = (counts[dc.rackSkuId] || 0) + 1;
|
|
}
|
|
const entries = Object.entries(counts);
|
|
return entries.length > 0 ? entries.sort((a, b) => b[1] - a[1])[0][0] as RackSkuId : availableSkus[0]?.id ?? null;
|
|
}, [allDCs, availableSkus]);
|
|
|
|
const [selectedSku, setSelectedSku] = useState<RackSkuId | null>(mostCommonSku);
|
|
|
|
const preview = useMemo(() => {
|
|
if (!selectedSku) return { campuses: [], totalQty: 0, totalCost: 0 };
|
|
let remaining = money;
|
|
const campuses = cluster.campuses.map(campus => {
|
|
if (campus.status !== 'operational') return { campus, dcs: [], totalQty: 0, totalCost: 0 };
|
|
const dcs = campus.dataCenters.map(dc => {
|
|
const { qty, cost } = computeFillForDC(dc, selectedSku, remaining);
|
|
if (qty > 0) remaining -= cost;
|
|
return { dc, qty, cost };
|
|
});
|
|
return { campus, dcs, totalQty: dcs.reduce((s, d) => s + d.qty, 0), totalCost: dcs.reduce((s, d) => s + d.cost, 0) };
|
|
});
|
|
return {
|
|
campuses,
|
|
totalQty: campuses.reduce((s, c) => s + c.totalQty, 0),
|
|
totalCost: campuses.reduce((s, c) => s + c.totalCost, 0),
|
|
};
|
|
}, [selectedSku, money, cluster.campuses]);
|
|
|
|
const fillableDCCount = preview.campuses.reduce((s, c) => s + c.dcs.filter(d => d.qty > 0).length, 0);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Rocket size={18} className="text-accent" /> Fill All DCs — {cluster.name}</h3>
|
|
|
|
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-2 block">Rack SKU</label>
|
|
<div className="space-y-1.5">
|
|
{availableSkus.map(s => (
|
|
<label key={s.id} className={`flex items-center gap-3 p-2.5 rounded-lg cursor-pointer border text-sm ${selectedSku === s.id ? 'border-accent bg-accent/10' : 'border-surface-600 hover:border-surface-500'}`}>
|
|
<input type="radio" name="clusterFillSku" checked={selectedSku === s.id} onChange={() => setSelectedSku(s.id)} className="accent-accent" />
|
|
<span className="font-medium">{s.name}</span>
|
|
<span className="text-xs text-surface-400 ml-auto">{formatMoney(s.baseCost)}/rack</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedSku && (
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-2 block">Preview by campus</label>
|
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
{preview.campuses.map(({ campus, totalQty, totalCost }) => (
|
|
<div key={campus.id} className="flex items-center justify-between text-xs px-2 py-1.5 rounded bg-surface-800">
|
|
<span className="truncate flex-1 mr-2">{campus.name} ({campus.dataCenters.length} DCs)</span>
|
|
{totalQty > 0 ? (
|
|
<span className="text-green-400 font-mono shrink-0">+{totalQty} racks ({formatMoney(totalCost)})</span>
|
|
) : (
|
|
<span className="text-surface-500 shrink-0">No change</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-surface-800 rounded-lg p-3 text-sm space-y-1">
|
|
<div className="flex justify-between"><span className="text-surface-400">Total racks:</span><span className="font-mono">{formatNumber(preview.totalQty)}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">Total cost:</span><span className="font-mono">{formatMoney(preview.totalCost)}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">Remaining budget:</span><span className="font-mono">{formatMoney(money - preview.totalCost)}</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3 mt-4">
|
|
<button onClick={onClose} className="flex-1 bg-surface-700 hover:bg-surface-600 rounded-lg py-2 text-sm">Cancel</button>
|
|
<button
|
|
onClick={() => selectedSku && onConfirm(selectedSku)}
|
|
disabled={!selectedSku || preview.totalQty === 0}
|
|
className="flex-1 bg-accent hover:bg-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
|
Fill {fillableDCCount} DCs ({formatNumber(preview.totalQty)} racks, {formatMoney(preview.totalCost)})
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Cluster Detail View ────────────────────────────────────────
|
|
|
|
function ClusterDetailView({ clusterId }: { clusterId: string }) {
|
|
const cluster = useGameStore((s) => s.infrastructure.clusters.find(c => c.id === clusterId));
|
|
const setNav = useGameStore((s) => s.setInfraNav);
|
|
const fillCluster = useGameStore((s) => s.fillClusterToCapacity);
|
|
const money = useGameStore((s) => s.economy.money);
|
|
const era = useGameStore((s) => s.meta.currentEra);
|
|
const research = useGameStore((s) => s.research.completedResearch);
|
|
const [showBuild, setShowBuild] = useState(false);
|
|
const [showFillAll, setShowFillAll] = useState(false);
|
|
|
|
if (!cluster) return <div className="text-surface-400">Cluster not found.</div>;
|
|
|
|
const location = LOCATION_CONFIGS[cluster.locationId];
|
|
|
|
const allDCs = cluster.campuses.flatMap(c => c.dataCenters);
|
|
const hasOperationalDCs = allDCs.some(dc => dc.status === 'operational');
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Globe size={22} className="text-accent" />
|
|
<div>
|
|
<h2 className="text-xl font-bold">{cluster.name}</h2>
|
|
<p className="text-sm text-surface-400">{location.name} — {location.energyCostMultiplier}x energy cost</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{hasOperationalDCs && (
|
|
<button onClick={() => setShowFillAll(true)}
|
|
className="flex items-center gap-1.5 bg-surface-700 hover:bg-surface-600 text-white rounded-lg px-3 py-2 text-sm">
|
|
<Rocket size={14} /> Fill All DCs
|
|
</button>
|
|
)}
|
|
<button onClick={() => setShowBuild(true)}
|
|
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium">
|
|
<Plus size={16} /> New Campus
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{cluster.campuses.length === 0 && (
|
|
<TutorialHint id="first-campus">
|
|
Build a campus to start housing data centers. All DCs on a campus share the same tier.
|
|
</TutorialHint>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{cluster.campuses.map(campus => (
|
|
<button key={campus.id}
|
|
onClick={() => campus.status === 'operational' && setNav({ level: 'campus', clusterId, campusId: campus.id })}
|
|
className="bg-surface-800 border border-surface-700 rounded-xl p-5 text-left hover:border-accent/50 transition-colors">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 size={18} className="text-blue-400" />
|
|
<h3 className="font-bold">{campus.name}</h3>
|
|
<span className="text-xs bg-surface-700 text-surface-300 px-2 py-0.5 rounded">{DC_TIER_CONFIGS[campus.dcTier].name}</span>
|
|
{campus.status === 'constructing' && (
|
|
<span className="text-xs bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded">Building...</span>
|
|
)}
|
|
{campus.retrofitQueue && (
|
|
<span className="text-xs bg-violet-500/20 text-violet-400 px-2 py-0.5 rounded">Retrofitting</span>
|
|
)}
|
|
</div>
|
|
<ChevronRight size={16} className="text-surface-400" />
|
|
</div>
|
|
|
|
{campus.status === 'constructing' ? (
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<span className="text-surface-400">Construction</span>
|
|
<span className="font-mono">{Math.floor((campus.constructionProgress / campus.constructionTotal) * 100)}%</span>
|
|
</div>
|
|
<div className="h-2 bg-surface-700 rounded-full overflow-hidden">
|
|
<div className="h-full bg-amber-500 rounded-full transition-all" style={{ width: `${(campus.constructionProgress / campus.constructionTotal) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
|
<div><span className="text-surface-400">DCs:</span> <span className="font-mono">{campus.dataCenters.length}</span></div>
|
|
<div><span className="text-surface-400">Racks:</span> <span className="font-mono">{
|
|
formatNumber(campus.dataCenters.reduce((s, d) => s + d.computeRacksOnline + d.computeRacksFailed, 0))
|
|
}</span></div>
|
|
<div><span className="text-surface-400">FLOPS:</span> <span className="font-mono">{
|
|
formatNumber(campus.dataCenters.reduce((s, d) => {
|
|
const sku = d.rackSkuId ? RACK_SKU_CONFIGS[d.rackSkuId] : null;
|
|
return s + (sku ? d.effectiveComputeRacks * skuTotalFlops(sku) : 0);
|
|
}, 0))
|
|
}</span></div>
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{showBuild && <BuildCampusModal clusterId={clusterId} onClose={() => setShowBuild(false)} />}
|
|
|
|
{showFillAll && (
|
|
<ClusterFillAllModal
|
|
cluster={cluster}
|
|
money={money}
|
|
era={era}
|
|
research={research}
|
|
onConfirm={(skuId) => { fillCluster(clusterId, skuId); setShowFillAll(false); }}
|
|
onClose={() => setShowFillAll(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Build Campus Modal ─────────────────────────────────────────
|
|
|
|
function BuildCampusModal({ clusterId, onClose }: { clusterId: string; onClose: () => void }) {
|
|
const era = useGameStore((s) => s.meta.currentEra);
|
|
const money = useGameStore((s) => s.economy.money);
|
|
const research = useGameStore((s) => s.research.completedResearch);
|
|
const buildCampus = useGameStore((s) => s.buildCampus);
|
|
const isFirstCampus = useGameStore((s) => s.infrastructure.clusters.every(c => c.campuses.length === 0));
|
|
|
|
const [name, setName] = useState('');
|
|
const [tier, setTier] = useState<DCTier>('small');
|
|
|
|
const tierConfig = DC_TIER_CONFIGS[tier];
|
|
const campusCost = CAMPUS_TIER_COSTS[tier];
|
|
const effectiveCost = isFirstCampus ? 0 : campusCost.baseCost;
|
|
const eraUnlocked = ERA_ORDER.indexOf(era) >= ERA_ORDER.indexOf(tierConfig.requiredEra);
|
|
const researchUnlocked = !tierConfig.requiredResearch || research.includes(tierConfig.requiredResearch);
|
|
const canBuild = name.trim() !== '' && eraUnlocked && researchUnlocked && money >= effectiveCost;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-bold mb-4">Build New Campus</h3>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-1 block">Campus Name</label>
|
|
<input value={name} onChange={e => setName(e.target.value)} placeholder="e.g., Campus Alpha"
|
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-1 block">DC Tier (all DCs on this campus)</label>
|
|
<select value={tier} onChange={e => setTier(e.target.value as DCTier)}
|
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-sm">
|
|
{Object.values(DC_TIER_CONFIGS).map(tc => {
|
|
const locked = ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(tc.requiredEra)
|
|
|| (tc.requiredResearch && !research.includes(tc.requiredResearch));
|
|
return (
|
|
<option key={tc.tier} value={tc.tier} disabled={!!locked}>
|
|
{tc.name} ({tc.rackSlots} slots, {formatMoney(tc.baseCost)}/DC) {locked ? '🔒' : ''}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="bg-surface-800 rounded-lg p-3 text-sm space-y-1">
|
|
<div className="flex justify-between"><span className="text-surface-400">Campus Cost:</span><span className="font-mono">{isFirstCampus ? 'Free' : formatMoney(campusCost.baseCost)}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">Build Time:</span><span className="font-mono">{isFirstCampus ? `${FIRST_CAMPUS_BUILD_TICKS}s` : `${campusCost.buildTimeTicks}s`}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">DC Slots:</span><span className="font-mono">{tierConfig.rackSlots} racks/DC</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">DC Power:</span><span className="font-mono">{formatNumber(tierConfig.powerBudgetKW)} kW/DC</span></div>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button onClick={onClose} className="flex-1 bg-surface-700 hover:bg-surface-600 rounded-lg py-2 text-sm">Cancel</button>
|
|
<button onClick={() => { buildCampus(name.trim(), clusterId, tier); onClose(); }} disabled={!canBuild}
|
|
className="flex-1 bg-accent hover:bg-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
|
Build {isFirstCampus ? '(Free)' : `(${formatMoney(effectiveCost)})`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Campus Bulk Action Components ─────────────────────────────
|
|
|
|
function FillAllDCsModal({ campus, money, era, research, onConfirm, onClose }: {
|
|
campus: Campus;
|
|
money: number;
|
|
era: Era;
|
|
research: string[];
|
|
onConfirm: (skuId: RackSkuId) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const availableSkus = Object.values(RACK_SKU_CONFIGS).filter(s => {
|
|
if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(s.era)) return false;
|
|
if (s.requiredResearch.length > 0 && !s.requiredResearch.every(r => research.includes(r))) return false;
|
|
return true;
|
|
});
|
|
|
|
const mostCommonSku = useMemo(() => {
|
|
const counts: Record<string, number> = {};
|
|
for (const dc of campus.dataCenters) {
|
|
if (dc.rackSkuId) counts[dc.rackSkuId] = (counts[dc.rackSkuId] || 0) + 1;
|
|
}
|
|
const entries = Object.entries(counts);
|
|
return entries.length > 0 ? entries.sort((a, b) => b[1] - a[1])[0][0] as RackSkuId : availableSkus[0]?.id ?? null;
|
|
}, [campus.dataCenters, availableSkus]);
|
|
|
|
const [selectedSku, setSelectedSku] = useState<RackSkuId | null>(mostCommonSku);
|
|
|
|
const preview = useMemo(() => {
|
|
if (!selectedSku) return { dcs: [], totalQty: 0, totalCost: 0 };
|
|
let remaining = money;
|
|
const dcs = campus.dataCenters.map(dc => {
|
|
if (dc.status !== 'operational') return { dc, qty: 0, cost: 0, reason: dc.status === 'constructing' ? 'Constructing' : 'Retrofitting' as string | null };
|
|
if (dc.status === 'operational' && dc.rackSkuId && dc.rackSkuId !== selectedSku) return { dc, qty: 0, cost: 0, reason: `Different SKU (${RACK_SKU_CONFIGS[dc.rackSkuId].name})` };
|
|
const { qty, cost } = computeFillForDC(dc, selectedSku, remaining);
|
|
if (qty === 0) {
|
|
const tierConfig = DC_TIER_CONFIGS[dc.tier];
|
|
const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier);
|
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
|
const isFull = maxCompute - (dc.computeRacksOnline + pipelineCount) <= 0;
|
|
return { dc, qty: 0, cost: 0, reason: isFull ? 'Already full' : 'No budget' };
|
|
}
|
|
remaining -= cost;
|
|
return { dc, qty, cost, reason: null };
|
|
});
|
|
return { dcs, totalQty: dcs.reduce((s, d) => s + d.qty, 0), totalCost: dcs.reduce((s, d) => s + d.cost, 0) };
|
|
}, [selectedSku, money, campus.dataCenters]);
|
|
|
|
const fillableDCCount = preview.dcs.filter(d => d.qty > 0).length;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Rocket size={18} className="text-accent" /> Fill All DCs</h3>
|
|
|
|
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-2 block">Rack SKU</label>
|
|
<div className="space-y-1.5">
|
|
{availableSkus.map(s => (
|
|
<label key={s.id} className={`flex items-center gap-3 p-2.5 rounded-lg cursor-pointer border text-sm ${selectedSku === s.id ? 'border-accent bg-accent/10' : 'border-surface-600 hover:border-surface-500'}`}>
|
|
<input type="radio" name="fillSku" checked={selectedSku === s.id} onChange={() => setSelectedSku(s.id)} className="accent-accent" />
|
|
<span className="font-medium">{s.name}</span>
|
|
<span className="text-xs text-surface-400 ml-auto">{formatMoney(s.baseCost)}/rack</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedSku && (
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-2 block">Preview ({fillableDCCount} of {campus.dataCenters.length} DCs)</label>
|
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
{preview.dcs.map(({ dc, qty, cost, reason }) => (
|
|
<div key={dc.id} className="flex items-center justify-between text-xs px-2 py-1.5 rounded bg-surface-800">
|
|
<span className="truncate flex-1 mr-2">{dc.name}</span>
|
|
{reason ? (
|
|
<span className="text-surface-500 shrink-0">{reason}</span>
|
|
) : (
|
|
<span className="text-green-400 font-mono shrink-0">+{qty} racks ({formatMoney(cost)})</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-surface-800 rounded-lg p-3 text-sm space-y-1">
|
|
<div className="flex justify-between"><span className="text-surface-400">Total racks:</span><span className="font-mono">{formatNumber(preview.totalQty)}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">Total cost:</span><span className="font-mono">{formatMoney(preview.totalCost)}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">Remaining budget:</span><span className="font-mono">{formatMoney(money - preview.totalCost)}</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3 mt-4">
|
|
<button onClick={onClose} className="flex-1 bg-surface-700 hover:bg-surface-600 rounded-lg py-2 text-sm">Cancel</button>
|
|
<button
|
|
onClick={() => selectedSku && onConfirm(selectedSku)}
|
|
disabled={!selectedSku || preview.totalQty === 0}
|
|
className="flex-1 bg-accent hover:bg-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
|
Fill {fillableDCCount} DCs ({formatNumber(preview.totalQty)} racks, {formatMoney(preview.totalCost)})
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RetrofitCampusModal({ campus, era, research, onConfirm, onClose }: {
|
|
campus: Campus;
|
|
era: Era;
|
|
research: string[];
|
|
onConfirm: (skuId: RackSkuId, maxConcurrent: number) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const [selectedSku, setSelectedSku] = useState<RackSkuId | null>(null);
|
|
const [concurrencyMode, setConcurrencyMode] = useState<'1' | '10' | '25' | 'custom'>('10');
|
|
const [customCount, setCustomCount] = useState(1);
|
|
|
|
const currentSkuIds = [...new Set(campus.dataCenters.filter(dc => dc.rackSkuId).map(dc => dc.rackSkuId!))];
|
|
|
|
const targetSkus = Object.values(RACK_SKU_CONFIGS).filter(s => {
|
|
if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(s.era)) return false;
|
|
if (s.requiredResearch.length > 0 && !s.requiredResearch.every(r => research.includes(r))) return false;
|
|
return true;
|
|
});
|
|
|
|
const eligible = useMemo(() => {
|
|
if (!selectedSku) return { count: 0, skipped: 0 };
|
|
let count = 0;
|
|
let skipped = 0;
|
|
for (const dc of campus.dataCenters) {
|
|
if (dc.status !== 'operational' || !dc.rackSkuId || dc.rackSkuId === selectedSku) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
|
if (dc.computeRacksOnline + pipelineCount <= 0) { skipped++; continue; }
|
|
count++;
|
|
}
|
|
return { count, skipped };
|
|
}, [selectedSku, campus.dataCenters]);
|
|
|
|
const maxConcurrent = useMemo(() => {
|
|
if (eligible.count === 0) return 1;
|
|
switch (concurrencyMode) {
|
|
case '1': return 1;
|
|
case '10': return Math.max(1, Math.ceil(eligible.count * 0.1));
|
|
case '25': return Math.max(1, Math.ceil(eligible.count * 0.25));
|
|
case 'custom': return Math.max(1, Math.min(customCount, eligible.count));
|
|
}
|
|
}, [concurrencyMode, customCount, eligible.count]);
|
|
|
|
const capacityMaintained = eligible.count > 0 ? Math.round(((eligible.count - maxConcurrent) / eligible.count) * 100) : 100;
|
|
|
|
const estimatedBatches = eligible.count > 0 ? Math.ceil(eligible.count / maxConcurrent) : 0;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><RefreshCw size={18} className="text-violet-400" /> Retrofit Campus</h3>
|
|
|
|
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-2 block">Target SKU</label>
|
|
<div className="space-y-1.5">
|
|
{targetSkus.map(s => {
|
|
const isCurrentOnly = currentSkuIds.length === 1 && currentSkuIds[0] === s.id;
|
|
return (
|
|
<button key={s.id}
|
|
onClick={() => !isCurrentOnly && setSelectedSku(s.id)}
|
|
disabled={isCurrentOnly}
|
|
className={`w-full flex items-center justify-between p-3 rounded-lg border text-left text-sm transition-colors ${
|
|
selectedSku === s.id ? 'border-violet-500 bg-violet-500/10' :
|
|
isCurrentOnly ? 'border-surface-700 opacity-40 cursor-not-allowed' :
|
|
'border-surface-600 hover:border-surface-500 cursor-pointer'
|
|
}`}>
|
|
<div>
|
|
<div className="font-medium">{s.name}</div>
|
|
<div className="text-xs text-surface-400">{s.trainingFlops}T / {s.inferenceFlops}I FLOPS | {s.totalVramGB}GB | {s.powerDrawKW} kW | {formatMoney(s.baseCost)}/rack</div>
|
|
</div>
|
|
{isCurrentOnly && <span className="text-xs text-surface-500">Current</span>}
|
|
{selectedSku === s.id && <CheckCircle size={16} className="text-violet-400" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedSku && eligible.count > 0 && (
|
|
<>
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-2 block">Concurrency</label>
|
|
<div className="flex gap-1.5">
|
|
{([
|
|
{ key: '1' as const, label: '1 at a time' },
|
|
{ key: '10' as const, label: `10% = ${Math.max(1, Math.ceil(eligible.count * 0.1))}` },
|
|
{ key: '25' as const, label: `25% = ${Math.max(1, Math.ceil(eligible.count * 0.25))}` },
|
|
{ key: 'custom' as const, label: 'Custom' },
|
|
]).map(opt => (
|
|
<button key={opt.key} onClick={() => setConcurrencyMode(opt.key)}
|
|
className={`flex-1 py-2 text-xs rounded-lg border transition-colors ${
|
|
concurrencyMode === opt.key ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-surface-600 text-surface-400 hover:border-surface-500'
|
|
}`}>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{concurrencyMode === 'custom' && (
|
|
<input type="number" min={1} max={eligible.count} value={customCount}
|
|
onChange={e => setCustomCount(Math.max(1, parseInt(e.target.value) || 1))}
|
|
className="mt-2 w-full bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-sm" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-surface-800 rounded-lg p-3 text-sm space-y-1.5">
|
|
<div className="flex justify-between"><span className="text-surface-400">DCs to retrofit:</span><span>{eligible.count} of {campus.dataCenters.length}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">Concurrent retrofits:</span><span>{maxConcurrent}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">Estimated batches:</span><span>{estimatedBatches}</span></div>
|
|
<div className="flex justify-between">
|
|
<span className="text-surface-400">Capacity maintained:</span>
|
|
<span className={capacityMaintained >= 75 ? 'text-green-400' : capacityMaintained >= 50 ? 'text-amber-400' : 'text-red-400'}>~{capacityMaintained}%</span>
|
|
</div>
|
|
{eligible.skipped > 0 && (
|
|
<div className="text-xs text-surface-500 mt-1">{eligible.skipped} DCs skipped (constructing, empty, or already target SKU)</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3 mt-4">
|
|
<button onClick={onClose} className="flex-1 bg-surface-700 hover:bg-surface-600 rounded-lg py-2 text-sm">Cancel</button>
|
|
<button
|
|
onClick={() => selectedSku && onConfirm(selectedSku, maxConcurrent)}
|
|
disabled={!selectedSku || eligible.count === 0}
|
|
className="flex-1 bg-violet-600 hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
|
Start Campus Retrofit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CampusRetrofitProgress({ campus, onCancel }: { campus: Campus; onCancel: () => void }) {
|
|
const queue = campus.retrofitQueue;
|
|
if (!queue) return null;
|
|
|
|
const total = queue.pendingDCIds.length + queue.activeDCIds.length + queue.completedDCIds.length;
|
|
const completed = queue.completedDCIds.length;
|
|
const active = queue.activeDCIds.length;
|
|
const pending = queue.pendingDCIds.length;
|
|
const pct = total > 0 ? (completed / total) * 100 : 0;
|
|
|
|
return (
|
|
<div className="bg-violet-500/10 border border-violet-500/30 rounded-xl p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<RefreshCw size={16} className="text-violet-400 animate-spin" style={{ animationDuration: '3s' }} />
|
|
<span className="text-sm font-medium">Campus Retrofit — {RACK_SKU_CONFIGS[queue.targetSkuId].name}</span>
|
|
</div>
|
|
<button onClick={onCancel} className="flex items-center gap-1 text-xs text-surface-400 hover:text-red-400 transition-colors">
|
|
<X size={12} /> Cancel
|
|
</button>
|
|
</div>
|
|
<div className="h-2 bg-surface-700 rounded-full overflow-hidden">
|
|
<div className="h-full bg-violet-500 rounded-full transition-all" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<div className="flex justify-between text-xs text-surface-400">
|
|
<span>{completed} of {total} complete</span>
|
|
<span>{active} active | {pending} pending</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Campus Detail View ────────────────────────────────────────���
|
|
|
|
function CampusDetailView({ clusterId, campusId }: { clusterId: string; campusId: string }) {
|
|
const cluster = useGameStore((s) => s.infrastructure.clusters.find(c => c.id === clusterId));
|
|
const campus = cluster?.campuses.find(c => c.id === campusId);
|
|
const setNav = useGameStore((s) => s.setInfraNav);
|
|
const buildDC = useGameStore((s) => s.buildDataCenter);
|
|
const addDCs = useGameStore((s) => s.addDCsToCampus);
|
|
const fillCampus = useGameStore((s) => s.fillCampusToCapacity);
|
|
const startRetrofit = useGameStore((s) => s.startCampusRetrofit);
|
|
const cancelRetrofit = useGameStore((s) => s.cancelCampusRetrofit);
|
|
const money = useGameStore((s) => s.economy.money);
|
|
const era = useGameStore((s) => s.meta.currentEra);
|
|
const research = useGameStore((s) => s.research.completedResearch);
|
|
|
|
const [showAddDC, setShowAddDC] = useState(false);
|
|
const [dcName, setDcName] = useState('');
|
|
const [bulkCount, setBulkCount] = useState(1);
|
|
const [showFillAll, setShowFillAll] = useState(false);
|
|
const [showRetrofit, setShowRetrofit] = useState(false);
|
|
|
|
if (!campus || !cluster) return <div className="text-surface-400">Campus not found.</div>;
|
|
|
|
const tierConfig = DC_TIER_CONFIGS[campus.dcTier];
|
|
const operationalDCs = campus.dataCenters.filter(dc => dc.status === 'operational');
|
|
const hasRetrofitQueue = !!campus.retrofitQueue;
|
|
|
|
const fillableDCs = operationalDCs.filter(dc => {
|
|
const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier);
|
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0);
|
|
return maxCompute - (dc.computeRacksOnline + pipelineCount) > 0;
|
|
});
|
|
|
|
const retrofitEligibleDCs = operationalDCs.filter(dc =>
|
|
dc.rackSkuId && (dc.computeRacksOnline + dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((sum, c) => sum + c.count, 0)) > 0,
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Building2 size={22} className="text-blue-400" />
|
|
<div>
|
|
<h2 className="text-xl font-bold">{campus.name}</h2>
|
|
<p className="text-sm text-surface-400">{tierConfig.name} campus — {campus.dataCenters.length} DCs</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{operationalDCs.length > 0 && !hasRetrofitQueue && (
|
|
<>
|
|
<button onClick={() => setShowFillAll(true)}
|
|
disabled={fillableDCs.length === 0}
|
|
className="flex items-center gap-1.5 bg-surface-700 hover:bg-surface-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg px-3 py-2 text-sm">
|
|
<Rocket size={14} /> Fill All DCs
|
|
</button>
|
|
<button onClick={() => setShowRetrofit(true)}
|
|
disabled={retrofitEligibleDCs.length === 0}
|
|
className="flex items-center gap-1.5 bg-violet-600 hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg px-3 py-2 text-sm">
|
|
<RefreshCw size={14} /> Retrofit Campus
|
|
</button>
|
|
</>
|
|
)}
|
|
<button onClick={() => setShowAddDC(true)}
|
|
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium">
|
|
<Plus size={16} /> Add DC
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Campus Retrofit Progress Banner */}
|
|
{campus.retrofitQueue && (
|
|
<CampusRetrofitProgress campus={campus} onCancel={() => cancelRetrofit(campusId)} />
|
|
)}
|
|
|
|
{campus.dataCenters.length === 0 && (
|
|
<TutorialHint id="first-dc">
|
|
Add a data center to this campus. Once built, you can deploy racks to start generating compute.
|
|
</TutorialHint>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{campus.dataCenters.map(dc => {
|
|
const queueStatus = campus.retrofitQueue
|
|
? campus.retrofitQueue.pendingDCIds.includes(dc.id) ? 'queued'
|
|
: campus.retrofitQueue.completedDCIds.includes(dc.id) ? 'retrofit-done'
|
|
: null
|
|
: null;
|
|
|
|
return (
|
|
<button key={dc.id}
|
|
onClick={() => (dc.status === 'operational' || dc.status === 'retrofitting') && setNav({
|
|
level: 'datacenter', clusterId, campusId, datacenterId: dc.id,
|
|
})}
|
|
className="bg-surface-800 border border-surface-700 rounded-xl p-4 text-left hover:border-accent/50 transition-colors">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Server size={16} className="text-violet-400" />
|
|
<span className="font-bold">{dc.name}</span>
|
|
{dc.rackSkuId && <span className="text-xs bg-surface-700 text-surface-300 px-2 py-0.5 rounded">{RACK_SKU_CONFIGS[dc.rackSkuId].name}</span>}
|
|
{dc.status === 'constructing' && <span className="text-xs bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded">Building</span>}
|
|
{dc.status === 'retrofitting' && <span className="text-xs bg-violet-500/20 text-violet-400 px-2 py-0.5 rounded">Retrofitting</span>}
|
|
{queueStatus === 'queued' && <span className="text-xs bg-surface-600 text-surface-300 px-2 py-0.5 rounded">Queued</span>}
|
|
{queueStatus === 'retrofit-done' && <span className="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded flex items-center gap-1"><CheckCircle size={10} /> Done</span>}
|
|
</div>
|
|
<ChevronRight size={14} className="text-surface-500" />
|
|
</div>
|
|
|
|
{dc.status === 'constructing' ? (
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<span className="text-surface-400">Construction</span>
|
|
<span className="font-mono">{Math.floor((dc.constructionProgress / dc.constructionTotal) * 100)}%</span>
|
|
</div>
|
|
<div className="h-2 bg-surface-700 rounded-full overflow-hidden">
|
|
<div className="h-full bg-amber-500 rounded-full transition-all" style={{ width: `${(dc.constructionProgress / dc.constructionTotal) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<DeploymentProgressBar dc={dc} />
|
|
<NetworkHealthIndicator dc={dc} />
|
|
{dc.deploymentCohorts.length > 0 && <CohortStageBreakdown cohorts={dc.deploymentCohorts} />}
|
|
<div className="flex justify-between text-xs text-surface-400">
|
|
<span>Uptime: {formatPercent(dc.currentUptime)}</span>
|
|
<span>Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{showAddDC && (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowAddDC(false)}>
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-bold mb-4">Add Data Center{bulkCount > 1 ? 's' : ''}</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-1 block">DC Name</label>
|
|
<input value={dcName} onChange={e => setDcName(e.target.value)} placeholder={`${campus.name}-DC-${campus.dataCenters.length + 1}`}
|
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-1 block">Quantity</label>
|
|
<input type="number" min={1} max={20} value={bulkCount} onChange={e => setBulkCount(Math.max(1, parseInt(e.target.value) || 1))}
|
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div className="bg-surface-800 rounded-lg p-3 text-sm space-y-1">
|
|
<div className="flex justify-between"><span className="text-surface-400">Tier:</span><span>{tierConfig.name}</span></div>
|
|
<div className="flex justify-between"><span className="text-surface-400">Cost per DC:</span><span className="font-mono">{formatMoney(tierConfig.baseCost)}</span></div>
|
|
<div className="flex justify-between font-medium"><span className="text-surface-400">Total:</span><span className="font-mono">{formatMoney(tierConfig.baseCost * bulkCount)}</span></div>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button onClick={() => setShowAddDC(false)} className="flex-1 bg-surface-700 hover:bg-surface-600 rounded-lg py-2 text-sm">Cancel</button>
|
|
<button
|
|
onClick={() => {
|
|
if (bulkCount === 1) {
|
|
buildDC(dcName.trim() || `${campus.name}-DC-${campus.dataCenters.length + 1}`, campusId);
|
|
} else {
|
|
addDCs(campusId, bulkCount);
|
|
}
|
|
setShowAddDC(false);
|
|
}}
|
|
disabled={money < tierConfig.baseCost * bulkCount}
|
|
className="flex-1 bg-accent hover:bg-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
|
Build {bulkCount > 1 ? `${bulkCount} DCs` : 'DC'} ({formatMoney(tierConfig.baseCost * bulkCount)})
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showFillAll && (
|
|
<FillAllDCsModal
|
|
campus={campus}
|
|
money={money}
|
|
era={era}
|
|
research={research}
|
|
onConfirm={(skuId) => { fillCampus(campusId, skuId); setShowFillAll(false); }}
|
|
onClose={() => setShowFillAll(false)}
|
|
/>
|
|
)}
|
|
|
|
{showRetrofit && (
|
|
<RetrofitCampusModal
|
|
campus={campus}
|
|
era={era}
|
|
research={research}
|
|
onConfirm={(skuId, maxConcurrent) => { startRetrofit(campusId, skuId, maxConcurrent); setShowRetrofit(false); }}
|
|
onClose={() => setShowRetrofit(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Data Center Detail View ────────────────────────────────────
|
|
|
|
function DataCenterDetailView({ clusterId, campusId, datacenterId }: {
|
|
clusterId: string; campusId: string; datacenterId: string;
|
|
}) {
|
|
const cluster = useGameStore((s) => s.infrastructure.clusters.find(c => c.id === clusterId));
|
|
const campus = cluster?.campuses.find(c => c.id === campusId);
|
|
const dc = campus?.dataCenters.find(d => d.id === datacenterId);
|
|
const money = useGameStore((s) => s.economy.money);
|
|
const era = useGameStore((s) => s.meta.currentEra);
|
|
const research = useGameStore((s) => s.research.completedResearch);
|
|
const deployRacks = useGameStore((s) => s.deployRacks);
|
|
const fillToCapacity = useGameStore((s) => s.fillDCToCapacity);
|
|
const retrofitDC = useGameStore((s) => s.retrofitDC);
|
|
const cancelRetrofit = useGameStore((s) => s.cancelRetrofit);
|
|
const upgradeDataCenter = useGameStore((s) => s.upgradeDataCenter);
|
|
|
|
const [activeTab, setActiveTab] = useState<'deploy' | 'retrofit' | 'upgrades' | 'network'>('deploy');
|
|
const [selectedSku, setSelectedSku] = useState<RackSkuId | null>(null);
|
|
const [deployQty, setDeployQty] = useState(10);
|
|
const [confirmRetrofit, setConfirmRetrofit] = useState<RackSkuId | null>(null);
|
|
|
|
if (!dc || !cluster) return <div className="text-surface-400">Data center not found.</div>;
|
|
|
|
const tierConfig = DC_TIER_CONFIGS[dc.tier];
|
|
const maxCompute = maxComputeRacks(tierConfig.rackSlots, dc.tier);
|
|
const pipelineCount = dc.deploymentCohorts.filter(c => c.stage !== 'decommission').reduce((s, c) => s + c.count, 0);
|
|
const existingCompute = dc.computeRacksOnline + pipelineCount;
|
|
const availableSlots = maxCompute - existingCompute;
|
|
const sku = dc.rackSkuId ? RACK_SKU_CONFIGS[dc.rackSkuId] : null;
|
|
const netSlots = estimateNetworkSlots(existingCompute, dc.tier);
|
|
|
|
const availableSkus = Object.values(RACK_SKU_CONFIGS).filter(s => {
|
|
if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(s.era)) return false;
|
|
if (s.requiredResearch.length > 0 && !s.requiredResearch.every(r => research.includes(r))) return false;
|
|
if (dc.rackSkuId && dc.rackSkuId !== s.id) return false;
|
|
return true;
|
|
});
|
|
|
|
const effectiveSku = selectedSku ? RACK_SKU_CONFIGS[selectedSku] : null;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Server size={22} className="text-violet-400" />
|
|
<div>
|
|
<h2 className="text-xl font-bold">{dc.name}</h2>
|
|
<p className="text-sm text-surface-400">
|
|
{tierConfig.name} — {sku?.name ?? 'No racks deployed'} — {LOCATION_CONFIGS[cluster.locationId].name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{dc.status === 'retrofitting' && (
|
|
<span className="text-sm bg-violet-500/20 text-violet-400 px-3 py-1 rounded-lg">
|
|
Retrofitting to {dc.retrofitState ? RACK_SKU_CONFIGS[dc.retrofitState.toSkuId].name : '...'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-5 gap-3">
|
|
<FleetStat icon={Cpu} label="Online" value={formatNumber(dc.computeRacksOnline)} sub={`of ${maxCompute} max compute`} />
|
|
<FleetStat icon={Zap} label="FLOPS" value={`${formatNumber(dc.dcTrainingFlops)}T / ${formatNumber(dc.dcInferenceFlops)}I`} />
|
|
<FleetStat icon={HardDrive} label="VRAM" value={`${formatNumber(dc.dcTotalVramGB)} GB`} />
|
|
<FleetStat icon={Activity} label="Uptime" value={formatPercent(dc.currentUptime)} />
|
|
<FleetStat icon={DollarSign} label="Cost/s" value={formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)} />
|
|
</div>
|
|
|
|
{/* Capacity Bars */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<CapacityBar label="Rack Slots" used={dc.usedSlots} total={tierConfig.rackSlots} />
|
|
<CapacityBar label="Power (kW)" used={dc.usedPowerKW} total={tierConfig.powerBudgetKW} />
|
|
</div>
|
|
|
|
{/* Deployment Progress */}
|
|
<div className="bg-surface-800 border border-surface-700 rounded-xl p-4">
|
|
<DeploymentProgressBar dc={dc} />
|
|
{dc.deploymentCohorts.length > 0 && (
|
|
<div className="mt-3">
|
|
<CohortStageBreakdown cohorts={dc.deploymentCohorts} />
|
|
</div>
|
|
)}
|
|
{dc.computeRacksFailed > 0 && (
|
|
<div className="mt-2 text-xs text-danger flex items-center gap-1">
|
|
<Wrench size={12} /> {dc.computeRacksFailed} rack{dc.computeRacksFailed > 1 ? 's' : ''} under repair
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Retrofit Progress */}
|
|
{dc.status === 'retrofitting' && dc.retrofitState && (
|
|
<div className="bg-violet-500/10 border border-violet-500/30 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<RefreshCw size={16} className="text-violet-400" />
|
|
<span className="font-medium">Retrofit: {dc.retrofitState.phase}</span>
|
|
</div>
|
|
<button onClick={() => cancelRetrofit(datacenterId)} className="text-xs text-surface-400 hover:text-surface-200">Cancel</button>
|
|
</div>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<span className="text-surface-400">{RACK_SKU_CONFIGS[dc.retrofitState.fromSkuId].name} → {RACK_SKU_CONFIGS[dc.retrofitState.toSkuId].name}</span>
|
|
<span className="font-mono">{Math.floor((dc.retrofitState.progress / dc.retrofitState.total) * 100)}%</span>
|
|
</div>
|
|
<div className="h-2 bg-surface-700 rounded-full overflow-hidden">
|
|
<div className="h-full bg-violet-500 rounded-full transition-all" style={{ width: `${(dc.retrofitState.progress / dc.retrofitState.total) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
{dc.status === 'operational' && (
|
|
<>
|
|
<div className="flex gap-1 bg-surface-800 rounded-lg p-1">
|
|
{(['deploy', 'retrofit', 'upgrades', 'network'] as const).map(tab => (
|
|
<button key={tab} onClick={() => setActiveTab(tab)}
|
|
className={`flex-1 py-2 text-sm rounded-md transition-colors ${activeTab === tab ? 'bg-surface-700 text-white' : 'text-surface-400 hover:text-surface-200'}`}>
|
|
{tab === 'deploy' ? 'Deploy Racks' : tab === 'retrofit' ? 'Retrofit' : tab === 'upgrades' ? 'Upgrades' : 'Network'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Deploy Tab */}
|
|
{activeTab === 'deploy' && (
|
|
<div className="bg-surface-800 border border-surface-700 rounded-xl p-4 space-y-4">
|
|
{dc.rackSkuId === null ? (
|
|
<>
|
|
<p className="text-sm text-surface-400">Select a rack SKU for this data center. All racks in a DC must be the same type.</p>
|
|
<div className="space-y-2">
|
|
{availableSkus.map(s => (
|
|
<label key={s.id} className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer border ${selectedSku === s.id ? 'border-accent bg-accent/10' : 'border-surface-600 hover:border-surface-500'}`}>
|
|
<input type="radio" name="sku" checked={selectedSku === s.id} onChange={() => setSelectedSku(s.id)} className="accent-accent" />
|
|
<div className="flex-1">
|
|
<div className="font-medium text-sm">{s.name}</div>
|
|
<div className="text-xs text-surface-400">{s.trainingFlops}T / {s.inferenceFlops}I FLOPS | {s.totalVramGB}GB | {s.powerDrawKW} kW | {formatMoney(s.baseCost)}</div>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-sm text-surface-400">
|
|
This DC runs <span className="text-surface-200 font-medium">{sku!.name}</span>. Available: {availableSlots} compute slots.
|
|
</div>
|
|
)}
|
|
|
|
{(dc.rackSkuId || selectedSku) && availableSlots > 0 && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-surface-400 mb-1 block">Quantity</label>
|
|
<input type="number" min={1} max={availableSlots} value={deployQty}
|
|
onChange={e => setDeployQty(Math.max(1, Math.min(availableSlots, parseInt(e.target.value) || 1)))}
|
|
className="w-full bg-surface-900 border border-surface-600 rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
|
|
{(() => {
|
|
const skuToUse = dc.rackSkuId ?? selectedSku!;
|
|
const skuConfig = RACK_SKU_CONFIGS[skuToUse];
|
|
const newNetSlots = estimateNetworkSlots(existingCompute + deployQty, dc.tier);
|
|
const addedNet = newNetSlots - netSlots;
|
|
const totalCost = skuConfig.baseCost * deployQty;
|
|
return (
|
|
<div className="bg-surface-900 rounded-lg p-3 text-sm space-y-1">
|
|
<div className="flex justify-between"><span className="text-surface-400">{deployQty} compute racks ({skuConfig.name})</span><span className="font-mono">{formatMoney(totalCost)}</span></div>
|
|
<div className="flex justify-between text-xs text-surface-500"><span>+ {addedNet} network racks (auto)</span><span>included</span></div>
|
|
<div className="flex justify-between text-xs text-surface-500"><span>= {existingCompute + deployQty + newNetSlots} / {tierConfig.rackSlots} total slots</span></div>
|
|
<div className="flex justify-between text-xs text-surface-500"><span>Power: {formatNumber((existingCompute + deployQty) * skuConfig.powerDrawKW)} / {formatNumber(tierConfig.powerBudgetKW)} kW</span></div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => deployRacks(datacenterId, dc.rackSkuId ?? selectedSku!, deployQty)}
|
|
disabled={money < (RACK_SKU_CONFIGS[dc.rackSkuId ?? selectedSku!].baseCost * deployQty)}
|
|
className="flex-1 flex items-center justify-center gap-1.5 bg-accent hover:bg-accent-dark disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-medium">
|
|
<Rocket size={14} /> Deploy {deployQty} Racks
|
|
</button>
|
|
<button
|
|
onClick={() => fillToCapacity(datacenterId, dc.rackSkuId ?? selectedSku!)}
|
|
disabled={availableSlots === 0}
|
|
className="flex items-center gap-1.5 bg-surface-700 hover:bg-surface-600 disabled:opacity-50 text-white rounded-lg px-4 py-2 text-sm">
|
|
Fill to Capacity
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Retrofit Tab */}
|
|
{activeTab === 'retrofit' && (
|
|
<div className="bg-surface-800 border border-surface-700 rounded-xl p-4 space-y-4">
|
|
{!dc.rackSkuId ? (
|
|
<p className="text-sm text-surface-400">No racks deployed yet. Deploy racks first before retrofitting.</p>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-surface-400">
|
|
Retrofit swaps all {dc.computeRacksOnline + pipelineCount} <span className="text-surface-200">{sku!.name}</span> racks to a new SKU.
|
|
The DC goes offline during retrofit.
|
|
</p>
|
|
<div className="space-y-2">
|
|
{Object.values(RACK_SKU_CONFIGS).filter(s => {
|
|
if (s.id === dc.rackSkuId) return false;
|
|
if (ERA_ORDER.indexOf(era) < ERA_ORDER.indexOf(s.era)) return false;
|
|
if (s.requiredResearch.length > 0 && !s.requiredResearch.every(r => research.includes(r))) return false;
|
|
return true;
|
|
}).map(s => (
|
|
<button key={s.id} onClick={() => setConfirmRetrofit(s.id)}
|
|
className="w-full flex items-center justify-between p-3 rounded-lg border border-surface-600 hover:border-accent/50 text-left">
|
|
<div>
|
|
<div className="font-medium text-sm">{s.name}</div>
|
|
<div className="text-xs text-surface-400">{s.trainingFlops}T / {s.inferenceFlops}I FLOPS | {s.totalVramGB}GB | {s.powerDrawKW} kW | {formatMoney(s.baseCost)}/rack</div>
|
|
</div>
|
|
<RefreshCw size={14} className="text-surface-400" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Upgrades Tab */}
|
|
{activeTab === 'upgrades' && (
|
|
<div className="bg-surface-800 border border-surface-700 rounded-xl p-4 space-y-4">
|
|
{/* Cooling & Network Fabric */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex items-center gap-3 p-3 border border-surface-600 rounded-lg">
|
|
<Thermometer size={18} className="text-cyan-400" />
|
|
<div>
|
|
<div className="text-xs text-surface-400">Cooling Type</div>
|
|
<div className="font-medium text-sm capitalize">{dc.coolingType}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 p-3 border border-surface-600 rounded-lg">
|
|
<Network size={18} className="text-blue-400" />
|
|
<div>
|
|
<div className="text-xs text-surface-400">Network Fabric</div>
|
|
<div className="font-medium text-sm">{dc.networkFabric}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{(['cooling', 'redundancy'] as const).map(upgrade => {
|
|
const level = upgrade === 'cooling' ? dc.coolingLevel : dc.redundancyLevel;
|
|
const cost = tierConfig.baseCost * DC_UPGRADE_COST_FRACTION;
|
|
const maxed = level >= 1.0;
|
|
return (
|
|
<div key={upgrade} className="flex items-center justify-between p-3 border border-surface-600 rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
{upgrade === 'cooling' ? <Thermometer size={18} className="text-cyan-400" /> : <Shield size={18} className="text-green-400" />}
|
|
<div>
|
|
<div className="font-medium text-sm capitalize">{upgrade}</div>
|
|
<div className="text-xs text-surface-400">Level {Math.round(level * 10)}/10 — reduces {upgrade === 'cooling' ? 'test' : 'production'} failure rates</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => upgradeDataCenter(datacenterId, upgrade)}
|
|
disabled={maxed || money < cost}
|
|
className="bg-surface-700 hover:bg-surface-600 disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5 rounded-lg text-xs font-medium">
|
|
{maxed ? 'Maxed' : `Upgrade (${formatMoney(cost)})`}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Network Tab */}
|
|
{activeTab === 'network' && (
|
|
<div className="bg-surface-800 border border-surface-700 rounded-xl p-4 space-y-4">
|
|
<h4 className="font-medium text-sm flex items-center gap-2"><Network size={16} className="text-cyan-400" /> Network Topology</h4>
|
|
{dc.computeRacksOnline === 0 ? (
|
|
<p className="text-sm text-surface-400">No racks online. Deploy racks to see network topology.</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{/* Bandwidth gauge */}
|
|
<div className="p-3 border border-surface-600 rounded-lg">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm font-medium">Bandwidth</span>
|
|
<span className={`text-sm font-mono ${dc.networkSummary.averageBandwidth < 0.8 ? 'text-warning' : dc.networkSummary.averageBandwidth < 0.5 ? 'text-danger' : 'text-green-400'}`}>
|
|
{formatPercent(dc.networkSummary.averageBandwidth)}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-surface-900 rounded-full h-2">
|
|
<div
|
|
className={`h-2 rounded-full transition-all ${dc.networkSummary.averageBandwidth >= 0.8 ? 'bg-green-500' : dc.networkSummary.averageBandwidth >= 0.5 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
|
style={{ width: `${dc.networkSummary.averageBandwidth * 100}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between mt-1 text-xs text-surface-500">
|
|
<span>Effective FLOPS: {formatPercent(dc.networkSummary.effectiveFlopsFraction)}</span>
|
|
<span>{dc.networkSummary.racksDegraded} degraded</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Per-tier switch health */}
|
|
{(['tor', 't1', 't2', 't3'] as const).map(tier => {
|
|
const total = dc.networkSummary.totalByTier[tier] ?? 0;
|
|
if (total === 0) return null;
|
|
const healthy = dc.networkSummary.healthyByTier[tier] ?? 0;
|
|
const failed = total - healthy;
|
|
const config = SWITCH_TIER_CONFIGS[tier];
|
|
return (
|
|
<div key={tier} className="flex items-center justify-between p-3 border border-surface-600 rounded-lg">
|
|
<div>
|
|
<div className="font-medium text-sm">{config.name}</div>
|
|
<div className="text-xs text-surface-400">
|
|
{tier === 'tor' ? '1 per rack (embedded)' : `Fan-out ${config.fanOut}, ${config.uplinkCount} uplinks`}
|
|
</div>
|
|
</div>
|
|
<div className={`text-sm font-mono ${failed > 0 ? 'text-danger' : 'text-green-400'}`}>
|
|
{healthy} / {total}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{dc.networkSummary.racksDisconnected > 0 && (
|
|
<div className="text-sm text-danger flex items-center gap-2 p-2">
|
|
<Activity size={14} /> {dc.networkSummary.racksDisconnected} compute racks disconnected due to network failures
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Retrofit Confirmation Modal */}
|
|
{confirmRetrofit && (
|
|
<ConfirmModal
|
|
title="Confirm Retrofit"
|
|
message={`This will decommission all ${dc.computeRacksOnline + pipelineCount} ${sku?.name} racks and install ${RACK_SKU_CONFIGS[confirmRetrofit].name}. The DC will go offline during this process.`}
|
|
confirmLabel="Start Retrofit"
|
|
onConfirm={() => { retrofitDC(datacenterId, confirmRetrofit); setConfirmRetrofit(null); }}
|
|
onCancel={() => setConfirmRetrofit(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ──────────────────────────────────────────────────
|
|
|
|
export function InfrastructurePage() {
|
|
const nav = useGameStore((s) => s.infraNav);
|
|
|
|
return (
|
|
<div>
|
|
<Breadcrumb nav={nav} />
|
|
{nav.level === 'clusters' && <ClustersListView />}
|
|
{nav.level === 'cluster' && nav.clusterId && <ClusterDetailView clusterId={nav.clusterId} />}
|
|
{nav.level === 'campus' && nav.clusterId && nav.campusId && <CampusDetailView clusterId={nav.clusterId} campusId={nav.campusId} />}
|
|
{nav.level === 'datacenter' && nav.clusterId && nav.campusId && nav.datacenterId && (
|
|
<DataCenterDetailView clusterId={nav.clusterId} campusId={nav.campusId} datacenterId={nav.datacenterId} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|