diff --git a/contributing/DOCS.md b/contributing/DOCS.md index a0b034a181..c47bb269ca 100644 --- a/contributing/DOCS.md +++ b/contributing/DOCS.md @@ -72,9 +72,8 @@ just website-dev # Vite dev server on http://127.0.0.1:5173 Docs/blog links on the landing resolve same-origin (`/docs`, `/blog`), which 404 in standalone dev. Point them at a live site while iterating: `just website-dev https://dstack.ai`. -The `/old` route is kept as a template for building future product pages (reachable in dev; not -part of the production deploy). Google Analytics and the social/OG image reuse the same property -and MkDocs-generated card as the rest of the site. +The Sky product page is served at `/products/sky/`. Google Analytics and the social/OG image +reuse the same property and MkDocs-generated card as the rest of the site. ## Building the whole site @@ -197,7 +196,7 @@ mkdocs/ # docs_dir for the mkdocs site website/ # React (Vite) landing page — served at "/" ├── index.html # Entry; title, OG/meta, Google Analytics -├── src/ # App, pages (Home, Old), components, routes +├── src/ # App, pages (Home, Sky), components, routes └── public/static/ # Landing assets (namespaced to avoid clashing with /assets) scripts/docs/ diff --git a/frontend/jest.auth.config.cjs b/frontend/jest.auth.config.cjs new file mode 100644 index 0000000000..fc57723338 --- /dev/null +++ b/frontend/jest.auth.config.cjs @@ -0,0 +1,10 @@ +module.exports = { + rootDir: '.', + clearMocks: true, + testEnvironment: 'node', + moduleDirectories: ['node_modules', 'src'], + testMatch: ['/src/App/auth.test.tsx', '/src/services/preset.test.tsx'], + transform: { + '\\.[jt]sx?$': 'babel-jest', + }, +}; diff --git a/frontend/package.json b/frontend/package.json index 2cdd847955..de869ace8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "eslint": "eslint ./src --ext .js,.jsx,.ts,.tsx", "eslint-fix": "eslint ./src --ext .js,.jsx,.ts,.tsx --fix", "test": "jest", + "test:auth": "cross-env NODE_ENV=test jest --config jest.auth.config.cjs", "test:update-snapshots": "jest -u", "generate-api": "npx @rtk-query/codegen-openapi openapi-config.ts", "pre-commit": "lint-staged" diff --git a/frontend/src/App/Login/EntraID/LoginByEntraID/index.tsx b/frontend/src/App/Login/EntraID/LoginByEntraID/index.tsx index c52d14fe10..19d6afef04 100644 --- a/frontend/src/App/Login/EntraID/LoginByEntraID/index.tsx +++ b/frontend/src/App/Login/EntraID/LoginByEntraID/index.tsx @@ -28,7 +28,7 @@ export const LoginByEntraID: React.FC<{ className?: string }> = ({ className }) return (
- + + By continuing with GitHub, you agree to the{' '} + Terms {' '} and{' '} - + Privacy policy -
- - - - - - - No credit card required - - - -
- - - - - - -
- - - ); -} - -function ProductOverview() { - return ( -
- -
- Overview -
-
- - dstack is an open-source container orchestrator that lets ML teams easily manage - clusters, volumes, dev environments, training, and inference. Its container-native interface boosts - productivity, maximizes GPU efficiency, and lowers costs. - - - dstack Sky adds a managed service, letting you use the cheapest GPUs from our - marketplace or connect your own cloud accounts. - -
- -
- - Features - - -
-
-
- - Open-source - -
-
dstack Sky
- -
- Bring your own cloud{' '} - - - Use compute from your own cloud account(s) by providing your credentials. - - - You pay for compute and storage usage directly to the configured cloud - provider(s) through their billing. dstack won't bill or charge you. - - - } - > - - - - -
-
- -
-
- -
- -
- GPU marketplace{' '} - - - Use compute from multiple cloud providers without needing your own cloud - account(s). - - - You pay for compute and storage usage directly to dstack. You can - top up your balance in your dstack user settings. - - When you sign up, you get $5 in credits. - - } - > - - - - -
-
-
- -
- -
- SSH fleets{' '} - - - - - -
-
- -
-
- -
- -
- Gateway{' '} - - - - - -
-
Configure your own domain
-
- Pre-configured *.sky.dstack.ai -
- -
Pricing
-
Free
-
Pay only if you use GPU marketplace
- -
-
Self-hosted
-
Hosted by dstack
-
-
-
- -
-
- Highlights -
- -
    -
  • Use compute from your own cloud account(s) or through GPU marketplace.
  • -
  • Create dev environments, run training tasks, and deploy inference services.
  • -
  • Manage volumes and fleets.
  • -
  • Manage multiple projects and teams.
  • -
-
-
- -
-
- Documentation -
- - - Want to learn more about dstack? Check out the{' '} - - documentation - - - -
-
-
- ); -} - -function OtherVersions() { - return ( -
- - Other versions - -
    -
  • - - - - Open-source - Self-hosted - - Fully customizable and self-hosted open-source version. - - - -
  • -
  • - - - - dstack Factory - Self-hosted - - Single sign-on, advanced governance controls, and dedicated support. - - - -
  • -
-
- ); -} - -export const LoginByGithub: React.FC = () => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const systemMode = useAppSelector(selectSystemMode) ?? ''; - const ThemeIcon = THEME_ICON_MAP[systemMode]; - - const onChangeSystemModeToggle = (event: React.MouseEvent) => { - event.preventDefault(); - switch (systemMode) { - case Mode.Light: - dispatch(setSystemMode(Mode.Dark)); - return; - default: - dispatch(setSystemMode(Mode.Light)); - } - }; - - return ( - <> - -
- goToUrl('https://dstack.ai/docs/', true), - }, - { - type: 'button', - text: t('common.discord'), - external: true, - onClick: () => goToUrl(DISCORD_URL, true), - }, - { - href: 'theme-button', - type: 'button', - iconSvg: , - onClick: onChangeSystemModeToggle, - }, - { - type: 'button', - iconName: 'gen-ai', - text: t('common.ask_ai'), - title: t('common.ask_ai'), - onClick: askAi, - }, - ]} - /> -
-
- - - - } - headerVariant="high-contrast" - header={} - defaultPadding={true} - maxContentWidth={1040} - disableOverlap={true} - > -
-
- -
- - - -
- - -
-
-
-
- + Sign in with a token + + + ); }; diff --git a/frontend/src/App/Login/LoginByGithub/styles.module.scss b/frontend/src/App/Login/LoginByGithub/styles.module.scss deleted file mode 100644 index d3efe6e389..0000000000 --- a/frontend/src/App/Login/LoginByGithub/styles.module.scss +++ /dev/null @@ -1,121 +0,0 @@ -@use '~@cloudscape-design/design-tokens' as cs; - -body { - background: cs.$color-background-layout-main; - position: relative; -} - -$viewport-breakpoint-s: 912px; - -body { - // Note: This token will be themed (see the product page index.tsx) - background: cs.$color-background-layout-main; -} - -.productPageContentGrid { - display: grid; - grid-template-columns: 3fr 1fr; - grid-template-rows: 0 auto 0; - margin-block-start: cs.$space-static-xxl; -} - -.onThisPageMobile { - grid-row: 1; - grid-column: 1 / 3; - display: none; - margin-block-end: cs.$space-static-xxl; -} - -.productPageAside { - grid-row: 2; - grid-column: 2 / 3; - padding-inline-start: calc(#{cs.$space-scaled-xxxl} /2); -} - -.productPageContent { - grid-row: 2; - grid-column: 1 / 2; - padding-inline-end: calc(#{cs.$space-scaled-xxxl} /2); - margin-bottom: 20px; -} - - -.productPageAsideSticky { - position: sticky; - inset-block-start: 40px; -} - -@media only screen and (max-width: $viewport-breakpoint-s) { - .productPageContentGrid { - grid-template-columns: 100%; - grid-template-rows: auto auto auto; - } - - .onThisPageMobile { - display: block; - } - - .productPageMobile { - display: block; - } - - .productPageAside { - display: none; - } -} - - -/* High-level sections of the main content area */ -.pageSection { - padding-block-end: cs.$space-static-xxxl; - margin-block-end: cs.$space-static-xxl; - border-bottom: 1px solid cs.$color-border-divider-default; - - &:last-child { - border: none; - margin-block-end: 0; - } -} - -/* Product details list containing keys and values */ -.productDetails { - display: grid; - grid-template-columns: 30% 35% 35%; - margin: 0; - padding: 0; - - dt { - color: cs.$color-text-body-default; - font-weight: bold; - } - - dt, - dd { - margin: 0; - padding: 0; - padding-block: cs.$space-scaled-xs; - border-block-end: 1px solid cs.$color-border-divider-default; - } -} - -/* List of product cards */ -.productCardsList { - display: flex; - flex-wrap: wrap; - column-gap: cs.$space-scaled-l; - row-gap: cs.$space-scaled-l; - - list-style-type: none; - margin: 0; - padding: 0; -} - -.productCardsListItem { - flex: 1; - flex-basis: 250px; - max-inline-size: 312px; - - list-style-type: none; - margin: 0; - padding: 0; -} diff --git a/frontend/src/App/Login/LoginByGithubCallback/index.tsx b/frontend/src/App/Login/LoginByGithubCallback/index.tsx index 45be311b6b..cc97cb88fa 100644 --- a/frontend/src/App/Login/LoginByGithubCallback/index.tsx +++ b/frontend/src/App/Login/LoginByGithubCallback/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -21,6 +21,7 @@ export const LoginByGithubCallback: React.FC = () => { const code = searchParams.get('code'); const state = searchParams.get('state'); const [isInvalidCode, setIsInvalidCode] = useState(false); + const callbackStarted = useRef(false); const dispatch = useAppDispatch(); const [getNextRedirect] = useGetNextRedirectMutation(); @@ -42,7 +43,7 @@ export const LoginByGithubCallback: React.FC = () => { dispatch(setAuthData({ token })); if (process.env.UI_VERSION === 'sky') { const result = await getProjects({}).unwrap(); - if (result?.length === 0) { + if (result.data.length === 0) { navigate(ROUTES.PROJECT.ADD); return; } @@ -60,7 +61,11 @@ export const LoginByGithubCallback: React.FC = () => { }; useEffect(() => { - if (code) { + // OAuth codes are single-use, including when StrictMode replays this effect. + if (callbackStarted.current) return; + callbackStarted.current = true; + + if (code && state) { checkCode(); } else { setIsInvalidCode(true); @@ -78,7 +83,7 @@ export const LoginByGithubCallback: React.FC = () => { return ( - ; + ); }; diff --git a/frontend/src/App/Login/LoginByGoogle/index.tsx b/frontend/src/App/Login/LoginByGoogle/index.tsx index b83a93f187..89dee1074e 100644 --- a/frontend/src/App/Login/LoginByGoogle/index.tsx +++ b/frontend/src/App/Login/LoginByGoogle/index.tsx @@ -26,7 +26,7 @@ export const LoginByGoogle: React.FC<{ className?: string }> = ({ className }) = return (
- -
- - - +
+ + + + +
); }; diff --git a/frontend/src/App/Login/LoginByTokenForm/styles.module.scss b/frontend/src/App/Login/LoginByTokenForm/styles.module.scss deleted file mode 100644 index 859b39ebcc..0000000000 --- a/frontend/src/App/Login/LoginByTokenForm/styles.module.scss +++ /dev/null @@ -1,24 +0,0 @@ -.form { - max-width: 440px; - margin-left: auto; - margin-right: auto; -} -.token { - display: flex; - align-items: flex-start; - gap: 12px; -} -.fieldWrap { - flex-grow: 1; - min-width: 0; -} -.buttonWrap { - display: flex; - flex-shrink: 0; - width: 104px; - margin-right: -20px; - - button { - white-space: nowrap !important; - } -} diff --git a/frontend/src/App/Login/SelfHostedLogin/index.tsx b/frontend/src/App/Login/SelfHostedLogin/index.tsx index 6cb247a4e5..406e591ec0 100644 --- a/frontend/src/App/Login/SelfHostedLogin/index.tsx +++ b/frontend/src/App/Login/SelfHostedLogin/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import cn from 'classnames'; +import { PublicApp } from 'PublicApp'; +import { colorBackgroundHomeHeader } from '@cloudscape-design/design-tokens'; -import { Box, NavigateLink, SpaceBetween } from 'components'; -import { UnauthorizedLayout } from 'layouts/UnauthorizedLayout'; +import { Box, Container, ContentLayout, Header, NavigateLink, SpaceBetween, Spinner } from 'components'; import { ROUTES } from 'routes'; import { useGetEntraInfoQuery, useGetGoogleInfoQuery, useGetOktaInfoQuery } from 'services/auth'; @@ -13,9 +13,7 @@ import { LoginByGoogle } from '../LoginByGoogle'; import { LoginByOkta } from '../LoginByOkta'; import { LoginByTokenForm } from '../LoginByTokenForm'; -import styles from './styles.module.scss'; - -export const SelfHostedLogin: React.FC = () => { +export const SelfHostedLogin: React.FC<{ tokenOnly?: boolean }> = ({ tokenOnly = false }) => { const { t } = useTranslation(); const { data: oktaData, isLoading: isLoadingOkta } = useGetOktaInfoQuery(); const { data: entraData, isLoading: isLoadingEntra } = useGetEntraInfoQuery(); @@ -24,30 +22,44 @@ export const SelfHostedLogin: React.FC = () => { const oktaEnabled = oktaData?.enabled; const entraEnabled = entraData?.enabled; const googleEnabled = googleData?.enabled; - - const isLoading = isLoadingOkta || isLoadingEntra; - const isShowTokenForm = !oktaEnabled && !entraEnabled; + const isLoading = isLoadingOkta || isLoadingEntra || isLoadingGoogle; + const hasSSO = oktaEnabled || entraEnabled || googleEnabled; + const showTokenForm = tokenOnly || (!isLoading && !hasSSO); return ( - -
- - + + {t('auth.sign_in_to_dstack')} - - {!isLoading && isShowTokenForm && } - {!isLoadingOkta && oktaEnabled && } - {!isLoadingEntra && entraEnabled && } - {!isLoadingGoogle && googleEnabled && } - - {!isLoading && !isShowTokenForm && ( - - {t('auth.login_by_token')} + } + > + +
{showTokenForm ? 'Sign in with a token' : t('common.login')}
- )} -
-
-
+ } + > + + {showTokenForm && } + {!tokenOnly && isLoading && } + {!tokenOnly && !isLoading && oktaEnabled && } + {!tokenOnly && !isLoading && entraEnabled && } + {!tokenOnly && !isLoading && googleEnabled && } + {!isLoading && hasSSO && ( + + {tokenOnly ? t('auth.another_login_methods') : 'Sign in with a token'} + + )} + + + + ); }; diff --git a/frontend/src/App/Login/SelfHostedLogin/styles.module.scss b/frontend/src/App/Login/SelfHostedLogin/styles.module.scss deleted file mode 100644 index 72a104a848..0000000000 --- a/frontend/src/App/Login/SelfHostedLogin/styles.module.scss +++ /dev/null @@ -1,16 +0,0 @@ -.form { - max-width: 440px; - width: 100%; - margin-left: auto; - margin-right: auto; - padding-top: 120px; -} -.token { - -} -.okta { - margin-top: 20px; -} -.entra { - margin-top: 20px; -} diff --git a/frontend/src/App/Login/TokenLogin/index.tsx b/frontend/src/App/Login/TokenLogin/index.tsx index 96c369424d..fbf45622af 100644 --- a/frontend/src/App/Login/TokenLogin/index.tsx +++ b/frontend/src/App/Login/TokenLogin/index.tsx @@ -1,34 +1,45 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import cn from 'classnames'; +import { colorBackgroundHomeHeader } from '@cloudscape-design/design-tokens'; -import { Box, NavigateLink, SpaceBetween } from 'components'; -import { UnauthorizedLayout } from 'layouts/UnauthorizedLayout'; +import { Box, Container, ContentLayout, Header, NavigateLink, SpaceBetween } from 'components'; import { ROUTES } from 'routes'; import { LoginByTokenForm } from '../LoginByTokenForm'; - -import styles from './styles.module.scss'; +import { SelfHostedLogin } from '../SelfHostedLogin'; export const TokenLogin: React.FC = () => { const { t } = useTranslation(); - return ( - -
- - - {t('auth.sign_in_to_dstack')} + if (process.env.UI_VERSION === 'sky') { + return ( + + {t('auth.sign_in_to_dstack_sky')} - - - - + } + > + +
Sign in with a token
+
+ } + > + + {t('auth.another_login_methods')} - - -
-
- ); + + + + ); + } + + return ; }; diff --git a/frontend/src/App/Login/TokenLogin/styles.module.scss b/frontend/src/App/Login/TokenLogin/styles.module.scss deleted file mode 100644 index b648c81066..0000000000 --- a/frontend/src/App/Login/TokenLogin/styles.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -.form { - max-width: 440px; - width: 100%; - margin-left: auto; - margin-right: auto; - padding-top: 120px; -} diff --git a/frontend/src/App/Logout/index.tsx b/frontend/src/App/Logout/index.tsx index 0526c92cca..f7a86c45a3 100644 --- a/frontend/src/App/Logout/index.tsx +++ b/frontend/src/App/Logout/index.tsx @@ -3,6 +3,7 @@ import { Navigate } from 'react-router-dom'; import { useAppDispatch } from 'hooks'; import { ROUTES } from 'routes'; +import { presetApi } from 'services/preset'; import { projectApi } from 'services/project'; import { userApi } from 'services/user'; @@ -16,6 +17,7 @@ export const Logout: React.FC = () => { dispatch(userApi.util.resetApiState()); dispatch(projectApi.util.resetApiState()); + dispatch(presetApi.util.resetApiState()); }, []); return ; diff --git a/frontend/src/App/auth.test.tsx b/frontend/src/App/auth.test.tsx new file mode 100644 index 0000000000..ebc49993cf --- /dev/null +++ b/frontend/src/App/auth.test.tsx @@ -0,0 +1,220 @@ +/** @jest-environment node */ +import React from 'react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; + +import { ROUTES } from 'routes'; +import { useGetUserDataQuery } from 'services/user'; + +import { LoginByGithub } from 'App/Login/LoginByGithub'; + +const mockDispatch = jest.fn(); +const mockPrivateQuery = jest.fn(); +let mockToken: string | undefined; +let mockUserQuery: { + currentData?: { username: string }; + data?: { username: string }; + error?: { status: number }; + isFetching: boolean; +}; + +jest.mock('hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: () => mockToken, +})); + +jest.mock('libs', () => ({ goToUrl: jest.fn() })); + +jest.mock('services/auth', () => ({ + useGithubAuthorizeMutation: () => [jest.fn(), { isLoading: false }], +})); + +jest.mock('services/user', () => ({ + useGetUserDataQuery: jest.fn((_, { skip }: { skip: boolean }) => (skip ? { isFetching: false } : mockUserQuery)), +})); + +jest.mock('./slice', () => ({ + selectAuthToken: jest.fn(), + setUserData: (payload: unknown) => ({ type: 'app/setUserData', payload }), +})); + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + +jest.mock('layouts/AppLayout', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => { + // The real layout starts account, project, billing, and onboarding requests when mounted. + mockPrivateQuery(); + return
{children}
; + }, +})); + +jest.mock('@cloudscape-design/design-tokens', () => ({ colorBackgroundHomeHeader: 'transparent' })); + +jest.mock('components', () => { + const Wrapper = ({ children }: { children: React.ReactNode }) => <>{children}; + return { + Box: ({ variant, children }: { variant?: string; children: React.ReactNode }) => + variant === 'h1' ?

{children}

: <>{children}, + Button: Wrapper, + Alert: Wrapper, + Container: Wrapper, + Header: Wrapper, + NavigateLink: Wrapper, + ContentLayout: ({ header }: { header: React.ReactNode }) => <>{header}, + Link: Wrapper, + SpaceBetween: Wrapper, + }; +}); + +jest.mock('./Login/SelfHostedLogin', () => ({ SelfHostedLogin: () =>

Server login

})); +jest.mock('./Loading', () => ({ Loading: () =>

Loading

})); +jest.mock('./AuthErrorMessage', () => ({ AuthErrorMessage: () =>

Storage unavailable

})); + +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); +let App: React.FC; +let rendered: ReactTestRenderer | undefined; + +const CurrentPath = () => {useLocation().pathname}; + +const renderApp = (path: string) => { + act(() => { + rendered = create( + + + } /> + }> + Runs} /> + + + + , + ); + }); + return rendered!; +}; + +beforeAll(() => { + jest.replaceProperty(process, 'env', { ...process.env, UI_VERSION: 'sky' }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { localStorage: {} }, + }); + App = jest.requireActual('./index').default; +}); + +beforeEach(() => { + Object.defineProperty(globalThis, 'window', { value: { localStorage: {} } }); + mockToken = undefined; + mockUserQuery = { isFetching: false }; +}); + +afterEach(() => { + act(() => rendered?.unmount()); + rendered = undefined; +}); + +afterAll(() => { + jest.restoreAllMocks(); + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); + else Reflect.deleteProperty(globalThis, 'window'); +}); + +describe('Sky public and protected pages', () => { + test('visitors can open sign-in without loading account data', () => { + const view = renderApp(ROUTES.BASE); + + expect(view.root.findByType('output').children).toEqual([ROUTES.BASE]); + expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); + expect(useGetUserDataQuery).toHaveBeenCalledWith({ token: undefined }, { skip: true }); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test('a validated user opening sign-in reaches their runs', () => { + mockToken = 'valid-token'; + mockUserQuery.currentData = mockUserQuery.data = { username: 'alice' }; + const view = renderApp(ROUTES.BASE); + + expect(view.root.findByType('output').children).toEqual(['/runs']); + expect(view.root.findByType('h1').children).toEqual(['Runs']); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'app/setUserData', payload: { username: 'alice' } }); + }); + + test.each([ + [ROUTES.BASE, ROUTES.BASE], + [ROUTES.RUNS.LIST, ROUTES.BASE], + ])('a rejected token at %s settles on %s', (path, expectedPath) => { + mockToken = 'expired-token'; + mockUserQuery.error = { status: 401 }; + const view = renderApp(path); + + expect(view.root.findByType('output').children).toEqual([expectedPath]); + expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test('a rejected token stays at sign-in even when its user data is cached', () => { + mockToken = 'expired-token'; + mockUserQuery.currentData = mockUserQuery.data = { username: 'alice' }; + mockUserQuery.error = { status: 401 }; + const view = renderApp(ROUTES.BASE); + + expect(view.root.findByType('output').children).toEqual([ROUTES.BASE]); + expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test('a rejected token cannot restore cached account data on a protected page', () => { + mockToken = 'expired-token'; + mockUserQuery.currentData = mockUserQuery.data = { username: 'alice' }; + mockUserQuery.error = { status: 403 }; + const view = renderApp(ROUTES.RUNS.LIST); + + expect(view.root.findByType('output').children).toEqual([ROUTES.BASE]); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test('a visitor opening a protected page reaches sign-in', () => { + const view = renderApp('/runs'); + + expect(view.root.findByType('output').children).toEqual([ROUTES.BASE]); + expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test.each([ROUTES.BASE, ROUTES.RUNS.LIST])('%s waits for token validation before showing private content', (path) => { + mockToken = 'pending-token'; + mockUserQuery.isFetching = true; + const view = renderApp(path); + + expect(view.root.findByProps({ role: 'status' }).children).toEqual(['Loading']); + expect(view.root.findByType('output').children).toEqual([path]); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test.each([ROUTES.BASE, ROUTES.RUNS.LIST])( + '%s waits for a new token even when data from the previous token is available', + (path) => { + mockToken = 'new-token'; + mockUserQuery.data = { username: 'previous-user' }; + mockUserQuery.isFetching = true; + const view = renderApp(path); + + expect(view.root.findByProps({ role: 'status' }).children).toEqual(['Loading']); + expect(view.root.findByType('output').children).toEqual([path]); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }, + ); + + test('sign-in remains available when local storage is unavailable', () => { + Object.defineProperty(globalThis, 'window', { value: {} }); + mockToken = 'saved-token'; + const view = renderApp(ROUTES.BASE); + + expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); + expect(useGetUserDataQuery).toHaveBeenCalledWith({ token: 'saved-token' }, { skip: true }); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/App/index.tsx b/frontend/src/App/index.tsx index 618257a453..e6b6de8f80 100644 --- a/frontend/src/App/index.tsx +++ b/frontend/src/App/index.tsx @@ -1,20 +1,18 @@ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Outlet, useLocation } from 'react-router-dom'; +import { Navigate, Outlet, useLocation } from 'react-router-dom'; import AppLayout from 'layouts/AppLayout'; import { useAppDispatch, useAppSelector } from 'hooks'; import { useGetUserDataQuery } from 'services/user'; -import { LoginByGithub } from './Login/LoginByGithub'; import { SelfHostedLogin } from './Login/SelfHostedLogin'; import { ROUTES } from '../routes'; import { AuthErrorMessage } from './AuthErrorMessage'; +import { Loading } from './Loading'; import { selectAuthToken, setUserData } from './slice'; -const localStorageIsAvailable = 'localStorage' in window; - const IGNORED_AUTH_PATHS = [ ROUTES.AUTH.GITHUB_CALLBACK, ROUTES.AUTH.OKTA_CALLBACK, @@ -23,58 +21,47 @@ const IGNORED_AUTH_PATHS = [ ROUTES.AUTH.TOKEN, ]; -const LoginFormComponent = process.env.UI_VERSION === 'sky' ? LoginByGithub : SelfHostedLogin; +const LoginFormComponent = process.env.UI_VERSION === 'sky' ? () => : SelfHostedLogin; const App: React.FC = () => { const { t } = useTranslation(); const token = useAppSelector(selectAuthToken); - const isAuthenticated = Boolean(token); + const localStorageIsAvailable = 'localStorage' in window; const dispatch = useAppDispatch(); const { pathname } = useLocation(); const { - isLoading, - data: userData, + isFetching, + currentData: userData, error: getUserError, } = useGetUserDataQuery( { token }, { - skip: !isAuthenticated || !localStorageIsAvailable, + skip: !token || !localStorageIsAvailable, }, ); useEffect(() => { - if (userData?.username || getUserError) { - if (userData?.username) { - dispatch(setUserData(userData)); - } + if (userData?.username && !getUserError) { + dispatch(setUserData(userData)); } - }, [userData, getUserError, isLoading]); + }, [userData, getUserError, dispatch]); + + if (IGNORED_AUTH_PATHS.includes(pathname)) { + return ; + } - const renderLocalstorageError = () => { + if (!localStorageIsAvailable) { return ( ); - }; - - const renderTokenError = () => { - return ; - }; - - const renderNotAuthorizedError = () => { - return ; - }; - - if (IGNORED_AUTH_PATHS.includes(pathname)) { - return ; } - - if (!localStorageIsAvailable) return renderLocalstorageError(); - if (getUserError) return renderTokenError(); - if (!isAuthenticated) return renderNotAuthorizedError(); + if (!token || getUserError) return ; + if (isFetching && !userData) return ; + if (!userData?.username) return ; return ( diff --git a/frontend/src/PublicApp/PresetApp.tsx b/frontend/src/PublicApp/PresetApp.tsx new file mode 100644 index 0000000000..55f66f84f9 --- /dev/null +++ b/frontend/src/PublicApp/PresetApp.tsx @@ -0,0 +1,26 @@ +import React, { createContext, useContext } from 'react'; + +import { useAppSelector } from 'hooks'; +import { useGetUserDataQuery } from 'services/user'; + +import App from 'App'; +import { Loading } from 'App/Loading'; +import { selectAuthToken } from 'App/slice'; + +import { PublicApp } from './index'; + +const PresetViewer = createContext({ isAuthenticated: false }); + +export const usePresetViewer = () => useContext(PresetViewer); + +export const PresetApp: React.FC = () => { + const token = useAppSelector(selectAuthToken); + const { currentData, error, isFetching } = useGetUserDataQuery({ token }, { skip: !token || !('localStorage' in window) }); + const isAuthenticated = Boolean(token && currentData?.username && !error); + + if (token && isFetching && !currentData) return ; + + return ( + {isAuthenticated ? : } + ); +}; diff --git a/frontend/src/PublicApp/index.tsx b/frontend/src/PublicApp/index.tsx new file mode 100644 index 0000000000..17fd98ba24 --- /dev/null +++ b/frontend/src/PublicApp/index.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import enMessages from '@cloudscape-design/components/i18n/messages/all.en.json'; +import { applyMode, Mode } from '@cloudscape-design/global-styles'; + +import { AppLayout, BreadcrumbGroup, BreadcrumbGroupProps, Button, I18nProvider, TopNavigation } from 'components'; +import { DarkThemeIcon, LightThemeIcon } from 'layouts/AppLayout/themeIcons'; + +import { DISCORD_URL } from 'consts'; +import { useAppDispatch, useAppSelector } from 'hooks'; +import { goToUrl } from 'libs'; +import { ROUTES } from 'routes'; + +import { selectBreadcrumbs, selectSystemMode, setSystemMode } from 'App/slice'; + +import logo from 'assets/images/logo.svg'; +import styles from './styles.module.scss'; + +type PortalProps = { + children: React.ReactNode; +}; + +const i18nStrings = { + overflowMenuTriggerText: '', + overflowMenuTitleText: '', + overflowMenuBackIconAriaLabel: '', + overflowMenuDismissIconAriaLabel: '', +}; + +const THEME_ICON_MAP: Record = { + [Mode.Dark]: DarkThemeIcon, + [Mode.Light]: LightThemeIcon, +}; + +const askAi = () => { + window.document.body.focus(); + window?.Kapa?.open(); +}; + +const HeaderPortal = ({ children }: PortalProps) => { + const domNode = document.querySelector('#header'); + if (domNode) return createPortal(children, domNode); + return null; +}; + +export const PublicApp: React.FC = ({ children }) => { + const isSky = process.env.UI_VERSION === 'sky'; + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const breadcrumbs = useAppSelector(selectBreadcrumbs); + const isAuth = pathname === ROUTES.BASE || pathname === ROUTES.AUTH.TOKEN; + const onFollow: BreadcrumbGroupProps['onFollow'] = (event) => { + event.preventDefault(); + navigate(event.detail.href); + }; + const systemMode = useAppSelector(selectSystemMode) ?? ''; + const ThemeIcon = THEME_ICON_MAP[systemMode]; + + const onChangeSystemModeToggle = (event: { preventDefault: () => void }) => { + event.preventDefault(); + switch (systemMode) { + case Mode.Light: + dispatch(setSystemMode(Mode.Dark)); + return; + default: + dispatch(setSystemMode(Mode.Light)); + } + }; + + return ( + <> + +
{ + if (element) applyMode(Mode.Dark, element); + }} + > + goToUrl('https://dstack.ai/docs/', true), + }, + { + type: 'button', + text: t('common.discord'), + external: true, + onClick: () => goToUrl(DISCORD_URL, true), + }, + { + href: 'theme-button', + type: 'button', + iconSvg: , + onClick: onChangeSystemModeToggle, + }, + { + type: 'button', + iconName: 'gen-ai', + text: t('common.ask_ai'), + title: t('common.ask_ai'), + onClick: askAi, + }, + ]} + /> + {isSky && !isAuth && ( +
+ +
+ )} +
+
+ + + } + breadcrumbs={ + isSky && pathname.startsWith(ROUTES.PRESETS.LIST) && breadcrumbs ? ( + + ) : undefined + } + /> + + + ); +}; diff --git a/frontend/src/PublicApp/styles.module.scss b/frontend/src/PublicApp/styles.module.scss new file mode 100644 index 0000000000..5ae0bcb23a --- /dev/null +++ b/frontend/src/PublicApp/styles.module.scss @@ -0,0 +1,24 @@ +@use '@cloudscape-design/design-tokens/index' as awsui; + +body { + background: awsui.$color-background-layout-main; + position: relative; +} + +.header { + display: flex; + background: awsui.$color-background-container-content; +} + +.navigation { + flex: 1; + min-width: 0; +} + +.signIn { + display: flex; + align-items: center; + flex-shrink: 0; + padding-inline-end: awsui.$space-static-l; + border-block-end: 1px solid awsui.$color-border-divider-default; +} diff --git a/frontend/src/analytics.ts b/frontend/src/analytics.ts new file mode 100644 index 0000000000..0b19aa85d4 --- /dev/null +++ b/frontend/src/analytics.ts @@ -0,0 +1,44 @@ +import { router } from './router'; + +const MEASUREMENT_ID = process.env.GA_MEASUREMENT_ID; + +export const initAnalytics = () => { + if (process.env.UI_VERSION !== 'sky' || !MEASUREMENT_ID) return; + + window.dataLayer = window.dataLayer || []; + window.gtag = function () { + // eslint-disable-next-line prefer-rest-params -- gtag expects an arguments object. + window.dataLayer?.push(arguments); + }; + window.gtag('js', new Date()); + window.gtag('set', { + page_location: window.location.origin, + page_referrer: '', + page_title: 'dstack Sky', + }); + // Enhanced Measurement must be disabled for the Sky stream. + window.gtag('config', MEASUREMENT_ID, { send_page_view: false }); + + let lastLocationKey: string | undefined; + let previousPageLocation = ''; + const trackPageView = () => { + const { location, matches, errors } = router.state; + if (location.key === lastLocationKey) return; + + // Route templates exclude names, IDs, and OAuth query parameters. + const path = errors ? '/404' : matches.reduce((path, { route }) => route.path || path, '/404'); + const pageLocation = window.location.origin + path; + window.gtag?.('set', { page_location: pageLocation, page_referrer: previousPageLocation }); + window.gtag?.('event', 'page_view'); + previousPageLocation = pageLocation; + lastLocationKey = location.key; + }; + + router.subscribe(trackPageView); + trackPageView(); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${MEASUREMENT_ID}`; + document.head.appendChild(script); +}; diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 58507d8477..cce66c2257 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -73,6 +73,7 @@ export const API = { ADD_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/add_members`, REMOVE_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/remove_members`, UPDATE: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/update`, + UPDATE_PUBLIC_PRESETS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/update_public_presets`, // Repos REPOS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/repos`, diff --git a/frontend/src/hooks/useInfiniteScroll.ts b/frontend/src/hooks/useInfiniteScroll.ts index 9064e6b346..5507cfb97e 100644 --- a/frontend/src/hooks/useInfiniteScroll.ts +++ b/frontend/src/hooks/useInfiniteScroll.ts @@ -36,21 +36,17 @@ export const useInfiniteScroll = ({ const isDisabledMoreRef = useRef(false); const lastRequestParams = useRef(undefined); const { limit, ...argsProp } = args; - const lastArgsProps = useRef>(null); + const lastArgsProps = useRef | null>(null); - const [getItems, { isLoading, isFetching }] = useLazyQuery({ ...args } as Args); + const [getItems, { isLoading, isFetching, error }] = useLazyQuery({ ...args } as Args); - const getDataRequest = (params: Args) => { - const request = getItems({ + const getDataRequest = async (params: Args) => { + const result = await getItems({ limit, ...params, } as Args).unwrap(); - - request.then(() => { - lastRequestParams.current = { ...params }; - }); - - return request; + lastRequestParams.current = { ...params }; + return result; }; const getEmptyList = () => { @@ -58,20 +54,24 @@ export const useInfiniteScroll = ({ setData([]); - getDataRequest(argsProp as Args).then((result: LazyQueryResponse) => { - // setDisabledMore(false); - isDisabledMoreRef.current = false; + getDataRequest(argsProp as Args) + .then((result: LazyQueryResponse) => { + isDisabledMoreRef.current = false; - if ('data' in result) { - setData(result.data as ListResponse); - setTotalCount(result.total_count); - } else { - setData(result as ListResponse); - setTotalCount(); - } - - isLoadingRef.current = false; - }); + if ('data' in result) { + setData(result.data as ListResponse); + setTotalCount(result.total_count); + } else { + setData(result as ListResponse); + setTotalCount(); + } + }) + .catch(() => { + isDisabledMoreRef.current = true; + }) + .finally(() => { + isLoadingRef.current = false; + }); }; useEffect(() => { @@ -82,7 +82,7 @@ export const useInfiniteScroll = ({ }, [argsProp, lastArgsProps, skip]); const getMore = async () => { - if (isLoadingRef.current || isDisabledMoreRef.current || skip) { + if (isLoadingRef.current || isDisabledMoreRef.current || skip || !data.length) { return; } @@ -158,6 +158,7 @@ export const useInfiniteScroll = ({ return { data, + error, totalCount, isLoading: isLoading || (data.length === 0 && isFetching), isLoadingMore, diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index c02da1084c..699aa0303f 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -5,6 +5,7 @@ import { Provider } from 'react-redux'; import { RouterProvider } from 'react-router-dom'; import { applyTheme, Theme } from '@cloudscape-design/components/theming'; +import { initAnalytics } from './analytics'; import { router } from './router'; import { store } from './store'; @@ -16,6 +17,8 @@ import 'assets/css/index.css'; import 'locale'; +initAnalytics(); + const container = document.getElementById('root'); const theme: Theme = { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index c1115ad421..bbfafc0cb3 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -33,10 +33,10 @@ "clearFilter": "Clear filter", "server_error": "Server error: {{error}}", "login": "Sign in", - "login_github": "Sign in with GitHub", - "login_okta": "Sign in with Okta", - "login_entra": "Sign in with EntraID", - "login_google": "Sign in with Google", + "login_github": "Continue with GitHub", + "login_okta": "Continue with Okta", + "login_entra": "Continue with EntraID", + "login_google": "Continue with Google", "general": "General", "test": "Test", "local_storage_unavailable": "Local Storage is unavailable", @@ -232,6 +232,10 @@ "change_visibility": "Change", "project_visibility": "Visibility", "project_visibility_settings": "Visibility", + "presets_settings": "Presets", + "presets_private_description": "Only project members and global admins can view and pull this project’s presets", + "presets_public_description": "Anyone can view and pull this project’s presets without signing in", + "update_presets_success": "Preset visibility updated successfully", "templates_repo": "Templates", "override_project_templates": "Templates", "transfer_ownership": "Ownership", @@ -361,7 +365,9 @@ }, "visibility": { "private": "Private", - "public": "Public" + "public": "Public", + "private_description": "Only project members and global admins can access this project", + "public_description": "Any authorized user can see this project and join it as a member" } }, "create": { @@ -838,6 +844,12 @@ }, "presets": { "list_page_title": "Presets", + "public_empty_message_title": "No public presets", + "public_empty_message_text": "There are no public presets to display", + "load_error_title": "Unable to load presets", + "load_error_message": "Refresh to try again", + "unavailable_title": "Preset unavailable", + "unavailable_message": "This preset may have been removed, or you may not have access to it", "empty_message_title": "No presets", "empty_message_text": "Presets are created and pushed with the CLI.", "documentation": "Documentation", @@ -847,7 +859,6 @@ "name": "Name", "id": "ID", "project": "Project", - "base": "Base", "repo": "Repo", "user": "User", "created_at": "Created", @@ -876,7 +887,7 @@ "output_tokens": "Output tokens", "shared_prefix": "Shared prefix", "no_fleet_description": "Deploying the service requires a fleet with matching resources.", - "fleets": "Fleets", + "no_fleet": "No fleet?", "superseded_alert": "A later push took this preset's name. It stays available by ID.", "fleets_link": "How to create a fleet" } diff --git a/frontend/src/pages/Presets/Details/Deploy/index.tsx b/frontend/src/pages/Presets/Details/Deploy/index.tsx index ec6fb2719e..4f78496f9b 100644 --- a/frontend/src/pages/Presets/Details/Deploy/index.tsx +++ b/frontend/src/pages/Presets/Details/Deploy/index.tsx @@ -1,10 +1,26 @@ import React, { FC } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; -import { Box, Button, ExpandableSection, Link, Popover, SpaceBetween, StatusIndicator, Tabs, Wizard } from 'components'; +import { + Box, + Button, + Container, + ExpandableSection, + Header, + Link, + Loader, + Popover, + SpaceBetween, + StatusIndicator, + Tabs, + Wizard, +} from 'components'; import { FLEETS_DOCS_URL } from 'consts'; import { copyToClipboard } from 'libs'; +import { ROUTES } from 'routes'; +import { useGetPresetQuery } from 'services/preset'; const UV_INSTALL_COMMAND = 'uv tool install dstack -U'; const PIP_INSTALL_COMMAND = 'pip install dstack -U'; @@ -28,10 +44,26 @@ const CopyableCommand: FC<{ command: string }> = ({ command }) => { ); }; -export const Deploy: FC<{ preset: IPresetDetails }> = ({ preset }) => { +export const PresetDeploy: FC = () => { const { t } = useTranslation(); - const [isExpanded, setIsExpanded] = React.useState(false); + const params = useParams(); + const navigate = useNavigate(); + const paramProjectName = params.projectName ?? ''; + const paramPresetId = params.presetId ?? ''; const [activeStepIndex, setActiveStepIndex] = React.useState(0); + + const { data: preset, isLoading } = useGetPresetQuery({ + project_name: paramProjectName, + id: paramPresetId, + }); + + if (isLoading || !preset) + return ( + + + + ); + // A preset is pulled by whatever reference reaches it: its name while one // points at it, its id otherwise. const reference = `${preset.project_name}/${preset.name ?? preset.id}`; @@ -43,23 +75,20 @@ export const Deploy: FC<{ preset: IPresetDetails }> = ({ preset }) => { const configurationFile = 'preset.dstack.yml'; return ( - setIsExpanded(detail.expanded)} - > + {t('presets.deploy')}}> `Step ${stepNumber}`, collapsedStepsLabel: (stepNumber, stepsCount) => `Step ${stepNumber} of ${stepsCount}`, navigationAriaLabel: 'Steps', + cancelButton: t('common.cancel'), previousButton: 'Previous', nextButton: 'Next', }} onNavigate={({ detail }) => setActiveStepIndex(detail.requestedStepIndex)} activeStepIndex={activeStepIndex} - onSubmit={() => setIsExpanded(false)} + onCancel={() => navigate(ROUTES.PRESETS.DETAILS.FORMAT(paramProjectName, paramPresetId))} + onSubmit={() => navigate(ROUTES.PRESETS.DETAILS.FORMAT(paramProjectName, paramPresetId))} submitButtonText="Done" steps={[ { @@ -106,13 +135,15 @@ export const Deploy: FC<{ preset: IPresetDetails }> = ({ preset }) => { - + - {t('presets.no_fleet_description')} - - {t('presets.fleets_link')} - + + {t('presets.no_fleet_description')}{' '} + + {t('presets.fleets_link')} + + @@ -120,6 +151,6 @@ export const Deploy: FC<{ preset: IPresetDetails }> = ({ preset }) => { }, ]} /> - + ); }; diff --git a/frontend/src/pages/Presets/Details/VerifiedOn/index.tsx b/frontend/src/pages/Presets/Details/VerifiedOn/index.tsx index acd00a6686..acf441c1a2 100644 --- a/frontend/src/pages/Presets/Details/VerifiedOn/index.tsx +++ b/frontend/src/pages/Presets/Details/VerifiedOn/index.tsx @@ -2,7 +2,7 @@ import React, { FC, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import { Container, Header, Loader, TreeView } from 'components'; +import { Container, ExpandableSection, Loader, TreeView } from 'components'; import { useGetPresetQuery } from 'services/preset'; @@ -59,8 +59,6 @@ export const PresetVerifiedOn: FC = () => { id: paramPresetId, }); - // Every group starts expanded: a preset has few of them, and the replicas - // are the point of the tab. const groupNames = useMemo(() => (data?.spec.preset.verified_on ?? []).map(({ name }) => name), [data]); const [collapsedItems, setCollapsedItems] = useState([]); const expandedItems = groupNames.filter((name) => !collapsedItems.includes(name)); @@ -78,12 +76,12 @@ export const PresetVerifiedOn: FC = () => { content: t('presets.replica_group', { name: group.name }), children: group.replicas.map((replicas, index) => ({ id: `${group.name}-${index}`, - content: `${t('presets.replica', { index: index + 1 })}: ${formatResources(replicas)}`, + content: `${t('presets.replica', { index })}: ${formatResources(replicas)}`, })), })); return ( - {t('presets.verified_on')}}> + { collapseButtonLabel: () => 'Collapse', }} /> - + ); }; diff --git a/frontend/src/pages/Presets/Details/index.tsx b/frontend/src/pages/Presets/Details/index.tsx index bd3479a7f9..6125128f88 100644 --- a/frontend/src/pages/Presets/Details/index.tsx +++ b/frontend/src/pages/Presets/Details/index.tsx @@ -26,11 +26,11 @@ import { useDeletePresetMutation, useGetPresetQuery } from 'services/preset'; import { PresetBenchmark } from './Benchmark'; import { PresetConstraints } from './Constraints'; -import { Deploy } from './Deploy'; +import { PresetVerifiedOn } from './VerifiedOn'; enum PresetTab { Details = 'details', - VerifiedOn = 'verified-on', + Deploy = 'deploy', Inspect = 'inspect', } @@ -42,18 +42,29 @@ export const PresetDetails: React.FC = () => { const paramProjectName = params.projectName ?? ''; const paramPresetId = params.presetId ?? ''; - const { data, isLoading } = useGetPresetQuery({ + const { + currentData: data, + isLoading, + isFetching, + error, + } = useGetPresetQuery({ project_name: paramProjectName, id: paramPresetId, }); const [deletePreset, { isLoading: isDeleting }] = useDeletePresetMutation(); + const isLoadingPreset = isLoading || (isFetching && !data); + const canDelete = !!data?.can_delete; useBreadcrumbs([ { text: t('navigation.presets'), href: ROUTES.PRESETS.LIST, }, + { + text: paramProjectName, + href: `${ROUTES.PRESETS.LIST}?${new URLSearchParams({ project_name: paramProjectName })}`, + }, { text: data?.name ?? paramPresetId, href: ROUTES.PRESETS.DETAILS.FORMAT(paramProjectName, paramPresetId), @@ -61,7 +72,8 @@ export const PresetDetails: React.FC = () => { ]); const deleteClickHandle = () => { - deletePreset({ project_name: paramProjectName, id: paramPresetId }) + if (!canDelete || !data) return; + deletePreset({ project_name: paramProjectName, id: data.id }) .unwrap() .then(() => navigate(ROUTES.PRESETS.LIST)) .catch((error) => { @@ -78,22 +90,30 @@ export const PresetDetails: React.FC = () => { - {t('common.delete')} - + canDelete && !error ? ( + + {t('common.delete')} + + ) : undefined } /> } > - {isLoading && !data && } + {isLoadingPreset && !data && } + + {!isLoadingPreset && (error || !data) && ( + + {t('presets.unavailable_message')} + + )} - {data && ( + {data && !error && ( {/* Nothing else on the page explains an empty name. */} {!data.name && {t('presets.superseded_alert')}} @@ -107,9 +127,9 @@ export const PresetDetails: React.FC = () => { href: ROUTES.PRESETS.DETAILS.FORMAT(paramProjectName, paramPresetId), }, { - label: t('presets.verified_on'), - id: PresetTab.VerifiedOn, - href: ROUTES.PRESETS.DETAILS.VERIFIED_ON.FORMAT(paramProjectName, paramPresetId), + label: t('presets.deploy'), + id: PresetTab.Deploy, + href: ROUTES.PRESETS.DETAILS.DEPLOY.FORMAT(paramProjectName, paramPresetId), }, { label: t('presets.inspect'), @@ -150,33 +170,25 @@ export const PresetDetailsOverview: React.FC = () => {
{t('presets.name')} -
{data.name}
+
+ {data.name && ( + + {data.name} + + )} +
{t('presets.id')} -
{data.id}
-
-
- {t('presets.base')} -
{data.base}
-
-
- {t('presets.repo')} -
{data.repo}
-
-
- {t('presets.project')}
- - {data.project_name} + + {data.id}
- {t('presets.user')} -
- {data.pushed_by} -
+ {t('presets.repo')} +
{data.repo}
{t('presets.created_at')} @@ -189,7 +201,7 @@ export const PresetDetailsOverview: React.FC = () => { - + ); }; diff --git a/frontend/src/pages/Presets/List/hooks.tsx b/frontend/src/pages/Presets/List/hooks.tsx index 7cb259949b..be009b1d85 100644 --- a/frontend/src/pages/Presets/List/hooks.tsx +++ b/frontend/src/pages/Presets/List/hooks.tsx @@ -10,7 +10,6 @@ import { DATE_TIME_FORMAT, PRESETS_DOCS_URL } from 'consts'; import { useNotifications } from 'hooks'; import { getServerError, goToUrl } from 'libs'; import { - EMPTY_QUERY, getNamePatternFilterRequestParams, requestParamsToTokens, tokensToRequestParams, @@ -34,13 +33,24 @@ const MAX_FILTER_OPTIONS = 100; export const usePresetsTableEmptyMessages = ({ clearFilter, isDisabledClearFilter, + isAuthenticated, }: { clearFilter?: () => void; isDisabledClearFilter?: boolean; + isAuthenticated: boolean; }) => { const { t } = useTranslation(); const renderEmptyMessage = (): React.ReactNode => { + if (isDisabledClearFilter && !isAuthenticated) { + return ( + + ); + } + if (isDisabledClearFilter) { // Presets are created and pushed with the CLI, so there is nothing // to create here - the docs are the useful next step. @@ -82,9 +92,10 @@ export const useColumnsDefinitions = () => { { id: 'name', header: t('presets.name'), - // The name says which preset it points at today; the id is what - // identifies one, so that is what links to it. - cell: (item: IPreset) => item.name, + cell: (item: IPreset) => + item.name ? ( + {item.name} + ) : null, }, { id: 'id', @@ -97,14 +108,17 @@ export const useColumnsDefinitions = () => { id: 'project', header: t('presets.project'), cell: (item: IPreset) => ( - {item.project_name} + + {item.project_name} + ), }, - { - id: 'base', - header: t('presets.base'), - cell: (item: IPreset) => item.base, - }, { id: 'repo', header: t('presets.repo'), @@ -113,9 +127,12 @@ export const useColumnsDefinitions = () => { { id: 'user', header: t('presets.user'), - cell: (item: IPreset) => ( - {item.pushed_by} - ), + cell: (item: IPreset) => + item.can_delete ? ( + {item.pushed_by} + ) : ( + item.pushed_by + ), }, { id: 'created', @@ -146,10 +163,11 @@ export const usePresetsDelete = () => { return { isDeleting, deletePresets } as const; }; -export const useFilters = () => { +export const useFilters = ({ isAuthenticated }: { isAuthenticated: boolean }) => { const [searchParams, setSearchParams] = useSearchParams(); - const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => - requestParamsToTokens({ searchParams, filterKeys }), + const propertyFilterQuery = useMemo( + () => requestParamsToTokens({ searchParams, filterKeys }), + [searchParams], ); const [filteringOptions, setFilteringOptions] = useState([]); const [filteringStatusType, setFilteringStatusType] = useState(); @@ -181,30 +199,39 @@ export const useFilters = () => { // other list pages use; a base model is typed in, as no API enumerates one. const handleLoadItems: PropertyFilterProps['onLoadItems'] = async ({ detail: { filteringProperty, filteringText } }) => { setFilteringOptions([]); + if (!isAuthenticated) { + setFilteringStatusType(undefined); + return; + } setFilteringStatusType('loading'); - if (filteringProperty?.key === filterKeys.PROJECT_NAME) { - await getProjects(getNamePatternFilterRequestParams(filteringText, MAX_FILTER_OPTIONS)) - .unwrap() - .then(({ data }) => - data.map(({ project_name }) => ({ - propertyKey: filterKeys.PROJECT_NAME, - value: project_name, - })), - ) - .then(setFilteringOptions); - } + try { + if (filteringProperty?.key === filterKeys.PROJECT_NAME) { + await getProjects(getNamePatternFilterRequestParams(filteringText, MAX_FILTER_OPTIONS)) + .unwrap() + .then(({ data }) => + data.map(({ project_name }) => ({ + propertyKey: filterKeys.PROJECT_NAME, + value: project_name, + })), + ) + .then(setFilteringOptions); + } - if (filteringProperty?.key === filterKeys.USERNAME) { - await getUsers(getNamePatternFilterRequestParams(filteringText, MAX_FILTER_OPTIONS)) - .unwrap() - .then(({ data }) => - data.map(({ username }) => ({ - propertyKey: filterKeys.USERNAME, - value: username, - })), - ) - .then(setFilteringOptions); + if (filteringProperty?.key === filterKeys.USERNAME) { + await getUsers(getNamePatternFilterRequestParams(filteringText, MAX_FILTER_OPTIONS)) + .unwrap() + .then(({ data }) => + data.map(({ username }) => ({ + propertyKey: filterKeys.USERNAME, + value: username, + })), + ) + .then(setFilteringOptions); + } + } catch { + setFilteringStatusType('error'); + return; } setFilteringStatusType(undefined); @@ -217,12 +244,10 @@ export const useFilters = () => { }); setSearchParams(tokensToSearchParams(filteredTokens)); - setPropertyFilterQuery({ ...detail, tokens: filteredTokens }); }; const clearFilter = () => { setSearchParams({}); - setPropertyFilterQuery(EMPTY_QUERY); }; const filteringRequestParams = useMemo(() => { diff --git a/frontend/src/pages/Presets/List/index.tsx b/frontend/src/pages/Presets/List/index.tsx index 67646fcfd3..b97db08f1a 100644 --- a/frontend/src/pages/Presets/List/index.tsx +++ b/frontend/src/pages/Presets/List/index.tsx @@ -1,16 +1,35 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { usePresetViewer } from 'PublicApp/PresetApp'; -import { Button, ButtonWithConfirmation, Header, Loader, PropertyFilter, SpaceBetween, Table } from 'components'; +import { Alert, Button, ButtonWithConfirmation, Header, Loader, PropertyFilter, SpaceBetween, Table } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; -import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; +import { useAppSelector, useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; import { ROUTES } from 'routes'; -import { useLazyGetAllPresetsQuery } from 'services/preset'; +import { PresetListQueryArgs, useLazyGetAllPresetsQuery } from 'services/preset'; + +import { selectAuthToken } from 'App/slice'; import { useColumnsDefinitions, useFilters, usePresetsDelete, usePresetsTableEmptyMessages } from './hooks'; export const PresetList: React.FC = () => { + const { isAuthenticated } = usePresetViewer(); + const authToken = useAppSelector(selectAuthToken); + + return ( + + ); +}; + +const PresetListTable: React.FC<{ + authToken?: string; + isAuthenticated: boolean; +}> = ({ authToken, isAuthenticated }) => { const { t } = useTranslation(); const { @@ -23,18 +42,19 @@ export const PresetList: React.FC = () => { isDisabledClearFilter, filteringStatusType, handleLoadItems, - } = useFilters(); + } = useFilters({ isAuthenticated }); const { isDeleting, deletePresets } = usePresetsDelete(); const { renderEmptyMessage, renderNoMatchMessage } = usePresetsTableEmptyMessages({ clearFilter, isDisabledClearFilter, + isAuthenticated, }); - const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ + const { data, error, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetAllPresetsQuery, - args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE } as TPresetsListRequestParams, + args: { ...filteringRequestParams, authToken, limit: DEFAULT_TABLE_PAGE_SIZE } as PresetListQueryArgs, getPaginationParams: (lastPreset) => ({ prev_created_at: lastPreset.created_at, @@ -50,19 +70,26 @@ export const PresetList: React.FC = () => { ]); const { columns } = useColumnsDefinitions(); + const loadError = error ? ( + + {t('presets.load_error_message')} + + ) : undefined; const { items, actions, collectionProps } = useCollection(data ?? [], { filtering: { - empty: renderEmptyMessage(), + empty: loadError ?? renderEmptyMessage(), noMatch: renderNoMatchMessage(), }, selection: {}, }); const { selectedItems } = collectionProps; + const canDelete = isAuthenticated && data.some((preset) => preset.can_delete); + const canDeleteSelected = canDelete && !!selectedItems?.length && selectedItems.every((preset) => preset.can_delete); const deleteSelected = () => { - if (!selectedItems?.length) return; + if (!canDeleteSelected || !selectedItems?.length) return; deletePresets([...selectedItems]).then(() => { actions.setSelectedItems([]); @@ -70,7 +97,7 @@ export const PresetList: React.FC = () => { }); }; - const isDisabledDelete = isDeleting || !selectedItems?.length; + const isDisabledDelete = isDeleting || !canDeleteSelected; return ( { items={items} loading={isLoading} loadingText={t('common.loading')} - selectionType="multi" + selectionType={canDelete ? 'multi' : undefined} + isItemDisabled={(preset) => !preset.can_delete} stickyHeader={true} header={
- - {t('common.delete')} - + {canDelete && ( + + {t('common.delete')} + + )} )} @@ -612,6 +603,34 @@ export const ProjectSettings: React.FC = () => { )} + setIsChangeVisibilityVisible(false)} + onConfirm={confirmChangeVisibility} + title={t(isSky ? 'projects.edit.presets_settings' : 'projects.edit.project_visibility_settings')} + confirmButtonLabel={t('projects.edit.change_visibility')} + content={ + +
+ setVisibilityEnabled(detail.selectedOption.value === 'public')} + disabled={isUpdatingVisibility} + expandToViewport + filteringType="auto" + /> +
+
+ } + /> + ), }; + +export const PRESETS_INFO = { + header:

Presets

, + body: ( +

+ Making presets public lets anyone browse them in Sky or pull them with the dstack CLI, without a Sky account. Keep + presets private to limit access to project members and global admins. +

+ ), +}; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 5a94dc6269..702bc6193d 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -2,9 +2,12 @@ import React from 'react'; import type { RouteObject } from 'react-router-dom'; import { createBrowserRouter } from 'react-router-dom'; import { Navigate } from 'react-router-dom'; +import { PublicApp } from 'PublicApp'; +import { PresetApp } from 'PublicApp/PresetApp'; import App from 'App'; import { LoginByEntraIDCallback } from 'App/Login/EntraID/LoginByEntraIDCallback'; +import { LoginByGithub } from 'App/Login/LoginByGithub'; import { LoginByGithubCallback } from 'App/Login/LoginByGithubCallback'; import { LoginByGoogleCallback } from 'App/Login/LoginByGoogleCallback'; import { LoginByOktaCallback } from 'App/Login/LoginByOktaCallback'; @@ -33,12 +36,44 @@ import { UserBilling, UserEvents, UserProjects, UserSettings } from 'pages/User/ import { AuthErrorMessage } from './App/AuthErrorMessage'; import { EventList } from './pages/Events'; import { OfferList } from './pages/Offers'; -import { PresetDetails, PresetDetailsOverview, PresetInspect, PresetList, PresetVerifiedOn } from './pages/Presets'; +import { PresetDeploy, PresetDetails, PresetDetailsOverview, PresetInspect, PresetList } from './pages/Presets'; import { JobDetails } from './pages/Runs/Details/Jobs/Details/JobDetails'; import { VolumeList } from './pages/Volumes'; import { ROUTES } from './routes'; export const router = createBrowserRouter([ + ...(process.env.UI_VERSION === 'sky' + ? [ + { + element: , + errorElement: , + children: [ + { path: ROUTES.BASE, element: }, + { path: ROUTES.AUTH.TOKEN, element: }, + ], + }, + { + element: , + errorElement: , + children: [ + { + path: ROUTES.PRESETS.LIST, + element: , + }, + { + path: ROUTES.PRESETS.DETAILS.TEMPLATE, + element: , + children: [ + { index: true, element: }, + { path: ROUTES.PRESETS.DETAILS.DEPLOY.TEMPLATE, element: }, + { path: 'verified-on', element: }, + { path: ROUTES.PRESETS.DETAILS.INSPECT.TEMPLATE, element: }, + ], + }, + ], + }, + ] + : []), { path: '/', element: , @@ -61,15 +96,11 @@ export const router = createBrowserRouter([ path: ROUTES.AUTH.GOOGLE_CALLBACK, element: , }, - { - path: ROUTES.AUTH.TOKEN, - element: , - }, + ...(process.env.UI_VERSION !== 'sky' ? [{ path: ROUTES.AUTH.TOKEN, element: }] : []), // hubs - { - path: ROUTES.BASE, - element: , - }, + ...(process.env.UI_VERSION !== 'sky' + ? [{ path: ROUTES.BASE, element: }] + : []), { path: ROUTES.PROJECT.LIST, element: , @@ -257,32 +288,6 @@ export const router = createBrowserRouter([ element: , }, - // Presets, which only a server with a registry serves - ...([ - process.env.UI_VERSION === 'sky' && { - path: ROUTES.PRESETS.LIST, - element: , - }, - process.env.UI_VERSION === 'sky' && { - path: ROUTES.PRESETS.DETAILS.TEMPLATE, - element: , - children: [ - { - index: true, - element: , - }, - { - path: ROUTES.PRESETS.DETAILS.VERIFIED_ON.TEMPLATE, - element: , - }, - { - path: ROUTES.PRESETS.DETAILS.INSPECT.TEMPLATE, - element: , - }, - ], - }, - ].filter(Boolean) as RouteObject[]), - // Users { path: ROUTES.USER.LIST, diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index b3803dbcef..613869acc3 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -192,10 +192,10 @@ export const ROUTES = { TEMPLATE: `/presets/:projectName/:presetId`, FORMAT: (projectName: string, presetId: string) => buildRoute(ROUTES.PRESETS.DETAILS.TEMPLATE, { projectName, presetId }), - VERIFIED_ON: { - TEMPLATE: `/presets/:projectName/:presetId/verified-on`, + DEPLOY: { + TEMPLATE: `/presets/:projectName/:presetId/deploy`, FORMAT: (projectName: string, presetId: string) => - buildRoute(ROUTES.PRESETS.DETAILS.VERIFIED_ON.TEMPLATE, { projectName, presetId }), + buildRoute(ROUTES.PRESETS.DETAILS.DEPLOY.TEMPLATE, { projectName, presetId }), }, INSPECT: { TEMPLATE: `/presets/:projectName/:presetId/inspect`, diff --git a/frontend/src/services/preset.test.tsx b/frontend/src/services/preset.test.tsx new file mode 100644 index 0000000000..aaa18c9dd3 --- /dev/null +++ b/frontend/src/services/preset.test.tsx @@ -0,0 +1,135 @@ +/** @jest-environment node */ +import React from 'react'; +import { Provider } from 'react-redux'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import { configureStore } from '@reduxjs/toolkit'; + +import { presetApi, useGetPresetQuery } from './preset'; + +jest.mock('api', () => ({ + API: { + PRESET: { LIST: () => 'http://localhost/api/presets/list' }, + PROJECTS: { + PRESETS_GET: (project: string) => `http://localhost/api/project/${project}/presets/get`, + PRESETS_DELETE: (project: string) => `http://localhost/api/project/${project}/presets/delete`, + }, + }, +})); + +jest.mock('App/slice', () => ({ + selectAuthToken: (state: { app: { authData?: { token?: string } } }) => state.app.authData?.token, +})); + +const createStore = () => + configureStore({ + reducer: { + app: (state = { authData: { token: 'viewer-a' as string | undefined } }, action) => + action.type === 'test/setToken' ? { authData: { token: action.payload } } : state, + [presetApi.reducerPath]: presetApi.reducer, + }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(presetApi.middleware), + }); + +const jsonResponse = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); + +const flushUpdates = async () => { + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); +}; + +describe('Preset viewer isolation', () => { + let store: ReturnType; + let rendered: ReactTestRenderer | undefined; + let fetchMock: jest.SpyInstance; + let requests: Request[]; + + beforeEach(() => { + store = createStore(); + requests = []; + fetchMock = jest.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + act(() => rendered?.unmount()); + rendered = undefined; + store.dispatch(presetApi.util.resetApiState()); + fetchMock.mockRestore(); + }); + + it('keeps list credentials out of the body and does not give guest queries the current account token', async () => { + fetchMock.mockImplementation(async (input: Request) => { + requests.push(input); + const name = input.headers.get('Authorization') ?? 'public'; + return jsonResponse({ presets: [{ id: name, name, project_name: 'project', can_delete: name !== 'public' }] }); + }); + + const member = await store.dispatch(presetApi.endpoints.getAllPresets.initiate({ authToken: 'viewer-a' })).unwrap(); + const guest = await store.dispatch(presetApi.endpoints.getAllPresets.initiate({})).unwrap(); + + expect(member[0].can_delete).toBe(true); + expect(guest[0].can_delete).toBe(false); + expect(requests).toHaveLength(2); + expect(requests[0].headers.get('Authorization')).toBe('Bearer viewer-a'); + expect(requests[1].headers.get('Authorization')).toBeNull(); + expect(await requests[0].json()).toEqual({}); + expect(await requests[1].json()).toEqual({}); + }); + + it('never renders a cached private detail after switching accounts or signing out', async () => { + let completeSecondRequest: ((response: Response) => void) | undefined; + fetchMock.mockImplementation(async (input: Request) => { + requests.push(input); + const authorization = input.headers.get('Authorization'); + if (authorization === 'Bearer viewer-a') { + return jsonResponse({ id: 'preset-id', name: 'Account A private preset', project_name: 'project' }); + } + if (authorization === 'Bearer viewer-b') { + return new Promise((resolve) => { + completeSecondRequest = resolve; + }); + } + return jsonResponse({ detail: 'Preset not found' }, 404); + }); + + const Detail = () => { + const { currentData, error } = useGetPresetQuery({ project_name: 'project', id: 'preset-id' }); + return {currentData?.name ?? (error ? 'Unavailable' : 'Loading')}; + }; + await act(async () => { + rendered = create( + + + , + ); + }); + await flushUpdates(); + expect(rendered?.root.findByType('span').children).toEqual(['Account A private preset']); + + act(() => { + store.dispatch({ type: 'test/setToken', payload: 'viewer-b' }); + }); + expect(rendered?.root.findByType('span').children).toEqual(['Loading']); + await flushUpdates(); + expect(completeSecondRequest).toBeDefined(); + await act(async () => { + completeSecondRequest?.(jsonResponse({ id: 'preset-id', name: 'Account B preset', project_name: 'project' })); + }); + await flushUpdates(); + expect(rendered?.root.findByType('span').children).toEqual(['Account B preset']); + + act(() => { + store.dispatch({ type: 'test/setToken', payload: undefined }); + }); + expect(rendered?.root.findByType('span').children).toEqual(['Loading']); + await flushUpdates(); + expect(rendered?.root.findByType('span').children).toEqual(['Unavailable']); + expect(requests.map((request) => request.headers.get('Authorization'))).toEqual([ + 'Bearer viewer-a', + 'Bearer viewer-b', + null, + ]); + expect(await requests[0].json()).toEqual({ name_or_id: 'preset-id' }); + }); +}); diff --git a/frontend/src/services/preset.ts b/frontend/src/services/preset.ts index 9c9726f873..4dba41b56f 100644 --- a/frontend/src/services/preset.ts +++ b/frontend/src/services/preset.ts @@ -1,21 +1,36 @@ +import { useSelector } from 'react-redux'; import { API } from 'api'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import fetchBaseQueryHeaders from 'libs/fetchBaseQueryHeaders'; +import { selectAuthToken } from 'App/slice'; + +// The viewer is part of the client cache key, never the API request body. +export type PresetListQueryArgs = TPresetsListRequestParams & { authToken?: string }; +type PresetQueryArgs = { project_name: IProject['project_name']; id: IPreset['id']; authToken?: string }; + export const presetApi = createApi({ reducerPath: 'presetApi', + refetchOnMountOrArgChange: true, baseQuery: fetchBaseQuery({ - prepareHeaders: fetchBaseQueryHeaders, + prepareHeaders: (headers, api) => { + if (api.endpoint === 'getAllPresets' || api.endpoint === 'getPreset') { + headers.set('X-API-VERSION', 'latest'); + return headers; + } + return fetchBaseQueryHeaders(headers, api); + }, }), tagTypes: ['Presets'], endpoints: (builder) => ({ - getAllPresets: builder.query({ - query: (body) => ({ + getAllPresets: builder.query({ + query: ({ authToken, ...body }) => ({ url: API.PRESET.LIST(), method: 'POST', + headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined, body, }), @@ -25,10 +40,11 @@ export const presetApi = createApi({ result ? [...result.map(({ id }) => ({ type: 'Presets' as const, id })), 'Presets'] : ['Presets'], }), - getPreset: builder.query({ - query: ({ project_name, id }) => ({ + getPreset: builder.query({ + query: ({ project_name, id, authToken }) => ({ url: API.PROJECTS.PRESETS_GET(project_name), method: 'POST', + headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined, body: { name_or_id: id }, }), @@ -47,4 +63,9 @@ export const presetApi = createApi({ }), }); -export const { useLazyGetAllPresetsQuery, useGetPresetQuery, useDeletePresetMutation } = presetApi; +export const { useLazyGetAllPresetsQuery, useDeletePresetMutation } = presetApi; + +export const useGetPresetQuery = (args: Omit) => { + const authToken = useSelector(selectAuthToken); + return presetApi.useGetPresetQuery({ ...args, authToken }); +}; diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index f8f8563c13..96e16e4492 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -194,6 +194,17 @@ export const projectApi = createApi({ providesTags: () => ['ProjectRepos'], }), + updateProjectPublicPresets: builder.mutation({ + query: ({ project_name, public_presets }) => ({ + url: API.PROJECTS.UPDATE_PUBLIC_PRESETS(project_name), + method: 'POST', + body: { public_presets }, + }), + transformResponse: transformProjectResponse, + invalidatesTags: (result, error, { project_name }) => + error ? [] : [{ type: 'Projects' as const, id: project_name }], + }), + updateProject: builder.mutation< IProject, { @@ -229,4 +240,5 @@ export const { useLazyGetProjectLogsQuery, useGetProjectReposQuery, useUpdateProjectMutation, + useUpdateProjectPublicPresetsMutation, } = projectApi; diff --git a/frontend/src/types/global.d.ts b/frontend/src/types/global.d.ts index 5a999b0124..0709a1bd4d 100644 --- a/frontend/src/types/global.d.ts +++ b/frontend/src/types/global.d.ts @@ -3,6 +3,12 @@ declare var Tally: { closePopup: (string) => void; }; +interface Window { + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + Kapa?: { open: () => void }; +} + declare type AddedEmptyString = { [Property in keyof Type]: Type[Property] | ''; }; @@ -23,6 +29,7 @@ declare interface HashMap { declare namespace NodeJS { interface ProcessEnv { readonly NODE_ENV: 'development' | 'production' | 'test'; + readonly GA_MEASUREMENT_ID: string; readonly UI_VERSION: 'sky' | 'factory' | 'oss'; readonly PUBLIC_URL: string; readonly API_URL: string; diff --git a/frontend/src/types/preset.d.ts b/frontend/src/types/preset.d.ts index 4c8425a9f6..c7063d59ed 100644 --- a/frontend/src/types/preset.d.ts +++ b/frontend/src/types/preset.d.ts @@ -41,6 +41,7 @@ declare interface IPreset { created_at: string; pushed_by: string; project_name: string; + can_delete?: boolean; } declare interface IPresetDetails extends IPreset { diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index fa8177b219..85da2d0bb8 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -30,6 +30,7 @@ declare interface IProject { owner: IUser | { username: string }; created_at: string; isPublic: boolean; + public_presets?: boolean; templates_repo?: string | null; } diff --git a/frontend/webpack/base.js b/frontend/webpack/base.js index 9a995fb503..a536869eb1 100644 --- a/frontend/webpack/base.js +++ b/frontend/webpack/base.js @@ -3,7 +3,7 @@ const {join} = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); const CopyPlugin = require("copy-webpack-plugin"); -const { environment, publicUrl, apiUrl, isDev, isProd, srcDir, landing, title, description, uiVersion } = require('./env'); +const { environment, publicUrl, apiUrl, isDev, isProd, srcDir, landing, title, description, uiVersion, gaMeasurementId } = require('./env'); const { getStyleLoaders } = require('./getStyleLoaders'); const env = { @@ -11,6 +11,7 @@ const env = { PUBLIC_URL: JSON.stringify(publicUrl), API_URL: JSON.stringify(apiUrl), UI_VERSION: JSON.stringify(uiVersion), + GA_MEASUREMENT_ID: JSON.stringify(gaMeasurementId), }; const sourceMap = !isProd; diff --git a/frontend/webpack/env.js b/frontend/webpack/env.js index 02940787b9..f87077c2cb 100644 --- a/frontend/webpack/env.js +++ b/frontend/webpack/env.js @@ -15,6 +15,7 @@ const buildDir = join(__dirname, '../build'); const publicDir = join(__dirname, '../public'); const apiUrl = process.env.API_URL || apiURLs; const publicUrl = process.env.PUBLIC_URL || publicURLs; +const gaMeasurementId = process.env.GA_MEASUREMENT_ID || ''; const uiVersion = ['sky', 'factory'].includes(process.env.UI_VERSION) ? process.env.UI_VERSION : 'oss'; const title = uiVersion === 'sky' ? 'dstack Sky' : 'dstack'; @@ -34,6 +35,7 @@ module.exports = { publicDir, apiUrl, publicUrl, + gaMeasurementId, title, description, uiVersion, diff --git a/mkdocs/assets/stylesheets/cloudscape-docs.css b/mkdocs/assets/stylesheets/cloudscape-docs.css index b8cb27ad8d..2a9caf2289 100644 --- a/mkdocs/assets/stylesheets/cloudscape-docs.css +++ b/mkdocs/assets/stylesheets/cloudscape-docs.css @@ -100,65 +100,47 @@ align-items: center; } - /* /old keeps the logo alone on the left and clusters the whole nav on the right. - Push the tab group (and the search + buttons that follow it in the DOM) to the - right edge, leaving an empty middle. */ - /* Search is FIRST in the right cluster (it ships before the tabs in the template), so it - carries the auto-margin that pushes search → tabs → buttons to the right edge as one group. - The tabs then sit a fixed gap to the search's right, just left of "Docs". */ + /* Keep Search, Products, tabs, and actions together on the right. */ .md-header .md-search { margin-left: auto; } - /* The theme toggle is the first item in the buttons block (before GitHub); 15px sits it a touch - tighter than the 20px GitHub→Get-started gap. */ .cs-theme-toggle--header { margin-right: 15px; } .md-tabs { display: block; /* was display:none until JS added .ready — tabs now ship in the header */ flex-grow: 0; - margin-left: 10px; /* makes the search→Docs gap (~22px) match the inter-menu gaps */ + margin-left: 20px; padding-left: 0; } + .md-header .md-tabs__list { + margin-left: 0; + } - /* The buttons block also has margin-left:auto by default, which would split the free - space into two gaps. Zero it so the single auto-margin on .md-tabs pushes the whole - cluster (tabs → search → buttons) to the right as one group. */ .md-header__inner .md-header__buttons { margin-left: 0; display: flex; align-items: center; } + .md-header__buttons .md-button.github { + margin-right: 0; + } + /* No Discord icon in the header. */ .md-tabs__item:nth-child(6) { display: none; } } -/* Below desktop the header's right cluster (search → GitHub → Get started) loses the desktop flex - layout: the buttons fall back to display:block (inline), so search abuts GitHub (0 gap), the - GitHub↔Get-started gap is just github's margin (unequal), and the pills sit a px or two off. - Restore a flex cluster with equal 16px gaps, vertically centered. */ @media screen and (max-width: 76.1875em) { .md-header__inner .md-header__buttons { display: flex; align-items: center; - /* search↔GitHub gap = the GitHub↔Get-started gap (github's existing margin-right: 20px), - so all three sit at an equal 20px. No flex `gap` — that would double with the margin. */ margin-left: 20px; } } -/* Below 60em the search box collapses to a 40px icon button (already padded). The 20px button - margin then reads as too much space before GitHub — trim it so the glyph→GitHub gap matches. */ -@media screen and (max-width: 59.9375em) { - .md-header__inner .md-header__buttons { - margin-left: 4px; - } -} -/* Keep every header pill vertically centered and free of stray vertical margins so GitHub - and the Get started split button line up exactly. */ .md-header__buttons .md-button { margin-top: 0; margin-bottom: 0; @@ -204,20 +186,6 @@ white-space: nowrap; } -/* "Get started" trigger uses the landing's primary-button weight (500) — lighter than the GitHub - pill (700), mirroring the landing where the primary and normal button variants differ in weight. */ -[data-md-color-primary=white] .md-header__buttons .md-button--primary.cs-gs-menu__trigger { - font-weight: 500 !important; - /* Inverts with the theme (like the landing's primary button): dark fill + white text in light - mode, light fill + dark text in dark mode. The global rgba(0,0,0,.87) stayed dark in both. */ - background: var(--cs-text) !important; - color: var(--cs-bg) !important; -} -[data-md-color-primary=white] .md-header__buttons .md-button--primary.cs-gs-menu__trigger:hover { - background: color-mix(in srgb, var(--cs-text) 88%, var(--cs-bg)) !important; - color: var(--cs-bg) !important; -} - /* GitHub → outlined (normal) "GitHub" with a trailing external-link glyph, mirroring the landing's normal-variant GitHub button (weight 700). */ [data-md-color-primary=white] .md-header__buttons .md-button--primary.github { @@ -227,11 +195,6 @@ font-weight: 700 !important; } -/* Gap between GitHub and dstack Sky → 20px to match /old (was 5px). */ -.md-header__buttons .md-button.github { - margin-right: 20px; -} - /* landing.css adds a GitHub octocat ::before; /old's button is just "GitHub ↗", so drop it. */ .md-header__buttons .md-button--primary.github::before { content: none !important; @@ -274,92 +237,72 @@ mask: var(--cs-ext-icon) center / contain no-repeat; } -/* "Get started" header dropdown — mirrors the landing's Products popup (featured open-source + - dstack Sky + Factory). Pure-CSS hover/focus-within (the docs are static); right-aligned so the - panel never overflows the header's right edge. Reuses the --cs-* tokens (defined in extra.css). */ -.cs-gs-menu { +/* Products menu matches website/src/styles.css. */ +.cs-products-menu { position: relative; display: inline-flex; align-items: center; + margin-left: 20px; + font-size: 16px; + line-height: 1.15; } -.cs-gs-menu__trigger { - display: inline-flex !important; +.cs-products-menu__trigger { + display: inline-flex; align-items: center; - gap: 5px; + gap: 4px; + min-height: 42px; + padding: 10px 0; + border: 0; + border-radius: 21px; + background: transparent; + color: var(--cs-text); + font: inherit; + font-weight: 700; cursor: pointer; - /* the caret is lighter than a full glyph, so trim the trailing padding (was 18px). */ - padding-right: 12px !important; } -.cs-gs-menu__caret { +.cs-products-menu__trigger:hover { + text-decoration: underline; + text-underline-offset: 0.2em; +} +.cs-products-menu__trigger:focus-visible { + background: var(--cs-panel); +} +.cs-products-menu__caret { + width: 12px; + height: 12px; transition: transform 0.15s ease; } -.cs-gs-menu--open .cs-gs-menu__caret { +.cs-products-menu--open .cs-products-menu__caret { transform: rotate(180deg); } -.cs-gs-menu__popup { +.cs-products-menu__popup { + display: none; position: absolute; - top: calc(100% + 8px); - right: 0; + top: calc(100% + 6px); + left: 0; z-index: 1000; width: 360px; - padding: 0; - /* A floating popup can't show a see-through hole between the groups (arbitrary page content - sits behind it) — back the rail with the unselected-row color so the gap reads as ground. */ background: var(--cs-panel); border-radius: 8px; - opacity: 0; - visibility: hidden; - transform: translateY(-4px); - transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s; } -.cs-gs-menu--open .cs-gs-menu__popup { - opacity: 1; - visibility: visible; - transform: translateY(0); +.cs-products-menu--open .cs-products-menu__popup { + display: block; } -/* No hole at all in the popup: the groups sit flush, like one continuous menu. */ -.cs-gs-menu__popup .gs-opt + .gs-rail__group { +.cs-products-menu__popup .gs-opt + .gs-rail__group { margin-top: 0; } -/* Text inside the popup must wrap — Material forces white-space:nowrap on header links/buttons - (the group bars keep their own nowrap). */ -.cs-gs-menu__popup, -.cs-gs-menu__popup .gs-opt, -.cs-gs-menu__popup .gs-opt * { - white-space: normal !important; -} -/* The switcher rail — copied pixel-for-pixel from the landing (website/src/styles.css, .gs-rail - block): grouped rows under solid bars, with a white indicator card that slides to the hovered - row. The docs and the landing are separate builds, so the styles can't be shared — any change - to the landing's rail must be mirrored here (and vice versa). */ +/* The switcher rail — copied from the landing (website/src/styles.css, .gs-rail block): grouped + rows under solid bars, with a selected card. The docs and the landing are separate + builds, so changes to the rail styles must be mirrored here (and vice versa). */ .gs-rail { display: flex; flex-direction: column; padding: 0; - position: relative; /* anchors the sliding selection indicator */ border-radius: 8px; overflow: hidden; /* clip the full-width group bars to the rounded corners */ background: transparent; /* the rows carry the panel ground, so the gap between groups is a true hole */ } -.gs-rail__indicator { - position: absolute; - left: 0; - right: 0; - z-index: 2; /* above the rows' ground, beneath their content (3) and the bars (4) */ - background: var(--cs-bg); - border: 1px solid var(--cs-text); - border-radius: 8px; - pointer-events: none; -} -/* No z-index on the rows: a row with its own stacking context would trap its content beneath - the indicator card, so the content is lifted above the card explicitly instead. */ -.gs-opt__icwrap, -.gs-opt__ic, -.gs-opt__body { - position: relative; - z-index: 3; -} .gs-rail a.gs-opt { color: var(--cs-text); text-decoration: none; @@ -386,6 +329,11 @@ border-bottom-left-radius: 0; border-bottom-right-radius: 0; } +.gs-opt.gs-opt--on { + border-radius: 8px; + background: var(--cs-bg); + box-shadow: inset 0 0 0 1px var(--cs-text); +} .gs-opt--feat { padding: 13px 14px; gap: 14px; @@ -406,19 +354,6 @@ line-height: 1.5; color: color-mix(in srgb, var(--cs-text) 68%, var(--cs-muted)); } -.gs-opt__icwrap { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - flex: 0 0 auto; -} -.gs-opt__stars { - font-size: 12px; - font-weight: 300; - font-variant-numeric: tabular-nums; - color: inherit; -} .gs-opt__ic { display: inline-flex; align-items: center; @@ -453,8 +388,7 @@ margin-bottom: -2px; /* tuck under the next row past any fractional-pixel rounding */ } /* A group bar that follows a row stands off from it, so the groups read as separate - blocks inside the rail (keyed on adjacency, not :first-child — the indicator div - is the rail's first DOM child). */ + blocks inside the rail. */ .gs-opt + .gs-rail__group { margin-top: 10px; } @@ -485,7 +419,21 @@ } /* On tablet/mobile the header buttons collapse; keep the dropdown from overflowing tiny screens. */ @media (max-width: 76.1875em) { - .cs-gs-menu__popup { width: min(360px, calc(100vw - 32px)); } + .cs-products-menu { + position: static; + } + .cs-products-menu__popup { + left: auto; + right: 16px; + width: min(360px, calc(100vw - 32px)); + } +} + +@media (max-width: 360px) { + .cs-products-menu, + .md-header__inner .md-header__buttons { + margin-left: 8px; + } } /* GitHub hover: outlined button gets a faint tint (landing.css forces black !important on primary @@ -560,17 +508,10 @@ @media screen and (max-width: 76.1875em) { .cs-nav-toggle { display: none; } - /* Tighten the header on mobile so the cluster fits: drop the icon buttons' (burger/search) - margins and shrink the gaps (search→GitHub and GitHub→Get started) to 5px. */ + /* Keep the navigation and search icons compact on smaller screens. */ .md-header__button.md-icon { margin: 0 !important; } - .md-header__inner .md-header__buttons { - margin-left: 5px; - } - .md-header__buttons .md-button.github { - margin-right: 5px; - } /* The theme-aware nav colors below are scoped to the desktop media query, so the MOBILE drawer fell back to extra.css's black text — invisible on the dark drawer. Apply them here too. */ diff --git a/mkdocs/docs/concepts/presets.md b/mkdocs/docs/concepts/presets.md index f265be85cc..6dd8757145 100644 --- a/mkdocs/docs/concepts/presets.md +++ b/mkdocs/docs/concepts/presets.md @@ -268,7 +268,9 @@ Pushing the same name again moves the name to the new preset. The previous one s ### Registry -Presets are pushed to and pulled from the registry hosted at [dstack Sky](https://sky.dstack.ai). To share a preset, create a project there, add the people you want to share it with, and push the preset to that project. To push or pull a preset from a project, you have to be its member. +Presets are pushed to and pulled from the registry hosted at [dstack Sky](https://sky.dstack.ai). Create a project there and push presets to it. Project members can push, pull, and delete presets. + +Presets are private by default. Set **Presets** to **Public** in the project settings to let anyone browse and pull its presets without signing in, including users of a self-hosted `dstack` server. Only project admins and global admins can change this setting; enabling it does not let anyone join the project. A self-hosted registry is part of [dstack Factory](https://calendly.com/dstackai/discovery-call){ target="_blank" }. @@ -390,7 +392,6 @@ At the same time, it's recommended to create presets using your own agent — ei ## Limitations * Currently, the agent doesn't upload compiled binaries anywhere; patches compile at runtime -* The registry doesn't support public presets (coming soon) * Doesn't support ranges for `concurrency` > Report bugs and request features on [GitHub](https://github.com/dstackai/dstack/issues), and ask questions on [Discord](https://discord.gg/u8SmfwPpMd). diff --git a/mkdocs/overrides/header-2.html b/mkdocs/overrides/header-2.html index ab688ef45e..cdea2fe8ab 100644 --- a/mkdocs/overrides/header-2.html +++ b/mkdocs/overrides/header-2.html @@ -66,7 +66,6 @@ {% include "partials/alternate.html" %} {% endif %} - {# Search sits BEFORE the tabs (left of "Docs"), like the user asked. #} {% if "material/search" in config.plugins %}
+ + + + + + + + + {gpuPrices.map(gpu => ( + + + + + + ))} + +
GPUOn-demandSpot
+ + + {gpu.name}{' '}{gpu.memory} + + {gpu.onDemand}{gpu.spot ?? '—'}
+ )} + {computeTab === 1 && ( + , title: 'GPU clusters', sub: 'Reserve for a fixed period at a price agreed in advance' }, + ]} /> + )} + {computeTab === 2 && ( + , title: 'GPU clouds', sub: 'AWS, GCP, Azure, Nebius, Runpod, and more' }, + { icon: , title: 'SSH fleets', sub: 'Pre-provisioned VMs or bare-metal' }, + { icon: , title: 'Kubernetes', sub: 'Existing Kubernetes clusters' }, + ]} /> + )} +
+
+ {computeTab !== 1 && ( + + {computeTab === 0 ? 'Prices in USD per GPU-hour' : 'Bring your own compute'} + + )} + {computeTab === 1 && ( + + )} +
+ + } + title="On-demand GPUs for AI workloads" + imageFirst + > + Access affordable on-demand and spot GPUs with pay-as-you-go pricing. +
+
+ You can also bring compute from your own cloud accounts or on-prem infrastructure + and use it alongside Sky GPUs. + + + + dstack Sky provides first-class primitives for compute management, training, + inference, and observability across heterogeneous AI compute. Use one interface + to efficiently utilize cloud GPUs, manage your own clusters, or run your own + AI token factory at scale. +
+
+ Sky is built on dstack and uses the same CLI and YAML configurations as a + self-hosted dstack server. +
+ + + + + + + + + + ); +} diff --git a/website/src/pages/Sky/pricing.ts b/website/src/pages/Sky/pricing.ts new file mode 100644 index 0000000000..744a1e469e --- /dev/null +++ b/website/src/pages/Sky/pricing.ts @@ -0,0 +1,13 @@ +// Indicative USD per GPU-hour from Sky API quotes, 2026-09-14; availability varies. +export const gpuPrices = [ + { name: 'B300', memory: '288GB', onDemand: '$7.58–17.80', spot: '$3.79–6.49' }, + { name: 'B200', memory: '192GB', onDemand: '$6.23–17.91', spot: '$2.41–5.44' }, + { name: 'H200', memory: '141GB', onDemand: '$4.20–9.89', spot: '$2.10–9.89' }, + { name: 'H100', memory: '80GB', onDemand: '$2.89–12.74', spot: '$1.09–8.94' }, + // Includes AMD Developer Cloud and Hot Aisle backend quotes. + { name: 'MI300X', memory: '192GB', onDemand: '$1.99–2.99', spot: null }, + { name: 'RTX PRO 6000', memory: '96GB', onDemand: '$0.97–7.20', spot: '$0.50–3.17' }, + { name: 'A100', memory: '80GB', onDemand: '$1.59–5.58', spot: '$0.87–4.29' }, + { name: 'A100', memory: '40GB', onDemand: '$1.99–3.75', spot: '$0.34–3.57' }, + { name: 'L40S', memory: '48GB', onDemand: '$1.09–4.71', spot: '$0.54–4.71' }, +]; diff --git a/website/src/router.tsx b/website/src/router.tsx index 6b63521522..fb46c14ddd 100644 --- a/website/src/router.tsx +++ b/website/src/router.tsx @@ -1,19 +1,17 @@ import { Navigate, createBrowserRouter } from 'react-router-dom'; import { App } from './App'; import { HomePage } from './pages/Home'; -import { OldPage } from './pages/Old'; +import { SkyPage } from './pages/Sky/SkyPage'; import { ROUTES } from './routes'; -// Single data router. In production this app owns only `/` (the landing) — docs and blog -// are served by MkDocs on the same origin. `/old` is kept as a template for future product -// pages: reachable in dev, and harmless in production (MkDocs serves unknown paths). Stray -// paths redirect home. +// The landing and product pages share one layout. Docs and blog are served by MkDocs +// on the same origin; stray paths redirect home. export const router = createBrowserRouter([ { element: , children: [ { index: true, element: }, - { path: ROUTES.OLD, element: }, + { path: ROUTES.SKY, element: }, { path: '*', element: }, ], }, diff --git a/website/src/routes.ts b/website/src/routes.ts index dd9c5fac47..4d759478cc 100644 --- a/website/src/routes.ts +++ b/website/src/routes.ts @@ -1,6 +1,6 @@ // Central route table + cross-links to the MkDocs-served parts of the site. // -// This app (the landing) owns only `/`. Docs and blog are served by MkDocs on the +// This app owns `/` and `/products/sky`. Docs and blog are served by MkDocs on the // SAME origin in production (`/docs`, `/blog`). For standalone landing development you // can point those links at the live site by setting VITE_DOCS_BASE, e.g. // VITE_DOCS_BASE=https://dstack.ai npm run dev @@ -8,10 +8,7 @@ const SITE_BASE = (import.meta.env.VITE_DOCS_BASE ?? '').replace(/\/+$/, ''); export const ROUTES = { HOME: '/', - // Kept as a template/reference for building future product pages. Reachable in dev - // (`npm run dev` at /old); not part of the integrated production deploy (where this app - // only owns `/` and MkDocs serves everything else). - OLD: '/old', + SKY: '/products/sky', } as const; export type Route = (typeof ROUTES)[keyof typeof ROUTES]; diff --git a/website/src/styles.css b/website/src/styles.css index cb83a43ec1..13d8fa9197 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -32,15 +32,12 @@ --nav-height: 4.875rem; --frame: 80rem; --page-gutter: clamp(1.5rem, 5.5vw, 5rem); - --section-gap: clamp(2.5rem, 4vw, 4rem); --card-gap: 1.25rem; --radius: 0; - --media-column: minmax(18rem, 40rem); --doc-article-width: 49.5rem; --doc-rail-width: 17.5rem; --doc-right-gap: 2.5rem; --doc-gap: var(--doc-right-gap); - --doc-main-width: calc(var(--doc-article-width) + var(--doc-right-gap) + var(--doc-rail-width)); } :root[data-theme='dark'] { @@ -220,16 +217,6 @@ p { display: none; } -/* On the Old page the nav drops the brand gradient accent for a plain 1px hairline in the - text color, matching the Cloudscape reference header. */ -.site-nav--old { - border-bottom: 1px solid var(--cs-text); -} - -.site-nav--old::after { - display: none; -} - .site-nav__inner { position: relative; z-index: 1; @@ -278,10 +265,6 @@ p { min-width: 0; } -.site-desktop-trigger { - flex: 0 0 auto; -} - .site-mobile-trigger, .site-mobile-spacer { display: none; @@ -376,12 +359,14 @@ p { pointer-events: none; } +.home-hero h1, .home-hero h2, .home-hero p, .home-hero__actions { pointer-events: auto; } +.home-hero h1, .home-hero h2 { margin-top: 0; max-width: 720px; @@ -439,6 +424,23 @@ p { z-index: 1; } +.home-main--sky::before { + position: absolute; + top: calc(-1 * var(--nav-height)); + left: 0; + right: 0; + height: 36rem; + z-index: -1; + content: ''; + pointer-events: none; + background: linear-gradient( + 180deg, + rgba(65, 157, 255, 0.5) 0%, + rgba(176, 65, 255, 0.06) 35%, + rgba(176, 65, 255, 0) 100% + ); +} + .home-stack { padding: 0 0 96px; } @@ -450,12 +452,6 @@ p { var(--doc-rail-width); } -.home-with-rail .image-text-row, -.home-with-rail .image-text-row:not(.image-first) { - grid-template-columns: minmax(16rem, 1fr) minmax(0, 1fr); - gap: var(--doc-gap); -} - .media-card h3, .doc-media-card h3 { margin-bottom: 8px; @@ -463,47 +459,6 @@ p { line-height: 1.2; } -.image-text-row { - display: grid; - grid-template-columns: var(--media-column) minmax(0, 1fr); - gap: var(--section-gap); - align-items: center; - margin-top: 40px; -} - -.image-text-row:not(.image-first) { - grid-template-columns: minmax(0, 1fr) var(--media-column); -} - -.image-text-row.image-first .landing-image { - order: -1; -} - -.docs-shell--old-page .image-text-row, -.docs-shell--old-page .image-text-row:not(.image-first) { - grid-template-columns: minmax(14rem, 24rem) minmax(0, 1fr); - gap: var(--doc-gap); -} - -.docs-shell--old-page .image-text-row:not(.image-first) { - grid-template-columns: minmax(0, 1fr) minmax(14rem, 24rem); -} - -.landing-image { - width: 100%; - aspect-ratio: 876 / 528; - height: auto; - object-fit: cover; - border-radius: var(--radius); -} - -.landing-copy { - max-width: 604px; -} - -.landing-copy h2, -.overview-section h2, -.start-building h2, .docs-section > h2 { margin-bottom: 12px; font-size: 28px; @@ -538,65 +493,6 @@ p { font-size: 16px; } -.landing-copy p + p { - margin-top: 18px; -} - -.overview-section { - margin-top: 56px; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--card-gap); - margin-top: 20px; -} - -.stat-card { - display: grid; - grid-template-columns: 72px minmax(0, 1fr); - gap: var(--card-gap); - align-items: center; - min-height: 160px; - padding: 24px 40px; - border-radius: 0; - background: var(--cs-panel); -} - -.stat-card span { - color: #3867ff; - font-size: 44px; - line-height: 1; - font-weight: 400; -} - -.stat-card p { - max-width: 14ch; - font-size: 24px; - line-height: 1.15; -} - -.core-features { - margin-top: 36px; -} - -.core-features > h3 { - margin-bottom: 4px; - font-size: 20px; -} - -.core-features > p { - max-width: 1230px; -} - -.feature-card-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 20px; - margin-top: 32px; -} - .media-card { border: 1px solid var(--cs-border); /* 1px / 12px */ border-radius: 12px; @@ -625,49 +521,6 @@ p { font-size: var(--font-small); } -.start-building { - margin-top: 64px; - padding-top: 48px; - border-top: 0.5px solid var(--cs-border); -} - -.start-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--card-gap); - margin-top: 20px; -} - -.start-grid article { - min-height: 152px; - padding: 24px 20px; - border: 0.5px solid var(--cs-border); - border-radius: 0; -} - -.start-grid h3 { - margin-bottom: 18px; - font-size: 20px; -} - -.start-grid p { - font-size: 16px; -} - -.docs-shell { - min-height: calc(100vh - var(--nav-height)); -} - -.docs-main { - width: min(var(--doc-main-width), calc(100vw - (2 * var(--page-gutter)))); - padding: 1.625rem 0 6rem; -} - -.docs-shell--navigation-collapsed .docs-main { - width: min(var(--frame), calc(100vw - (2 * var(--page-gutter)))); - margin-inline: auto; -} - .docs-body { display: grid; grid-template-columns: minmax(0, var(--doc-article-width)) var(--doc-right-gap) var(--doc-rail-width); @@ -678,82 +531,10 @@ p { grid-template-columns: minmax(0, 1fr); } -.docs-shell--navigation-collapsed .docs-body { - grid-template-columns: - minmax(0, calc(100% - var(--doc-right-gap) - var(--doc-rail-width))) - var(--doc-right-gap) - var(--doc-rail-width); -} - -.docs-shell--old-page .docs-main { - width: min(var(--frame), 100%); - margin-inline: auto; - /* The footer is the last child here; sit it flush at the bottom of the content - column instead of leaving the article's bottom padding below it. */ - padding-bottom: 0; -} - -/* Cloudscape gives the AppLayout content region a 40px bottom margin. With the footer living - inside it, that leaves a 40px strip below the footer; drop it so the footer sits flush at the - page bottom, matching the global site footer. */ -.docs-shell--old-page [class*='awsui_main_'] { - margin-bottom: 0 !important; -} - -.docs-shell--old-page .docs-body { - grid-template-columns: - minmax(0, calc(100% - var(--doc-right-gap) - var(--doc-rail-width))) - var(--doc-right-gap) - var(--doc-rail-width); -} - .docs-article { grid-column: 1; } -.docs-right { - grid-column: 3; - position: sticky; - /* Clear the sticky header (banner + nav), matching scroll-padding-top above. */ - top: calc(var(--nav-height) + 3.25rem); - margin-top: 1.5rem; -} - -/* The section divider in the docs side nav is inset by default (unlike the header - divider); pull it out to the drawer edges so it spans the full sidebar width. */ -.docs-shell [class*='awsui_divider-default'] { - margin-left: -28px !important; - margin-right: -24px !important; -} - -/* Cloudscape's expandable nav group renders the caret before the label, indenting the parent - past its sibling links, and indents the children 40px. Pull the caret into the left gutter so - the parent label lines up with its siblings, and tighten the children to one indent level. */ -.docs-shell [class*='awsui_expandable-link-group'] [class*='awsui_expand-button'] { - margin-left: -22px !important; -} - -.docs-shell [class*='awsui_list-variant-expandable-link-group'] { - padding-left: 20px !important; -} - -.docs-title { - margin-top: 1rem; - padding-bottom: 1.125rem; - border-bottom: 0.5px solid var(--cs-border); -} - -.docs-title h1 { - font-size: 36px; - line-height: 1.2; -} - -.docs-title p { - margin-top: 8px; - color: var(--cs-muted); - font-size: 18px; -} - .docs-section { padding: 1.5rem 0 3rem; border-bottom: 0.5px solid var(--cs-border); @@ -809,31 +590,6 @@ p { margin-top: 20px; } - -.doc-alternating img { - width: 100%; - aspect-ratio: 980 / 640; - height: auto; - object-fit: cover; - border-radius: var(--radius); -} - -.doc-alternating img.doc-diagram { - object-fit: contain; -} - -.doc-diagram--dark { - display: none; -} - -:root[data-theme='dark'] .doc-diagram--light { - display: none; -} - -:root[data-theme='dark'] .doc-diagram--dark { - display: block; -} - .doc-alternating h2 { margin-bottom: 12px; font-size: 28px; @@ -1256,13 +1012,7 @@ p { margin-top: 0; } -/* "Get started" deployment switcher. The Products-popup component reused as a vertical switcher: - a bordered selector panel on the left whose options carry a caption + description, with the - selected option filled by the brand gradient. Open-source is featured (bigger, with a live star - count and a Documentation button) and selected by default; dstack Sky / Factory are compact - rows. The right column shows the selection's detail — install code for open-source, an included- - features list + CTA for the hosted / self-hosted tiers. align-items: stretch keeps the selector - and the detail equal height and top-aligned. */ +/* Product links beside the dstack installation controls. */ .gs-deploy { display: grid; grid-template-columns: 1fr 2fr; /* selector 1/3, detail 2/3 */ @@ -1276,35 +1026,15 @@ p { display: flex; flex-direction: column; padding: 0; - position: relative; /* anchors the sliding selection indicator */ - border-radius: 8px; /* same radius as the rows and the indicator card */ + border-radius: 8px; overflow: hidden; /* clip the full-width group bars to the rounded corners */ background: transparent; /* the rows carry the panel ground, so the gap between groups is a true hole */ } -/* The selection indicator: a white card (0.5px solid ring) that slides up/down to the selected - row. Rows are transparent; bars sit above it so it passes underneath between groups. */ -.gs-rail__indicator { - position: absolute; - left: 0; - right: 0; - z-index: 2; /* above the rows' ground, beneath their content (3) and the bars (4) */ - background: var(--cs-bg); - border: 1px solid var(--cs-text); - border-radius: 8px; - pointer-events: none; -} -/* No z-index on the rows: a row with its own stacking context would trap its content beneath - the indicator card, so the content is lifted above the card explicitly instead. */ -.gs-opt__icwrap, -.gs-opt__ic, -.gs-opt__body { - position: relative; - z-index: 3; -} -/* In the Products popup the rows are links; render them exactly like the section's buttons. */ +/* Product links share the same styling in the section and popup. */ .gs-rail a.gs-opt { color: var(--cs-text); text-decoration: none; + cursor: pointer; } .gs-rail__group { position: relative; @@ -1320,8 +1050,6 @@ p { font: inherit; line-height: 1.15; /* pinned (not inherited) so the rail renders identically on the docs site */ text-align: left; - cursor: pointer; - transition: background-color 0.2s ease, border-color 0.2s ease; } /* Adjacent rows inside a group square off their shared edge, so the group's ground reads as one @@ -1334,6 +1062,11 @@ p { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } +.gs-opt.gs-opt--on { + border-radius: 8px; + background: var(--cs-bg); + box-shadow: inset 0 0 0 1px var(--cs-text); +} /* Row names mirror the tab typography: 16px / 700. */ .gs-opt__name { @@ -1356,18 +1089,13 @@ p { } /* The first bar sits inside the rail's border corner: a 12px outer radius with a 1px border has an 11px inner curve, so the bar needs the nested radius to fill it without a sliver. */ -/* A group bar that follows a row stands off from it, so the groups read as separate - blocks inside the rail (keyed on adjacency, not :first-child — the indicator div - is the rail's first DOM child). */ +/* A group bar that follows a row stands off from it, so the groups read as separate blocks. */ .gs-opt + .gs-rail__group { margin-top: 10px; } .gs-rail__group:first-child { border-radius: 8px 8px 0 0; /* flush with the borderless rail's 8px clip */ } -/* A white inset ring around the state fill, like the active tab chip floating in its strip. - Drawn as an inset shadow so the rows' borders, radii, and the bar hug stay untouched. */ - /* Rows keep their full rounding. The bar hugs the first row's top corners with two inverted-radius notch shapes painted over the corners (nothing black sits behind the row's antialiased edge, so no hairline outline), and tucks 1px under the row to kill the seam. */ @@ -1404,29 +1132,13 @@ p { color: color-mix(in srgb, var(--cs-text) 68%, var(--cs-muted)); } -/* Open-source: the featured card — bigger, but now with an icon tile (like the Sky/Factory rows) - and the star count moved beneath the name, so all three controls read consistently. */ +/* Open-source product row. */ .gs-opt--feat { padding: 13px 14px; display: flex; align-items: flex-start; gap: 14px; } -/* Left column: the icon tile with the star count centered beneath it (the icon itself lays out - exactly like the Sky / Factory icon tiles). */ -.gs-opt__icwrap { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - flex: 0 0 auto; -} -.gs-opt__stars { - font-size: 12px; - font-weight: 300; - font-variant-numeric: tabular-nums; - color: inherit; -} /* dstack Sky / Factory: compact rows with an icon. */ .gs-opt--row { @@ -1472,10 +1184,66 @@ p { border-radius: 12px; overflow: hidden; } +.gs-box--compute { + height: auto; + min-height: 360px; +} +.gs-box--compute.gs-box--offers { + height: 360px; +} +.gs-box--offers .gs-skybody { + overflow-y: auto; + padding-top: 0; +} +.sky-gpu-prices { + flex: 0 0 auto; + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.sky-gpu-prices th, +.sky-gpu-prices td { + padding: 6px 0 6px 14px; + text-align: right; + vertical-align: top; +} +.sky-gpu-prices tr > :first-child { + padding-left: 0; + text-align: left; +} +.sky-gpu-prices thead th { + padding-block: 14px; + position: sticky; + top: 0; + z-index: 1; + background: var(--cs-bg); + color: var(--cs-muted); + font-weight: 600; +} +.sky-gpu-prices td { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: nowrap; +} +@media (max-width: 480px) { + .gs-box--offers .gs-skybody { + padding-inline: 16px; + } + .sky-gpu-prices, + .sky-gpu-prices .gs-mkt__g { + font-size: 12px; + } + .sky-gpu-prices th, + .sky-gpu-prices td { + padding-left: 8px; + } + .gs-box--compute .gs-tabs, + .gs-box--compute .gs-tab { + padding-inline: 4px; + } +} .gs-boxfoot { display: flex; align-items: center; - /* Buttons only (the footer notes are gone) — keep them together at the right edge. */ justify-content: flex-end; gap: 14px; padding: 13px 16px 13px 20px; @@ -1487,17 +1255,13 @@ p { min-width: 0; /* allow the note to shrink/wrap so it shares the row with the CTA on narrow screens */ margin-inline-end: auto; /* note left, button(s) right — the footer itself is flex-end */ } -/* Footer note has a full (desktop) and a shorter (mobile) wording; the mobile breakpoint swaps them. */ -.gs-foot__short { - display: none; -} + /* The footer CTA(s) keep their width (never shrink/wrap mid-word); the note takes the rest. */ .gs-boxfoot > [class*='awsui_button'] { flex: 0 0 auto; } -/* Tab strip — shared by the open-source code box (uv / pip / Docker) and dstack Sky (GPU - marketplace / bring your own clouds), so both boxes read the same: tabs on top, content, footer. */ +/* Shared tabs for installation methods and Sky compute options. */ .gs-tabs { display: flex; gap: 2px; @@ -1570,34 +1334,7 @@ p { .gs-sky .gs-col ~ .gs-col { border-left: 1px solid color-mix(in srgb, var(--cs-border) 14%, transparent); } -.gs-col__list { - list-style: none; - margin: 0; - padding: 0 2px 14px 0; - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; - gap: 12px; - overflow-y: auto; - /* soften the cut-off row so the scroll reads cleanly into the footer */ - -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent); - mask-image: linear-gradient(to bottom, #000 calc(100% - 18px), transparent); -} -/* Sky pane lists — multi-column grids sized so everything fits without scrolling, all - top-aligned under the tabs like the GPU marketplace. */ -.gs-col__list--grid { - display: grid; - column-gap: 28px; - align-content: start; - overflow-y: visible; - -webkit-mask-image: none; - mask-image: none; -} -.gs-col__list--offers { - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); -} /* The explore block's categorized clouds grid: one column per group (hyperscalers, top GPU neoclouds, smaller GPU clouds), each column an ordered list. Rows match .gs-cloud styling. */ .gs-cloudcols { @@ -1624,27 +1361,19 @@ p { } } -/* Sky tab pane body. The 44px top padding reproduces the offset the GPU marketplace had when - its near-full grid was vertically centered — now fixed, so every tab starts at the same line. */ +/* Sky compute tab content. */ .gs-skybody { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; - padding: 36px 28px 18px; /* was 44px; trimmed so the 5-item Governance caps grid fits the box */ + padding: 36px 28px 18px; } -/* The on-prem tab reuses the Factory capability rows, top-aligned like the other Sky tabs. */ .gs-skybody .gs-caps { flex: 1 1 auto; align-content: start; } -.gs-mkt__row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} .gs-mkt__g { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; @@ -1653,12 +1382,7 @@ p { .gs-mkt__name { font-weight: 600; /* emphasize the GPU name by weight, not size */ } -.gs-mkt__p { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 13px; - font-weight: 400; /* price is not emphasized — only the GPU name is */ - white-space: nowrap; -} + .gs-cloud { display: flex; align-items: center; @@ -1684,14 +1408,9 @@ p { padding: 0; display: grid; grid-template-columns: 1fr 1fr; - gap: 12px 26px; /* row gap was 18px; tightened for the same reason */ -} -/* Column-flow variant (Governance): items fill the left column top-to-bottom, then the right — - so the array order is the reading order, on desktop and in the single mobile column alike. */ -.gs-caps--cols { - grid-auto-flow: column; - grid-template-rows: repeat(3, auto); + gap: 12px 26px; } + .gs-cap { display: flex; align-items: flex-start; @@ -1757,39 +1476,12 @@ p { .gs-sky .gs-col ~ .gs-col { border-left: 0; } /* Separate the second pane (its header follows the first list). */ .gs-sky .gs-col + .gs-skyhalf { margin-top: 12px; } - .gs-col__list { overflow-y: visible; -webkit-mask-image: none; mask-image: none; } .gs-caps { grid-template-columns: 1fr; } - .gs-caps--cols { grid-auto-flow: row; grid-template-rows: none; } /* Read-only install code is wider than the phone; let it scroll instead of clipping. */ .gs-codebody { overflow-x: auto; } - /* Footer stays a single row (note + CTA, like desktop), never split onto separate rows. The note - uses its shorter wording and the CTAs use tighter padding so the note keeps room beside them. */ + /* Keep footer notes and buttons on one row. */ .gs-boxfoot { align-items: center; gap: 10px; } .gs-boxfoot [class*='awsui_button'] { padding-inline: 14px !important; } - .gs-foot__full { display: none; } - .gs-foot__short { display: inline; } -} - -.right-rail-block { - margin-top: 32px; - padding-top: 28px; - border-top: 0.5px solid var(--cs-border); -} - -.docs-right > h2 { - margin-bottom: 0.5rem; - font-size: 1rem; - line-height: 1.4; -} - -.right-rail-block h2 { - margin-bottom: 8px; - font-size: 18px; -} - -.right-rail-block p { - margin-bottom: 10px; - color: var(--cs-muted); } .site-footer { @@ -1832,23 +1524,6 @@ p { font-size: 13px; } -/* The Old page renders the footer inside the AppLayout content column (so the side nav runs - full-height beside it). Fill the column instead of the centered site frame, and reserve - space on the right so the last link column isn't flush with the edge (matches the column gap). */ -.docs-main .footer-content { - width: auto; - max-width: none; - margin-inline: 0; -} - -/* Drop the divider line and the opaque block: Cloudscape paints the content area a slightly - different grey than --cs-bg, so an opaque footer reads as a mismatched rectangle (most - visible in dark mode). Going transparent + borderless lets it blend into the column. */ -.docs-main .site-footer { - border-top: 0; - background: transparent; -} - /* Top row: brand block (logo + social) on the left, the link group flush to the right edge (justify-content on this outer row), so the columns themselves keep a tight content width + fixed gap rather than being stretched apart. */ @@ -2004,7 +1679,6 @@ p { .home-stack a, .docs-article a, -.right-rail-block a, .site-footer a { text-decoration-line: underline !important; text-decoration-color: currentColor !important; @@ -2013,17 +1687,10 @@ p { .home-stack a:hover, .docs-article a:hover, -.right-rail-block a:hover, .site-footer a:hover { text-decoration-thickness: 2px !important; } -/* The rail's "Need help?" Discord CTA is a Cloudscape Button (renders an ); keep the - link-underline rules above from underlining it, matching the landing's Discord button. */ -.right-rail-block a[class*='awsui_button'] { - text-decoration-line: none !important; -} - /* Footer column links: full-strength text, same 16px as the column headings, no underline until hover. */ .footer-links a { @@ -2103,9 +1770,6 @@ p { top: 12px; } - .feature-card-grid, - .stats-grid, - .start-grid, .doc-card-grid, .product-grid, .concept-grid, @@ -2113,21 +1777,11 @@ p { grid-template-columns: 1fr; } - .image-text-row, - .image-text-row:not(.image-first), - .home-with-rail .image-text-row, - .home-with-rail .image-text-row:not(.image-first), - .docs-shell--old-page .image-text-row, - .docs-shell--old-page .image-text-row:not(.image-first), .doc-alternating, .doc-alternating:not(.image-first) { grid-template-columns: 1fr; } - .image-text-row.image-first .landing-image { - order: initial; - } - /* Stacked on mobile, always lead with the text block; the visual follows below (regardless of the desktop image-first / image-last alternation). */ .doc-alternating .doc-visual { @@ -2139,38 +1793,10 @@ p { margin-top: 32px; } - .landing-image, - .doc-alternating img { - width: 100%; - height: auto; - } - - .docs-shell { - display: block; - } - - .docs-main { - width: calc(100vw - 48px); - margin: 0 auto; - padding-top: 24px; - } - .docs-body { display: flex; flex-direction: column; } - - .docs-right { - display: block; - order: -1; - position: static; - width: 100%; - margin: 1.5rem 0 2rem; - } - - .right-rail-block { - display: none; - } } /* Switch to the burger nav at ≤1024px: the full menu (Documentation + the three blog links + @@ -2201,10 +1827,6 @@ p { flex: 0 0 28px; } - .site-desktop-trigger { - display: none; - } - .site-logo { position: absolute; left: 50%; @@ -2282,10 +1904,7 @@ p { text-align: center !important; } - .home-hero h1 { - font-size: 36px; - } - + .home-hero h1, .home-hero h2 { font-size: 32px; } @@ -2294,11 +1913,6 @@ p { padding-top: 0; } - .stat-card { - grid-template-columns: 1fr; - gap: 10px; - } - .figma-card, .figma-card img { width: 100%; diff --git a/website/vite.config.ts b/website/vite.config.ts index b4a54b2c88..18790fe849 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; @@ -7,11 +8,17 @@ import react from '@vitejs/plugin-react'; // `assetsDir` is namespaced to `website-assets/` (not Vite's default `assets/`) so the // build can be overlaid onto the MkDocs `site/` output without colliding with MkDocs's // own `/assets/...` tree. Public files live under `public/static/` for the same reason — -// after the overlay the only root file this app contributes is `index.html`. +// the HTML entries provide the landing and Sky product routes on static hosting. export default defineConfig({ base: process.env.BASE_PATH || '/', plugins: [react()], build: { assetsDir: 'website-assets', + rollupOptions: { + input: { + home: fileURLToPath(new URL('./index.html', import.meta.url)), + sky: fileURLToPath(new URL('./products/sky/index.html', import.meta.url)), + }, + }, }, });