283c7c7932
CI / build-and-push (push) Successful in 37s
Add compute history time-series (capacity vs demand chart), revenue vs expenses dual-line chart, enhanced system status (training allocation, network uptime, model freshness), active operations panel, market position bars, and competitor snapshot. Stat cards expand from 3 to 6 as player progresses through eras. Graceful v9→v10 save migration preserves existing games. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
630 lines
26 KiB
TypeScript
630 lines
26 KiB
TypeScript
import type React from 'react';
|
|
import { useGameStore, type ActivePage } from '@/store';
|
|
import { formatMoney, formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
|
|
import type { Era } from '@ai-tycoon/shared';
|
|
import { TECH_TREE } from '@ai-tycoon/game-engine';
|
|
import {
|
|
DollarSign, TrendingUp, TrendingDown, Minus, Cpu, Brain, Users,
|
|
Shield, ChevronRight, Zap, Wifi, Sparkles, FlaskConical, Building2,
|
|
HardDrive, Clock,
|
|
} from 'lucide-react';
|
|
import {
|
|
XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart, Line, LineChart,
|
|
} from 'recharts';
|
|
import { TutorialHint } from '@/components/game/TutorialHint';
|
|
|
|
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
|
|
|
function isEraAtLeast(current: Era, threshold: Era): boolean {
|
|
return ERA_ORDER.indexOf(current) >= ERA_ORDER.indexOf(threshold);
|
|
}
|
|
|
|
export function DashboardPage() {
|
|
const money = useGameStore((s) => s.economy.money);
|
|
const revenuePerTick = useGameStore((s) => s.economy.revenuePerTick);
|
|
const expensesPerTick = useGameStore((s) => s.economy.expensesPerTick);
|
|
const financialHistory = useGameStore((s) => s.economy.financialHistory);
|
|
const totalFlops = useGameStore((s) => s.infrastructure.totalFlops);
|
|
const totalDCs = useGameStore((s) => s.infrastructure.totalDataCenterCount);
|
|
const clusters = useGameStore((s) => s.infrastructure.clusters);
|
|
const baseModels = useGameStore((s) => s.models.baseModels);
|
|
const activePipelines = useGameStore((s) => s.models.activeTrainingPipelines);
|
|
const subscribers = useGameStore((s) => s.market.consumerTiers.totalUsers);
|
|
const satisfaction = useGameStore((s) => s.market.consumerTiers.satisfaction);
|
|
const reputation = useGameStore((s) => s.reputation.score);
|
|
const reputationHistory = useGameStore((s) => s.reputation.reputationHistory);
|
|
const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization);
|
|
const effectiveInferenceFlops = useGameStore((s) => s.compute.effectiveInferenceFlops);
|
|
const trainingAllocation = useGameStore((s) => s.compute.trainingAllocation);
|
|
const computeHistory = useGameStore((s) => s.compute.computeHistory);
|
|
const totalUptime = useGameStore((s) => s.infrastructure.totalUptime);
|
|
const modelFreshness = useGameStore((s) => s.market.obsolescence.playerModelFreshness);
|
|
const era = useGameStore((s) => s.meta.currentEra);
|
|
const activeResearch = useGameStore((s) => s.research.activeResearch);
|
|
const tam = useGameStore((s) => s.market.tam);
|
|
const competitors = useGameStore((s) => s.competitors.rivals);
|
|
|
|
const netIncome = revenuePerTick - expensesPerTick;
|
|
const scaleup = isEraAtLeast(era, 'scaleup');
|
|
const hasDeployedModel = baseModels.some(m => m.isDeployed);
|
|
|
|
const bestDeployedCapability = baseModels
|
|
.filter(m => m.isDeployed)
|
|
.reduce((best, m) => Math.max(best, m.rawCapability), 0);
|
|
|
|
const revenueTrend = (() => {
|
|
if (financialHistory.length < 6) return 'neutral' as const;
|
|
const recent = financialHistory[financialHistory.length - 1].revenue;
|
|
const earlier = financialHistory[financialHistory.length - 6].revenue;
|
|
if (recent > earlier * 1.01) return 'up' as const;
|
|
if (recent < earlier * 0.99) return 'down' as const;
|
|
return 'neutral' as const;
|
|
})();
|
|
|
|
const repTrend = (() => {
|
|
if (reputationHistory.length < 2) return 'neutral' as const;
|
|
const last = reputationHistory[reputationHistory.length - 1].score;
|
|
const prev = reputationHistory[reputationHistory.length - 2].score;
|
|
if (last > prev) return 'up' as const;
|
|
if (last < prev) return 'down' as const;
|
|
return 'neutral' as const;
|
|
})();
|
|
|
|
const constructingDCs = clusters.reduce((count, cluster) => {
|
|
for (const campus of cluster.campuses) {
|
|
for (const dc of campus.dataCenters) {
|
|
if (dc.status === 'constructing') count++;
|
|
}
|
|
}
|
|
return count;
|
|
}, 0);
|
|
|
|
const deployingRacks = clusters.reduce((count, cluster) => {
|
|
for (const campus of cluster.campuses) {
|
|
for (const dc of campus.dataCenters) {
|
|
count += dc.deploymentCohorts.length;
|
|
}
|
|
}
|
|
return count;
|
|
}, 0);
|
|
|
|
const navigate = useGameStore.getState().setActivePage;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h2 className="text-2xl font-bold">Dashboard</h2>
|
|
|
|
{totalDCs === 0 && (
|
|
<TutorialHint id="welcome">
|
|
Welcome to AI Tycoon! Start by building a cluster in the Infrastructure tab, then add a campus and data center to deploy racks and train your first AI model.
|
|
</TutorialHint>
|
|
)}
|
|
|
|
{totalDCs > 0 && baseModels.length === 0 && activePipelines.length === 0 && (
|
|
<TutorialHint id="train-first-model">
|
|
You have compute available! Head to the Models tab to allocate compute for training and start your first model.
|
|
</TutorialHint>
|
|
)}
|
|
|
|
{baseModels.length > 0 && !baseModels.some(m => m.isDeployed) && (
|
|
<TutorialHint id="deploy-model">
|
|
Your model is trained! Deploy it from the Models tab to start serving customers and earning revenue.
|
|
</TutorialHint>
|
|
)}
|
|
|
|
{/* Section 1: Stat Cards */}
|
|
<div className={`grid gap-4 ${scaleup ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6' : 'grid-cols-3'}`}>
|
|
<StatCard
|
|
icon={DollarSign}
|
|
label="Cash"
|
|
value={formatMoney(money)}
|
|
subValue={`${netIncome >= 0 ? '+' : ''}${formatMoney(netIncome)}/s`}
|
|
trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'}
|
|
color="text-green-400"
|
|
onClick={() => navigate('finance')}
|
|
/>
|
|
{scaleup && (
|
|
<StatCard
|
|
icon={TrendingUp}
|
|
label="Revenue"
|
|
value={`${formatMoney(revenuePerTick)}/s`}
|
|
subValue={`${formatMoney(revenuePerTick * 3600)}/hr`}
|
|
trend={revenueTrend}
|
|
color="text-emerald-400"
|
|
onClick={() => navigate('finance')}
|
|
/>
|
|
)}
|
|
<StatCard
|
|
icon={Cpu}
|
|
label="Compute"
|
|
value={`${formatPercent(inferenceUtil)} util`}
|
|
subValue={`${formatNumber(effectiveInferenceFlops)} FLOPS`}
|
|
trend={inferenceUtil > 0.9 ? 'down' : inferenceUtil < 0.5 ? 'up' : 'neutral'}
|
|
color="text-cyan-400"
|
|
onClick={() => navigate('infrastructure')}
|
|
/>
|
|
<StatCard
|
|
icon={Brain}
|
|
label="Models"
|
|
value={baseModels.length.toString()}
|
|
subValue={
|
|
activePipelines.filter(p => p.status === 'active').length > 0
|
|
? `Training: ${activePipelines.filter(p => p.status === 'active').length} active`
|
|
: hasDeployedModel
|
|
? `Best: ${bestDeployedCapability.toFixed(1)}`
|
|
: 'Idle'
|
|
}
|
|
color="text-purple-400"
|
|
onClick={() => navigate('models')}
|
|
/>
|
|
{scaleup && (
|
|
<StatCard
|
|
icon={Users}
|
|
label="Subscribers"
|
|
value={formatNumber(subscribers)}
|
|
subValue={`Satisfaction: ${formatPercent(satisfaction)}`}
|
|
color="text-orange-400"
|
|
onClick={() => navigate('market')}
|
|
/>
|
|
)}
|
|
{scaleup && (
|
|
<StatCard
|
|
icon={Shield}
|
|
label="Reputation"
|
|
value={`${reputation}/100`}
|
|
trend={repTrend}
|
|
color="text-violet-400"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Section 2: Primary Charts */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<h3 className="text-sm font-medium text-surface-400">Compute: Capacity vs Demand</h3>
|
|
<div className="flex items-center gap-3 ml-auto text-[10px]">
|
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-cyan-400" />Capacity</span>
|
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400" />Demand</span>
|
|
</div>
|
|
</div>
|
|
{computeHistory.length > 1 ? (
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<AreaChart data={computeHistory}>
|
|
<defs>
|
|
<linearGradient id="computeCapGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#22d3ee" stopOpacity={0.3} />
|
|
<stop offset="100%" stopColor="#22d3ee" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="computeDemGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.3} />
|
|
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<XAxis dataKey="tick" hide />
|
|
<YAxis width={55} tickFormatter={(v: number) => `${formatNumber(v)}`} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
|
<Tooltip
|
|
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
|
labelStyle={{ color: '#94a3b8' }}
|
|
formatter={(value: number, name: string) => {
|
|
const label = name === 'tokensPerSecondCapacity' ? 'Capacity' : 'Demand';
|
|
return [`${formatNumber(value)} tok/s`, label];
|
|
}}
|
|
/>
|
|
<Area type="monotone" dataKey="tokensPerSecondCapacity" stroke="#22d3ee" fill="url(#computeCapGrad)" />
|
|
<Area type="monotone" dataKey="tokensPerSecondDemand" stroke="#f59e0b" fill="url(#computeDemGrad)" />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-[200px] flex items-center justify-center text-surface-500 text-sm">
|
|
No compute data yet — deploy racks to start tracking
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<h3 className="text-sm font-medium text-surface-400">Revenue vs Expenses</h3>
|
|
<div className="flex items-center gap-3 ml-auto text-[10px]">
|
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500" />Revenue</span>
|
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500" />Expenses</span>
|
|
</div>
|
|
</div>
|
|
{financialHistory.length > 1 ? (
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<LineChart data={financialHistory}>
|
|
<XAxis dataKey="tick" hide />
|
|
<YAxis width={55} tickFormatter={(v: number) => formatMoney(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
|
<Tooltip
|
|
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
|
labelStyle={{ color: '#94a3b8' }}
|
|
formatter={(value: number, name: string) => [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']}
|
|
/>
|
|
<Line type="monotone" dataKey="revenue" stroke="#22c55e" strokeWidth={2} dot={false} />
|
|
<Line type="monotone" dataKey="expenses" stroke="#ef4444" strokeWidth={2} dot={false} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-[200px] flex items-center justify-center text-surface-500 text-sm">
|
|
No financial data yet — start earning revenue
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 3: System Health + Active Operations */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<h3 className="text-sm font-medium text-surface-400 mb-4">System Status</h3>
|
|
<div className="space-y-4">
|
|
<StatusRow
|
|
icon={Cpu}
|
|
label="Inference Utilization"
|
|
value={formatPercent(inferenceUtil)}
|
|
bar={inferenceUtil}
|
|
barColor={inferenceUtil > 0.9 ? 'bg-danger' : inferenceUtil > 0.7 ? 'bg-warning' : 'bg-success'}
|
|
/>
|
|
<StatusRow
|
|
icon={Zap}
|
|
label="Training Allocation"
|
|
value={formatPercent(trainingAllocation)}
|
|
bar={trainingAllocation}
|
|
barColor="bg-cyan-500"
|
|
/>
|
|
{scaleup && (
|
|
<StatusRow
|
|
icon={Wifi}
|
|
label="Network Uptime"
|
|
value={formatPercent(totalUptime)}
|
|
bar={totalUptime}
|
|
barColor={totalUptime < 0.95 ? 'bg-danger' : totalUptime < 0.99 ? 'bg-warning' : 'bg-success'}
|
|
/>
|
|
)}
|
|
{scaleup && hasDeployedModel && (
|
|
<StatusRow
|
|
icon={Sparkles}
|
|
label="Model Freshness"
|
|
value={formatPercent(modelFreshness)}
|
|
bar={modelFreshness}
|
|
barColor={modelFreshness < 0.3 ? 'bg-danger' : modelFreshness < 0.6 ? 'bg-warning' : 'bg-success'}
|
|
/>
|
|
)}
|
|
<StatusRow
|
|
icon={Shield}
|
|
label="Reputation"
|
|
value={`${reputation}/100`}
|
|
bar={reputation / 100}
|
|
barColor={reputation < 40 ? 'bg-danger' : reputation < 70 ? 'bg-warning' : 'bg-success'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<h3 className="text-sm font-medium text-surface-400 mb-4">Active Operations</h3>
|
|
<ActiveOperations
|
|
pipelines={activePipelines}
|
|
activeResearch={activeResearch}
|
|
constructingDCs={constructingDCs}
|
|
deployingRacks={deployingRacks}
|
|
navigate={navigate}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 4: Secondary Charts (scaleup+) */}
|
|
{scaleup && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<h3 className="text-sm font-medium text-surface-400 mb-4">Subscribers Over Time</h3>
|
|
{(useGameStore.getState().market.subscriberHistory?.length ?? 0) > 1 ? (
|
|
<ResponsiveContainer width="100%" height={180}>
|
|
<AreaChart data={useGameStore.getState().market.subscriberHistory}>
|
|
<defs>
|
|
<linearGradient id="subsGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#f97316" stopOpacity={0.3} />
|
|
<stop offset="100%" stopColor="#f97316" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<XAxis dataKey="tick" hide />
|
|
<YAxis width={50} tickFormatter={(v: number) => formatNumber(v)} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
|
<Tooltip
|
|
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
|
formatter={(value: number) => [formatNumber(value), 'Subscribers']}
|
|
/>
|
|
<Area type="monotone" dataKey="subscribers" stroke="#f97316" fill="url(#subsGrad)" />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-[180px] flex items-center justify-center text-surface-500 text-sm">
|
|
No subscriber data yet
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<h3 className="text-sm font-medium text-surface-400 mb-4">Market Position</h3>
|
|
<div className="space-y-3">
|
|
<MarketShareBar label="Consumer" color="bg-orange-400" segment={tam.segments.consumer} />
|
|
<MarketShareBar label="Developer" color="bg-blue-400" segment={tam.segments.developer} />
|
|
<MarketShareBar label="Enterprise" color="bg-purple-400" segment={tam.segments.enterprise} />
|
|
<MarketShareBar label="Government" color="bg-surface-400" segment={tam.segments.government} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Section 5: Competitor Snapshot (scaleup+) */}
|
|
{scaleup && competitors.filter(r => r.status === 'active').length > 0 && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-surface-400 mb-3">Competitors</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
{competitors.filter(r => r.status === 'active').map(rival => (
|
|
<div key={rival.id} className="bg-surface-900 border border-surface-700 rounded-xl p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium">{rival.name}</span>
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-700 text-surface-400">
|
|
{rival.archetype.replace('-', ' ')}
|
|
</span>
|
|
</div>
|
|
<div className="mb-2">
|
|
<div className="flex items-center justify-between text-xs text-surface-400 mb-1">
|
|
<span>Capability</span>
|
|
<span className="font-mono">{rival.estimatedCapability.toFixed(1)}</span>
|
|
</div>
|
|
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden flex">
|
|
<div
|
|
className="h-full bg-accent rounded-full"
|
|
style={{ width: `${Math.min(100, bestDeployedCapability)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden mt-1">
|
|
<div
|
|
className="h-full bg-danger/70 rounded-full"
|
|
style={{ width: `${Math.min(100, rival.estimatedCapability)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-[10px] text-surface-500 mt-0.5">
|
|
<span>You: {bestDeployedCapability.toFixed(1)}</span>
|
|
<span>Them: {rival.estimatedCapability.toFixed(1)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-surface-500">
|
|
Latest: <span className="text-surface-300">{rival.latestModelName || 'None'}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{totalDCs === 0 && (
|
|
<div className="bg-surface-900 border border-accent/30 rounded-xl p-6 text-center">
|
|
<h3 className="text-lg font-semibold mb-2">Get Started</h3>
|
|
<p className="text-surface-400 text-sm mb-4">
|
|
Build your first data center to start training AI models.
|
|
</p>
|
|
<button
|
|
onClick={() => navigate('infrastructure')}
|
|
className="bg-accent hover:bg-accent-dark text-white font-medium px-6 py-2 rounded-lg transition-colors"
|
|
>
|
|
Build Data Center
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActiveOperations({
|
|
pipelines,
|
|
activeResearch,
|
|
constructingDCs,
|
|
deployingRacks,
|
|
navigate,
|
|
}: {
|
|
pipelines: { modelName: string; status: string; currentStage: string; stages: Record<string, { progressTicks: number; totalTicks: number; isComplete: boolean }> }[];
|
|
activeResearch: { researchId: string; progressTicks: number; totalTicks: number } | null;
|
|
constructingDCs: number;
|
|
deployingRacks: number;
|
|
navigate: (page: ActivePage) => void;
|
|
}) {
|
|
const activePipes = pipelines.filter(p => p.status === 'active');
|
|
const items: React.ReactNode[] = [];
|
|
|
|
for (let i = 0; i < Math.min(2, activePipes.length); i++) {
|
|
const p = activePipes[i];
|
|
const stage = p.stages[p.currentStage as keyof typeof p.stages];
|
|
const progress = stage ? stage.progressTicks / stage.totalTicks : 0;
|
|
const eta = stage ? stage.totalTicks - stage.progressTicks : 0;
|
|
items.push(
|
|
<OperationRow
|
|
key={`train-${i}`}
|
|
icon={Brain}
|
|
label={p.modelName}
|
|
detail={p.currentStage}
|
|
progress={progress}
|
|
eta={eta}
|
|
onClick={() => navigate('models')}
|
|
/>,
|
|
);
|
|
}
|
|
if (activePipes.length > 2) {
|
|
items.push(
|
|
<button key="more-train" onClick={() => navigate('models')} className="text-xs text-accent hover:text-accent-light transition-colors">
|
|
+{activePipes.length - 2} more training...
|
|
</button>,
|
|
);
|
|
}
|
|
|
|
if (activeResearch) {
|
|
const researchName = TECH_TREE.find(n => n.id === activeResearch.researchId)?.name ?? activeResearch.researchId;
|
|
const progress = activeResearch.progressTicks / activeResearch.totalTicks;
|
|
const eta = activeResearch.totalTicks - activeResearch.progressTicks;
|
|
items.push(
|
|
<OperationRow
|
|
key="research"
|
|
icon={FlaskConical}
|
|
label={researchName}
|
|
detail="researching"
|
|
progress={progress}
|
|
eta={eta}
|
|
onClick={() => navigate('research')}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
if (constructingDCs > 0) {
|
|
items.push(
|
|
<div key="construction" className="flex items-center gap-2 py-1 cursor-pointer hover:bg-surface-800/50 rounded px-1 -mx-1 transition-colors" onClick={() => navigate('infrastructure')}>
|
|
<Building2 size={14} className="text-surface-400" />
|
|
<span className="text-sm text-surface-300">{constructingDCs} DC{constructingDCs > 1 ? 's' : ''} building</span>
|
|
</div>,
|
|
);
|
|
}
|
|
|
|
if (deployingRacks > 0) {
|
|
items.push(
|
|
<div key="deploy" className="flex items-center gap-2 py-1 cursor-pointer hover:bg-surface-800/50 rounded px-1 -mx-1 transition-colors" onClick={() => navigate('infrastructure')}>
|
|
<HardDrive size={14} className="text-surface-400" />
|
|
<span className="text-sm text-surface-300">{deployingRacks} rack batch{deployingRacks > 1 ? 'es' : ''} deploying</span>
|
|
</div>,
|
|
);
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center text-surface-500 text-sm min-h-[100px]">
|
|
<div className="text-center">
|
|
<Clock size={20} className="mx-auto mb-2 text-surface-600" />
|
|
All systems idle
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <div className="space-y-3">{items}</div>;
|
|
}
|
|
|
|
function OperationRow({
|
|
icon: Icon,
|
|
label,
|
|
detail,
|
|
progress,
|
|
eta,
|
|
onClick,
|
|
}: {
|
|
icon: typeof Brain;
|
|
label: string;
|
|
detail: string;
|
|
progress: number;
|
|
eta: number;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<div className="cursor-pointer hover:bg-surface-800/50 rounded px-1 -mx-1 py-1 transition-colors" onClick={onClick}>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={14} className="text-surface-400" />
|
|
<span className="text-sm text-surface-300 truncate max-w-[140px]">{label}</span>
|
|
<span className="text-[10px] text-surface-500">{detail}</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-surface-500">{formatDuration(eta)}</span>
|
|
</div>
|
|
<div className="h-1 bg-surface-800 rounded-full overflow-hidden">
|
|
<div className="h-full bg-accent rounded-full transition-all duration-500" style={{ width: `${Math.min(100, progress * 100)}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MarketShareBar({
|
|
label,
|
|
color,
|
|
segment,
|
|
}: {
|
|
label: string;
|
|
color: string;
|
|
segment: { shares: { playerId: string; sharePercent: number }[] };
|
|
}) {
|
|
const playerShare = segment.shares.find(s => s.playerId === 'player')?.sharePercent ?? 0;
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-xs text-surface-400">{label}</span>
|
|
<span className="text-xs font-mono text-surface-300">{formatPercent(playerShare / 100)}</span>
|
|
</div>
|
|
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all duration-500 ${color}`} style={{ width: `${Math.min(100, playerShare)}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({
|
|
icon: Icon, label, value, subValue, trend, color, onClick,
|
|
}: {
|
|
icon: typeof DollarSign;
|
|
label: string;
|
|
value: string;
|
|
subValue?: string;
|
|
trend?: 'up' | 'down' | 'neutral';
|
|
color?: string;
|
|
onClick?: () => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={`bg-surface-900 border border-surface-700 rounded-xl p-4 ${onClick ? 'cursor-pointer hover:border-accent/50 transition-colors group' : ''}`}
|
|
onClick={onClick}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={16} className={color ?? 'text-surface-400'} />
|
|
<span className="text-xs text-surface-400 uppercase tracking-wider">{label}</span>
|
|
</div>
|
|
{onClick && <ChevronRight size={14} className="text-surface-500 group-hover:text-accent-light group-hover:translate-x-0.5 transition-all" />}
|
|
</div>
|
|
<div className="text-2xl font-bold font-mono">{value}</div>
|
|
{subValue && (
|
|
<div className={`text-xs mt-1 flex items-center gap-1 ${
|
|
trend === 'up' ? 'text-success' : trend === 'down' ? 'text-danger' : 'text-surface-400'
|
|
}`}>
|
|
{trend === 'up' && <TrendingUp size={12} />}
|
|
{trend === 'down' && <TrendingDown size={12} />}
|
|
{trend === 'neutral' && <Minus size={12} />}
|
|
{subValue}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusRow({
|
|
icon: Icon, label, value, bar, barColor,
|
|
}: {
|
|
icon: typeof Cpu;
|
|
label: string;
|
|
value: string;
|
|
bar: number;
|
|
barColor: string;
|
|
}) {
|
|
const severity = barColor.includes('danger') ? 'Critical' : barColor.includes('warning') ? 'Warning' : null;
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={14} className="text-surface-400" />
|
|
<span className="text-sm text-surface-300">{label}</span>
|
|
{severity && <span className={`text-[10px] px-1.5 py-0.5 rounded ${barColor.includes('danger') ? 'bg-danger/20 text-danger' : 'bg-warning/20 text-warning'}`}>{severity}</span>}
|
|
</div>
|
|
<span className="text-sm font-mono text-surface-200">{value}</span>
|
|
</div>
|
|
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
|
style={{ width: `${Math.min(100, bar * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|