# Price Tracking Dashboard (React)

## What We're Building

A React-based dashboard that tracks GPU prices across the Clore.ai marketplace in real-time, with historical charts, price alerts, and availability notifications. Perfect for finding the best deals and optimizing your GPU rental costs.

**Key Features:**

* Real-time price monitoring for all GPU types
* Historical price charts with trends
* Email/Slack alerts when prices drop
* Availability tracking
* Cost calculator for your workloads
* Mobile-responsive design
* Data export (CSV/JSON)

## Prerequisites

* Clore.ai account with API key ([get one here](https://clore.ai))
* Node.js 18+
* Basic React knowledge

```bash
npx create-react-app gpu-price-dashboard
cd gpu-price-dashboard
npm install axios recharts date-fns @tanstack/react-query lucide-react
```

## Architecture Overview

```
┌─────────────────────────────────────────────────────────────┐
│                     React Dashboard                          │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────┐  ┌──────────┐  ┌────────────┐  ┌───────────┐  │
│  │ Price   │  │ History  │  │ Alerts     │  │ Calculator│  │
│  │ Cards   │  │ Charts   │  │ Settings   │  │           │  │
│  └─────────┘  └──────────┘  └────────────┘  └───────────┘  │
├─────────────────────────────────────────────────────────────┤
│                    State Management                          │
│                   (React Query + Context)                    │
├─────────────────────────────────────────────────────────────┤
│                      Backend Proxy                           │
│                   (Express.js + SQLite)                      │
├─────────────────────────────────────────────────────────────┤
│                    Clore.ai API                              │
│                /v1/marketplace endpoint                      │
└─────────────────────────────────────────────────────────────┘
```

## Step 1: Backend Proxy Server

```javascript
// server/index.js
const express = require('express');
const cors = require('cors');
const axios = require('axios');
const Database = require('better-sqlite3');
const cron = require('node-cron');

const app = express();
app.use(cors());
app.use(express.json());

// Initialize SQLite database
const db = new Database('prices.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS price_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    gpu_type TEXT NOT NULL,
    price_spot REAL,
    price_ondemand REAL,
    available_count INTEGER,
    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
  );
  
  CREATE INDEX IF NOT EXISTS idx_gpu_timestamp ON price_history(gpu_type, timestamp);
  
  CREATE TABLE IF NOT EXISTS alerts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    gpu_type TEXT NOT NULL,
    target_price REAL NOT NULL,
    email TEXT,
    webhook_url TEXT,
    is_active INTEGER DEFAULT 1,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );
`);

const CLORE_API_KEY = process.env.CLORE_API_KEY || 'YOUR_API_KEY';
const CLORE_API_URL = 'https://api.clore.ai';

// Fetch marketplace data
async function fetchMarketplace() {
  try {
    const response = await axios.get(`${CLORE_API_URL}/v1/marketplace`, {
      headers: { 'auth': CLORE_API_KEY }
    });
    return response.data.servers || [];
  } catch (error) {
    console.error('Failed to fetch marketplace:', error.message);
    return [];
  }
}

// Aggregate prices by GPU type
function aggregatePrices(servers) {
  const gpuPrices = {};
  
  servers.forEach(server => {
    const gpuArray = server.gpu_array || [];
    gpuArray.forEach(gpu => {
      // Normalize GPU name
      const gpuType = normalizeGpuName(gpu);
      
      if (!gpuPrices[gpuType]) {
        gpuPrices[gpuType] = {
          spotPrices: [],
          ondemandPrices: [],
          availableCount: 0,
          totalCount: 0
        };
      }
      
      gpuPrices[gpuType].totalCount++;
      
      if (!server.rented) {
        gpuPrices[gpuType].availableCount++;
        
        const usdPrices = server.price?.usd || {};
        if (usdPrices.spot) {
          gpuPrices[gpuType].spotPrices.push(usdPrices.spot);
        }
        if (usdPrices.on_demand_clore) {
          gpuPrices[gpuType].ondemandPrices.push(usdPrices.on_demand_clore);
        }
      }
    });
  });
  
  // Calculate aggregates
  const result = {};
  Object.entries(gpuPrices).forEach(([gpuType, data]) => {
    result[gpuType] = {
      gpu_type: gpuType,
      spot_min: data.spotPrices.length ? Math.min(...data.spotPrices) : null,
      spot_avg: data.spotPrices.length ? average(data.spotPrices) : null,
      spot_max: data.spotPrices.length ? Math.max(...data.spotPrices) : null,
      ondemand_min: data.ondemandPrices.length ? Math.min(...data.ondemandPrices) : null,
      ondemand_avg: data.ondemandPrices.length ? average(data.ondemandPrices) : null,
      ondemand_max: data.ondemandPrices.length ? Math.max(...data.ondemandPrices) : null,
      available_count: data.availableCount,
      total_count: data.totalCount
    };
  });
  
  return result;
}

function normalizeGpuName(gpu) {
  const patterns = [
    { match: /RTX\s*4090/i, name: 'RTX 4090' },
    { match: /RTX\s*4080/i, name: 'RTX 4080' },
    { match: /RTX\s*4070/i, name: 'RTX 4070' },
    { match: /RTX\s*3090/i, name: 'RTX 3090' },
    { match: /RTX\s*3080/i, name: 'RTX 3080' },
    { match: /RTX\s*3070/i, name: 'RTX 3070' },
    { match: /A100.*80/i, name: 'A100 80GB' },
    { match: /A100/i, name: 'A100 40GB' },
    { match: /A6000/i, name: 'A6000' },
    { match: /A5000/i, name: 'A5000' },
    { match: /A4000/i, name: 'A4000' },
  ];
  
  for (const pattern of patterns) {
    if (pattern.match.test(gpu)) {
      return pattern.name;
    }
  }
  return gpu;
}

function average(arr) {
  return arr.reduce((a, b) => a + b, 0) / arr.length;
}

// Store prices in database
function storePrices(prices) {
  const stmt = db.prepare(`
    INSERT INTO price_history (gpu_type, price_spot, price_ondemand, available_count)
    VALUES (?, ?, ?, ?)
  `);
  
  Object.values(prices).forEach(data => {
    stmt.run(data.gpu_type, data.spot_min, data.ondemand_min, data.available_count);
  });
}

// Check alerts and trigger notifications
async function checkAlerts(prices) {
  const alerts = db.prepare('SELECT * FROM alerts WHERE is_active = 1').all();
  
  for (const alert of alerts) {
    const priceData = prices[alert.gpu_type];
    if (!priceData) continue;
    
    const currentPrice = priceData.spot_min || priceData.ondemand_min;
    if (currentPrice && currentPrice <= alert.target_price) {
      await sendAlert(alert, priceData);
    }
  }
}

async function sendAlert(alert, priceData) {
  console.log(`🚨 Price alert triggered: ${alert.gpu_type} at $${priceData.spot_min}/hr`);
  
  if (alert.webhook_url) {
    try {
      await axios.post(alert.webhook_url, {
        text: `🚨 GPU Price Alert: ${alert.gpu_type} now at $${priceData.spot_min}/hr (target: $${alert.target_price})`
      });
    } catch (e) {
      console.error('Webhook failed:', e.message);
    }
  }
}

// Scheduled price collection (every 5 minutes)
cron.schedule('*/5 * * * *', async () => {
  console.log('Collecting prices...');
  const servers = await fetchMarketplace();
  const prices = aggregatePrices(servers);
  storePrices(prices);
  await checkAlerts(prices);
});

// API Routes

// Get current prices
app.get('/api/prices', async (req, res) => {
  const servers = await fetchMarketplace();
  const prices = aggregatePrices(servers);
  res.json(Object.values(prices));
});

// Get price history
app.get('/api/prices/history', (req, res) => {
  const { gpu_type, hours = 24 } = req.query;
  
  let query = `
    SELECT gpu_type, price_spot, price_ondemand, available_count, timestamp
    FROM price_history
    WHERE timestamp > datetime('now', '-${parseInt(hours)} hours')
  `;
  
  if (gpu_type) {
    query += ` AND gpu_type = '${gpu_type}'`;
  }
  
  query += ' ORDER BY timestamp ASC';
  
  const history = db.prepare(query).all();
  res.json(history);
});

// Get detailed server list
app.get('/api/servers', async (req, res) => {
  const { gpu_type, max_price, available_only } = req.query;
  const servers = await fetchMarketplace();
  
  let filtered = servers;
  
  if (gpu_type) {
    filtered = filtered.filter(s => 
      (s.gpu_array || []).some(g => normalizeGpuName(g) === gpu_type)
    );
  }
  
  if (available_only === 'true') {
    filtered = filtered.filter(s => !s.rented);
  }
  
  if (max_price) {
    filtered = filtered.filter(s => {
      const price = s.price?.usd?.spot || s.price?.usd?.on_demand_clore;
      return price && price <= parseFloat(max_price);
    });
  }
  
  // Format response
  const result = filtered.map(s => ({
    id: s.id,
    gpus: s.gpu_array || [],
    gpu_count: (s.gpu_array || []).length,
    rented: s.rented,
    reliability: s.reliability,
    rating: s.rating,
    price_spot: s.price?.usd?.spot,
    price_ondemand: s.price?.usd?.on_demand_clore,
    specs: s.specs
  }));
  
  res.json(result);
});

// Alerts management
app.get('/api/alerts', (req, res) => {
  const alerts = db.prepare('SELECT * FROM alerts ORDER BY created_at DESC').all();
  res.json(alerts);
});

app.post('/api/alerts', (req, res) => {
  const { gpu_type, target_price, email, webhook_url } = req.body;
  
  const result = db.prepare(`
    INSERT INTO alerts (gpu_type, target_price, email, webhook_url)
    VALUES (?, ?, ?, ?)
  `).run(gpu_type, target_price, email, webhook_url);
  
  res.json({ id: result.lastInsertRowid, success: true });
});

app.delete('/api/alerts/:id', (req, res) => {
  db.prepare('DELETE FROM alerts WHERE id = ?').run(req.params.id);
  res.json({ success: true });
});

// Cost calculator
app.post('/api/calculate', async (req, res) => {
  const { gpu_type, hours, use_spot } = req.body;
  
  const servers = await fetchMarketplace();
  const prices = aggregatePrices(servers);
  const gpuData = prices[gpu_type];
  
  if (!gpuData) {
    return res.status(404).json({ error: 'GPU type not found' });
  }
  
  const pricePerHour = use_spot ? gpuData.spot_min : gpuData.ondemand_min;
  const totalCost = pricePerHour * hours;
  
  res.json({
    gpu_type,
    price_per_hour: pricePerHour,
    hours,
    total_cost: totalCost,
    pricing_type: use_spot ? 'spot' : 'on-demand'
  });
});

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
```

## Step 2: React Dashboard Components

### API Client

```javascript
// src/api/cloreApi.js
import axios from 'axios';

const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:3001';

const api = axios.create({
  baseURL: API_BASE,
  timeout: 10000
});

export const cloreApi = {
  // Get current prices
  getPrices: async () => {
    const { data } = await api.get('/api/prices');
    return data;
  },
  
  // Get price history
  getPriceHistory: async (gpuType, hours = 24) => {
    const params = { hours };
    if (gpuType) params.gpu_type = gpuType;
    const { data } = await api.get('/api/prices/history', { params });
    return data;
  },
  
  // Get server list
  getServers: async (filters = {}) => {
    const { data } = await api.get('/api/servers', { params: filters });
    return data;
  },
  
  // Alerts
  getAlerts: async () => {
    const { data } = await api.get('/api/alerts');
    return data;
  },
  
  createAlert: async (alert) => {
    const { data } = await api.post('/api/alerts', alert);
    return data;
  },
  
  deleteAlert: async (id) => {
    const { data } = await api.delete(`/api/alerts/${id}`);
    return data;
  },
  
  // Cost calculator
  calculateCost: async (gpuType, hours, useSpot) => {
    const { data } = await api.post('/api/calculate', {
      gpu_type: gpuType,
      hours,
      use_spot: useSpot
    });
    return data;
  }
};
```

### Price Card Component

```jsx
// src/components/PriceCard.jsx
import React from 'react';
import { TrendingUp, TrendingDown, Minus, Monitor } from 'lucide-react';

export function PriceCard({ gpu, onSelect, isSelected }) {
  const spotPrice = gpu.spot_min;
  const ondemandPrice = gpu.ondemand_min;
  const availability = gpu.available_count;
  const total = gpu.total_count;
  
  const availabilityPercent = total > 0 ? (availability / total) * 100 : 0;
  
  const getAvailabilityColor = () => {
    if (availabilityPercent > 50) return 'text-green-500';
    if (availabilityPercent > 20) return 'text-yellow-500';
    return 'text-red-500';
  };
  
  return (
    <div 
      className={`
        bg-white rounded-xl shadow-sm border-2 p-4 cursor-pointer transition-all
        hover:shadow-md hover:border-blue-300
        ${isSelected ? 'border-blue-500 ring-2 ring-blue-200' : 'border-gray-100'}
      `}
      onClick={() => onSelect(gpu.gpu_type)}
    >
      <div className="flex justify-between items-start mb-3">
        <div className="flex items-center gap-2">
          <Monitor className="w-5 h-5 text-gray-600" />
          <h3 className="font-semibold text-gray-800">{gpu.gpu_type}</h3>
        </div>
        <span className={`text-sm ${getAvailabilityColor()}`}>
          {availability}/{total} available
        </span>
      </div>
      
      <div className="space-y-2">
        {spotPrice !== null && (
          <div className="flex justify-between items-center">
            <span className="text-sm text-gray-500">Spot</span>
            <span className="font-mono font-semibold text-green-600">
              ${spotPrice.toFixed(3)}/hr
            </span>
          </div>
        )}
        
        {ondemandPrice !== null && (
          <div className="flex justify-between items-center">
            <span className="text-sm text-gray-500">On-Demand</span>
            <span className="font-mono font-semibold text-blue-600">
              ${ondemandPrice.toFixed(3)}/hr
            </span>
          </div>
        )}
      </div>
      
      {/* Availability bar */}
      <div className="mt-3">
        <div className="h-1.5 bg-gray-200 rounded-full overflow-hidden">
          <div 
            className={`h-full transition-all ${
              availabilityPercent > 50 ? 'bg-green-500' :
              availabilityPercent > 20 ? 'bg-yellow-500' : 'bg-red-500'
            }`}
            style={{ width: `${availabilityPercent}%` }}
          />
        </div>
      </div>
    </div>
  );
}
```

### Price History Chart

```jsx
// src/components/PriceChart.jsx
import React from 'react';
import {
  LineChart, Line, XAxis, YAxis, CartesianGrid, 
  Tooltip, Legend, ResponsiveContainer
} from 'recharts';
import { format } from 'date-fns';

export function PriceChart({ data, gpuType }) {
  // Process data for chart
  const chartData = data.map(point => ({
    timestamp: new Date(point.timestamp),
    spot: point.price_spot,
    ondemand: point.price_ondemand,
    available: point.available_count
  }));
  
  const formatXAxis = (timestamp) => {
    return format(new Date(timestamp), 'HH:mm');
  };
  
  const formatTooltip = (value, name) => {
    if (name === 'spot' || name === 'ondemand') {
      return [`$${value?.toFixed(4)}/hr`, name === 'spot' ? 'Spot' : 'On-Demand'];
    }
    return [value, 'Available'];
  };
  
  return (
    <div className="bg-white rounded-xl shadow-sm p-4">
      <h3 className="text-lg font-semibold mb-4">
        {gpuType ? `${gpuType} Price History` : 'Price History'}
      </h3>
      
      <ResponsiveContainer width="100%" height={300}>
        <LineChart data={chartData}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis 
            dataKey="timestamp" 
            tickFormatter={formatXAxis}
            tick={{ fontSize: 12 }}
          />
          <YAxis 
            yAxisId="price"
            tick={{ fontSize: 12 }}
            tickFormatter={(v) => `$${v.toFixed(2)}`}
          />
          <YAxis 
            yAxisId="count"
            orientation="right"
            tick={{ fontSize: 12 }}
          />
          <Tooltip formatter={formatTooltip} />
          <Legend />
          <Line 
            yAxisId="price"
            type="monotone" 
            dataKey="spot" 
            stroke="#10b981" 
            strokeWidth={2}
            dot={false}
            name="Spot Price"
          />
          <Line 
            yAxisId="price"
            type="monotone" 
            dataKey="ondemand" 
            stroke="#3b82f6" 
            strokeWidth={2}
            dot={false}
            name="On-Demand Price"
          />
          <Line 
            yAxisId="count"
            type="monotone" 
            dataKey="available" 
            stroke="#f59e0b" 
            strokeWidth={1}
            strokeDasharray="5 5"
            dot={false}
            name="Available"
          />
        </LineChart>
      </ResponsiveContainer>
    </div>
  );
}
```

### Alert Manager

```jsx
// src/components/AlertManager.jsx
import React, { useState } from 'react';
import { Bell, Trash2, Plus } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { cloreApi } from '../api/cloreApi';

export function AlertManager({ alerts, gpuTypes }) {
  const [showForm, setShowForm] = useState(false);
  const [newAlert, setNewAlert] = useState({
    gpu_type: '',
    target_price: '',
    webhook_url: ''
  });
  
  const queryClient = useQueryClient();
  
  const createMutation = useMutation({
    mutationFn: cloreApi.createAlert,
    onSuccess: () => {
      queryClient.invalidateQueries(['alerts']);
      setShowForm(false);
      setNewAlert({ gpu_type: '', target_price: '', webhook_url: '' });
    }
  });
  
  const deleteMutation = useMutation({
    mutationFn: cloreApi.deleteAlert,
    onSuccess: () => queryClient.invalidateQueries(['alerts'])
  });
  
  return (
    <div className="bg-white rounded-xl shadow-sm p-4">
      <div className="flex justify-between items-center mb-4">
        <h3 className="text-lg font-semibold flex items-center gap-2">
          <Bell className="w-5 h-5" />
          Price Alerts
        </h3>
        <button
          onClick={() => setShowForm(!showForm)}
          className="flex items-center gap-1 px-3 py-1.5 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600"
        >
          <Plus className="w-4 h-4" />
          Add Alert
        </button>
      </div>
      
      {showForm && (
        <div className="mb-4 p-3 bg-gray-50 rounded-lg space-y-3">
          <select
            value={newAlert.gpu_type}
            onChange={(e) => setNewAlert({ ...newAlert, gpu_type: e.target.value })}
            className="w-full p-2 border rounded"
          >
            <option value="">Select GPU</option>
            {gpuTypes.map(gpu => (
              <option key={gpu} value={gpu}>{gpu}</option>
            ))}
          </select>
          
          <input
            type="number"
            step="0.01"
            placeholder="Target price ($/hr)"
            value={newAlert.target_price}
            onChange={(e) => setNewAlert({ ...newAlert, target_price: e.target.value })}
            className="w-full p-2 border rounded"
          />
          
          <input
            type="url"
            placeholder="Slack/Discord webhook URL (optional)"
            value={newAlert.webhook_url}
            onChange={(e) => setNewAlert({ ...newAlert, webhook_url: e.target.value })}
            className="w-full p-2 border rounded"
          />
          
          <button
            onClick={() => createMutation.mutate(newAlert)}
            disabled={!newAlert.gpu_type || !newAlert.target_price}
            className="w-full py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
          >
            Create Alert
          </button>
        </div>
      )}
      
      <div className="space-y-2">
        {alerts.map(alert => (
          <div key={alert.id} className="flex justify-between items-center p-2 bg-gray-50 rounded">
            <div>
              <span className="font-medium">{alert.gpu_type}</span>
              <span className="text-gray-500 ml-2">≤ ${alert.target_price}/hr</span>
            </div>
            <button
              onClick={() => deleteMutation.mutate(alert.id)}
              className="p-1 text-red-500 hover:bg-red-50 rounded"
            >
              <Trash2 className="w-4 h-4" />
            </button>
          </div>
        ))}
        
        {alerts.length === 0 && (
          <p className="text-gray-500 text-center py-4">No alerts configured</p>
        )}
      </div>
    </div>
  );
}
```

### Cost Calculator

```jsx
// src/components/CostCalculator.jsx
import React, { useState } from 'react';
import { Calculator, DollarSign } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { cloreApi } from '../api/cloreApi';

export function CostCalculator({ gpuTypes }) {
  const [config, setConfig] = useState({
    gpu_type: 'RTX 4090',
    hours: 24,
    use_spot: true
  });
  
  const [result, setResult] = useState(null);
  
  const calculateMutation = useMutation({
    mutationFn: () => cloreApi.calculateCost(config.gpu_type, config.hours, config.use_spot),
    onSuccess: setResult
  });
  
  return (
    <div className="bg-white rounded-xl shadow-sm p-4">
      <h3 className="text-lg font-semibold flex items-center gap-2 mb-4">
        <Calculator className="w-5 h-5" />
        Cost Calculator
      </h3>
      
      <div className="space-y-4">
        <div>
          <label className="block text-sm text-gray-600 mb-1">GPU Type</label>
          <select
            value={config.gpu_type}
            onChange={(e) => setConfig({ ...config, gpu_type: e.target.value })}
            className="w-full p-2 border rounded"
          >
            {gpuTypes.map(gpu => (
              <option key={gpu} value={gpu}>{gpu}</option>
            ))}
          </select>
        </div>
        
        <div>
          <label className="block text-sm text-gray-600 mb-1">Duration (hours)</label>
          <input
            type="number"
            value={config.hours}
            onChange={(e) => setConfig({ ...config, hours: parseInt(e.target.value) || 0 })}
            className="w-full p-2 border rounded"
          />
        </div>
        
        <div className="flex items-center gap-2">
          <input
            type="checkbox"
            id="useSpot"
            checked={config.use_spot}
            onChange={(e) => setConfig({ ...config, use_spot: e.target.checked })}
          />
          <label htmlFor="useSpot" className="text-sm">Use spot pricing (cheaper, can be interrupted)</label>
        </div>
        
        <button
          onClick={() => calculateMutation.mutate()}
          className="w-full py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        >
          Calculate
        </button>
        
        {result && (
          <div className="mt-4 p-4 bg-green-50 rounded-lg">
            <div className="flex items-center justify-between">
              <span className="text-gray-600">Estimated Cost</span>
              <span className="text-2xl font-bold text-green-600">
                ${result.total_cost.toFixed(2)}
              </span>
            </div>
            <div className="mt-2 text-sm text-gray-500">
              {result.gpu_type} × {result.hours}h @ ${result.price_per_hour.toFixed(4)}/hr ({result.pricing_type})
            </div>
          </div>
        )}
      </div>
    </div>
  );
}
```

### Main Dashboard

```jsx
// src/App.jsx
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RefreshCw, Sun, Moon } from 'lucide-react';

import { cloreApi } from './api/cloreApi';
import { PriceCard } from './components/PriceCard';
import { PriceChart } from './components/PriceChart';
import { AlertManager } from './components/AlertManager';
import { CostCalculator } from './components/CostCalculator';

const queryClient = new QueryClient();

function Dashboard() {
  const [selectedGpu, setSelectedGpu] = useState(null);
  const [timeRange, setTimeRange] = useState(24);
  
  // Fetch current prices
  const { data: prices = [], isLoading: pricesLoading, refetch } = useQuery({
    queryKey: ['prices'],
    queryFn: cloreApi.getPrices,
    refetchInterval: 60000 // Auto-refresh every minute
  });
  
  // Fetch price history
  const { data: history = [] } = useQuery({
    queryKey: ['priceHistory', selectedGpu, timeRange],
    queryFn: () => cloreApi.getPriceHistory(selectedGpu, timeRange),
    enabled: true
  });
  
  // Fetch alerts
  const { data: alerts = [] } = useQuery({
    queryKey: ['alerts'],
    queryFn: cloreApi.getAlerts
  });
  
  const gpuTypes = prices.map(p => p.gpu_type).sort();
  
  // Sort prices by spot price (cheapest first)
  const sortedPrices = [...prices].sort((a, b) => {
    const priceA = a.spot_min ?? Infinity;
    const priceB = b.spot_min ?? Infinity;
    return priceA - priceB;
  });
  
  return (
    <div className="min-h-screen bg-gray-50">
      {/* Header */}
      <header className="bg-white border-b sticky top-0 z-10">
        <div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
          <h1 className="text-2xl font-bold text-gray-800">
            🖥️ Clore.ai GPU Price Tracker
          </h1>
          <div className="flex items-center gap-4">
            <select
              value={timeRange}
              onChange={(e) => setTimeRange(parseInt(e.target.value))}
              className="p-2 border rounded"
            >
              <option value={6}>6 hours</option>
              <option value={24}>24 hours</option>
              <option value={72}>3 days</option>
              <option value={168}>7 days</option>
            </select>
            <button
              onClick={() => refetch()}
              className="p-2 hover:bg-gray-100 rounded-full"
              title="Refresh"
            >
              <RefreshCw className={`w-5 h-5 ${pricesLoading ? 'animate-spin' : ''}`} />
            </button>
          </div>
        </div>
      </header>
      
      <main className="max-w-7xl mx-auto px-4 py-6">
        <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
          {/* Main content */}
          <div className="lg:col-span-2 space-y-6">
            {/* Price cards grid */}
            <div>
              <h2 className="text-lg font-semibold mb-3">Current Prices</h2>
              <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
                {sortedPrices.map(gpu => (
                  <PriceCard
                    key={gpu.gpu_type}
                    gpu={gpu}
                    onSelect={setSelectedGpu}
                    isSelected={selectedGpu === gpu.gpu_type}
                  />
                ))}
              </div>
            </div>
            
            {/* Price chart */}
            <PriceChart data={history} gpuType={selectedGpu} />
          </div>
          
          {/* Sidebar */}
          <div className="space-y-6">
            <AlertManager alerts={alerts} gpuTypes={gpuTypes} />
            <CostCalculator gpuTypes={gpuTypes} />
          </div>
        </div>
      </main>
    </div>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Dashboard />
    </QueryClientProvider>
  );
}
```

## Step 3: Docker Deployment

```dockerfile
# Dockerfile
FROM node:18-alpine

WORKDIR /app

# Install backend dependencies
COPY server/package*.json ./server/
RUN cd server && npm install

# Install frontend dependencies
COPY package*.json ./
RUN npm install

# Copy source code
COPY . .

# Build frontend
RUN npm run build

# Start server
WORKDIR /app/server
EXPOSE 3001
CMD ["node", "index.js"]
```

```yaml
# docker-compose.yml
version: '3.8'

services:
  dashboard:
    build: .
    ports:
      - "3001:3001"
    environment:
      - CLORE_API_KEY=YOUR_API_KEY
      - NODE_ENV=production
    volumes:
      - ./data:/app/server/data
    restart: unless-stopped
```

## Running the Dashboard

```bash
# Development
cd gpu-price-dashboard

# Start backend
cd server && npm install && node index.js &

# Start frontend
npm start

# Production
docker-compose up -d
```

## Cost of Running

| Component                | Cost                   |
| ------------------------ | ---------------------- |
| Clore.ai API             | Free (1 req/sec limit) |
| Hosting (Railway/Render) | Free tier available    |
| Database (SQLite)        | Free (local file)      |
| **Total**                | **$0/month**           |

## Features Summary

* ✅ Real-time price monitoring
* ✅ Historical price charts
* ✅ Price drop alerts (Slack/Discord)
* ✅ Availability tracking
* ✅ Cost calculator
* ✅ Mobile responsive
* ✅ Auto-refresh

## Next Steps

* [Building a Python SDK](https://docs.clore.ai/dev/advanced-use-cases/python-sdk)
* [Mining Calculator](https://docs.clore.ai/dev/advanced-use-cases/mining-calculator)
* [Webhook Order Management](https://docs.clore.ai/dev/advanced-use-cases/webhook-orders)
