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 */} +
+
+
+ + setConfig((p) => ({ ...p, quantity: Math.max(1, Number(e.target.value)) }))} + className="w-full px-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" + /> +
+
+ + setConfig((p) => ({ ...p, duration: Math.max(1, Number(e.target.value)) }))} + className="w-full px-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" + /> +
+
+ +
+ +
+ {(["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) --- */} +
+
+
+

Market Inventory

+
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 */} -
-

- Scraped nightly from public pricing pages ·{" "} - - View raw JSON - {" "} - · Historical snapshots at{" "} - freellm.org/history/YYYY-MM-DD/all.json -

-
+ {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...")