# 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](/advanced-use-cases/python-sdk.md)
* [Mining Calculator](/advanced-use-cases/mining-calculator.md)
* [Webhook Order Management](/advanced-use-cases/webhook-orders.md)


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://dev.clore.ai/advanced-use-cases/price-dashboard.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
