Redesign Infrastructure page with AWS-style provisioning UX
CI / build-and-push (push) Successful in 37s

Replace basic admin panel with cloud console-style interactions:
- Fleet summary bar with aggregate stats (DCs, racks, FLOPS, uptime, cost)
- Launch Racks provisioning panel with SKU table, quantity stepper, live
  cost sidebar, and review-before-launch flow
- Rack inventory table with sortable columns, checkbox multi-select,
  and bulk decommission with confirmation modal
- Pipeline kanban grouping (same SKU/stage collapsed) with ETA display
- Tabbed DC cards (Inventory | Launch | Upgrades) to reduce scroll
- Build DC panel with cost breakdown and capacity preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 22:17:48 -04:00
parent f9f6233b69
commit f702a82539
+700 -128
View File
@@ -1,13 +1,19 @@
import { useState, useMemo, useCallback } from 'react';
import { Plus, Server, MapPin, Zap, HardDrive, Wrench, ChevronDown, ChevronUp, Thermometer, Shield, X } from 'lucide-react';
import {
Plus, Server, MapPin, Zap, HardDrive, Wrench,
ChevronDown, ChevronUp, Thermometer, Shield,
Rocket, Clock, Lock, Trash2, ArrowUpDown, Cpu, Minus,
Activity, DollarSign,
} from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { ConfirmModal } from '@/components/common/ConfirmModal';
import { useGameStore } from '@/store';
import { useShallow } from 'zustand/shallow';
import {
formatMoney, formatNumber, formatPercent,
LOCATION_CONFIGS, DC_TIER_CONFIGS, RACK_SKU_CONFIGS,
} from '@ai-tycoon/shared';
import type { DCTier, RackSkuId, LocationId, RackOrder, PipelineStage, Era } from '@ai-tycoon/shared';
import type { DCTier, DCTierConfig, RackSkuId, LocationId, RackOrder, PipelineStage, Era, Rack } from '@ai-tycoon/shared';
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
const collapsedDCs = new Set<string>();
@@ -32,29 +38,130 @@ const STAGE_COLORS: Record<PipelineStage, string> = {
decommission: 'bg-surface-500',
};
function PipelineKanban() {
// ─── Fleet Summary ─────────────────────────────────────────────
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 FleetSummary() {
const dataCenters = useGameStore((s) => s.infrastructure.dataCenters);
const pipeline = useGameStore((s) => s.infrastructure.rackPipeline);
const stats = useMemo(() => {
const operational = dataCenters.filter(dc => dc.status === 'operational');
const totalRacks = operational.reduce((sum, dc) => sum + dc.racks.length, 0);
const totalSlots = operational.reduce((sum, dc) => sum + DC_TIER_CONFIGS[dc.tier].rackSlots, 0);
const totalFlops = operational.reduce((sum, dc) =>
sum + dc.racks.reduce((s, r) => s + RACK_SKU_CONFIGS[r.skuId].flopsPerRack, 0), 0);
const avgUptime = operational.length > 0
? operational.reduce((sum, dc) => sum + dc.currentUptime, 0) / operational.length
: 0;
const totalCost = operational.reduce((sum, dc) => sum + dc.energyCostPerTick + dc.maintenanceCostPerTick, 0);
const inPipeline = pipeline.filter(o => o.stage !== 'decommission').length;
const constructing = dataCenters.length - operational.length;
return { dcCount: operational.length, constructing, totalRacks, totalSlots, totalFlops, avgUptime, totalCost, inPipeline };
}, [dataCenters, pipeline]);
if (dataCenters.length === 0) return null;
return (
<div className="grid grid-cols-5 gap-3">
<FleetStat
label="Data Centers"
value={`${stats.dcCount}`}
sub={stats.constructing > 0 ? `${stats.constructing} building` : 'operational'}
icon={Server}
/>
<FleetStat label="Total Racks" value={`${stats.totalRacks}`} sub={`of ${stats.totalSlots} slots`} icon={HardDrive} />
<FleetStat
label="Total FLOPS"
value={formatNumber(stats.totalFlops)}
sub={stats.inPipeline > 0 ? `${stats.inPipeline} in pipeline` : undefined}
icon={Cpu}
/>
<FleetStat label="Avg Uptime" value={formatPercent(stats.avgUptime)} icon={Activity} />
<FleetStat label="Infra Cost" value={`${formatMoney(stats.totalCost)}/s`} icon={DollarSign} />
</div>
);
}
// ─── Pipeline Kanban ───────────────────────────────────────────
interface PipelineGroup {
key: string;
skuId: RackSkuId;
stage: PipelineStage;
orders: RackOrder[];
avgProgress: number;
stageTotal: number;
}
function PipelineKanban() {
const pipeline = useGameStore((s) => s.infrastructure.rackPipeline);
if (pipeline.length === 0) return null;
const stages: PipelineStage[] = ['ordered', 'manufacturing', 'receiving', 'installation', 'testing', 'repair', 'decommission'];
const grouped = stages.map(stage => ({
stage,
orders: pipeline.filter(o => o.stage === stage),
}));
const grouped = stages.map(stage => {
const stageOrders = pipeline.filter(o => o.stage === stage);
const skuMap = new Map<RackSkuId, RackOrder[]>();
for (const order of stageOrders) {
if (!skuMap.has(order.skuId)) skuMap.set(order.skuId, []);
skuMap.get(order.skuId)!.push(order);
}
const groups: PipelineGroup[] = [];
for (const [skuId, orders] of skuMap) {
const avgProgress = orders.reduce((s, o) => s + (o.stageTotal > 0 ? o.stageProgress / o.stageTotal : 0), 0) / orders.length;
groups.push({ key: `${skuId}-${stage}`, skuId, stage, orders, avgProgress, stageTotal: orders[0].stageTotal });
}
return { stage, groups, count: stageOrders.length };
});
const activeOrders = pipeline.filter(o => o.stage !== 'decommission');
const testingOrders = pipeline.filter(o => o.stage === 'testing');
const nearestToOnline = testingOrders.length > 0
? Math.min(...testingOrders.map(o => o.stageTotal - o.stageProgress))
: null;
return (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-semibold text-surface-300 uppercase mb-3">Rack Pipeline</h3>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-surface-300 uppercase">Rack Pipeline</h3>
<div className="flex items-center gap-3 text-[11px] text-surface-400">
<span>{activeOrders.length} order{activeOrders.length !== 1 ? 's' : ''} in pipeline</span>
{nearestToOnline !== null && (
<span className="flex items-center gap-1">
<Clock size={10} />
Next online in ~{nearestToOnline}s
</span>
)}
</div>
</div>
<div className="grid grid-cols-7 gap-2">
{grouped.map(({ stage, orders }) => (
{grouped.map(({ stage, groups, count }) => (
<div key={stage}>
<div className="text-[10px] text-surface-400 uppercase mb-1.5 text-center">
{STAGE_LABELS[stage]} ({orders.length})
{STAGE_LABELS[stage]} ({count})
</div>
<div className="space-y-1.5 min-h-[60px]">
{orders.map(order => (
<PipelineCard key={order.id} order={order} />
{groups.map(group => (
<PipelineGroupCard key={group.key} group={group} />
))}
</div>
</div>
@@ -64,31 +171,41 @@ function PipelineKanban() {
);
}
function PipelineCard({ order }: { order: RackOrder }) {
const sku = RACK_SKU_CONFIGS[order.skuId];
const progress = order.stageTotal > 0 ? order.stageProgress / order.stageTotal : 0;
function PipelineGroupCard({ group }: { group: PipelineGroup }) {
const sku = RACK_SKU_CONFIGS[group.skuId];
const remaining = Math.round(group.stageTotal * (1 - group.avgProgress));
const hasRepairs = group.orders.some(o => o.repairCount > 0);
return (
<div className="bg-surface-800 border border-surface-600 rounded p-1.5 text-[11px]">
<div className="font-medium truncate">{sku.name}</div>
<div className="font-medium truncate">
{sku.name}
{group.orders.length > 1 && <span className="text-accent ml-1">&times;{group.orders.length}</span>}
</div>
<div className="w-full bg-surface-700 rounded-full h-1 mt-1">
<div
className={`h-1 rounded-full ${STAGE_COLORS[order.stage]}`}
style={{ width: `${Math.min(100, progress * 100)}%` }}
className={`h-1 rounded-full ${STAGE_COLORS[group.stage]}`}
style={{ width: `${Math.min(100, group.avgProgress * 100)}%` }}
/>
</div>
{order.repairCount > 0 && (
<div className="text-danger mt-0.5 flex items-center gap-0.5">
<Wrench size={8} /> {order.repairCount}x
</div>
<div className="flex items-center justify-between mt-0.5">
<span className="text-surface-500 flex items-center gap-0.5">
<Clock size={8} />{remaining}s
</span>
{hasRepairs && (
<span className="text-danger flex items-center gap-0.5">
<Wrench size={8} />
</span>
)}
</div>
</div>
);
}
// ─── Capacity Bar ──────────────────────────────────────────────
function CapacityBar({ label, used, max, unit, icon: Icon }: {
label: string; used: number; max: number; unit: string;
icon: typeof HardDrive;
label: string; used: number; max: number; unit: string; icon: typeof HardDrive;
}) {
const pct = max > 0 ? used / max : 0;
const color = pct > 0.9 ? 'bg-danger' : pct > 0.7 ? 'bg-amber-500' : 'bg-accent';
@@ -106,17 +223,431 @@ function CapacityBar({ label, used, max, unit, icon: Icon }: {
);
}
function DataCenterCard({ dcId }: { dcId: string }) {
const dc = useGameStore((s) => s.infrastructure.dataCenters.find(d => d.id === dcId))!;
const pipelineForDc = useGameStore(useShallow((s) => s.infrastructure.rackPipeline.filter(o => o.dataCenterId === dcId)));
// ─── Rack Inventory Table ──────────────────────────────────────
type SortField = 'sku' | 'flops' | 'power' | 'health';
type SortDir = 'asc' | 'desc';
function SortableHeader({ field, label, align, sortField, sortDir, onToggle }: {
field: SortField; label: string; align?: 'right';
sortField: SortField; sortDir: SortDir; onToggle: (f: SortField) => void;
}) {
return (
<th
className={`text-[11px] text-surface-400 uppercase py-2 px-2 cursor-pointer hover:text-surface-200 select-none ${
align === 'right' ? 'text-right' : 'text-left'
}`}
onClick={() => onToggle(field)}
>
<span className={`inline-flex items-center gap-1 ${align === 'right' ? 'justify-end' : ''}`}>
{label}
{sortField === field ? (
sortDir === 'asc' ? <ChevronUp size={10} /> : <ChevronDown size={10} />
) : (
<ArrowUpDown size={10} className="opacity-30" />
)}
</span>
</th>
);
}
function RackTable({ racks, dcId }: { racks: Rack[]; dcId: string }) {
const decommissionRack = useGameStore((s) => s.decommissionRack);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sortField, setSortField] = useState<SortField>('sku');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const [showDecomConfirm, setShowDecomConfirm] = useState(false);
const sortedRacks = useMemo(() => {
const sorted = [...racks];
sorted.sort((a, b) => {
const skuA = RACK_SKU_CONFIGS[a.skuId];
const skuB = RACK_SKU_CONFIGS[b.skuId];
let cmp = 0;
switch (sortField) {
case 'sku': cmp = skuA.name.localeCompare(skuB.name); break;
case 'flops': cmp = skuA.flopsPerRack - skuB.flopsPerRack; break;
case 'power': cmp = skuA.powerDrawKW - skuB.powerDrawKW; break;
case 'health': cmp = (a.isHealthy ? 1 : 0) - (b.isHealthy ? 1 : 0); break;
}
return sortDir === 'desc' ? -cmp : cmp;
});
return sorted;
}, [racks, sortField, sortDir]);
const toggleSort = (field: SortField) => {
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortDir('asc'); }
};
const toggleSelect = (id: string) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleAll = () => {
if (selected.size === racks.length) setSelected(new Set());
else setSelected(new Set(racks.map(r => r.id)));
};
const handleBulkDecom = () => {
for (const rackId of selected) {
decommissionRack(dcId, rackId);
}
setSelected(new Set());
setShowDecomConfirm(false);
};
const decomImpact = useMemo(() => {
let flops = 0, power = 0;
for (const rackId of selected) {
const rack = racks.find(r => r.id === rackId);
if (rack) {
const sku = RACK_SKU_CONFIGS[rack.skuId];
flops += sku.flopsPerRack;
power += sku.powerDrawKW;
}
}
return { flops, power };
}, [selected, racks]);
if (racks.length === 0) {
return (
<div className="text-center py-8 text-surface-500 text-sm">
<Server size={32} className="mx-auto mb-2 opacity-30" />
No racks installed yet.
</div>
);
}
return (
<div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-700">
<th className="w-8 py-2 px-2">
<input
type="checkbox"
checked={selected.size === racks.length && racks.length > 0}
onChange={toggleAll}
className="accent-accent"
/>
</th>
<SortableHeader field="sku" label="SKU" sortField={sortField} sortDir={sortDir} onToggle={toggleSort} />
<SortableHeader field="flops" label="FLOPS" align="right" sortField={sortField} sortDir={sortDir} onToggle={toggleSort} />
<SortableHeader field="power" label="Power" align="right" sortField={sortField} sortDir={sortDir} onToggle={toggleSort} />
<SortableHeader field="health" label="Health" sortField={sortField} sortDir={sortDir} onToggle={toggleSort} />
</tr>
</thead>
<tbody>
{sortedRacks.map(rack => {
const sku = RACK_SKU_CONFIGS[rack.skuId];
const isSelected = selected.has(rack.id);
return (
<tr
key={rack.id}
className={`border-b border-surface-800 hover:bg-surface-800/50 transition-colors ${
isSelected ? 'bg-accent/5' : ''
}`}
>
<td className="py-1.5 px-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(rack.id)}
className="accent-accent"
/>
</td>
<td className="py-1.5 px-2 font-medium">{sku.name}</td>
<td className="py-1.5 px-2 text-right font-mono text-surface-300">{formatNumber(sku.flopsPerRack)}</td>
<td className="py-1.5 px-2 text-right font-mono text-surface-300">{sku.powerDrawKW}kW</td>
<td className="py-1.5 px-2">
{rack.isHealthy ? (
<span className="flex items-center gap-1 text-success text-xs">
<span className="w-1.5 h-1.5 rounded-full bg-success inline-block" />
Healthy
</span>
) : (
<span className="flex items-center gap-1 text-warning text-xs">
<span className="w-1.5 h-1.5 rounded-full bg-warning inline-block" />
Faulted
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{selected.size > 0 && (
<div className="flex items-center justify-between mt-3 px-3 py-2 bg-danger/5 border border-danger/20 rounded-lg">
<span className="text-sm text-surface-300">
{selected.size} rack{selected.size !== 1 ? 's' : ''} selected
<span className="text-surface-500 ml-2">
({formatNumber(decomImpact.flops)} FLOPS &middot; {decomImpact.power}kW)
</span>
</span>
<button
onClick={() => setShowDecomConfirm(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger text-sm font-medium transition-colors"
>
<Trash2 size={14} />
Decommission Selected
</button>
</div>
)}
{showDecomConfirm && (
<ConfirmModal
title={`Decommission ${selected.size} Rack${selected.size !== 1 ? 's' : ''}?`}
message={`This will remove ${formatNumber(decomImpact.flops)} FLOPS and free ${decomImpact.power}kW of power. Racks will enter the decommission pipeline.`}
confirmLabel="Decommission"
danger
onConfirm={handleBulkDecom}
onCancel={() => setShowDecomConfirm(false)}
/>
)}
</div>
);
}
// ─── Launch Racks Panel ────────────────────────────────────────
function LaunchRacksPanel({ dcId, tierConfig, liveUsedSlots, liveUsedPower }: {
dcId: string;
tierConfig: DCTierConfig;
liveUsedSlots: number;
liveUsedPower: number;
}) {
const money = useGameStore((s) => s.economy.money);
const era = useGameStore((s) => s.meta.currentEra);
const completedResearch = useGameStore((s) => s.research.completedResearch);
const orderRack = useGameStore((s) => s.orderRack);
const decommissionRack = useGameStore((s) => s.decommissionRack);
const [selectedSku, setSelectedSku] = useState<RackSkuId | null>(null);
const [quantity, setQuantity] = useState(1);
const currentEraIdx = ERA_ORDER.indexOf(era);
const allSkus = useMemo(() => {
return Object.values(RACK_SKU_CONFIGS).map(sku => {
const eraLocked = ERA_ORDER.indexOf(sku.era) > currentEraIdx;
const researchLocked = !!(sku.requiredResearch && !completedResearch.includes(sku.requiredResearch));
const available = !eraLocked && !researchLocked;
const lockReason = eraLocked
? `Requires ${sku.era} era`
: researchLocked
? `Requires "${sku.requiredResearch}"`
: null;
return { ...sku, available, lockReason };
});
}, [currentEraIdx, completedResearch]);
const selected = selectedSku ? RACK_SKU_CONFIGS[selectedSku] : null;
const { maxQuantity, constraintInfo } = useMemo(() => {
if (!selected) return { maxQuantity: 0, constraintInfo: null };
const bySlots = tierConfig.rackSlots - liveUsedSlots;
const byPower = Math.floor((tierConfig.powerBudgetKW - liveUsedPower) / selected.powerDrawKW);
const byMoney = Math.floor(money / selected.baseCost);
const max = Math.max(0, Math.min(bySlots, byPower, byMoney));
let info: { text: string; color: string } | null = null;
if (max === 0) {
if (byMoney <= 0) info = { text: 'Insufficient funds', color: 'text-danger' };
else if (bySlots <= 0) info = { text: 'No rack slots available', color: 'text-danger' };
else info = { text: 'Exceeds power budget', color: 'text-danger' };
} else {
const limiting = max === byMoney ? 'budget' : max === bySlots ? 'slots' : max === byPower ? 'power' : null;
if (limiting) info = { text: `Max ${max} (${limiting} limit)`, color: 'text-surface-400' };
}
return { maxQuantity: max, constraintInfo: info };
}, [selected, tierConfig, liveUsedSlots, liveUsedPower, money]);
const handleSelectSku = (skuId: RackSkuId) => {
setSelectedSku(skuId);
setQuantity(1);
};
const handleLaunch = () => {
if (!selectedSku || quantity <= 0) return;
for (let i = 0; i < quantity; i++) {
orderRack(dcId, selectedSku);
}
setQuantity(1);
setSelectedSku(null);
};
const totalCost = selected ? selected.baseCost * quantity : 0;
const newSlots = liveUsedSlots + quantity;
const newPower = selected ? liveUsedPower + selected.powerDrawKW * quantity : liveUsedPower;
return (
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-700">
<th className="w-8 py-2 px-2" />
<th className="text-left text-[11px] text-surface-400 uppercase py-2 px-2">SKU</th>
<th className="text-right text-[11px] text-surface-400 uppercase py-2 px-2">GPUs</th>
<th className="text-right text-[11px] text-surface-400 uppercase py-2 px-2">FLOPS</th>
<th className="text-right text-[11px] text-surface-400 uppercase py-2 px-2">Power</th>
<th className="text-right text-[11px] text-surface-400 uppercase py-2 px-2">Cost</th>
</tr>
</thead>
<tbody>
{allSkus.map(sku => {
const isSelected = selectedSku === sku.id;
return (
<tr
key={sku.id}
className={`border-b border-surface-800 transition-colors ${
sku.available
? isSelected ? 'bg-accent/10' : 'hover:bg-surface-800/50 cursor-pointer'
: 'opacity-40'
}`}
onClick={() => sku.available && handleSelectSku(sku.id)}
>
<td className="py-2 px-2">
{sku.available ? (
<input
type="radio"
name={`sku-${dcId}`}
checked={isSelected}
onChange={() => handleSelectSku(sku.id)}
className="accent-accent"
/>
) : (
<Lock size={12} className="text-surface-500" />
)}
</td>
<td className="py-2 px-2">
<div className="font-medium">{sku.name}</div>
{sku.lockReason && <div className="text-[10px] text-surface-500">{sku.lockReason}</div>}
</td>
<td className="py-2 px-2 text-right font-mono text-surface-300">{sku.gpuCount}</td>
<td className="py-2 px-2 text-right font-mono text-surface-300">{formatNumber(sku.flopsPerRack)}</td>
<td className="py-2 px-2 text-right font-mono text-surface-300">{sku.powerDrawKW}kW</td>
<td className="py-2 px-2 text-right font-mono text-surface-300">{formatMoney(sku.baseCost)}</td>
</tr>
);
})}
</tbody>
</table>
{selectedSku && (
<div className="flex items-center gap-4 mt-3 px-2">
<span className="text-sm text-surface-400">Quantity:</span>
<div className="flex items-center">
<button
onClick={() => setQuantity(q => Math.max(1, q - 1))}
disabled={quantity <= 1}
className="px-2 py-1 bg-surface-800 border border-surface-600 rounded-l hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Minus size={14} />
</button>
<input
type="number"
value={quantity}
onChange={(e) => {
const v = parseInt(e.target.value) || 1;
setQuantity(Math.max(1, Math.min(maxQuantity, v)));
}}
className="w-16 text-center bg-surface-800 border-y border-surface-600 py-1 text-sm font-mono focus:outline-none"
min={1}
max={maxQuantity}
/>
<button
onClick={() => setQuantity(q => Math.min(maxQuantity, q + 1))}
disabled={quantity >= maxQuantity}
className="px-2 py-1 bg-surface-800 border border-surface-600 rounded-r hover:bg-surface-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Plus size={14} />
</button>
</div>
{constraintInfo && (
<span className={`text-xs ${constraintInfo.color}`}>{constraintInfo.text}</span>
)}
</div>
)}
</div>
<div className="bg-surface-800 border border-surface-600 rounded-lg p-4 space-y-3 h-fit">
<h4 className="text-xs text-surface-400 uppercase font-semibold">Order Summary</h4>
{selectedSku && selected ? (
<>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-surface-400">Instance</span>
<span className="font-medium">{selected.name}</span>
</div>
<div className="flex justify-between">
<span className="text-surface-400">Quantity</span>
<span className="font-mono">{quantity}</span>
</div>
<div className="border-t border-surface-700 pt-2 flex justify-between">
<span className="text-surface-400">Total Cost</span>
<span className="font-mono font-bold text-accent-light">{formatMoney(totalCost)}</span>
</div>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between text-surface-400">
<span>Slots</span>
<span>
{liveUsedSlots}/{tierConfig.rackSlots}
<span className="text-accent ml-1">&rarr; {newSlots}/{tierConfig.rackSlots}</span>
</span>
</div>
<div className="flex justify-between text-surface-400">
<span>Power</span>
<span>
{liveUsedPower.toFixed(1)}kW
<span className="text-accent ml-1">&rarr; {newPower.toFixed(1)}kW</span>
</span>
</div>
<div className="flex justify-between text-surface-400">
<span>FLOPS added</span>
<span className="text-accent">+{formatNumber(selected.flopsPerRack * quantity)}</span>
</div>
</div>
<button
onClick={handleLaunch}
disabled={maxQuantity === 0 || quantity <= 0}
className="w-full flex items-center justify-center gap-2 bg-accent hover:bg-accent-dark text-white px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Rocket size={16} />
Launch {quantity} Rack{quantity !== 1 ? 's' : ''}
</button>
</>
) : (
<div className="text-center py-6 text-surface-500 text-sm">
Select a rack SKU to configure your order.
</div>
)}
</div>
</div>
);
}
// ─── Data Center Card ──────────────────────────────────────────
type DCTab = 'inventory' | 'launch' | 'upgrades';
function DataCenterCard({ dcId }: { dcId: string }) {
const dc = useGameStore((s) => s.infrastructure.dataCenters.find(d => d.id === dcId))!;
const pipelineForDc = useGameStore(useShallow((s) => s.infrastructure.rackPipeline.filter(o => o.dataCenterId === dcId)));
const money = useGameStore((s) => s.economy.money);
const upgradeDataCenter = useGameStore((s) => s.upgradeDataCenter);
const [expanded, setExpanded] = useState(!collapsedDCs.has(dcId));
const [confirmDecom, setConfirmDecom] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<DCTab>(dc.racks.length === 0 ? 'launch' : 'inventory');
const toggleExpanded = useCallback(() => {
setExpanded(prev => {
@@ -128,17 +659,18 @@ function DataCenterCard({ dcId }: { dcId: string }) {
}, [dcId]);
const tierConfig = DC_TIER_CONFIGS[dc.tier];
const currentEraIdx = ERA_ORDER.indexOf(era);
const activePipeline = pipelineForDc.filter(o => o.stage !== 'decommission');
const liveUsedSlots = dc.racks.length + activePipeline.length;
const liveUsedPower = dc.racks.reduce((s, r) => s + RACK_SKU_CONFIGS[r.skuId].powerDrawKW, 0)
+ activePipeline.reduce((s, o) => s + RACK_SKU_CONFIGS[o.skuId].powerDrawKW, 0);
const availableSkus = Object.values(RACK_SKU_CONFIGS).filter(sku => {
if (ERA_ORDER.indexOf(sku.era) > currentEraIdx) return false;
if (sku.requiredResearch && !completedResearch.includes(sku.requiredResearch)) return false;
return true;
});
const handleLaunchClick = useCallback(() => {
if (!expanded) {
collapsedDCs.delete(dcId);
setExpanded(true);
}
setActiveTab('launch');
}, [expanded, dcId]);
if (dc.status === 'constructing') {
const pct = dc.constructionTotal > 0 ? dc.constructionProgress / dc.constructionTotal : 0;
@@ -147,7 +679,7 @@ function DataCenterCard({ dcId }: { dcId: string }) {
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="font-semibold">{dc.name}</h3>
<div className="text-xs text-amber-400">Under Construction {tierConfig.name}</div>
<div className="text-xs text-amber-400">Under Construction &mdash; {tierConfig.name}</div>
</div>
<div className="flex items-center gap-1 text-xs text-surface-400">
<MapPin size={12} />
@@ -158,12 +690,18 @@ function DataCenterCard({ dcId }: { dcId: string }) {
<div className="h-3 rounded-full bg-amber-500 transition-all" style={{ width: `${pct * 100}%` }} />
</div>
<div className="text-xs text-surface-400 mt-1 text-right">
{Math.round(pct * 100)}% {dc.constructionTotal - dc.constructionProgress}s remaining
{Math.round(pct * 100)}% &mdash; {dc.constructionTotal - dc.constructionProgress}s remaining
</div>
</div>
);
}
const tabs: { id: DCTab; label: string; count?: number }[] = [
{ id: 'inventory', label: 'Inventory', count: dc.racks.length },
{ id: 'launch', label: 'Launch Racks' },
{ id: 'upgrades', label: 'Upgrades' },
];
return (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
@@ -174,14 +712,25 @@ function DataCenterCard({ dcId }: { dcId: string }) {
</div>
<div className="flex items-center gap-3 text-sm text-surface-400">
<span className="flex items-center gap-1"><MapPin size={12} />{LOCATION_CONFIGS[dc.location].name}</span>
<span>{dc.racks.length} rack{dc.racks.length !== 1 ? 's' : ''}</span>
{activePipeline.length > 0 && <span className="text-blue-400">{activePipeline.length} in pipeline</span>}
<span>Uptime: {formatPercent(dc.currentUptime)}</span>
<span className="text-danger">Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleLaunchClick}
className="flex items-center gap-1.5 bg-accent/10 hover:bg-accent/20 text-accent-light px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
>
<Rocket size={14} />
Launch
</button>
<button onClick={toggleExpanded} className="text-surface-400 hover:text-surface-200">
{expanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</button>
</div>
</div>
<div className="flex gap-4 mb-3">
<CapacityBar label="Slots" used={liveUsedSlots} max={tierConfig.rackSlots} unit="" icon={HardDrive} />
@@ -190,111 +739,91 @@ function DataCenterCard({ dcId }: { dcId: string }) {
{expanded && (
<>
{dc.racks.length > 0 && (
<div className="mb-3">
<h4 className="text-xs text-surface-400 uppercase mb-1.5">Production Racks ({dc.racks.length})</h4>
<div className="grid grid-cols-4 gap-1.5">
{dc.racks.map(rack => {
const sku = RACK_SKU_CONFIGS[rack.skuId];
return (
<div key={rack.id} className={`text-[11px] rounded p-1.5 border relative group ${
rack.isHealthy
? 'bg-surface-800 border-surface-600'
: 'bg-danger/10 border-danger/30'
}`}>
{confirmDecom === rack.id ? (
<div className="flex items-center justify-between gap-1">
<span className="text-danger text-[10px]">Remove?</span>
<div className="flex gap-0.5">
<div className="flex gap-1 border-b border-surface-700 mb-3">
{tabs.map(tab => (
<button
onClick={() => { decommissionRack(dc.id, rack.id); setConfirmDecom(null); }}
className="px-1 py-0.5 rounded bg-danger/20 text-danger hover:bg-danger/30 text-[10px]"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-accent text-accent-light'
: 'border-transparent text-surface-400 hover:text-surface-200'
}`}
>
Yes
</button>
<button
onClick={() => setConfirmDecom(null)}
className="px-1 py-0.5 rounded hover:bg-surface-700 text-surface-400 text-[10px]"
>
No
</button>
</div>
</div>
) : (
<>
<button
onClick={() => setConfirmDecom(rack.id)}
className="absolute top-0.5 right-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-danger/20 text-surface-400 hover:text-danger transition-all"
title="Decommission rack"
>
<X size={10} />
</button>
<div className="font-medium truncate">{sku.name}</div>
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS</div>
</>
{tab.label}
{tab.count !== undefined && (
<span className="ml-1.5 text-xs text-surface-500">({tab.count})</span>
)}
</button>
))}
</div>
);
})}
</div>
</div>
{activeTab === 'inventory' && (
<RackTable racks={dc.racks} dcId={dc.id} />
)}
<div className="mb-3">
<h4 className="text-xs text-surface-400 uppercase mb-1.5">Order Racks</h4>
<div className="flex gap-2 flex-wrap">
{availableSkus.map(sku => {
const canAfford = money >= sku.baseCost;
const hasSlot = liveUsedSlots < tierConfig.rackSlots;
const hasPower = liveUsedPower + sku.powerDrawKW <= tierConfig.powerBudgetKW;
const disabled = !canAfford || !hasSlot || !hasPower;
const reason = !canAfford ? `Need ${formatMoney(sku.baseCost)}` : !hasSlot ? 'No slots available' : !hasPower ? 'Exceeds power budget' : '';
{activeTab === 'launch' && (
<LaunchRacksPanel
dcId={dc.id}
tierConfig={tierConfig}
liveUsedSlots={liveUsedSlots}
liveUsedPower={liveUsedPower}
/>
)}
return (
<button
key={sku.id}
onClick={() => orderRack(dc.id, sku.id)}
disabled={disabled}
className="bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-left"
>
<div className="font-medium">{sku.name}</div>
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS · {sku.powerDrawKW}kW · {formatMoney(sku.baseCost)}</div>
{disabled && reason && <div className="text-warning mt-0.5">{reason}</div>}
</button>
);
})}
</div>
</div>
<div>
<h4 className="text-xs text-surface-400 uppercase mb-1.5">Upgrades</h4>
<div className="flex gap-2">
{activeTab === 'upgrades' && (
<div className="space-y-3">
<div className="flex gap-3">
<button
onClick={() => upgradeDataCenter(dc.id, 'cooling')}
disabled={dc.coolingLevel >= 1.0 || money < tierConfig.baseCost * 0.25}
className="flex items-center gap-1.5 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-3 py-2 text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="flex-1 flex items-center gap-2 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-4 py-3 text-sm disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Thermometer size={14} />
Cooling Lv{Math.round(dc.coolingLevel * 10)}
<span className="text-surface-400">{formatMoney(tierConfig.baseCost * 0.25)}</span>
<Thermometer size={16} className="text-cyan-400" />
<div className="text-left">
<div className="font-medium">Cooling Level {Math.round(dc.coolingLevel * 10)}/10</div>
<div className="text-xs text-surface-400">
{dc.coolingLevel >= 1.0 ? 'Maxed out' : `Upgrade for ${formatMoney(tierConfig.baseCost * 0.25)}`}
</div>
</div>
<div className="ml-auto">
<div className="w-20 bg-surface-700 rounded-full h-1.5">
<div className="h-1.5 rounded-full bg-cyan-400 transition-all" style={{ width: `${dc.coolingLevel * 100}%` }} />
</div>
</div>
</button>
<button
onClick={() => upgradeDataCenter(dc.id, 'redundancy')}
disabled={dc.redundancyLevel >= 1.0 || money < tierConfig.baseCost * 0.25}
className="flex items-center gap-1.5 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-3 py-2 text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="flex-1 flex items-center gap-2 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-4 py-3 text-sm disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Shield size={14} />
Redundancy Lv{Math.round(dc.redundancyLevel * 10)}
<span className="text-surface-400">{formatMoney(tierConfig.baseCost * 0.25)}</span>
<Shield size={16} className="text-green-400" />
<div className="text-left">
<div className="font-medium">Redundancy Level {Math.round(dc.redundancyLevel * 10)}/10</div>
<div className="text-xs text-surface-400">
{dc.redundancyLevel >= 1.0 ? 'Maxed out' : `Upgrade for ${formatMoney(tierConfig.baseCost * 0.25)}`}
</div>
</div>
<div className="ml-auto">
<div className="w-20 bg-surface-700 rounded-full h-1.5">
<div className="h-1.5 rounded-full bg-green-400 transition-all" style={{ width: `${dc.redundancyLevel * 100}%` }} />
</div>
</div>
</button>
</div>
<p className="text-[11px] text-surface-500">
Cooling reduces hardware failure rates. Redundancy improves uptime during outages.
</p>
</div>
)}
</>
)}
</div>
);
}
// ─── Build Data Center Panel ───────────────────────────────────
function BuildDCPanel({ onClose }: { onClose: () => void }) {
const money = useGameStore((s) => s.economy.money);
const era = useGameStore((s) => s.meta.currentEra);
@@ -311,13 +840,23 @@ function BuildDCPanel({ onClose }: { onClose: () => void }) {
loc => ERA_ORDER.indexOf(loc.availableAt) <= currentEraIdx,
);
const availableTiers = Object.values(DC_TIER_CONFIGS).filter(t => {
if (ERA_ORDER.indexOf(t.requiredEra) > currentEraIdx) return false;
if (t.requiredResearch && !completedResearch.includes(t.requiredResearch)) return false;
return true;
const tierAvailability = useMemo(() => {
return Object.values(DC_TIER_CONFIGS).map(t => {
const eraLocked = ERA_ORDER.indexOf(t.requiredEra) > currentEraIdx;
const researchLocked = !!(t.requiredResearch && !completedResearch.includes(t.requiredResearch));
const available = !eraLocked && !researchLocked;
const lockReason = eraLocked
? `Requires ${t.requiredEra} era`
: researchLocked
? `Requires "${t.requiredResearch}"`
: null;
return { config: t, available, lockReason };
});
}, [currentEraIdx, completedResearch]);
const tierConfig = DC_TIER_CONFIGS[tier];
const locationConfig = LOCATION_CONFIGS[location];
const estimatedOpCost = tierConfig.baseEnergyCostPerTick * locationConfig.energyCostMultiplier;
const handleBuild = () => {
if (!name.trim()) return;
@@ -359,29 +898,60 @@ function BuildDCPanel({ onClose }: { onClose: () => void }) {
onChange={(e) => setTier(e.target.value as DCTier)}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
>
{availableTiers.map(t => (
<option key={t.tier} value={t.tier}>{t.name} ({t.rackSlots} slots, {t.powerBudgetKW}kW)</option>
{tierAvailability.map(({ config: t, available, lockReason }) => (
<option key={t.tier} value={t.tier} disabled={!available}>
{t.name} ({t.rackSlots} slots, {t.powerBudgetKW}kW){lockReason ? `${lockReason}` : ''}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-between text-sm text-surface-400">
<span>Cost: {formatMoney(tierConfig.baseCost)} · Build time: {tierConfig.buildTimeTicks}s</span>
<div className="flex gap-2">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-surface-800 rounded-lg p-3 space-y-1.5">
<div className="text-xs text-surface-400 uppercase font-semibold">Cost Breakdown</div>
<div className="flex justify-between">
<span className="text-surface-400">Build cost</span>
<span className="font-mono">{formatMoney(tierConfig.baseCost)}</span>
</div>
<div className="flex justify-between">
<span className="text-surface-400">Build time</span>
<span className="font-mono">{tierConfig.buildTimeTicks}s</span>
</div>
<div className="flex justify-between border-t border-surface-700 pt-1.5">
<span className="text-surface-400">Est. operating cost</span>
<span className="font-mono">~{formatMoney(estimatedOpCost)}/s</span>
</div>
</div>
<div className="bg-surface-800 rounded-lg p-3 space-y-1.5">
<div className="text-xs text-surface-400 uppercase font-semibold">Capacity</div>
<div className="flex justify-between">
<span className="text-surface-400">Rack slots</span>
<span className="font-mono">{tierConfig.rackSlots}</span>
</div>
<div className="flex justify-between">
<span className="text-surface-400">Power budget</span>
<span className="font-mono">{tierConfig.powerBudgetKW}kW</span>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 rounded text-sm text-surface-400 hover:text-surface-200">Cancel</button>
<button
onClick={handleBuild}
disabled={money < tierConfig.baseCost || !name.trim()}
className="px-4 py-2 rounded bg-accent hover:bg-accent-dark text-white text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Build
Build ({formatMoney(tierConfig.baseCost)})
</button>
</div>
</div>
</div>
);
}
// ─── Page ──────────────────────────────────────────────────────
export function InfrastructurePage() {
const dataCenters = useGameStore((s) => s.infrastructure.dataCenters);
const [showNewDC, setShowNewDC] = useState(false);
@@ -403,6 +973,8 @@ export function InfrastructurePage() {
Choose a location and tier for your data center, then order GPU racks to add compute capacity. Racks go through a build pipeline before they come online.
</TutorialHint>
<FleetSummary />
{showNewDC && <BuildDCPanel onClose={() => setShowNewDC(false)} />}
<PipelineKanban />