diff --git a/nextjs-dashboard/package-lock.json b/nextjs-dashboard/package-lock.json
index c9dd082..f0a81d5 100644
--- a/nextjs-dashboard/package-lock.json
+++ b/nextjs-dashboard/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@opennextjs/cloudflare": "^1.17.1",
+ "lucide-react": "^1.7.0",
"next": "16.1.5",
"react": "19.1.5",
"react-dom": "19.1.5",
@@ -99,9 +100,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -118,9 +116,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -137,9 +132,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -156,9 +148,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2276,9 +2265,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2295,9 +2281,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2314,9 +2297,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2333,9 +2313,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2352,9 +2329,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2371,9 +2345,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2390,9 +2361,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2409,9 +2377,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2428,9 +2393,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2453,9 +2415,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2478,9 +2437,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2503,9 +2459,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2528,9 +2481,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2553,9 +2503,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2578,9 +2525,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2603,9 +2547,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2830,9 +2771,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2849,9 +2787,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2868,9 +2803,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2887,9 +2819,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4176,9 +4105,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4196,9 +4122,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4216,9 +4139,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4236,9 +4156,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4860,9 +4777,20 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
+ "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
+ "cpu": [
+ "arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4877,9 +4805,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4894,9 +4819,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4911,9 +4833,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4928,9 +4847,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4945,9 +4861,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4962,9 +4875,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -8634,9 +8544,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8658,9 +8565,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8682,9 +8586,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8706,9 +8607,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8809,6 +8707,15 @@
"node": "20 || >=22"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
+ "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -9634,6 +9541,13 @@
"react-is": "^16.13.1"
}
},
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -9739,10 +9653,11 @@
}
},
"node_modules/react-is": {
- "version": "16.13.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "license": "MIT"
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
+ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
+ "license": "MIT",
+ "peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
@@ -10951,10 +10866,6 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
}
},
- "node_modules/unrs-resolver/node_modules/@unrs/resolver-binding-linux-arm64-musl": {
- "dev": true,
- "optional": true
- },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
diff --git a/nextjs-dashboard/package.json b/nextjs-dashboard/package.json
index 26c27fa..00bb794 100644
--- a/nextjs-dashboard/package.json
+++ b/nextjs-dashboard/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"@opennextjs/cloudflare": "^1.17.1",
+ "lucide-react": "^1.7.0",
"next": "16.1.5",
"react": "19.1.5",
"react-dom": "19.1.5",
diff --git a/nextjs-dashboard/src/components/CostCalculator.tsx b/nextjs-dashboard/src/components/CostCalculator.tsx
new file mode 100644
index 0000000..9a358c2
--- /dev/null
+++ b/nextjs-dashboard/src/components/CostCalculator.tsx
@@ -0,0 +1,266 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { GPURow } from "@/types/gpu";
+import {
+ Calculator,
+ Cpu,
+ Clock,
+ Layers,
+ Search,
+ Info,
+ ChevronDown,
+ CircleDollarSign,
+ TrendingUp
+} from "lucide-react";
+
+const TIME_CONVERSIONS = {
+ hour: 1,
+ day: 24,
+ month: 730.48,
+};
+
+const formatCurrency = (amount: number, precision: number = 2) => {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: precision,
+ maximumFractionDigits: precision > 2 ? 4 : 2,
+ }).format(amount);
+};
+
+export default function CostCalculator({ data }: { data: GPURow[] }) {
+ const [config, setConfig] = useState({
+ selectedId: "",
+ quantity: 1,
+ duration: 1,
+ unit: "hour" as keyof typeof TIME_CONVERSIONS,
+ includeTax: false,
+ });
+
+ const [searchGPU, setSearchGPU] = useState("");
+
+ const options = useMemo(() => {
+ return data
+ .filter((r) => r.price_num !== null)
+ .map((r, i) => ({
+ ...r,
+ id: `${r.provider}-${r.gpu}-${i}`,
+ price: Number(r.price_num),
+ }));
+ }, [data]);
+
+ const selected = useMemo(
+ () => options.find((o) => o.id === config.selectedId),
+ [config.selectedId, options]
+ );
+
+ const filteredOptions = useMemo(() => {
+ return options.filter((o) => {
+ const text = `${o.provider} ${o.gpu}`.toLowerCase();
+ return text.includes(searchGPU.toLowerCase());
+ });
+ }, [options, searchGPU]);
+
+ const calc = useMemo(() => {
+ if (!selected) return null;
+ const baseHourly = selected.price * config.quantity;
+ const totalHours = config.duration * TIME_CONVERSIONS[config.unit];
+ const subtotal = baseHourly * totalHours;
+ const tax = config.includeTax ? subtotal * 0.1 : 0;
+ const grandTotal = subtotal + tax;
+
+ return {
+ hourlyRate: baseHourly,
+ subtotal,
+ tax,
+ grandTotal,
+ effectiveRate: grandTotal / (config.duration || 1),
+ };
+ }, [selected, config]);
+
+ return (
+
+
+
+ {/* Header Section */}
+
+
+
+
+
+
+
+
GPU Cost Estimator
+
CloudDealHunt Engine
+
+
+ {calc && (
+
+
Current Rate
+
{formatCurrency(calc.hourlyRate, 3)}/hr
+
+ )}
+
+
+
+
+ {/* Main Inputs Grid */}
+
+
+ {/* Left Column: Selection */}
+
+
+
+ setSearchGPU(e.target.value)}
+ className="w-full pl-4 pr-4 py-3 rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-950 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all shadow-sm"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {/* Right Column: Quantity & Time */}
+
+
+
+
+
+
+ {(["hour", "day", "month"] as const).map((u) => (
+
+ ))}
+
+
+
+
+
+ {/* Tax Switch */}
+
+
+
+
+
+
+
Estimated Tax
+
Apply standard 10% VAT/Fees
+
+
+
+
+
+ {/* Result Section */}
+ {calc ? (
+
+
+
Total Estimated Investment
+
+
+ {formatCurrency(calc.grandTotal)}
+
+ USD
+
+
+
+
+ {config.quantity}x Nodes Active
+
+
+
{config.duration} {config.unit}(s) billing cycle
+
+
+
+
+
+
+ Subtotal
+
+
{formatCurrency(calc.subtotal)}
+
+
+
Effective Rate
+
+ {formatCurrency(calc.effectiveRate)}/{config.unit}
+
+
+
+
+ ) : (
+
+
+
+
+
No Instance Selected
+
Pick a GPU from the list to calculate real-time infrastructure costs.
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/nextjs-dashboard/src/components/Dashboard.tsx b/nextjs-dashboard/src/components/Dashboard.tsx
index 8313e5b..e275852 100644
--- a/nextjs-dashboard/src/components/Dashboard.tsx
+++ b/nextjs-dashboard/src/components/Dashboard.tsx
@@ -3,13 +3,16 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { DATA_URL, META_URL, ITEMS_PER_PAGE } from "@/lib/constants";
import { normalizeRow, unique } from "@/lib/utils";
+import CostCalculator from "./CostCalculator";
import type { GPURaw, GPURow, Meta } from "@/types/gpu";
import Header from "./Header";
import DataTable from "./DataTable";
import TableSkeleton from "./TableSkeleton";
import PriceHistoryPanel from "./PriceHistoryPanel";
+import GPUCompare from "./GPUCompare";
+import { LayoutDashboard, Settings2, TrendingDown, Zap } from "lucide-react";
-// ─── Debounce ──────────────────────────────────────────────────────────────────
+// --- Debounce Hook ---
function useDebounce(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
@@ -19,17 +22,13 @@ function useDebounce(value: T, delay: number): T {
return debounced;
}
-// ─── Dashboard ─────────────────────────────────────────────────────────────────
export default function Dashboard() {
const [data, setData] = useState([]);
const [meta, setMeta] = useState(null);
const [status, setStatus] = useState<"loading" | "error" | "done">("loading");
const [error, setError] = useState(null);
-
- // Row selection for price history (max 4)
+ const [showTools, setShowTools] = useState(true);
const [selectedRows, setSelectedRows] = useState([]);
-
- // Filter / sort
const [search, setSearch] = useState("");
const [provider, setProvider] = useState("all");
const [gpuType, setGpuType] = useState("all");
@@ -40,45 +39,16 @@ export default function Dashboard() {
const debouncedSearch = useDebounce(search, 200);
- // ── Fetch ────────────────────────────────────────────────────────────────────
const loadData = useCallback(async () => {
setStatus("loading");
setError(null);
try {
- const [dataRes, metaRes] = await Promise.allSettled([
- fetch(DATA_URL),
- fetch(META_URL),
- ]);
-
- if (dataRes.status !== "fulfilled" || !dataRes.value.ok) {
- throw new Error(
- `Failed to fetch pricing data (${
- dataRes.status === "fulfilled"
- ? `HTTP ${dataRes.value.status}`
- : "network error"
- })`
- );
- }
-
+ const [dataRes, metaRes] = await Promise.allSettled([fetch(DATA_URL), fetch(META_URL)]);
+ if (dataRes.status !== "fulfilled" || !dataRes.value.ok) throw new Error("Fetch failed");
const raw = (await dataRes.value.json()) as GPURaw[];
- if (!Array.isArray(raw)) throw new Error("Unexpected response format");
-
- const rows = raw
- .map(normalizeRow)
- .filter(
- (r) =>
- (r.gpu || r.provider) &&
- r.gpu_count !== null &&
- r.vram !== null &&
- r.vcpu !== null &&
- r.ram !== null
- );
+ const rows = raw.map(normalizeRow).filter(r => (r.gpu || r.provider) && r.gpu_count !== null);
setData(rows);
-
- if (metaRes.status === "fulfilled" && metaRes.value.ok) {
- setMeta((await metaRes.value.json()) as Meta);
- }
-
+ if (metaRes.status === "fulfilled" && metaRes.value.ok) setMeta((await metaRes.value.json()) as Meta);
setStatus("done");
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
@@ -86,27 +56,11 @@ export default function Dashboard() {
}
}, []);
- useEffect(() => {
- loadData();
- }, [loadData]);
-
- // ── Derived state ─────────────────────────────────────────────────────────────
- const providers = useMemo(
- () => unique(data.map((r) => r.provider).filter(Boolean)).sort(),
- [data]
- );
-
- const gpuTypes = useMemo(
- () => unique(data.map((r) => r.gpu).filter(Boolean)).sort(),
- [data]
- );
+ useEffect(() => { loadData(); }, [loadData]);
- const gpuCounts = useMemo(
- () =>
- unique(data.map((r) => (r.gpu_count !== null ? String(r.gpu_count) : "")).filter(Boolean))
- .sort((a, b) => Number(a) - Number(b)),
- [data]
- );
+ const providers = useMemo(() => unique(data.map((r) => r.provider).filter(Boolean)).sort(), [data]);
+ const gpuTypes = useMemo(() => unique(data.map((r) => r.gpu).filter(Boolean)).sort(), [data]);
+ const gpuCounts = useMemo(() => unique(data.map((r) => String(r.gpu_count)).filter(Boolean)).sort((a, b) => Number(a) - Number(b)), [data]);
const filtered = useMemo(() => {
const q = debouncedSearch.toLowerCase().trim();
@@ -115,124 +69,117 @@ export default function Dashboard() {
if (provider !== "all" && r.provider !== provider) return false;
if (gpuType !== "all" && r.gpu !== gpuType) return false;
if (numGpus !== "all" && String(r.gpu_count) !== numGpus) return false;
- if (q) {
- const hay = `${r.provider} ${r.gpu}`.toLowerCase();
- if (!hay.includes(q)) return false;
- }
+ if (q) return `${r.provider} ${r.gpu}`.toLowerCase().includes(q);
return true;
})
.sort((a, b) => {
const va = a[sortKey];
const vb = b[sortKey];
- if (va === null && vb === null) return 0;
- if (va === null) return 1;
- if (vb === null) return -1;
- let c = 0;
- if (typeof va === "number" && typeof vb === "number") {
- c = va - vb;
- } else {
- c = String(va).localeCompare(String(vb));
- }
+ if (va === null || vb === null) return 0;
+ const c = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
return sortDir === "asc" ? c : -c;
});
}, [data, debouncedSearch, provider, gpuType, numGpus, sortKey, sortDir]);
- const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
- const safePage = Math.min(page, totalPages);
- const paginated = filtered.slice(
- (safePage - 1) * ITEMS_PER_PAGE,
- safePage * ITEMS_PER_PAGE
- );
-
- // ── Handlers ──────────────────────────────────────────────────────────────────
- const handleSort = (key: keyof GPURow) => {
- if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
- else {
- setSortKey(key);
- setSortDir("asc");
- }
- setPage(1);
- };
-
- const handleReset = () => {
- setSearch("");
- setProvider("all");
- setGpuType("all");
- setNumGpus("all");
- setSortKey("price_num");
- setSortDir("asc");
- setPage(1);
- setSelectedRows([]);
- };
-
- const handleSelectRow = (row: GPURow) => {
- setSelectedRows((prev) => {
- const exists = prev.some((r) => r.provider === row.provider && r.gpu === row.gpu);
- if (exists) return prev.filter((r) => !(r.provider === row.provider && r.gpu === row.gpu));
- if (prev.length >= 4) return prev;
- return [...prev, row];
+ const insights = useMemo(() => {
+ const valid = filtered.filter((r) => r.price_num !== null);
+ if (valid.length === 0) return null;
+ const cheapest = valid.reduce((min, r) => (r.price_num as number) < (min.price_num as number) ? r : min);
+ const bestValue = valid.reduce((best, r) => {
+ const val = (r.vram as number) / (r.price_num as number);
+ const bestVal = (best.vram as number) / (best.price_num as number);
+ return val > bestVal ? r : best;
});
- };
-
- const handleRemoveRow = (row: GPURow) => {
- setSelectedRows((prev) => prev.filter((r) => !(r.provider === row.provider && r.gpu === row.gpu)));
- };
+ return { cheapest, bestValue };
+ }, [filtered]);
- // ── Stats ─────────────────────────────────────────────────────────────────────
- const stats = useMemo(() => {
- const prices = data
- .filter((r) => r.price_num !== null)
- .map((r) => r.price_num as number);
- const minPrice = prices.length ? Math.min(...prices) : null;
- return {
- minPrice,
- updatedAt: meta?.generated_at_utc ?? meta?.generated_at ?? null,
- };
- }, [data, meta]);
+ const stats = useMemo(() => ({
+ minPrice: data.length ? Math.min(...data.filter(r => r.price_num).map(r => r.price_num as number)) : null,
+ updatedAt: meta?.generated_at_utc ?? meta?.generated_at ?? null,
+ }), [data, meta]);
- // ── Render ────────────────────────────────────────────────────────────────────
return (
-
-
+
+
- {/* Page title */}
-
-
- GPU Cloud Pricing
+
+ {/* --- Hero Section --- */}
+
+
+ CloudDealHunt Analytics
-
- Compare pricing across neo cloud providers · Data refreshes nightly
+
+ Advanced GPU Pricing Engine. Compare rates across neo-cloud providers with real-time value indexing.
-
-
- {/* Table */}
-
- {status === "loading" &&
}
-
- {status === "error" && (
-
-
{error}
+
+
+ {status === "loading" &&
}
+
+ {status === "done" && (
+
+
+ {/* --- Control Bar --- */}
+
+
+
+ Dashboard View
+
- )}
- {status === "done" && (
- <>
+ {/* --- Tools & Insights Wrapper --- */}
+ {showTools ? (
+
+ {/* Advanced Tools */}
+
+
+ {/* Insight Cards */}
+ {insights && (
+
+ }
+ row={insights.cheapest}
+ color="emerald"
+ metric={`$${insights.cheapest.price_num}/hr`}
+ />
+ }
+ row={insights.bestValue}
+ color="indigo"
+ metric={`${((insights.bestValue.vram || 0) / (insights.bestValue.price_num || 1)).toFixed(2)} GB/$`}
+ />
+
+ )}
+
+ ) : null}
+
+ {/* --- Main Data Table (Moves up when tools are hidden) --- */}
+
+
r.provider)).sort()}
+ gpuTypes={unique(data.map(r => r.gpu)).sort()}
+ gpuCounts={unique(data.map(r => String(r.gpu_count))).sort((a,b) => Number(a)-Number(b))}
selectedProvider={provider}
selectedGpuType={gpuType}
selectedNumGpus={numGpus}
@@ -241,46 +188,46 @@ export default function Dashboard() {
onProvider={(p) => { setProvider(p); setPage(1); }}
onGpuType={(t) => { setGpuType(t); setPage(1); }}
onNumGpus={(n) => { setNumGpus(n); setPage(1); }}
- onReset={handleReset}
+ onReset={() => { setProvider("all"); setGpuType("all"); setNumGpus("all"); setSearch(""); }}
sortKey={sortKey}
sortDir={sortDir}
- onSort={handleSort}
- page={safePage}
- totalPages={totalPages}
+ onSort={(k) => { if(sortKey === k) setSortDir(sortDir === "asc" ? "desc" : "asc"); else setSortKey(k); }}
+ page={page}
+ totalPages={Math.ceil(filtered.length / ITEMS_PER_PAGE)}
onPageChange={setPage}
minPrice={stats.minPrice}
selectedRows={selectedRows}
- onSelectRow={handleSelectRow}
+ onSelectRow={(row) => setSelectedRows(prev => prev.find(r => r.id === row.id) ? prev.filter(r => r.id !== row.id) : [...prev, row].slice(-4))}
/>
-
- {selectedRows.length > 0 && (
- setSelectedRows([])}
- onRemoveRow={handleRemoveRow}
- />
- )}
- >
- )}
-
+
+
+ )}
- {/* Footer */}
-
+ {selectedRows.length > 0 && (
+
setSelectedRows([])} onRemoveRow={(row) => setSelectedRows(selectedRows.filter((r) => !(r.provider === row.provider && r.gpu === row.gpu)))} />
+ )}
);
}
+
+// --- Sub-component: Insight Card ---
+function InsightCard({ title, icon, row, color, metric }: { title: string, icon: React.ReactNode, row: GPURow, color: string, metric: string }) {
+ const colors: Record = {
+ emerald: "border-emerald-200 dark:border-emerald-900/40 bg-emerald-50 dark:bg-emerald-900/10 text-emerald-600",
+ indigo: "border-indigo-200 dark:border-indigo-900/40 bg-indigo-50 dark:bg-indigo-900/10 text-indigo-600",
+ };
+
+ return (
+
+
+
{title}
+
{row.gpu} via {row.provider}
+
{metric}
+
+
+ {icon}
+
+
+ );
+}
\ No newline at end of file
diff --git a/nextjs-dashboard/src/components/GPUCompare.tsx b/nextjs-dashboard/src/components/GPUCompare.tsx
new file mode 100644
index 0000000..3e17045
--- /dev/null
+++ b/nextjs-dashboard/src/components/GPUCompare.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { GPURow } from "@/types/gpu";
+import {
+ Swords,
+ Trophy,
+ Zap,
+ BarChart3,
+ AlertCircle,
+ Cpu,
+ ChevronDown
+} from "lucide-react";
+
+export default function GPUCompare({ data }: { data: GPURow[] }) {
+ const [gpu1, setGpu1] = useState("");
+ const [gpu2, setGpu2] = useState("");
+
+ const options = useMemo(() => {
+ return data
+ .filter((r) => r.price_num !== null)
+ .map((r, i) => ({
+ ...r,
+ id: `${r.provider}-${r.gpu}-${i}`,
+ price: Number(r.price_num),
+ }));
+ }, [data]);
+
+ const selected1 = options.find((o) => o.id === gpu1);
+ const selected2 = options.find((o) => o.id === gpu2);
+
+ const result = useMemo(() => {
+ if (!selected1 || !selected2) return null;
+
+ const value1 = (selected1.vram || 0) / (selected1.price || 1);
+ const value2 = (selected2.vram || 0) / (selected2.price || 1);
+ const priceDiff = Math.abs(selected1.price - selected2.price);
+
+ let winner = "";
+ if (value1 > value2) winner = "GPU 1";
+ else if (value2 > value1) winner = "GPU 2";
+ else winner = "Tie";
+
+ return { value1, value2, winner, priceDiff };
+ }, [selected1, selected2]);
+
+ return (
+
+ {/* Header Card */}
+
+
+
+
+
+
+
+
+
+
+
+ GPU Battleground
+
+
COMPARE SPECS & VALUE EFFICIENCY
+
+
+
+ {/* Selectors Grid */}
+
+ {/* "VS" Badge in middle for Desktop */}
+
+ VS
+
+
+ {[1, 2].map((num) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+ {/* Comparison Results */}
+ {selected1 && selected2 && result ? (
+
+
+
+ {/* GPU 1 Card */}
+
+
+ {/* GPU 2 Card */}
+
+
+
+ {/* Value Summary Banner */}
+
+
+
+
+
+
+
+
+ {result.winner === "Tie" ? "Statistical Deadlock" : `${result.winner} is Dominating`}
+
+
+ Calculated by VRAM-to-Price efficiency ratio
+
+
+
+
+
+
+
Price Gap
+
+ ${result.priceDiff.toFixed(3)}/hr
+
+
+
+
Recommendation
+
+
+ {result.winner === "Tie" ? "PICK EITHER" : `GO WITH ${result.winner === "GPU 1" ? "GPU 1" : "GPU 2"}`}
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
Awaiting Selection
+
Select two different GPU configurations to trigger the value analysis engine.
+
+ )}
+
+ );
+}
+
+// Sub-component for individual GPU cards
+type GPUOption = GPURow & { price: number };
+
+function ComparisonCard({
+ gpu,
+ isWinner,
+ score,
+ label,
+}: {
+ gpu: GPUOption;
+ isWinner: boolean;
+ score: number;
+ label: string;
+}) {
+ return (
+
+ {isWinner && (
+
+ BEST VALUE
+
+ )}
+
+
+
{label}
+
+ {gpu.gpu}
+
+
{gpu.provider}
+
+
+
+
+
+
+
+
+
Efficiency Score
+
+ {score.toFixed(2)}
+
+
+
GB PER $
+
+
+
+
+ );
+}
+
+function StatRow({ label, value, highlight = false }: { label: string, value: string, highlight?: boolean }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
\ No newline at end of file
diff --git a/nextjs-dashboard/src/components/TableSkeleton.tsx b/nextjs-dashboard/src/components/TableSkeleton.tsx
index 47fa511..13dd82d 100644
--- a/nextjs-dashboard/src/components/TableSkeleton.tsx
+++ b/nextjs-dashboard/src/components/TableSkeleton.tsx
@@ -1,60 +1,78 @@
-const COL_WIDTHS = ["w-24", "w-48", "w-14", "w-6", "w-8", "w-8", "w-8"];
+"use client";
export default function TableSkeleton() {
+ // Column widths mapping to match the actual Table columns
+ const COL_WIDTHS = ["w-32", "w-40", "w-24", "w-20", "w-16"];
+
return (
-
- {/* Filter bar */}
-
-
-
-
-
+
+
+ {/* 🔍 Filter Bar Skeleton */}
+
- {/* Table */}
-
-
-
-
- {COL_WIDTHS.map((w, i) => (
- |
-
- |
- ))}
-
-
-
- {Array.from({ length: 10 }).map((_, i) => (
-
- |
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
- |
+ {/* 📊 Main Table Skeleton */}
+
+
+
+
+
+ {/* GPU Model Col */}
+ |
+ {/* Provider Col */}
+ |
+ {/* Status Col */}
+ |
+ {/* Price Col */}
+ |
+ {/* Action Col */}
+ |
- ))}
-
-
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ {/* GPU Icon + Name */}
+ |
+
+ |
+
+ {/* Provider Info */}
+
+
+ |
+
+ {/* Status Badge Placeholder */}
+
+
+ |
+
+ {/* Price Placeholder */}
+
+
+ |
+
+ {/* Action Button Placeholder */}
+
+
+ |
+
+ ))}
+
+
+
);
-}
+}
\ No newline at end of file
diff --git a/run_all_scrapers.py b/run_all_scrapers.py
index f6e1e9f..6bfad7b 100755
--- a/run_all_scrapers.py
+++ b/run_all_scrapers.py
@@ -24,4 +24,4 @@
sources.crusoe.scrape(url="https://www.crusoe.ai/cloud/pricing", out_csv="data/crusoe_gpu_prices.csv", out_json="data/crusoe_gpu_prices.json")
sources.lambdalabs.scrape(url="https://lambda.ai/pricing", out_csv="data/lambda_gpu_prices.csv", out_json="data/lambda_gpu_prices.json")
-
+print("Running Coreweave...")