diff --git a/apps/api/src/gpu/routes/gpu.router.ts b/apps/api/src/gpu/routes/gpu.router.ts index cf2794d28c..dfc5431320 100644 --- a/apps/api/src/gpu/routes/gpu.router.ts +++ b/apps/api/src/gpu/routes/gpu.router.ts @@ -160,6 +160,7 @@ const getGpuPricesRoute = createRoute({ method: "get", path: "/v1/gpu-prices", summary: "Get a list of gpu models with their availability and pricing.", + operationId: "listGpuPrices", tags: ["Gpu"], security: SECURITY_NONE, cache: { maxAge: 120, staleWhileRevalidate: 300 }, diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index d4a339558a..d6f8a0fc99 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -1947,6 +1947,7 @@ "schema": { "type": "string", "format": "date", + "default": "2026-05-12", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -2072,6 +2073,7 @@ "schema": { "type": "string", "format": "date", + "default": "2026-05-12", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -15349,6 +15351,7 @@ "/v1/gpu-prices": { "get": { "summary": "Get a list of gpu models with their availability and pricing.", + "operationId": "listGpuPrices", "tags": [ "Gpu" ], diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 550e6a6f7b..603a5b5415 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -8155,6 +8155,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` }, "/v1/gpu-prices": { "get": { + "operationId": "listGpuPrices", "responses": { "200": { "content": { diff --git a/apps/deploy-web/package.json b/apps/deploy-web/package.json index 028374278a..54cb548efb 100644 --- a/apps/deploy-web/package.json +++ b/apps/deploy-web/package.json @@ -77,6 +77,7 @@ "axios": "^1.7.2", "chain-registry": "^1.69.31", "clsx": "^2.0.0", + "cobe": "^2.0.1", "cockatiel": "^3.2.1", "date-fns": "^2.29.3", "dotenv": "^16.4.5", diff --git a/apps/deploy-web/public/images/auth-panel-noise.webp b/apps/deploy-web/public/images/auth-panel-noise.webp new file mode 100644 index 0000000000..02d39371ad Binary files /dev/null and b/apps/deploy-web/public/images/auth-panel-noise.webp differ diff --git a/apps/deploy-web/src/components/auth/AuthLayout/AuthLayout.spec.tsx b/apps/deploy-web/src/components/auth/AuthLayout/AuthLayout.spec.tsx index 0c7901ae9e..b7166019a1 100644 --- a/apps/deploy-web/src/components/auth/AuthLayout/AuthLayout.spec.tsx +++ b/apps/deploy-web/src/components/auth/AuthLayout/AuthLayout.spec.tsx @@ -21,7 +21,7 @@ describe(AuthLayout.name, () => { return render( - Sidebar}> +
Children
diff --git a/apps/deploy-web/src/components/auth/AuthLayout/AuthLayout.tsx b/apps/deploy-web/src/components/auth/AuthLayout/AuthLayout.tsx index 6fcd685251..4d5781e52e 100644 --- a/apps/deploy-web/src/components/auth/AuthLayout/AuthLayout.tsx +++ b/apps/deploy-web/src/components/auth/AuthLayout/AuthLayout.tsx @@ -1,16 +1,18 @@ import type { ReactNode } from "react"; import { cn } from "@akashnetwork/ui/utils"; +import { DollarSignIcon, RocketIcon, ZapIcon } from "lucide-react"; +import { AkashConsoleLogo } from "@src/components/icons/AkashConsoleLogo"; import useCookieTheme from "@src/hooks/useTheme"; interface Props { - sidebar: ReactNode; children: ReactNode; } -export function AuthLayout({ sidebar, children }: Props) { +export function AuthLayout({ children }: Props) { const theme = useCookieTheme(); const sidebarClass = theme === "dark" ? "bg-[#171717] lg:bg-[hsl(var(--background))]" : "bg-[hsl(var(--background))] dark"; + return (
- {sidebar} +
+ +

The fastest way to deploy an application on Akash.Network

+
+
+ +
+
+
Generous Free Trial
+

$100 of cloud compute credits so you can test real workloads.

+
+
+
+
+ +
+
+
Optimized for AI/ML
+

Container native with a library of templates for leading open source AI models and applications.

+
+
+
+
+ +
+
+
Cost Savings
+

The most competitive prices for GPUs on-demand, anywhere on the internet.

+
+
+
{children}
diff --git a/apps/deploy-web/src/components/auth/AuthLayout/__snapshots__/AuthLayout.spec.tsx.snap b/apps/deploy-web/src/components/auth/AuthLayout/__snapshots__/AuthLayout.spec.tsx.snap index faf941943e..a2bd4634fc 100644 --- a/apps/deploy-web/src/components/auth/AuthLayout/__snapshots__/AuthLayout.spec.tsx.snap +++ b/apps/deploy-web/src/components/auth/AuthLayout/__snapshots__/AuthLayout.spec.tsx.snap @@ -15,8 +15,214 @@ exports[`AuthLayout > renders dark theme layout 1`] = ` class="absolute flex h-full w-full items-center justify-center overflow-y-auto lg:static lg:w-1/2 bg-[#171717] lg:bg-[hsl(var(--background))] bg-[radial-gradient(circle,rgba(255,255,255,0.07)_2px,transparent_2px)]" style="background-size: 24px 24px;" > -
- Sidebar +
renders light theme layout 1`] = ` class="absolute flex h-full w-full items-center justify-center overflow-y-auto lg:static lg:w-1/2 bg-[hsl(var(--background))] dark bg-[radial-gradient(circle,rgba(255,255,255,0.07)_2px,transparent_2px)]" style="background-size: 24px 24px;" > -
- Sidebar +
{ + it("renders the Globe with REGION_MARKERS", () => { + const GlobeMock = vi.fn<(props: React.ComponentProps) => React.ReactElement>(() =>
); + render( + +
Children
+
+ ); + expect(GlobeMock).toHaveBeenCalled(); + expect(GlobeMock.mock.calls[0][0]).toMatchObject({ + markers: expect.arrayContaining([expect.objectContaining({ label: "us-east-1" })]) + }); + }); +}); diff --git a/apps/deploy-web/src/components/auth/AuthLayoutV2/AuthLayoutV2.tsx b/apps/deploy-web/src/components/auth/AuthLayoutV2/AuthLayoutV2.tsx new file mode 100644 index 0000000000..e16b30adbf --- /dev/null +++ b/apps/deploy-web/src/components/auth/AuthLayoutV2/AuthLayoutV2.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; +import dynamic from "next/dynamic"; + +import { AkashConsoleLogo } from "@src/components/icons/AkashConsoleLogo"; +import { REGION_MARKERS } from "./markers"; + +const Globe = dynamic(() => import("@src/components/globe/Globe/Globe").then(m => m.Globe), { + ssr: false, + loading: () =>
+}); + +const DEPLOYMENT_GUIDE_URL = "https://akash.network/docs/getting-started/quick-start/"; + +export const DEPENDENCIES = { + Globe, + AkashConsoleLogo, + REGION_MARKERS, + DEPLOYMENT_GUIDE_URL +}; + +interface Props { + children: ReactNode; + topRightContent?: ReactNode; + dependencies?: typeof DEPENDENCIES; +} + +export function AuthLayoutV2({ children, topRightContent, dependencies: d = DEPENDENCIES }: Props) { + return ( +
+
+
+
+ +
+ + {topRightContent} +
+ +
+
+ +
+
+ + +
+ +
{children}
+
+ ); +} diff --git a/apps/deploy-web/src/components/auth/AuthLayoutV2/markers.ts b/apps/deploy-web/src/components/auth/AuthLayoutV2/markers.ts new file mode 100644 index 0000000000..74ad6ff71f --- /dev/null +++ b/apps/deploy-web/src/components/auth/AuthLayoutV2/markers.ts @@ -0,0 +1,12 @@ +import type { GlobeMarker } from "@src/components/globe/Globe/Globe"; + +export const REGION_MARKERS: GlobeMarker[] = [ + { id: "us-west-1", lat: 37.37, lng: -121.92, label: "us-west-1" }, + { id: "us-east-1", lat: 39.04, lng: -77.49, label: "us-east-1" }, + { id: "eu-west-1", lat: 53.35, lng: -6.26, label: "eu-west-1" }, + { id: "eu-central-1", lat: 50.11, lng: 8.68, label: "eu-central-1" }, + { id: "ap-southeast-1", lat: 1.35, lng: 103.82, label: "ap-southeast-1" }, + { id: "ap-northeast-1", lat: 35.68, lng: 139.65, label: "ap-northeast-1" }, + { id: "ap-southeast-2", lat: -33.87, lng: 151.21, label: "ap-southeast-2" }, + { id: "sa-east-1", lat: -23.55, lng: -46.63, label: "sa-east-1" } +]; diff --git a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx index ed1f5f7909..3de54f5136 100644 --- a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx +++ b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx @@ -1,4 +1,4 @@ -import { type RefObject, useState } from "react"; +import { type ComponentProps, type RefObject, useState } from "react"; import type { Tabs } from "@akashnetwork/ui/components"; import type { ReadonlyURLSearchParams } from "next/navigation"; import type { NextRouter } from "next/router"; @@ -8,6 +8,8 @@ import { mock } from "vitest-mock-extended"; import type { TurnstileRef } from "@src/components/turnstile/Turnstile"; import type { AnalyticsService } from "@src/services/analytics/analytics.service"; import type { AuthService } from "@src/services/auth/auth/auth.service"; +import type { AuthLayout } from "../AuthLayout/AuthLayout"; +import type { AuthLayoutV2 } from "../AuthLayoutV2/AuthLayoutV2"; import type { SignInForm, SignInFormValues } from "../SignInForm/SignInForm"; import type { SignUpForm, SignUpFormValues } from "../SignUpForm/SignUpForm"; import { AuthPage, DEPENDENCIES } from "./AuthPage"; @@ -93,6 +95,34 @@ describe(AuthPage.name, () => { }); }); + it("renders AuthLayout when console_auth_redesign flag is off", () => { + const AuthLayoutMock = vi.fn(({ children }) =>
{children}
); + const AuthLayoutV2Mock = vi.fn(({ children }: ComponentProps) =>
{children}
); + setup({ + isRedesignEnabled: false, + dependencies: { + AuthLayout: AuthLayoutMock, + AuthLayoutV2: AuthLayoutV2Mock + } + }); + expect(screen.getByTestId("v1")).toBeInTheDocument(); + expect(screen.queryByTestId("v2")).not.toBeInTheDocument(); + }); + + it("renders AuthLayoutV2 when console_auth_redesign flag is on", () => { + const AuthLayoutMock = vi.fn(({ children }) =>
{children}
); + const AuthLayoutV2Mock = vi.fn(({ children }: ComponentProps) =>
{children}
); + setup({ + isRedesignEnabled: true, + dependencies: { + AuthLayout: AuthLayoutMock, + AuthLayoutV2: AuthLayoutV2Mock + } + }); + expect(screen.getByTestId("v2")).toBeInTheDocument(); + expect(screen.queryByTestId("v1")).not.toBeInTheDocument(); + }); + describe("when SignIn tab is open", () => { it("runs sign-in flow and redirects to return url", async () => { const SignInFormMock = vi.fn(ComponentMock as typeof SignInForm); @@ -265,6 +295,7 @@ describe(AuthPage.name, () => { returnTo?: string; from?: string; }; + isRedesignEnabled?: boolean; dependencies?: Partial; }) { const authService = mock(); @@ -334,6 +365,8 @@ describe(AuthPage.name, () => { }); const analyticsService = mock(); + const useFlag: typeof DEPENDENCIES.useFlag = () => input.isRedesignEnabled ?? false; + render( authService, analyticsService: () => analyticsService }}> { useRouter: () => router, useReturnTo: input.dependencies?.useReturnTo || useReturnTo, useWallet: input.dependencies?.useWallet || useWallet, + useFlag: input.dependencies?.useFlag || useFlag, Turnstile, ...input.dependencies }} diff --git a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx index 44d76b8f89..48c3c7e316 100644 --- a/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx +++ b/apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx @@ -3,20 +3,22 @@ import { useCallback, useRef, useState } from "react"; import { Button, Separator, Tabs, TabsContent, TabsList, TabsTrigger } from "@akashnetwork/ui/components"; import { useMutation } from "@tanstack/react-query"; -import { DollarSignIcon, RocketIcon, ZapIcon } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { useRouter } from "next/router"; import { NextSeo } from "next-seo"; +import { H100PriceStatus } from "@src/components/gpu/H100PriceStatus/H100PriceStatus"; import { AkashConsoleLogo } from "@src/components/icons/AkashConsoleLogo"; import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError"; import type { TurnstileRef } from "@src/components/turnstile/Turnstile"; import { ClientOnlyTurnstile } from "@src/components/turnstile/Turnstile"; import { useServices } from "@src/context/ServicesProvider"; import { useWallet as useWalletOriginal } from "@src/context/WalletProvider"; +import { useFlag } from "@src/hooks/useFlag"; import { useReturnTo } from "@src/hooks/useReturnTo"; import { useUser } from "@src/hooks/useUser"; import { AuthLayout } from "../AuthLayout/AuthLayout"; +import { AuthLayoutV2 } from "../AuthLayoutV2/AuthLayoutV2"; import { ForgotPasswordForm } from "../ForgotPasswordForm/ForgotPasswordForm"; import type { SignInFormValues } from "../SignInForm/SignInForm"; import { SignInForm } from "../SignInForm/SignInForm"; @@ -26,6 +28,7 @@ import { SocialAuth } from "../SocialAuth/SocialAuth"; export const DEPENDENCIES = { AuthLayout, + AuthLayoutV2, NextSeo, SocialAuth, SignInForm, @@ -37,17 +40,16 @@ export const DEPENDENCIES = { TabsContent, TabsTrigger, TabsList, - DollarSignIcon, - RocketIcon, - ZapIcon, AkashConsoleLogo, + H100PriceStatus, Button, Separator, useUser, useSearchParams, useRouter, useReturnTo, - useWallet: useWalletOriginal + useWallet: useWalletOriginal, + useFlag }; interface Props { @@ -55,6 +57,7 @@ interface Props { } export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) { + const isRedesignEnabled = d.useFlag("console_auth_redesign"); const { authService, publicConfig, analyticsService } = useServices(); const router = d.useRouter(); const searchParams = d.useSearchParams(); @@ -117,141 +120,112 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) { forgotPassword.reset(); }, [signInOrSignUp, forgotPassword]); - return ( - - -

The fastest way to deploy an application on Akash.Network

-
-
- -
-
-
Generous Free Trial
-

$100 of cloud compute credits so you can test real workloads.

-
-
-
-
- -
-
-
Optimized for AI/ML
-

Container native with a library of templates for leading open source AI models and applications.

-
-
-
-
- -
-
-
Cost Savings
-

The most competitive prices for GPUs on-demand, anywhere on the internet.

-
-
+ const formContent = ( + <> + +
+
+ +

+ {(activeView === "forgot-password" && "Reset your password") || ( +
+ Log in or sign up + to get started +
+ )} +

+

+ {activeView === "forgot-password" + ? "Enter your email address and we'll send you instructions to reset your password." + : "Create your Akash account or log in to an existing one."} +

- } - > - <> - -
-
- -

- {(activeView === "forgot-password" && "Reset your password") || ( -
- Log in or sign up - to get started -
- )} -

-

- {activeView === "forgot-password" - ? "Enter your email address and we'll send you instructions to reset your password." - : "Create your Akash account or log in to an existing one."} -

-
-
- {(activeView === "forgot-password" && ( - <> - - setActiveView("login")} - /> - - )) || ( - -
- - -
- - Log in - -
-
- -
- - Sign up - -
-
-
+
+ {(activeView === "forgot-password" && ( + <> + + setActiveView("login")} + /> + + )) || ( + +
+ + +
+ + Log in + +
+
+ +
+ + Sign up + +
+
+
+
+ + + +
+ +
+ Or continue with
+
- - -
- -
- Or continue with -
-
+ - - - - signInOrSignUp.mutate({ type: "signin", value })} - onForgotPasswordClick={() => setActiveView("forgot-password")} - /> - - - - signInOrSignUp.mutate({ type: "signup", value })} /> - -
- )} - -
+ + signInOrSignUp.mutate({ type: "signin", value })} + onForgotPasswordClick={() => setActiveView("forgot-password")} + /> + + + + signInOrSignUp.mutate({ type: "signup", value })} /> + + + )} +
- - +
+ ); + + if (isRedesignEnabled) { + return }>{formContent}; + } + return {formContent}; } type Tagged = { type: TType; value: TValue }; diff --git a/apps/deploy-web/src/components/globe/Globe/Globe.spec.tsx b/apps/deploy-web/src/components/globe/Globe/Globe.spec.tsx new file mode 100644 index 0000000000..79080d4ed7 --- /dev/null +++ b/apps/deploy-web/src/components/globe/Globe/Globe.spec.tsx @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DEPENDENCIES, GlobeMarker } from "./Globe"; +import { Globe } from "./Globe"; + +import { render, screen } from "@testing-library/react"; + +const destroy = vi.fn(); +const update = vi.fn(); +const createGlobe = vi.fn(() => ({ destroy, update })); + +describe(Globe.name, () => { + beforeEach(() => { + createGlobe.mockClear(); + destroy.mockClear(); + update.mockClear(); + }); + + it("initializes cobe on mount and tears it down on unmount", () => { + const { unmount } = setup({ markers: [{ id: "test-a", lat: 0, lng: 0 }] }); + + expect(createGlobe).toHaveBeenCalledTimes(1); + const [canvas, opts] = createGlobe.mock.calls[0]; + expect(canvas).toBeInstanceOf(HTMLCanvasElement); + expect((opts as { markers: unknown[] }).markers).toEqual([{ id: "test-a", location: [0, 0], size: 0.04 }]); + + unmount(); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it("initializes cobe with dark=0 when mounted under the light theme", () => { + setup({ markers: [{ id: "test-a", lat: 0, lng: 0 }], theme: "light" }); + + expect(createGlobe).toHaveBeenCalledTimes(1); + const dark = (createGlobe.mock.calls[0][1] as { dark: number }).dark; + expect(dark).toBe(0); + }); + + it("initializes cobe with dark=1 when mounted under the dark theme", () => { + setup({ markers: [{ id: "test-a", lat: 0, lng: 0 }], theme: "dark" }); + + expect(createGlobe).toHaveBeenCalledTimes(1); + const dark = (createGlobe.mock.calls[0][1] as { dark: number }).dark; + expect(dark).toBe(1); + }); + + it("renders label overlays for markers that have a label", () => { + setup({ + markers: [ + { id: "us-east-1", lat: 0, lng: 0, label: "US-EAST-1" }, + { id: "unlabeled", lat: 10, lng: 10 } // no label — should not render + ] + }); + + expect(screen.queryByText("US-EAST-1")).toBeInTheDocument(); + }); + + function setup(input: { markers: GlobeMarker[]; theme?: "light" | "dark" }) { + const useTheme: typeof DEPENDENCIES.useTheme = () => input.theme ?? "light"; + return render(); + } +}); diff --git a/apps/deploy-web/src/components/globe/Globe/Globe.tsx b/apps/deploy-web/src/components/globe/Globe/Globe.tsx new file mode 100644 index 0000000000..226b47b39d --- /dev/null +++ b/apps/deploy-web/src/components/globe/Globe/Globe.tsx @@ -0,0 +1,203 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { useEffect, useRef, useState } from "react"; +import createGlobe from "cobe"; + +import useCookieTheme from "@src/hooks/useTheme"; + +export type GlobeMarker = { + id: string; + lat: number; + lng: number; + label?: string; +}; + +export const DEPENDENCIES = { + createGlobe, + useTheme: useCookieTheme +}; + +interface Props { + markers: GlobeMarker[]; + /** Diameter in CSS pixels. If omitted, the globe sizes to its container. */ + size?: number; + /** Rotation speed, radians per frame at 60fps; scaled by elapsed time so wall-clock speed is independent of refresh rate. */ + rotationSpeed?: number; + /** Force a surface theme for color resolution. Set when rendering inside an inverted-theme scope. */ + surfaceTheme?: "light" | "dark"; + className?: string; + dependencies?: typeof DEPENDENCIES; +} + +type Rgb = [number, number, number]; + +/** Default angular velocity; chosen to match the cobe playground (one full revolution every ~21s at 60fps). */ +const DEFAULT_ROTATION_SPEED = 0.005; + +/** Vertical tilt of the camera in radians. Positive values pitch the north hemisphere toward the viewer. */ +const GLOBE_THETA = 0.2; + +/** Marker dot radius as a fraction of the globe's unit-sphere radius. Mirrors the cobe playground "Data Centers" preset. */ +const MARKER_SIZE = 0.04; + +/** Pink-500 (#ec4899) normalized to 0–1 RGB. cobe takes colors as `[r, g, b]` floats. */ +const MARKER_COLOR_PINK: Rgb = [236 / 255, 72 / 255, 153 / 255]; + +/** Tooltip pill background. Lab() color from cobe playground `--ink` token — saturated electric indigo-blue. */ +const TOOLTIP_BG = "lab(36 55.64 -107.68)"; + +export function Globe({ markers, size, rotationSpeed = DEFAULT_ROTATION_SPEED, surfaceTheme, className, dependencies: d = DEPENDENCIES }: Props) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const phi = useRef(0); + const documentTheme = d.useTheme(); + const effectiveTheme = surfaceTheme ?? documentTheme; + + const [measuredSize, setMeasuredSize] = useState(null); + const resolvedSize = size ?? measuredSize ?? 400; + + useEffect( + function trackContainerSize() { + if (size || !containerRef.current) return; + const el = containerRef.current; + const observer = new ResizeObserver(function onContainerResize(entries) { + const rect = entries[0].contentRect; + const next = Math.floor(Math.min(rect.width, rect.height)); + if (next > 0) setMeasuredSize(next); + }); + observer.observe(el); + return function disconnectResizeObserver() { + observer.disconnect(); + }; + }, + [size] + ); + + useEffect( + function createAndDriveGlobe() { + if (!canvasRef.current) return; + const isDark = effectiveTheme === "dark"; + + const baseColor = readToken(canvasRef.current, "--card", [0.09, 0.09, 0.09]); + const glowColor = readToken(canvasRef.current, "--background", isDark ? [0.05, 0.05, 0.05] : [0.96, 0.96, 0.96]); + + const globe = d.createGlobe(canvasRef.current, { + devicePixelRatio: 2, + width: resolvedSize * 2, + height: resolvedSize * 2, + phi: 0, + theta: GLOBE_THETA, + dark: isDark ? 1 : 0, + diffuse: 1.2, + mapSamples: 16000, + mapBrightness: 6, + baseColor, + markerColor: MARKER_COLOR_PINK, + markerElevation: 0, + glowColor, + markers: markers.map(m => ({ id: m.id, location: [m.lat, m.lng], size: MARKER_SIZE })) + }); + + let rafId = 0; + let lastTime = performance.now(); + function tickGlobeRotation(now: number) { + const deltaFrames = (now - lastTime) / (1000 / 60); + lastTime = now; + phi.current += rotationSpeed * deltaFrames; + globe.update({ phi: phi.current }); + rafId = requestAnimationFrame(tickGlobeRotation); + } + rafId = requestAnimationFrame(tickGlobeRotation); + + return function teardownGlobe() { + cancelAnimationFrame(rafId); + globe.destroy(); + }; + }, + [markers, resolvedSize, rotationSpeed, effectiveTheme, d] + ); + + return ( +
+ + {markers + .filter(m => m.label) + .map(m => ( +
+ {m.label} + +
+ ))} +
+ ); +} + +/** + * Read a CSS custom property as an RGB triplet, resolving HSL via {@link hslStringToRgb}. + * Reading from `scope` rather than `document.documentElement` lets the value cascade through + * an inverted-theme wrapper (e.g. a `.dark`-class panel rendered inside a light app). + */ +function readToken(scope: Element | null, name: string, fallback: Rgb): Rgb { + if (!scope) return fallback; + const raw = getComputedStyle(scope).getPropertyValue(name); + if (!raw) return fallback; + try { + return hslStringToRgb(raw); + } catch { + return fallback; + } +} + +/** + * Convert a shadcn-style `"H S% L%"` token (no `hsl()` wrapper) to a `[r, g, b]` triplet in 0–1. + * Throws on malformed input so callers can fall back to a baked default. + */ +function hslStringToRgb(hsl: string): Rgb { + const [hStr, sStr, lStr] = hsl.trim().split(/\s+/); + if (!hStr || !sStr || !lStr) throw new Error("Invalid HSL token"); + const h = parseFloat(hStr); + const s = parseFloat(sStr) / 100; + const l = parseFloat(lStr) / 100; + if (![h, s, l].every(Number.isFinite)) throw new Error("Invalid numeric HSL token"); + + const c = (1 - Math.abs(2 * l - 1)) * s; + const hPrime = h / 60; + const x = c * (1 - Math.abs((hPrime % 2) - 1)); + const m = l - c / 2; + + const [r, g, b]: Rgb = hPrime < 1 ? [c, x, 0] : hPrime < 2 ? [x, c, 0] : hPrime < 3 ? [0, c, x] : hPrime < 4 ? [0, x, c] : hPrime < 5 ? [x, 0, c] : [c, 0, x]; + + return [r + m, g + m, b + m]; +} diff --git a/apps/deploy-web/src/components/gpu/H100PriceStatus/H100PriceStatus.spec.tsx b/apps/deploy-web/src/components/gpu/H100PriceStatus/H100PriceStatus.spec.tsx new file mode 100644 index 0000000000..549742fdf4 --- /dev/null +++ b/apps/deploy-web/src/components/gpu/H100PriceStatus/H100PriceStatus.spec.tsx @@ -0,0 +1,76 @@ +import type { paths } from "@akashnetwork/console-api-types"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { type DEPENDENCIES, H100PriceStatus } from "./H100PriceStatus"; + +import { render, screen } from "@testing-library/react"; + +type ListGpuPricesData = paths["/v1/gpu-prices"]["get"]["responses"][200]["content"]["application/json"]; +type ListGpuPricesResult = ReturnType["api"]["v1"]["listGpuPrices"]["useQuery"]>; + +describe(H100PriceStatus.name, () => { + it("renders the fallback price while data is loading", () => { + setup({ data: undefined }); + expect(screen.getByText(/H100 GPUs: Starting at \$1\.80\/hr/i)).toBeInTheDocument(); + }); + + it("renders the lowest h100 min price across variants", () => { + setup({ + data: { + availability: { total: 0, available: 0 }, + models: [ + { + vendor: "nvidia", + model: "h100", + ram: "80Gi", + interface: "pcie", + availability: { total: 0, available: 0 }, + providerAvailability: { total: 0, available: 0 }, + price: { currency: "USD", min: 2.4, max: 5, avg: 3, weightedAverage: 3, med: 3 } + }, + { + vendor: "nvidia", + model: "h100", + ram: "80Gi", + interface: "sxm", + availability: { total: 0, available: 0 }, + providerAvailability: { total: 0, available: 0 }, + price: { currency: "USD", min: 1.95, max: 4, avg: 2.5, weightedAverage: 2.5, med: 2.5 } + }, + { + vendor: "nvidia", + model: "a100", + ram: "80Gi", + interface: "pcie", + availability: { total: 0, available: 0 }, + providerAvailability: { total: 0, available: 0 }, + price: { currency: "USD", min: 0.5, max: 1, avg: 0.7, weightedAverage: 0.7, med: 0.7 } + } + ] + } + }); + expect(screen.getByText(/H100 GPUs: Starting at \$1\.95\/hr/i)).toBeInTheDocument(); + }); + + it("falls back when no h100 model is returned", () => { + setup({ data: { availability: { total: 0, available: 0 }, models: [] } }); + expect(screen.getByText(/H100 GPUs: Starting at \$1\.80\/hr/i)).toBeInTheDocument(); + }); + + function setup(input: { data: ListGpuPricesData | undefined }) { + const queryResult = mock({ data: input.data }); + const useServices: typeof DEPENDENCIES.useServices = () => + mock>({ + api: { + v1: { + listGpuPrices: { + useQuery: () => queryResult + } + } + } + }); + + return render(); + } +}); diff --git a/apps/deploy-web/src/components/gpu/H100PriceStatus/H100PriceStatus.tsx b/apps/deploy-web/src/components/gpu/H100PriceStatus/H100PriceStatus.tsx new file mode 100644 index 0000000000..83fec7df87 --- /dev/null +++ b/apps/deploy-web/src/components/gpu/H100PriceStatus/H100PriceStatus.tsx @@ -0,0 +1,38 @@ +"use client"; + +import type { paths } from "@akashnetwork/console-api-types"; + +import { useServices } from "@src/context/ServicesProvider"; + +/** Shown until live pricing resolves or when no H100 models are returned. Keep in sync with marketing copy. */ +const FALLBACK_H100_PRICE = 1.8; + +export const DEPENDENCIES = { + useServices +}; + +interface Props { + dependencies?: typeof DEPENDENCIES; +} + +export function H100PriceStatus({ dependencies: d = DEPENDENCIES }: Props = {}) { + const { api } = d.useServices(); + const { data } = api.v1.listGpuPrices.useQuery(); + const minPrice = getH100MinPrice(data) ?? FALLBACK_H100_PRICE; + + return ( +
+ + H100 GPUs: Starting at ${minPrice.toFixed(2)}/hr +
+ ); +} + +type GpuPricesData = paths["/v1/gpu-prices"]["get"]["responses"][200]["content"]["application/json"] | undefined; + +function getH100MinPrice(data: GpuPricesData): number | undefined { + if (!data) return undefined; + const prices = data.models.map(m => (m.model === "h100" ? m.price?.min : undefined)).filter((price): price is number => Number.isFinite(price)); + if (prices.length === 0) return undefined; + return Math.min(...prices); +} diff --git a/apps/deploy-web/src/types/feature-flags.ts b/apps/deploy-web/src/types/feature-flags.ts index ff1b3e7b09..428f45dc06 100644 --- a/apps/deploy-web/src/types/feature-flags.ts +++ b/apps/deploy-web/src/types/feature-flags.ts @@ -8,4 +8,5 @@ export type FeatureFlag = | "maintenance_banner" | "auto_credit_reload" | "console_embedded_login" - | "self_custody"; + | "self_custody" + | "console_auth_redesign"; diff --git a/package-lock.json b/package-lock.json index a507586213..8dcb78fc04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -577,6 +577,7 @@ "axios": "^1.7.2", "chain-registry": "^1.69.31", "clsx": "^2.0.0", + "cobe": "^2.0.1", "cockatiel": "^3.2.1", "date-fns": "^2.29.3", "dotenv": "^16.4.5", @@ -25531,6 +25532,12 @@ "node": ">= 0.12.0" } }, + "node_modules/cobe": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cobe/-/cobe-2.0.1.tgz", + "integrity": "sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag==", + "license": "MIT" + }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", diff --git a/packages/console-api-types/src/operations.gen.ts b/packages/console-api-types/src/operations.gen.ts index df9bbe0bee..567ba4655c 100644 --- a/packages/console-api-types/src/operations.gen.ts +++ b/packages/console-api-types/src/operations.gen.ts @@ -4,6 +4,7 @@ export const operations = { v1: { listApiKeys: { path: "/v1/api-keys", method: "get", operationId: "listApiKeys", pathParams: [], queryParams: [], hasBody: false }, + listGpuPrices: { path: "/v1/gpu-prices", method: "get", operationId: "listGpuPrices", pathParams: [], queryParams: [], hasBody: false }, createAlert: { path: "/v1/alerts", method: "post", operationId: "createAlert", pathParams: [], queryParams: [], hasBody: true }, listAlerts: { path: "/v1/alerts", diff --git a/packages/console-api-types/src/schema.d.ts b/packages/console-api-types/src/schema.d.ts index 8cd86b0544..fa7e3883d6 100644 --- a/packages/console-api-types/src/schema.d.ts +++ b/packages/console-api-types/src/schema.d.ts @@ -7011,54 +7011,7 @@ export interface paths { cookie?: never; }; /** Get a list of gpu models with their availability and pricing. */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description List of gpu models with their availability and pricing. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - availability: { - total: number; - available: number; - }; - models: { - vendor: string; - model: string; - ram: string; - interface: string; - availability: { - total: number; - available: number; - }; - providerAvailability: { - total: number; - available: number; - }; - price: { - /** @example USD */ - currency: string; - min: number; - max: number; - avg: number; - weightedAverage: number; - med: number; - } | null; - }[]; - }; - }; - }; - }; - }; + get: operations["listGpuPrices"]; put?: never; post?: never; delete?: never; @@ -8694,6 +8647,54 @@ export interface operations { }; }; }; + listGpuPrices: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of gpu models with their availability and pricing. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + availability: { + total: number; + available: number; + }; + models: { + vendor: string; + model: string; + ram: string; + interface: string; + availability: { + total: number; + available: number; + }; + providerAvailability: { + total: number; + available: number; + }; + price: { + /** @example USD */ + currency: string; + min: number; + max: number; + avg: number; + weightedAverage: number; + med: number; + } | null; + }[]; + }; + }; + }; + }; + }; listAlerts: { parameters: { query?: {