From 5df582af2de45c0bc3de451373ecfc791f43b820 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Fri, 11 Sep 2026 16:42:18 +0200 Subject: [PATCH 1/8] [Presets] Add public registry browsing and anonymous pulls - [x] Allow anyone to browse public presets in Sky using the existing preset URLs - [x] Add My projects and Public tabs to the preset list for signed-in users - [x] Add a Presets setting to Sky project creation and settings - [x] Allow the CLI to pull public presets without Sky credentials - [x] Introduce PublicApp with separate home and sign-in pages, and use Cloudscape layouts for Sky, OSS, and Fabric sign-in --- frontend/jest.auth.config.cjs | 10 + frontend/package.json | 1 + .../Login/EntraID/LoginByEntraID/index.tsx | 2 +- .../src/App/Login/LoginByGithub/index.tsx | 529 ++---------------- .../App/Login/LoginByGithubCallback/index.tsx | 17 +- .../src/App/Login/LoginByGoogle/index.tsx | 2 +- frontend/src/App/Login/LoginByOkta/index.tsx | 2 +- .../src/App/Login/LoginByTokenForm/index.tsx | 53 +- .../Login/LoginByTokenForm/styles.module.scss | 24 - .../src/App/Login/SelfHostedLogin/index.tsx | 64 ++- .../Login/SelfHostedLogin/styles.module.scss | 16 - frontend/src/App/Login/TokenLogin/index.tsx | 53 +- .../App/Login/TokenLogin/styles.module.scss | 7 - frontend/src/App/Logout/index.tsx | 2 + frontend/src/App/auth.test.tsx | 284 ++++++++++ frontend/src/App/index.tsx | 50 +- frontend/src/PublicApp/Home/index.tsx | 382 +++++++++++++ .../Home}/styles.module.scss | 0 frontend/src/PublicApp/PresetApp.tsx | 33 ++ frontend/src/PublicApp/index.tsx | 152 +++++ frontend/src/PublicApp/styles.module.scss | 19 + frontend/src/api.ts | 1 + frontend/src/hooks/useInfiniteScroll.ts | 49 +- frontend/src/locale/en.json | 24 +- frontend/src/pages/Presets/Details/index.tsx | 70 ++- frontend/src/pages/Presets/List/hooks.tsx | 96 ++-- frontend/src/pages/Presets/List/index.tsx | 129 +++-- frontend/src/pages/Project/Add/index.tsx | 4 +- .../src/pages/Project/CreateWizard/index.tsx | 46 +- .../src/pages/Project/CreateWizard/types.ts | 1 + .../pages/Project/Details/Settings/index.tsx | 151 ++--- frontend/src/pages/Project/constants.tsx | 10 + frontend/src/router.tsx | 74 +-- frontend/src/routes.ts | 1 + frontend/src/services/preset.test.tsx | 137 +++++ frontend/src/services/preset.ts | 33 +- frontend/src/services/project.ts | 12 + frontend/src/types/global.d.ts | 4 + frontend/src/types/preset.d.ts | 2 + frontend/src/types/project.d.ts | 2 + mkdocs/docs/concepts/presets.md | 5 +- .../cli/services/presets/registry.py | 14 +- .../cli/services/presets/test_registry.py | 95 +++- website/src/pages/Home/GetStartedSection.tsx | 2 +- 44 files changed, 1786 insertions(+), 878 deletions(-) create mode 100644 frontend/jest.auth.config.cjs delete mode 100644 frontend/src/App/Login/LoginByTokenForm/styles.module.scss delete mode 100644 frontend/src/App/Login/SelfHostedLogin/styles.module.scss delete mode 100644 frontend/src/App/Login/TokenLogin/styles.module.scss create mode 100644 frontend/src/App/auth.test.tsx create mode 100644 frontend/src/PublicApp/Home/index.tsx rename frontend/src/{App/Login/LoginByGithub => PublicApp/Home}/styles.module.scss (100%) create mode 100644 frontend/src/PublicApp/PresetApp.tsx create mode 100644 frontend/src/PublicApp/index.tsx create mode 100644 frontend/src/PublicApp/styles.module.scss create mode 100644 frontend/src/services/preset.test.tsx 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/LoginByGithubCallback/index.tsx b/frontend/src/App/Login/LoginByGithubCallback/index.tsx index 45be311b6b..fa61642906 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); @@ -71,14 +76,16 @@ export const LoginByGithubCallback: React.FC = () => { return ( - {t('auth.try_again')} + + {t('auth.try_again')} + ); 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..ff910558ac 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')} - - - - - - {t('auth.another_login_methods')} + 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..5efd66268c --- /dev/null +++ b/frontend/src/App/auth.test.tsx @@ -0,0 +1,284 @@ +/** @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 { Home } from 'PublicApp/Home'; + +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; + isLoading: 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 ? { isLoading: false, 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('routes', () => ({ + ROUTES: { + BASE: '/', + RUNS: { LIST: '/runs' }, + AUTH: { + LOGIN: '/auth', + GITHUB_CALLBACK: '/auth/github/callback', + OKTA_CALLBACK: '/auth/okta/callback', + ENTRA_CALLBACK: '/auth/entra/callback', + GOOGLE_CALLBACK: '/auth/google/callback', + TOKEN: '/auth/token', + }, + }, +})); + +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 { + // Render the real home and login headers without mounting their Cloudscape presentation. + Box: ({ variant, children }: { variant?: string; children: React.ReactNode }) => + variant === 'h1' ?

{children}

: <>{children}, + BreadcrumbGroup: () => null, + Button: ({ + children, + href, + onFollow, + }: { + children: React.ReactNode; + href?: string; + onFollow?: (event: { preventDefault: () => void }) => void; + }) => ( + + {children} + + ), + Alert: Wrapper, + Container: Wrapper, + Header: Wrapper, + NavigateLink: Wrapper, + ContentLayout: ({ header }: { header: React.ReactNode }) => <>{header}, + Grid: Wrapper, + Link: Wrapper, + SpaceBetween: Wrapper, + }; +}); + +jest.mock('PublicApp/Home/styles.module.scss', () => ({})); +jest.mock('./Login/SelfHostedLogin', () => ({ SelfHostedLogin: () =>

Server login

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

Loading

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

Storage unavailable

})); + +const publicPaths = [ROUTES.BASE, ROUTES.AUTH.LOGIN]; +const ignoredAuthPaths = Object.values(ROUTES.AUTH).filter((path) => path !== ROUTES.AUTH.LOGIN); +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} /> + {ignoredAuthPaths.map((authPath) => ( + Authentication page} /> + ))} + + + + , + ); + }); + 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, isLoading: 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.each(publicPaths)('visitors can open %s without loading account data', (path) => { + const view = renderApp(path); + + expect(view.root.findByType('output').children).toEqual([path]); + expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); + expect(useGetUserDataQuery).toHaveBeenCalledWith({ token: undefined }, { skip: true }); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test('the home sign-in link opens the dedicated auth page', () => { + const view = renderApp(ROUTES.BASE); + const signInLink = view.root.findByType('a'); + expect(signInLink.props.href).toBe(ROUTES.AUTH.LOGIN); + + act(() => signInLink.props.onClick({ preventDefault: jest.fn() })); + + expect(view.root.findByType('output').children).toEqual([ROUTES.AUTH.LOGIN]); + expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test.each(publicPaths)('a validated user opening %s reaches their runs', (path) => { + mockToken = 'valid-token'; + mockUserQuery.currentData = mockUserQuery.data = { username: 'alice' }; + const view = renderApp(path); + + 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.AUTH.LOGIN, ROUTES.AUTH.LOGIN], + [ROUTES.RUNS.LIST, ROUTES.AUTH.LOGIN], + ])('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.each(publicPaths)('a rejected token stays at %s even when its user data is cached', (path) => { + mockToken = 'expired-token'; + mockUserQuery.currentData = mockUserQuery.data = { username: 'alice' }; + mockUserQuery.error = { status: 401 }; + const view = renderApp(path); + + expect(view.root.findByType('output').children).toEqual([path]); + 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.AUTH.LOGIN]); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test('a visitor opening a protected page reaches the auth page', () => { + const view = renderApp('/runs'); + + expect(view.root.findByType('output').children).toEqual([ROUTES.AUTH.LOGIN]); + expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test.each([...publicPaths, ROUTES.RUNS.LIST])('%s waits for token validation before showing private content', (path) => { + mockToken = 'pending-token'; + mockUserQuery.isFetching = mockUserQuery.isLoading = 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([...publicPaths, 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.each(publicPaths)('%s remains available when local storage is unavailable', (path) => { + Object.defineProperty(globalThis, 'window', { value: {} }); + mockToken = 'saved-token'; + const view = renderApp(path); + + expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); + expect(useGetUserDataQuery).toHaveBeenCalledWith({ token: 'saved-token' }, { skip: true }); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); + + test.each(ignoredAuthPaths)('authentication page %s remains outside the auth guard', (path) => { + mockToken = 'expired-token'; + mockUserQuery.error = { status: 401 }; + const view = renderApp(path); + + expect(view.root.findByType('output').children).toEqual([path]); + expect(view.root.findByType('h1').children).toEqual(['Authentication page']); + expect(mockPrivateQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/App/index.tsx b/frontend/src/App/index.tsx index 618257a453..fa671a8d32 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,48 @@ 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/Home/index.tsx b/frontend/src/PublicApp/Home/index.tsx new file mode 100644 index 0000000000..1c456322af --- /dev/null +++ b/frontend/src/PublicApp/Home/index.tsx @@ -0,0 +1,382 @@ +import React from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; + +import { + AnchorNavigation, + Box, + BreadcrumbGroup, + Button, + Container, + ContentLayout, + ExpandableSection, + Grid, + Header, + Icon, + Link, + Popover, + SpaceBetween, + TextContent, +} from 'components'; + +import { useAppSelector } from 'hooks'; +import { ROUTES } from 'routes'; +import { useGetUserDataQuery } from 'services/user'; + +import { Loading } from 'App/Loading'; +import { selectAuthToken } from 'App/slice'; + +import styles from './styles.module.scss'; + +function OnThisPageNavigation({ variant }: { variant: 'mobile' | 'side' }) { + const anchorNavigation = ( + + ); + + return variant === 'side' ? ( +
+ + On this page + + {anchorNavigation} +
+ ) : ( + + {anchorNavigation} + + ); +} + +function HeroHeader() { + const navigate = useNavigate(); + + return ( + + +
+ Welcome to dstack Sky + + Enjoy the full power of dstack without the hassle of hosting it yourself or managing + your own infrastructure.
+ Sign up for dstack Sky to use the cheapest GPUs from our marketplace or connect it to + your own cloud accounts. +
+
+ + + + + + Sign up to get $5 credit + + + +
+
+ ); +} + +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 Home: React.FC = () => { + const token = useAppSelector(selectAuthToken); + const localStorageIsAvailable = 'localStorage' in window; + const { + currentData: userData, + error, + isFetching, + } = useGetUserDataQuery({ token }, { skip: !token || !localStorageIsAvailable }); + + if (token && localStorageIsAvailable && isFetching && !userData) return ; + if (token && localStorageIsAvailable && userData?.username && !error) { + return ; + } + + return ( + + } + headerVariant="high-contrast" + header={} + defaultPadding={true} + maxContentWidth={1040} + disableOverlap={true} + > +
+
+ +
+ + + +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/App/Login/LoginByGithub/styles.module.scss b/frontend/src/PublicApp/Home/styles.module.scss similarity index 100% rename from frontend/src/App/Login/LoginByGithub/styles.module.scss rename to frontend/src/PublicApp/Home/styles.module.scss diff --git a/frontend/src/PublicApp/PresetApp.tsx b/frontend/src/PublicApp/PresetApp.tsx new file mode 100644 index 0000000000..47d5ca8edd --- /dev/null +++ b/frontend/src/PublicApp/PresetApp.tsx @@ -0,0 +1,33 @@ +import React, { createContext, useContext, useState } 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: boolean; + scope: 'mine' | 'public'; + setScope: (scope: 'mine' | 'public') => void; +}>({ isAuthenticated: false, scope: 'public', setScope: () => undefined }); + +export const usePresetViewer = () => useContext(PresetViewer); + +export const PresetApp: React.FC = () => { + const token = useAppSelector(selectAuthToken); + const [scope, setScope] = useState<'mine' | 'public'>('mine'); + 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..4bde6696ce --- /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.AUTH.LOGIN || 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..7ec922da03 --- /dev/null +++ b/frontend/src/PublicApp/styles.module.scss @@ -0,0 +1,19 @@ +@use '@cloudscape-design/design-tokens/index' as awsui; + +.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/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/locale/en.json b/frontend/src/locale/en.json index c1115ad421..e7e117918f 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,14 @@ }, "presets": { "list_page_title": "Presets", + "my_projects": "My projects", + "public": "Public", + "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", diff --git a/frontend/src/pages/Presets/Details/index.tsx b/frontend/src/pages/Presets/Details/index.tsx index bd3479a7f9..08a0dc6989 100644 --- a/frontend/src/pages/Presets/Details/index.tsx +++ b/frontend/src/pages/Presets/Details/index.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Outlet, useNavigate, useParams } from 'react-router-dom'; +import { Outlet, useNavigate, useOutletContext, useParams } from 'react-router-dom'; import { format } from 'date-fns'; +import { usePresetViewer } from 'PublicApp/PresetApp'; import { Alert, @@ -38,16 +39,29 @@ export const PresetDetails: React.FC = () => { const { t } = useTranslation(); const params = useParams(); const navigate = useNavigate(); + const { isAuthenticated, scope, setScope } = usePresetViewer(); const [pushNotification] = useNotifications(); 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 = isAuthenticated && !!data?.can_delete; + const isPublic = scope === 'public' || !canDelete; + + useEffect(() => { + if (data && !data.can_delete) setScope('public'); + }, [data, setScope]); useBreadcrumbs([ { @@ -61,7 +75,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 +93,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')}} @@ -119,7 +142,7 @@ export const PresetDetails: React.FC = () => { ]} /> - + )} @@ -128,6 +151,7 @@ export const PresetDetails: React.FC = () => { export const PresetDetailsOverview: React.FC = () => { const { t } = useTranslation(); + const { isPublic } = useOutletContext<{ isPublic: boolean }>(); const params = useParams(); const paramProjectName = params.projectName ?? ''; const paramPresetId = params.presetId ?? ''; @@ -167,7 +191,13 @@ export const PresetDetailsOverview: React.FC = () => {
{t('presets.project')}
- + {data.project_name}
@@ -175,7 +205,11 @@ export const PresetDetailsOverview: React.FC = () => {
{t('presets.user')}
- {data.pushed_by} + {isPublic ? ( + data.pushed_by + ) : ( + {data.pushed_by} + )}
diff --git a/frontend/src/pages/Presets/List/hooks.tsx b/frontend/src/pages/Presets/List/hooks.tsx index 7cb259949b..f21d4adee4 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, + isPublic, }: { clearFilter?: () => void; isDisabledClearFilter?: boolean; + isPublic: boolean; }) => { const { t } = useTranslation(); const renderEmptyMessage = (): React.ReactNode => { + if (isDisabledClearFilter && isPublic) { + 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. @@ -75,7 +85,7 @@ export const usePresetsTableEmptyMessages = ({ return { renderEmptyMessage, renderNoMatchMessage } as const; }; -export const useColumnsDefinitions = () => { +export const useColumnsDefinitions = ({ isPublic }: { isPublic: boolean }) => { const { t } = useTranslation(); const columns = [ @@ -97,7 +107,15 @@ export const useColumnsDefinitions = () => { id: 'project', header: t('presets.project'), cell: (item: IPreset) => ( - {item.project_name} + + {item.project_name} + ), }, { @@ -113,9 +131,12 @@ export const useColumnsDefinitions = () => { { id: 'user', header: t('presets.user'), - cell: (item: IPreset) => ( - {item.pushed_by} - ), + cell: (item: IPreset) => + isPublic ? ( + item.pushed_by + ) : ( + {item.pushed_by} + ), }, { id: 'created', @@ -146,10 +167,11 @@ export const usePresetsDelete = () => { return { isDeleting, deletePresets } as const; }; -export const useFilters = () => { +export const useFilters = ({ scope }: { scope: 'public' | 'mine' }) => { 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 +203,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 (scope === 'public') { + 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); @@ -216,13 +247,12 @@ export const useFilters = () => { return !detail.tokens.some((item, index) => tokenIndex < index && item.propertyKey === token.propertyKey); }); - setSearchParams(tokensToSearchParams(filteredTokens)); - setPropertyFilterQuery({ ...detail, tokens: filteredTokens }); + const nextSearchParams = tokensToSearchParams(filteredTokens); + setSearchParams(nextSearchParams); }; 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..9dff5048c2 100644 --- a/frontend/src/pages/Presets/List/index.tsx +++ b/frontend/src/pages/Presets/List/index.tsx @@ -1,17 +1,53 @@ 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, Tabs } 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 { t } = useTranslation(); + const { isAuthenticated, scope, setScope } = usePresetViewer(); + const authToken = useAppSelector(selectAuthToken); + + return ( + setScope(detail.activeTabId as 'mine' | 'public')} + /> + ) + } + /> + ); +}; + +const PresetListTable: React.FC<{ + authToken?: string; + scope: 'public' | 'mine'; + isAuthenticated: boolean; + tabs?: React.ReactNode; +}> = ({ authToken, scope, isAuthenticated, tabs }) => { + const { t } = useTranslation(); + const isPublic = scope === 'public'; const { clearFilter, @@ -23,18 +59,19 @@ export const PresetList: React.FC = () => { isDisabledClearFilter, filteringStatusType, handleLoadItems, - } = useFilters(); + } = useFilters({ scope }); const { isDeleting, deletePresets } = usePresetsDelete(); const { renderEmptyMessage, renderNoMatchMessage } = usePresetsTableEmptyMessages({ clearFilter, isDisabledClearFilter, + isPublic, }); - 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, scope, authToken, limit: DEFAULT_TABLE_PAGE_SIZE } as PresetListQueryArgs, getPaginationParams: (lastPreset) => ({ prev_created_at: lastPreset.created_at, @@ -49,20 +86,27 @@ export const PresetList: React.FC = () => { }, ]); - const { columns } = useColumnsDefinitions(); + const { columns } = useColumnsDefinitions({ isPublic }); + 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 +114,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')} + + )} + +
+ {tabs} +
} filter={ { onLoadItems={handleLoadItems} /> } - footer={} + footer={ + + {data.length > 0 && loadError} + + + } /> ); }; diff --git a/frontend/src/pages/Project/Add/index.tsx b/frontend/src/pages/Project/Add/index.tsx index e14fa18514..f23a848339 100644 --- a/frontend/src/pages/Project/Add/index.tsx +++ b/frontend/src/pages/Project/Add/index.tsx @@ -305,8 +305,8 @@ export const ProjectAdd: React.FC = () => { label={t('projects.edit.project_visibility')} description={ field.value - ? 'Any authorized user can see this project and join it as a member' - : 'Only project members and global admins can access this project' + ? t('projects.edit.visibility.public_description') + : t('projects.edit.visibility.private_description') } errorText={error?.message} > diff --git a/frontend/src/pages/Project/CreateWizard/index.tsx b/frontend/src/pages/Project/CreateWizard/index.tsx index d2ffa2aee9..64ea093590 100644 --- a/frontend/src/pages/Project/CreateWizard/index.tsx +++ b/frontend/src/pages/Project/CreateWizard/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { isNil } from 'lodash'; @@ -18,6 +18,7 @@ import { FormToggle, InfoLink, KeyValuePairs, + SelectCSD, SpaceBetween, Wizard, } from 'components'; @@ -54,6 +55,7 @@ const projectValidationSchema = yup.object({ .required(requiredFieldError) .matches(/^[a-zA-Z0-9-_]+$/, namesFieldError), project_type: yup.string().required(requiredFieldError), + public_presets: yup.boolean(), backends: yup.array().when('project_type', { is: 'gpu_marketplace', then: yup.array().min(1, minOneLengthError).required(requiredFieldError), @@ -129,6 +131,7 @@ export const CreateProjectWizard: React.FC = () => { resolver, defaultValues: { project_type: 'own_cloud', + public_presets: false, fleet: { ...fleetFormDefaultValues, enable_default: true, @@ -140,15 +143,21 @@ export const CreateProjectWizard: React.FC = () => { const formValues = watch(); const selectedProjectTypeOption = projectTypeOptions.find(({ value }) => value === formValues['project_type']); + const presetsOptions = [ + { label: t('projects.edit.visibility.private'), value: 'private' }, + { label: t('projects.edit.visibility.public'), value: 'public' }, + ]; + const onCancelHandler = () => { navigate(ROUTES.PROJECT.LIST); }; const getFormValuesForServer = (): TCreateWizardProjectParams => { - const { project_name, backends, project_type } = getValues(); + const { project_name, backends, project_type, public_presets } = getValues(); return { project_name, + public_presets, config: { base_backends: project_type === 'gpu_marketplace' ? (backends ?? []) : [], }, @@ -366,6 +375,35 @@ export const CreateProjectWizard: React.FC = () => { disabled={loading} /> + ( + + + field.onChange(detail.selectedOption.value === 'public') + } + onBlur={field.onBlur} + disabled={loading} + expandToViewport + filteringType="auto" + /> + + )} + /> +
{ label: t('projects.edit.project_name'), value: formValues['project_name'], }, + { + label: t('projects.edit.presets_settings'), + value: presetsOptions[formValues.public_presets ? 1 : 0].label, + }, { label: t('projects.edit.project_type'), value: selectedProjectTypeOption?.label, diff --git a/frontend/src/pages/Project/CreateWizard/types.ts b/frontend/src/pages/Project/CreateWizard/types.ts index 62f63d725b..957c274058 100644 --- a/frontend/src/pages/Project/CreateWizard/types.ts +++ b/frontend/src/pages/Project/CreateWizard/types.ts @@ -2,6 +2,7 @@ import { FleetFormFields } from 'pages/Fleets/Add/FleetFormFields/type'; export interface IProjectWizardForm extends Pick { project_type: 'gpu_marketplace' | 'own_cloud'; + public_presets: boolean; backends: TBackendType[]; fleet: FleetFormFields & { enable_default?: boolean; diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index 93130e90e4..db3a6fe339 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -31,7 +31,13 @@ import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProj import { riseRouterException } from 'libs'; import { copyToClipboard } from 'libs'; import { ROUTES } from 'routes'; -import { useGetProjectQuery, useUpdateProjectMembersMutation, useUpdateProjectMutation } from 'services/project'; +import { presetApi } from 'services/preset'; +import { + useGetProjectQuery, + useUpdateProjectMembersMutation, + useUpdateProjectMutation, + useUpdateProjectPublicPresetsMutation, +} from 'services/project'; import { useGetRunsQuery } from 'services/run'; import { templateApi } from 'services/templates'; import { useGetUserDataQuery } from 'services/user'; @@ -45,7 +51,7 @@ import { getProjectRoleByUserName } from 'pages/Project/utils'; import { useBackendsTable } from '../../Backends/hooks'; import { BackendsTable } from '../../Backends/Table'; import { NoFleetProjectAlert } from '../../components/NoFleetProjectAlert'; -import { VISIBILITY_INFO } from '../../constants'; +import { PRESETS_INFO, VISIBILITY_INFO } from '../../constants'; import { GatewaysTable } from '../../Gateways'; import { useGatewaysTable } from '../../Gateways/hooks'; import { ProjectSecrets } from '../../Secrets'; @@ -74,7 +80,8 @@ export const ProjectSettings: React.FC = () => { const [openHelpPanel] = useHelpPanel(); const dispatch = useDispatch(); const [updateProjectMembers] = useUpdateProjectMembersMutation(); - const [updateProject] = useUpdateProjectMutation(); + const [updateProject, { isLoading: isUpdatingProject }] = useUpdateProjectMutation(); + const [updateProjectPublicPresets, { isLoading: isUpdatingPublicPresets }] = useUpdateProjectPublicPresetsMutation(); const { deleteProject, isDeleting } = useDeleteProject(); const { data: currentUser } = useGetUserDataQuery({}); @@ -108,12 +115,14 @@ export const ProjectSettings: React.FC = () => { value: data?.owner.username, }; + const isSky = process.env.UI_VERSION === 'sky'; const visibilityOptions = [ - { label: t('projects.edit.visibility.private') || '', value: 'private' }, - { label: t('projects.edit.visibility.public') || '', value: 'public' }, + { label: t('projects.edit.visibility.private'), value: 'private' }, + { label: t('projects.edit.visibility.public'), value: 'public' }, ]; - - const [selectedVisibility, setSelectedVisibility] = useState(data?.isPublic ? visibilityOptions[1] : visibilityOptions[0]); + const isUpdatingVisibility = isUpdatingProject || isUpdatingPublicPresets; + const [visibilityEnabled, setVisibilityEnabled] = useState(false); + const [isChangeVisibilityVisible, setIsChangeVisibilityVisible] = useState(false); const [templatesRepoValue, setTemplatesRepoValue] = useState(''); const [templatesRepoError, setTemplatesRepoError] = useState(null); const [isChangeTemplatesRepoVisible, setIsChangeTemplatesRepoVisible] = useState(false); @@ -121,10 +130,6 @@ export const ProjectSettings: React.FC = () => { const changeTemplatesRepoInputWrapperRef = React.useRef(null); const dangerZoneRef = React.useRef(null); - useEffect(() => { - setSelectedVisibility(data?.isPublic ? visibilityOptions[1] : visibilityOptions[0]); - }, [data]); - useEffect(() => { setTemplatesRepoValue(data?.templates_repo ?? ''); }, [data?.templates_repo]); @@ -193,27 +198,6 @@ export const ProjectSettings: React.FC = () => { const debouncedMembersHandler = useCallback(debounce(changeMembersHandler, 1000), []); - const changeVisibilityHandler = (is_public: boolean) => { - updateProject({ - project_name: paramProjectName, - is_public: is_public, - }) - .unwrap() - .then(() => { - pushNotification({ - type: 'success', - content: t('projects.edit.update_visibility_success'), - }); - }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .catch((error: any) => { - pushNotification({ - type: 'error', - content: t('common.server_error', { error: error?.data?.detail?.msg }), - }); - }); - }; - const getApiErrorMessage = (error: unknown): string => { const detail = isFetchBaseQueryError(error) ? (error.data as ApiErrorResponse | undefined)?.detail : undefined; if (Array.isArray(detail)) { @@ -228,6 +212,34 @@ export const ProjectSettings: React.FC = () => { return t('common.server_error', { error: 'Unknown error' }); }; + const openChangeVisibilityDialog = () => { + setVisibilityEnabled(isSky ? (data?.public_presets ?? false) : !!data?.isPublic); + setIsChangeVisibilityVisible(true); + }; + + const confirmChangeVisibility = async () => { + if (!data || !isProjectAdmin(data) || isUpdatingVisibility) return; + + try { + if (isSky) { + await updateProjectPublicPresets({ + project_name: paramProjectName, + public_presets: visibilityEnabled, + }).unwrap(); + dispatch(presetApi.util.invalidateTags(['Presets'])); + } else { + await updateProject({ project_name: paramProjectName, is_public: visibilityEnabled }).unwrap(); + } + setIsChangeVisibilityVisible(false); + pushNotification({ + type: 'success', + content: t(isSky ? 'projects.edit.update_presets_success' : 'projects.edit.update_visibility_success'), + }); + } catch (error: unknown) { + pushNotification({ type: 'error', content: getApiErrorMessage(error) }); + } + }; + const updateTemplatesRepoHandler = async (): Promise => { const templates_repo = templatesRepoValue.trim() === '' ? null : templatesRepoValue.trim(); try { @@ -499,51 +511,30 @@ export const ProjectSettings: React.FC = () => { )} - {process.env.UI_VERSION !== 'sky' && isAvailableProjectManaging && ( + {(isSky || isAvailableProjectManaging) && ( <>
- {t('projects.edit.project_visibility_settings')} + {t( + isSky + ? 'projects.edit.presets_settings' + : 'projects.edit.project_visibility_settings', + )} - openHelpPanel(VISIBILITY_INFO)} /> + openHelpPanel(isSky ? PRESETS_INFO : VISIBILITY_INFO)} + />
- - changeVisibilityHandler(selectedVisibility.value === 'public') - } - confirmTitle={t('projects.edit.update_visibility_confirm_title')} - confirmButtonLabel={t('projects.edit.change_visibility')} - confirmContent={ - - - {t('projects.edit.update_visibility_confirm_message')} - -
- - setSelectedVisibility( - event.detail.selectedOption as { - label: string; - value: string; - }, - ) - } - expandToViewport={true} - filteringType="auto" - /> -
-
- } + onClick={openChangeVisibilityDialog} > {t('projects.edit.change_visibility')} -
+
)} @@ -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..ef753114b8 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -2,9 +2,13 @@ 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 { Home } from 'PublicApp/Home'; +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'; @@ -39,6 +43,38 @@ 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.LOGIN, 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.VERIFIED_ON.TEMPLATE, element: }, + { path: ROUTES.PRESETS.DETAILS.INSPECT.TEMPLATE, element: }, + ], + }, + ], + }, + ] + : []), { path: '/', element: , @@ -61,15 +97,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 +289,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..25065ea7ac 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -5,6 +5,7 @@ export const ROUTES = { LOGOUT: '/logout', AUTH: { + LOGIN: '/auth', GITHUB_CALLBACK: `/auth/github/callback`, OKTA_CALLBACK: `/auth/okta/callback`, ENTRA_CALLBACK: `/auth/entra/callback`, diff --git a/frontend/src/services/preset.test.tsx b/frontend/src/services/preset.test.tsx new file mode 100644 index 0000000000..eec80d41e0 --- /dev/null +++ b/frontend/src/services/preset.test.tsx @@ -0,0 +1,137 @@ +/** @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({ scope: 'public', authToken: 'viewer-a' })) + .unwrap(); + const guest = await store.dispatch(presetApi.endpoints.getAllPresets.initiate({ scope: 'public' })).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({ scope: 'public' }); + expect(await requests[1].json()).toEqual({ scope: 'public' }); + }); + + 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..d2a615fcae 100644 --- a/frontend/src/types/global.d.ts +++ b/frontend/src/types/global.d.ts @@ -3,6 +3,10 @@ declare var Tally: { closePopup: (string) => void; }; +interface Window { + Kapa?: { open: () => void }; +} + declare type AddedEmptyString = { [Property in keyof Type]: Type[Property] | ''; }; diff --git a/frontend/src/types/preset.d.ts b/frontend/src/types/preset.d.ts index 4c8425a9f6..a1e76b9094 100644 --- a/frontend/src/types/preset.d.ts +++ b/frontend/src/types/preset.d.ts @@ -1,4 +1,5 @@ declare type TPresetsListRequestParams = { + scope?: 'public' | 'mine'; project_name?: string; username?: string; base?: string; @@ -41,6 +42,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..38818ed2c6 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -2,6 +2,7 @@ declare type TCreateWizardProjectParams = { project_name: string; dry?: boolean; is_public?: boolean; + public_presets?: boolean; config: { base_backends: string[]; }; @@ -30,6 +31,7 @@ declare interface IProject { owner: IUser | { username: string }; created_at: string; isPublic: boolean; + public_presets?: boolean; templates_repo?: string | null; } diff --git a/mkdocs/docs/concepts/presets.md b/mkdocs/docs/concepts/presets.md index f265be85cc..da061aaa37 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** when creating a project or in its 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/src/dstack/_internal/cli/services/presets/registry.py b/src/dstack/_internal/cli/services/presets/registry.py index 82e6f7008b..8b7239b83c 100644 --- a/src/dstack/_internal/cli/services/presets/registry.py +++ b/src/dstack/_internal/cli/services/presets/registry.py @@ -45,11 +45,10 @@ def parse_registry_ref(ref: str) -> tuple[str, str]: return project, rest -def resolve_registry_client(project: str) -> APIClient: - """The server hosting the registry a ref points at: the config entry with - the project's name, or - Sky with any configured Sky token (dstack tokens are user tokens, so one Sky - entry authenticates any Sky project the user is a member of).""" +def resolve_registry_client(project: str, *, allow_anonymous: bool = False) -> APIClient: + """Use the project's configured server, or Sky unless fallback is disabled. + Prefer a configured Sky token; pulls can opt into anonymous Sky access. + """ projects = ConfigManager().list_project_configs() for entry in projects: if entry.name == project: @@ -61,6 +60,9 @@ def resolve_registry_client(project: str) -> APIClient: # know going to a remote default — is the one worth announcing. _print_registry_server(SKY_BASE_URL, project) return APIClient(base_url=SKY_BASE_URL, token=entry.token) + if allow_anonymous: + _print_registry_server(SKY_BASE_URL, project) + return APIClient(base_url=SKY_BASE_URL) raise CLIError( f"No server is configured for project {project!r}. Log in with `dstack project add`" ) @@ -123,7 +125,7 @@ def push_preset_to_registry(store: PresetStore, local_ref: str, registry_ref: st def pull_preset_from_registry(store: PresetStore, registry_ref: str) -> None: project, name_or_id = parse_registry_ref(registry_ref) - client = resolve_registry_client(project) + client = resolve_registry_client(project, allow_anonymous=True) try: remote = client.presets.get(project, name_or_id) except (URLNotFoundError, MethodNotAllowedError): diff --git a/src/tests/_internal/cli/services/presets/test_registry.py b/src/tests/_internal/cli/services/presets/test_registry.py index c5bd5a0092..6441a6fcd9 100644 --- a/src/tests/_internal/cli/services/presets/test_registry.py +++ b/src/tests/_internal/cli/services/presets/test_registry.py @@ -1,4 +1,5 @@ import io +import json import logging import tarfile from datetime import datetime @@ -8,6 +9,7 @@ from uuid import uuid4 import pytest +import requests import yaml from dstack._internal.cli.models.presets import PulledPreset @@ -184,7 +186,9 @@ def _archive_member_texts(blob: bytes) -> dict[str, str]: def stub_client(monkeypatch: pytest.MonkeyPatch): fake = FakePresetsAPIClient() client = SimpleNamespace(presets=fake, files=fake.files, base_url="http://test-server") - monkeypatch.setattr(registry_module, "resolve_registry_client", lambda project: client) + monkeypatch.setattr( + registry_module, "resolve_registry_client", lambda project, **kwargs: client + ) return fake @@ -208,19 +212,23 @@ def _configure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, projects: "dstack._internal.core.services.configs.get_dstack_dir", lambda: dstack_dir ) - def test_uses_the_matching_config_entry(self, tmp_path, monkeypatch): + @pytest.mark.parametrize("allow_anonymous", [False, True]) + def test_uses_the_matching_config_entry(self, tmp_path, monkeypatch, allow_anonymous): self._configure( tmp_path, monkeypatch, [{"name": "main", "url": "http://my-server", "token": "t1", "default": True}], ) - client = resolve_registry_client("main") + client = resolve_registry_client("main", allow_anonymous=allow_anonymous) assert client.base_url == "http://my-server" assert client.token == "t1" - def test_falls_back_to_sky_with_a_sky_entry_token(self, tmp_path, monkeypatch): + @pytest.mark.parametrize("allow_anonymous", [False, True]) + def test_falls_back_to_sky_with_a_sky_entry_token( + self, tmp_path, monkeypatch, allow_anonymous + ): self._configure( tmp_path, monkeypatch, @@ -230,12 +238,15 @@ def test_falls_back_to_sky_with_a_sky_entry_token(self, tmp_path, monkeypatch): ], ) - client = resolve_registry_client("someone-elses-project") + client = resolve_registry_client("someone-elses-project", allow_anonymous=allow_anonymous) assert client.base_url == "https://sky.dstack.ai" assert client.token == "sky-token" - def test_no_sky_fallback_env_disables_the_fallback(self, tmp_path, monkeypatch): + @pytest.mark.parametrize("allow_anonymous", [False, True]) + def test_no_sky_fallback_env_disables_the_fallback( + self, tmp_path, monkeypatch, allow_anonymous + ): self._configure( tmp_path, monkeypatch, @@ -244,7 +255,7 @@ def test_no_sky_fallback_env_disables_the_fallback(self, tmp_path, monkeypatch): monkeypatch.setenv("DSTACK_NO_SKY_FALLBACK", "1") with pytest.raises(CLIError, match="dstack project add"): - resolve_registry_client("someone-elses-project") + resolve_registry_client("someone-elses-project", allow_anonymous=allow_anonymous) def test_errors_without_any_usable_entry(self, tmp_path, monkeypatch): self._configure( @@ -438,6 +449,76 @@ def test_maps_a_missing_endpoint_to_a_registry_support_error( class TestPullPresetFromRegistry: + @pytest.mark.parametrize( + "projects", + [ + [], + [ + { + "name": "local", + "url": "http://local-server", + "token": "local-token", + "default": True, + } + ], + ], + ids=["no-config", "local-default"], + ) + def test_pulls_from_sky_without_registry_credentials(self, tmp_path, monkeypatch, projects): + base_url = registry_module.SKY_BASE_URL + dstack_dir = tmp_path / ".dstack" + dstack_dir.mkdir() + if projects: + (dstack_dir / "config.yml").write_text(yaml.safe_dump({"projects": projects})) + monkeypatch.setattr( + "dstack._internal.core.services.configs.get_dstack_dir", lambda: dstack_dir + ) + monkeypatch.delenv("DSTACK_NO_SKY_FALLBACK", raising=False) + monkeypatch.setenv("DSTACK_SERVER_URL", "http://local-server") + monkeypatch.setenv("DSTACK_PROJECT", "local") + monkeypatch.setenv("DSTACK_TOKEN", "local-environment-token") + # Request preparation must not read the developer's own netrc credentials. + monkeypatch.setenv("NETRC", str(tmp_path / "no-netrc")) + remote = _registry_preset( + file_archives=[FileArchiveMapping(id=uuid4(), path="/app/a.txt")], + file_mappings=[FilePathMapping(local_path="patch/a.txt", path="/app/a.txt")], + ) + remote.project_name = "dstack" + blob = _file_archive_blob(tmp_path, "a.txt", "public preset file") + file_path = b"/app/a.txt" + stream = ( + len(file_path).to_bytes(4, "big") + file_path + len(blob).to_bytes(8, "big") + blob + ) + requests_sent = [] + + def send(session, request, **kwargs): + # Inspect fully prepared requests, after session headers/environment + # handling, while keeping both real API calls off the network. + requests_sent.append(request) + assert request.method == "POST" + assert request.headers.get("Authorization") is None + response = requests.Response() + response.status_code = 200 + response.request = request + if request.url == f"{base_url}/api/project/dstack/presets/get": + assert json.loads(request.body) == {"name_or_id": "qwen38"} + response.raw = io.BytesIO(remote.model_dump_json().encode()) + else: + assert request.url == f"{base_url}/api/project/dstack/presets/get_files" + assert json.loads(request.body) == {"name_or_id": str(remote.id)} + response.raw = io.BytesIO(stream) + return response + + monkeypatch.setattr(requests.Session, "send", send) + store = PresetStore(tmp_path / "presets") + + pull_preset_from_registry(store, "dstack/qwen38") + + assert len(requests_sent) == 2 + pulled = store.get(str(remote.id)) + assert pulled.name == "dstack/qwen38" + assert Path(pulled.service.files[0].local_path).read_text() == "public preset file" + def test_materializes_the_preset_under_its_qualified_name(self, tmp_path, stub_client): store = PresetStore(tmp_path / "presets") stub_client.remote = _registry_preset( diff --git a/website/src/pages/Home/GetStartedSection.tsx b/website/src/pages/Home/GetStartedSection.tsx index 18f51c825f..18db290fcc 100644 --- a/website/src/pages/Home/GetStartedSection.tsx +++ b/website/src/pages/Home/GetStartedSection.tsx @@ -365,7 +365,7 @@ export function GetStartedSection() { Pull ready-to-deploy presets from the public registry )} - +
From 071268c3876b224c8521cef3e0622530ff858a87 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Fri, 11 Sep 2026 16:57:49 +0200 Subject: [PATCH 2/8] [Presets] Use one list and keep visibility in project settings --- frontend/src/App/auth.test.tsx | 64 +------------ frontend/src/PublicApp/PresetApp.tsx | 13 +-- frontend/src/locale/en.json | 2 - frontend/src/pages/Presets/Details/index.tsx | 28 +++--- frontend/src/pages/Presets/List/hooks.tsx | 27 +++--- frontend/src/pages/Presets/List/index.tsx | 90 ++++++++----------- frontend/src/pages/Project/Add/index.tsx | 4 +- .../src/pages/Project/CreateWizard/index.tsx | 46 +--------- .../src/pages/Project/CreateWizard/types.ts | 1 - frontend/src/services/preset.test.tsx | 10 +-- frontend/src/types/preset.d.ts | 1 - frontend/src/types/project.d.ts | 1 - mkdocs/docs/concepts/presets.md | 2 +- 13 files changed, 74 insertions(+), 215 deletions(-) diff --git a/frontend/src/App/auth.test.tsx b/frontend/src/App/auth.test.tsx index 5efd66268c..1daff1e6eb 100644 --- a/frontend/src/App/auth.test.tsx +++ b/frontend/src/App/auth.test.tsx @@ -17,7 +17,6 @@ let mockUserQuery: { data?: { username: string }; error?: { status: number }; isFetching: boolean; - isLoading: boolean; }; jest.mock('hooks', () => ({ @@ -32,9 +31,7 @@ jest.mock('services/auth', () => ({ })); jest.mock('services/user', () => ({ - useGetUserDataQuery: jest.fn((_, { skip }: { skip: boolean }) => - skip ? { isLoading: false, isFetching: false } : mockUserQuery, - ), + useGetUserDataQuery: jest.fn((_, { skip }: { skip: boolean }) => (skip ? { isFetching: false } : mockUserQuery)), })); jest.mock('./slice', () => ({ @@ -44,21 +41,6 @@ jest.mock('./slice', () => ({ jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); -jest.mock('routes', () => ({ - ROUTES: { - BASE: '/', - RUNS: { LIST: '/runs' }, - AUTH: { - LOGIN: '/auth', - GITHUB_CALLBACK: '/auth/github/callback', - OKTA_CALLBACK: '/auth/okta/callback', - ENTRA_CALLBACK: '/auth/entra/callback', - GOOGLE_CALLBACK: '/auth/google/callback', - TOKEN: '/auth/token', - }, - }, -})); - jest.mock('layouts/AppLayout', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => { @@ -77,19 +59,7 @@ jest.mock('components', () => { Box: ({ variant, children }: { variant?: string; children: React.ReactNode }) => variant === 'h1' ?

{children}

: <>{children}, BreadcrumbGroup: () => null, - Button: ({ - children, - href, - onFollow, - }: { - children: React.ReactNode; - href?: string; - onFollow?: (event: { preventDefault: () => void }) => void; - }) => ( - - {children} - - ), + Button: Wrapper, Alert: Wrapper, Container: Wrapper, Header: Wrapper, @@ -107,7 +77,6 @@ jest.mock('./Loading', () => ({ Loading: () =>

Loading

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

Storage unavailable

})); const publicPaths = [ROUTES.BASE, ROUTES.AUTH.LOGIN]; -const ignoredAuthPaths = Object.values(ROUTES.AUTH).filter((path) => path !== ROUTES.AUTH.LOGIN); const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); let App: React.FC; let rendered: ReactTestRenderer | undefined; @@ -123,9 +92,6 @@ const renderApp = (path: string) => { } /> }> Runs} /> - {ignoredAuthPaths.map((authPath) => ( - Authentication page} /> - ))} @@ -147,7 +113,7 @@ beforeAll(() => { beforeEach(() => { Object.defineProperty(globalThis, 'window', { value: { localStorage: {} } }); mockToken = undefined; - mockUserQuery = { isFetching: false, isLoading: false }; + mockUserQuery = { isFetching: false }; }); afterEach(() => { @@ -171,18 +137,6 @@ describe('Sky public and protected pages', () => { expect(mockPrivateQuery).not.toHaveBeenCalled(); }); - test('the home sign-in link opens the dedicated auth page', () => { - const view = renderApp(ROUTES.BASE); - const signInLink = view.root.findByType('a'); - expect(signInLink.props.href).toBe(ROUTES.AUTH.LOGIN); - - act(() => signInLink.props.onClick({ preventDefault: jest.fn() })); - - expect(view.root.findByType('output').children).toEqual([ROUTES.AUTH.LOGIN]); - expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); - expect(mockPrivateQuery).not.toHaveBeenCalled(); - }); - test.each(publicPaths)('a validated user opening %s reaches their runs', (path) => { mockToken = 'valid-token'; mockUserQuery.currentData = mockUserQuery.data = { username: 'alice' }; @@ -239,7 +193,7 @@ describe('Sky public and protected pages', () => { test.each([...publicPaths, ROUTES.RUNS.LIST])('%s waits for token validation before showing private content', (path) => { mockToken = 'pending-token'; - mockUserQuery.isFetching = mockUserQuery.isLoading = true; + mockUserQuery.isFetching = true; const view = renderApp(path); expect(view.root.findByProps({ role: 'status' }).children).toEqual(['Loading']); @@ -271,14 +225,4 @@ describe('Sky public and protected pages', () => { expect(useGetUserDataQuery).toHaveBeenCalledWith({ token: 'saved-token' }, { skip: true }); expect(mockPrivateQuery).not.toHaveBeenCalled(); }); - - test.each(ignoredAuthPaths)('authentication page %s remains outside the auth guard', (path) => { - mockToken = 'expired-token'; - mockUserQuery.error = { status: 401 }; - const view = renderApp(path); - - expect(view.root.findByType('output').children).toEqual([path]); - expect(view.root.findByType('h1').children).toEqual(['Authentication page']); - expect(mockPrivateQuery).not.toHaveBeenCalled(); - }); }); diff --git a/frontend/src/PublicApp/PresetApp.tsx b/frontend/src/PublicApp/PresetApp.tsx index 47d5ca8edd..55f66f84f9 100644 --- a/frontend/src/PublicApp/PresetApp.tsx +++ b/frontend/src/PublicApp/PresetApp.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState } from 'react'; +import React, { createContext, useContext } from 'react'; import { useAppSelector } from 'hooks'; import { useGetUserDataQuery } from 'services/user'; @@ -9,25 +9,18 @@ import { selectAuthToken } from 'App/slice'; import { PublicApp } from './index'; -const PresetViewer = createContext<{ - isAuthenticated: boolean; - scope: 'mine' | 'public'; - setScope: (scope: 'mine' | 'public') => void; -}>({ isAuthenticated: false, scope: 'public', setScope: () => undefined }); +const PresetViewer = createContext({ isAuthenticated: false }); export const usePresetViewer = () => useContext(PresetViewer); export const PresetApp: React.FC = () => { const token = useAppSelector(selectAuthToken); - const [scope, setScope] = useState<'mine' | 'public'>('mine'); 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 ? : } - + {isAuthenticated ? : } ); }; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index e7e117918f..de0ddae44b 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -844,8 +844,6 @@ }, "presets": { "list_page_title": "Presets", - "my_projects": "My projects", - "public": "Public", "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", diff --git a/frontend/src/pages/Presets/Details/index.tsx b/frontend/src/pages/Presets/Details/index.tsx index 08a0dc6989..3b184d4e09 100644 --- a/frontend/src/pages/Presets/Details/index.tsx +++ b/frontend/src/pages/Presets/Details/index.tsx @@ -1,8 +1,7 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Outlet, useNavigate, useOutletContext, useParams } from 'react-router-dom'; +import { Outlet, useNavigate, useParams } from 'react-router-dom'; import { format } from 'date-fns'; -import { usePresetViewer } from 'PublicApp/PresetApp'; import { Alert, @@ -39,7 +38,6 @@ export const PresetDetails: React.FC = () => { const { t } = useTranslation(); const params = useParams(); const navigate = useNavigate(); - const { isAuthenticated, scope, setScope } = usePresetViewer(); const [pushNotification] = useNotifications(); const paramProjectName = params.projectName ?? ''; const paramPresetId = params.presetId ?? ''; @@ -56,12 +54,7 @@ export const PresetDetails: React.FC = () => { const [deletePreset, { isLoading: isDeleting }] = useDeletePresetMutation(); const isLoadingPreset = isLoading || (isFetching && !data); - const canDelete = isAuthenticated && !!data?.can_delete; - const isPublic = scope === 'public' || !canDelete; - - useEffect(() => { - if (data && !data.can_delete) setScope('public'); - }, [data, setScope]); + const canDelete = !!data?.can_delete; useBreadcrumbs([ { @@ -142,7 +135,7 @@ export const PresetDetails: React.FC = () => { ]} /> - + )} @@ -151,7 +144,6 @@ export const PresetDetails: React.FC = () => { export const PresetDetailsOverview: React.FC = () => { const { t } = useTranslation(); - const { isPublic } = useOutletContext<{ isPublic: boolean }>(); const params = useParams(); const paramProjectName = params.projectName ?? ''; const paramPresetId = params.presetId ?? ''; @@ -193,9 +185,9 @@ export const PresetDetailsOverview: React.FC = () => {
{data.project_name} @@ -205,10 +197,10 @@ export const PresetDetailsOverview: React.FC = () => {
{t('presets.user')}
- {isPublic ? ( - data.pushed_by - ) : ( + {data.can_delete ? ( {data.pushed_by} + ) : ( + data.pushed_by )}
diff --git a/frontend/src/pages/Presets/List/hooks.tsx b/frontend/src/pages/Presets/List/hooks.tsx index f21d4adee4..6736052708 100644 --- a/frontend/src/pages/Presets/List/hooks.tsx +++ b/frontend/src/pages/Presets/List/hooks.tsx @@ -33,16 +33,16 @@ const MAX_FILTER_OPTIONS = 100; export const usePresetsTableEmptyMessages = ({ clearFilter, isDisabledClearFilter, - isPublic, + isAuthenticated, }: { clearFilter?: () => void; isDisabledClearFilter?: boolean; - isPublic: boolean; + isAuthenticated: boolean; }) => { const { t } = useTranslation(); const renderEmptyMessage = (): React.ReactNode => { - if (isDisabledClearFilter && isPublic) { + if (isDisabledClearFilter && !isAuthenticated) { return ( { +export const useColumnsDefinitions = () => { const { t } = useTranslation(); const columns = [ @@ -109,9 +109,9 @@ export const useColumnsDefinitions = ({ isPublic }: { isPublic: boolean }) => { cell: (item: IPreset) => ( {item.project_name} @@ -132,10 +132,10 @@ export const useColumnsDefinitions = ({ isPublic }: { isPublic: boolean }) => { id: 'user', header: t('presets.user'), cell: (item: IPreset) => - isPublic ? ( - item.pushed_by - ) : ( + item.can_delete ? ( {item.pushed_by} + ) : ( + item.pushed_by ), }, { @@ -167,7 +167,7 @@ export const usePresetsDelete = () => { return { isDeleting, deletePresets } as const; }; -export const useFilters = ({ scope }: { scope: 'public' | 'mine' }) => { +export const useFilters = ({ isAuthenticated }: { isAuthenticated: boolean }) => { const [searchParams, setSearchParams] = useSearchParams(); const propertyFilterQuery = useMemo( () => requestParamsToTokens({ searchParams, filterKeys }), @@ -203,7 +203,7 @@ export const useFilters = ({ scope }: { scope: 'public' | 'mine' }) => { // 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 (scope === 'public') { + if (!isAuthenticated) { setFilteringStatusType(undefined); return; } @@ -247,8 +247,7 @@ export const useFilters = ({ scope }: { scope: 'public' | 'mine' }) => { return !detail.tokens.some((item, index) => tokenIndex < index && item.propertyKey === token.propertyKey); }); - const nextSearchParams = tokensToSearchParams(filteredTokens); - setSearchParams(nextSearchParams); + setSearchParams(tokensToSearchParams(filteredTokens)); }; const clearFilter = () => { diff --git a/frontend/src/pages/Presets/List/index.tsx b/frontend/src/pages/Presets/List/index.tsx index 9dff5048c2..b97db08f1a 100644 --- a/frontend/src/pages/Presets/List/index.tsx +++ b/frontend/src/pages/Presets/List/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { usePresetViewer } from 'PublicApp/PresetApp'; -import { Alert, Button, ButtonWithConfirmation, Header, Loader, PropertyFilter, SpaceBetween, Table, Tabs } from 'components'; +import { Alert, Button, ButtonWithConfirmation, Header, Loader, PropertyFilter, SpaceBetween, Table } from 'components'; import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useAppSelector, useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; @@ -14,40 +14,23 @@ import { selectAuthToken } from 'App/slice'; import { useColumnsDefinitions, useFilters, usePresetsDelete, usePresetsTableEmptyMessages } from './hooks'; export const PresetList: React.FC = () => { - const { t } = useTranslation(); - const { isAuthenticated, scope, setScope } = usePresetViewer(); + const { isAuthenticated } = usePresetViewer(); const authToken = useAppSelector(selectAuthToken); return ( setScope(detail.activeTabId as 'mine' | 'public')} - /> - ) - } /> ); }; const PresetListTable: React.FC<{ authToken?: string; - scope: 'public' | 'mine'; isAuthenticated: boolean; - tabs?: React.ReactNode; -}> = ({ authToken, scope, isAuthenticated, tabs }) => { +}> = ({ authToken, isAuthenticated }) => { const { t } = useTranslation(); - const isPublic = scope === 'public'; const { clearFilter, @@ -59,19 +42,19 @@ const PresetListTable: React.FC<{ isDisabledClearFilter, filteringStatusType, handleLoadItems, - } = useFilters({ scope }); + } = useFilters({ isAuthenticated }); const { isDeleting, deletePresets } = usePresetsDelete(); const { renderEmptyMessage, renderNoMatchMessage } = usePresetsTableEmptyMessages({ clearFilter, isDisabledClearFilter, - isPublic, + isAuthenticated, }); const { data, error, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetAllPresetsQuery, - args: { ...filteringRequestParams, scope, authToken, limit: DEFAULT_TABLE_PAGE_SIZE } as PresetListQueryArgs, + args: { ...filteringRequestParams, authToken, limit: DEFAULT_TABLE_PAGE_SIZE } as PresetListQueryArgs, getPaginationParams: (lastPreset) => ({ prev_created_at: lastPreset.created_at, @@ -86,7 +69,7 @@ const PresetListTable: React.FC<{ }, ]); - const { columns } = useColumnsDefinitions({ isPublic }); + const { columns } = useColumnsDefinitions(); const loadError = error ? ( {t('presets.load_error_message')} @@ -128,36 +111,33 @@ const PresetListTable: React.FC<{ isItemDisabled={(preset) => !preset.can_delete} stickyHeader={true} header={ - -
- {canDelete && ( - - {t('common.delete')} - - )} - -
- {tabs} -
+
+ {canDelete && ( + + {t('common.delete')} + + )} + +
} filter={ { label={t('projects.edit.project_visibility')} description={ field.value - ? t('projects.edit.visibility.public_description') - : t('projects.edit.visibility.private_description') + ? 'Any authorized user can see this project and join it as a member' + : 'Only project members and global admins can access this project' } errorText={error?.message} > diff --git a/frontend/src/pages/Project/CreateWizard/index.tsx b/frontend/src/pages/Project/CreateWizard/index.tsx index 64ea093590..d2ffa2aee9 100644 --- a/frontend/src/pages/Project/CreateWizard/index.tsx +++ b/frontend/src/pages/Project/CreateWizard/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { isNil } from 'lodash'; @@ -18,7 +18,6 @@ import { FormToggle, InfoLink, KeyValuePairs, - SelectCSD, SpaceBetween, Wizard, } from 'components'; @@ -55,7 +54,6 @@ const projectValidationSchema = yup.object({ .required(requiredFieldError) .matches(/^[a-zA-Z0-9-_]+$/, namesFieldError), project_type: yup.string().required(requiredFieldError), - public_presets: yup.boolean(), backends: yup.array().when('project_type', { is: 'gpu_marketplace', then: yup.array().min(1, minOneLengthError).required(requiredFieldError), @@ -131,7 +129,6 @@ export const CreateProjectWizard: React.FC = () => { resolver, defaultValues: { project_type: 'own_cloud', - public_presets: false, fleet: { ...fleetFormDefaultValues, enable_default: true, @@ -143,21 +140,15 @@ export const CreateProjectWizard: React.FC = () => { const formValues = watch(); const selectedProjectTypeOption = projectTypeOptions.find(({ value }) => value === formValues['project_type']); - const presetsOptions = [ - { label: t('projects.edit.visibility.private'), value: 'private' }, - { label: t('projects.edit.visibility.public'), value: 'public' }, - ]; - const onCancelHandler = () => { navigate(ROUTES.PROJECT.LIST); }; const getFormValuesForServer = (): TCreateWizardProjectParams => { - const { project_name, backends, project_type, public_presets } = getValues(); + const { project_name, backends, project_type } = getValues(); return { project_name, - public_presets, config: { base_backends: project_type === 'gpu_marketplace' ? (backends ?? []) : [], }, @@ -375,35 +366,6 @@ export const CreateProjectWizard: React.FC = () => { disabled={loading} /> - ( - - - field.onChange(detail.selectedOption.value === 'public') - } - onBlur={field.onBlur} - disabled={loading} - expandToViewport - filteringType="auto" - /> - - )} - /> -
{ label: t('projects.edit.project_name'), value: formValues['project_name'], }, - { - label: t('projects.edit.presets_settings'), - value: presetsOptions[formValues.public_presets ? 1 : 0].label, - }, { label: t('projects.edit.project_type'), value: selectedProjectTypeOption?.label, diff --git a/frontend/src/pages/Project/CreateWizard/types.ts b/frontend/src/pages/Project/CreateWizard/types.ts index 957c274058..62f63d725b 100644 --- a/frontend/src/pages/Project/CreateWizard/types.ts +++ b/frontend/src/pages/Project/CreateWizard/types.ts @@ -2,7 +2,6 @@ import { FleetFormFields } from 'pages/Fleets/Add/FleetFormFields/type'; export interface IProjectWizardForm extends Pick { project_type: 'gpu_marketplace' | 'own_cloud'; - public_presets: boolean; backends: TBackendType[]; fleet: FleetFormFields & { enable_default?: boolean; diff --git a/frontend/src/services/preset.test.tsx b/frontend/src/services/preset.test.tsx index eec80d41e0..aaa18c9dd3 100644 --- a/frontend/src/services/preset.test.tsx +++ b/frontend/src/services/preset.test.tsx @@ -65,18 +65,16 @@ describe('Preset viewer isolation', () => { return jsonResponse({ presets: [{ id: name, name, project_name: 'project', can_delete: name !== 'public' }] }); }); - const member = await store - .dispatch(presetApi.endpoints.getAllPresets.initiate({ scope: 'public', authToken: 'viewer-a' })) - .unwrap(); - const guest = await store.dispatch(presetApi.endpoints.getAllPresets.initiate({ scope: 'public' })).unwrap(); + 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({ scope: 'public' }); - expect(await requests[1].json()).toEqual({ scope: 'public' }); + 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 () => { diff --git a/frontend/src/types/preset.d.ts b/frontend/src/types/preset.d.ts index a1e76b9094..c7063d59ed 100644 --- a/frontend/src/types/preset.d.ts +++ b/frontend/src/types/preset.d.ts @@ -1,5 +1,4 @@ declare type TPresetsListRequestParams = { - scope?: 'public' | 'mine'; project_name?: string; username?: string; base?: string; diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index 38818ed2c6..85da2d0bb8 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -2,7 +2,6 @@ declare type TCreateWizardProjectParams = { project_name: string; dry?: boolean; is_public?: boolean; - public_presets?: boolean; config: { base_backends: string[]; }; diff --git a/mkdocs/docs/concepts/presets.md b/mkdocs/docs/concepts/presets.md index da061aaa37..6dd8757145 100644 --- a/mkdocs/docs/concepts/presets.md +++ b/mkdocs/docs/concepts/presets.md @@ -270,7 +270,7 @@ Pushing the same name again moves the name to the new preset. The previous one s 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** when creating a project or in its 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. +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" }. From dbbb44fc21ca76a10b8d46a7d8f66913c2189e5b Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Sun, 13 Sep 2026 16:19:20 +0200 Subject: [PATCH 3/8] [UI] Add Sky page analytics --- frontend/src/analytics.ts | 44 ++++++++++++++++++++++++++++++++++ frontend/src/index.tsx | 3 +++ frontend/src/types/global.d.ts | 3 +++ frontend/webpack/base.js | 3 ++- frontend/webpack/env.js | 2 ++ 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 frontend/src/analytics.ts 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/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/types/global.d.ts b/frontend/src/types/global.d.ts index d2a615fcae..0709a1bd4d 100644 --- a/frontend/src/types/global.d.ts +++ b/frontend/src/types/global.d.ts @@ -4,6 +4,8 @@ declare var Tally: { }; interface Window { + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; Kapa?: { open: () => void }; } @@ -27,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/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, From 30411fbb6e3b2a778ad93eb14d23c1aaa1b97ffa Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 14 Sep 2026 13:31:17 +0200 Subject: [PATCH 4/8] [UI] Replace Sky home with sign-in --- .../App/Login/LoginByGithubCallback/index.tsx | 4 +- frontend/src/App/Login/TokenLogin/index.tsx | 2 +- frontend/src/App/auth.test.tsx | 42 +- frontend/src/App/index.tsx | 3 +- frontend/src/PublicApp/Home/index.tsx | 382 ------------------ .../src/PublicApp/Home/styles.module.scss | 121 ------ frontend/src/PublicApp/index.tsx | 8 +- frontend/src/PublicApp/styles.module.scss | 5 + frontend/src/router.tsx | 4 +- frontend/src/routes.ts | 1 - 10 files changed, 30 insertions(+), 542 deletions(-) delete mode 100644 frontend/src/PublicApp/Home/index.tsx delete mode 100644 frontend/src/PublicApp/Home/styles.module.scss diff --git a/frontend/src/App/Login/LoginByGithubCallback/index.tsx b/frontend/src/App/Login/LoginByGithubCallback/index.tsx index fa61642906..cc97cb88fa 100644 --- a/frontend/src/App/Login/LoginByGithubCallback/index.tsx +++ b/frontend/src/App/Login/LoginByGithubCallback/index.tsx @@ -76,9 +76,7 @@ export const LoginByGithubCallback: React.FC = () => { return ( - - {t('auth.try_again')} - + {t('auth.try_again')} ); diff --git a/frontend/src/App/Login/TokenLogin/index.tsx b/frontend/src/App/Login/TokenLogin/index.tsx index ff910558ac..fbf45622af 100644 --- a/frontend/src/App/Login/TokenLogin/index.tsx +++ b/frontend/src/App/Login/TokenLogin/index.tsx @@ -34,7 +34,7 @@ export const TokenLogin: React.FC = () => { > - {t('auth.another_login_methods')} + {t('auth.another_login_methods')} diff --git a/frontend/src/App/auth.test.tsx b/frontend/src/App/auth.test.tsx index 1daff1e6eb..ebc49993cf 100644 --- a/frontend/src/App/auth.test.tsx +++ b/frontend/src/App/auth.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { act, create, ReactTestRenderer } from 'react-test-renderer'; -import { Home } from 'PublicApp/Home'; import { ROUTES } from 'routes'; import { useGetUserDataQuery } from 'services/user'; @@ -55,28 +54,23 @@ jest.mock('@cloudscape-design/design-tokens', () => ({ colorBackgroundHomeHeader jest.mock('components', () => { const Wrapper = ({ children }: { children: React.ReactNode }) => <>{children}; return { - // Render the real home and login headers without mounting their Cloudscape presentation. Box: ({ variant, children }: { variant?: string; children: React.ReactNode }) => variant === 'h1' ?

{children}

: <>{children}, - BreadcrumbGroup: () => null, Button: Wrapper, Alert: Wrapper, Container: Wrapper, Header: Wrapper, NavigateLink: Wrapper, ContentLayout: ({ header }: { header: React.ReactNode }) => <>{header}, - Grid: Wrapper, Link: Wrapper, SpaceBetween: Wrapper, }; }); -jest.mock('PublicApp/Home/styles.module.scss', () => ({})); jest.mock('./Login/SelfHostedLogin', () => ({ SelfHostedLogin: () =>

Server login

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

Loading

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

Storage unavailable

})); -const publicPaths = [ROUTES.BASE, ROUTES.AUTH.LOGIN]; const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); let App: React.FC; let rendered: ReactTestRenderer | undefined; @@ -88,8 +82,7 @@ const renderApp = (path: string) => { rendered = create( - } /> - } /> + } /> }> Runs} /> @@ -128,19 +121,19 @@ afterAll(() => { }); describe('Sky public and protected pages', () => { - test.each(publicPaths)('visitors can open %s without loading account data', (path) => { - const view = renderApp(path); + test('visitors can open sign-in without loading account data', () => { + const view = renderApp(ROUTES.BASE); - expect(view.root.findByType('output').children).toEqual([path]); + 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.each(publicPaths)('a validated user opening %s reaches their runs', (path) => { + test('a validated user opening sign-in reaches their runs', () => { mockToken = 'valid-token'; mockUserQuery.currentData = mockUserQuery.data = { username: 'alice' }; - const view = renderApp(path); + const view = renderApp(ROUTES.BASE); expect(view.root.findByType('output').children).toEqual(['/runs']); expect(view.root.findByType('h1').children).toEqual(['Runs']); @@ -149,8 +142,7 @@ describe('Sky public and protected pages', () => { test.each([ [ROUTES.BASE, ROUTES.BASE], - [ROUTES.AUTH.LOGIN, ROUTES.AUTH.LOGIN], - [ROUTES.RUNS.LIST, ROUTES.AUTH.LOGIN], + [ROUTES.RUNS.LIST, ROUTES.BASE], ])('a rejected token at %s settles on %s', (path, expectedPath) => { mockToken = 'expired-token'; mockUserQuery.error = { status: 401 }; @@ -161,13 +153,13 @@ describe('Sky public and protected pages', () => { expect(mockPrivateQuery).not.toHaveBeenCalled(); }); - test.each(publicPaths)('a rejected token stays at %s even when its user data is cached', (path) => { + 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(path); + const view = renderApp(ROUTES.BASE); - expect(view.root.findByType('output').children).toEqual([path]); + expect(view.root.findByType('output').children).toEqual([ROUTES.BASE]); expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); expect(mockPrivateQuery).not.toHaveBeenCalled(); }); @@ -178,20 +170,20 @@ describe('Sky public and protected pages', () => { mockUserQuery.error = { status: 403 }; const view = renderApp(ROUTES.RUNS.LIST); - expect(view.root.findByType('output').children).toEqual([ROUTES.AUTH.LOGIN]); + 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 the auth page', () => { + test('a visitor opening a protected page reaches sign-in', () => { const view = renderApp('/runs'); - expect(view.root.findByType('output').children).toEqual([ROUTES.AUTH.LOGIN]); + 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([...publicPaths, ROUTES.RUNS.LIST])('%s waits for token validation before showing private content', (path) => { + 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); @@ -201,7 +193,7 @@ describe('Sky public and protected pages', () => { expect(mockPrivateQuery).not.toHaveBeenCalled(); }); - test.each([...publicPaths, ROUTES.RUNS.LIST])( + 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'; @@ -216,10 +208,10 @@ describe('Sky public and protected pages', () => { }, ); - test.each(publicPaths)('%s remains available when local storage is unavailable', (path) => { + test('sign-in remains available when local storage is unavailable', () => { Object.defineProperty(globalThis, 'window', { value: {} }); mockToken = 'saved-token'; - const view = renderApp(path); + const view = renderApp(ROUTES.BASE); expect(view.root.findByType('h1').children).toEqual(['Welcome to dstack Sky']); expect(useGetUserDataQuery).toHaveBeenCalledWith({ token: 'saved-token' }, { skip: true }); diff --git a/frontend/src/App/index.tsx b/frontend/src/App/index.tsx index fa671a8d32..e6b6de8f80 100644 --- a/frontend/src/App/index.tsx +++ b/frontend/src/App/index.tsx @@ -21,8 +21,7 @@ const IGNORED_AUTH_PATHS = [ ROUTES.AUTH.TOKEN, ]; -const LoginFormComponent = - process.env.UI_VERSION === 'sky' ? () => : SelfHostedLogin; +const LoginFormComponent = process.env.UI_VERSION === 'sky' ? () => : SelfHostedLogin; const App: React.FC = () => { const { t } = useTranslation(); diff --git a/frontend/src/PublicApp/Home/index.tsx b/frontend/src/PublicApp/Home/index.tsx deleted file mode 100644 index 1c456322af..0000000000 --- a/frontend/src/PublicApp/Home/index.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import React from 'react'; -import { Navigate, useNavigate } from 'react-router-dom'; - -import { - AnchorNavigation, - Box, - BreadcrumbGroup, - Button, - Container, - ContentLayout, - ExpandableSection, - Grid, - Header, - Icon, - Link, - Popover, - SpaceBetween, - TextContent, -} from 'components'; - -import { useAppSelector } from 'hooks'; -import { ROUTES } from 'routes'; -import { useGetUserDataQuery } from 'services/user'; - -import { Loading } from 'App/Loading'; -import { selectAuthToken } from 'App/slice'; - -import styles from './styles.module.scss'; - -function OnThisPageNavigation({ variant }: { variant: 'mobile' | 'side' }) { - const anchorNavigation = ( - - ); - - return variant === 'side' ? ( -
- - On this page - - {anchorNavigation} -
- ) : ( - - {anchorNavigation} - - ); -} - -function HeroHeader() { - const navigate = useNavigate(); - - return ( - - -
- Welcome to dstack Sky - - Enjoy the full power of dstack without the hassle of hosting it yourself or managing - your own infrastructure.
- Sign up for dstack Sky to use the cheapest GPUs from our marketplace or connect it to - your own cloud accounts. -
-
- - - - - - Sign up to get $5 credit - - - -
-
- ); -} - -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 Home: React.FC = () => { - const token = useAppSelector(selectAuthToken); - const localStorageIsAvailable = 'localStorage' in window; - const { - currentData: userData, - error, - isFetching, - } = useGetUserDataQuery({ token }, { skip: !token || !localStorageIsAvailable }); - - if (token && localStorageIsAvailable && isFetching && !userData) return ; - if (token && localStorageIsAvailable && userData?.username && !error) { - return ; - } - - return ( - - } - headerVariant="high-contrast" - header={} - defaultPadding={true} - maxContentWidth={1040} - disableOverlap={true} - > -
-
- -
- - - -
- - -
-
-
- ); -}; diff --git a/frontend/src/PublicApp/Home/styles.module.scss b/frontend/src/PublicApp/Home/styles.module.scss deleted file mode 100644 index d3efe6e389..0000000000 --- a/frontend/src/PublicApp/Home/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/PublicApp/index.tsx b/frontend/src/PublicApp/index.tsx index 4bde6696ce..17fd98ba24 100644 --- a/frontend/src/PublicApp/index.tsx +++ b/frontend/src/PublicApp/index.tsx @@ -52,7 +52,7 @@ export const PublicApp: React.FC = ({ children }) => { const navigate = useNavigate(); const { pathname } = useLocation(); const breadcrumbs = useAppSelector(selectBreadcrumbs); - const isAuth = pathname === ROUTES.AUTH.LOGIN || pathname === ROUTES.AUTH.TOKEN; + const isAuth = pathname === ROUTES.BASE || pathname === ROUTES.AUTH.TOKEN; const onFollow: BreadcrumbGroupProps['onFollow'] = (event) => { event.preventDefault(); navigate(event.detail.href); @@ -119,10 +119,10 @@ export const PublicApp: React.FC = ({ children }) => {
+ +
+ {# Tabs rendered directly in the header (not relocated via JS) to avoid a load flash. base.html's below-header tabs block is suppressed in main.html. #} {% if "navigation.tabs" in features %} @@ -98,55 +141,6 @@ GitHub - {# "Get started" dropdown — the landing's product switcher rail as a popup. The product - descriptions are duplicated in website/src/components/SiteNavigation.tsx (products) and - website/src/pages/Home/GetStartedSection.tsx — keep all three in sync. Opens below, - right-aligned so it never overflows the header's right edge. #} -
- - -
+ + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/website/src/App/index.tsx b/website/src/App/index.tsx index 6abef731bb..166e607d5a 100644 --- a/website/src/App/index.tsx +++ b/website/src/App/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect } from 'react'; import { Outlet, useLocation, useOutletContext } from 'react-router-dom'; import { SiteBanner } from '../components/SiteBanner'; import { SiteFooter } from '../components/SiteFooter'; @@ -6,13 +6,8 @@ import { SiteNavigation } from '../components/SiteNavigation'; import { ROUTES } from '../routes'; import { useTheme, ThemeMode } from '../theme'; -// State shared from the layout down to routed pages via the router Outlet context. -// Used by the Old page (its side-nav drawer + in-content footer) and the top-nav trigger. export type LayoutContext = { - oldNavigationOpen: boolean; - setOldNavigationOpen: (open: boolean) => void; theme: ThemeMode; - toggleTheme: () => void; }; export function useLayoutContext() { @@ -23,27 +18,37 @@ export function useLayoutContext() { export function App() { const { theme, toggleTheme } = useTheme(); const { pathname } = useLocation(); - const [oldNavigationOpen, setOldNavigationOpen] = useState(true); + const isSkyPage = pathname.replace(/\/$/, '') === ROUTES.SKY; - const layoutContext: LayoutContext = { oldNavigationOpen, setOldNavigationOpen, theme, toggleTheme }; + useEffect(() => { + const title = isSkyPage + ? 'dstack Sky — The AI cloud with AI-native orchestration' + : 'dstack — The orchestration stack for AI infrastructure'; + const description = isSkyPage + ? 'A heterogeneous AI cloud with NVIDIA Blackwell, Hopper, and AMD Instinct GPUs. Access on-demand instances, spot capacity, and reserved clusters.' + : 'dstack is a unified control plane for GPU provisioning and orchestration that works with any GPU cloud, Kubernetes, or on-prem clusters.'; + const url = isSkyPage ? 'https://dstack.ai/products/sky/' : 'https://dstack.ai/'; + document.title = title; + document.querySelector('link[rel="canonical"]')?.setAttribute('href', url); + document.querySelector('meta[property="og:url"]')?.setAttribute('content', url); + for (const selector of ['meta[property="og:title"]', 'meta[name="twitter:title"]']) { + document.querySelector(selector)?.setAttribute('content', title); + } + for (const selector of ['meta[name="description"]', 'meta[property="og:description"]', 'meta[name="twitter:description"]']) { + document.querySelector(selector)?.setAttribute('content', description); + } + }, [isSkyPage]); + + const layoutContext: LayoutContext = { theme }; return ( <>
- setOldNavigationOpen(open => !open)} - theme={theme} - onToggleTheme={toggleTheme} - /> +
- {/* The Old page renders its own footer inside the AppLayout content, so the side nav - runs full-height beside it; every other page uses the global footer here. */} - {pathname !== ROUTES.OLD && ( - - )} + ); } diff --git a/website/src/cloudscape-overrides.css b/website/src/cloudscape-overrides.css index 42230d5044..7a66906e2d 100644 --- a/website/src/cloudscape-overrides.css +++ b/website/src/cloudscape-overrides.css @@ -99,8 +99,7 @@ body { font-size: 16px !important; } -/* Primary (filled) buttons use 500 weight per design — outline/normal buttons keep their weight. - Applies app-wide (new landing + /old). */ +/* Primary (filled) buttons use 500 weight per design — outline/normal buttons keep their weight. */ [class*='awsui_variant-primary'] { font-weight: 500 !important; } diff --git a/website/src/cloudscape-theme.ts b/website/src/cloudscape-theme.ts index f5da772ff3..18d98325f9 100644 --- a/website/src/cloudscape-theme.ts +++ b/website/src/cloudscape-theme.ts @@ -112,8 +112,3 @@ export const mainButtonStyle: ButtonProps.Style = { export const menuButtonStyle: ButtonProps.Style = { root: { paddingBlock: '7px', paddingInline: '18px' }, }; -// Header "Get started" — same as menuButtonStyle but with 1px extra on the right so the label -// doesn't sit a hair close to the edge (inline-end 19px vs inline-start 18px). -export const getStartedButtonStyle: ButtonProps.Style = { - root: { paddingBlock: '7px', paddingInline: '18px 19px' }, -}; diff --git a/website/src/components/AlternatingDocBlock.tsx b/website/src/components/AlternatingDocBlock.tsx index 4fea813ff9..a49fc32e40 100644 --- a/website/src/components/AlternatingDocBlock.tsx +++ b/website/src/components/AlternatingDocBlock.tsx @@ -1,34 +1,14 @@ import { ReactNode } from 'react'; -import { ThemedImage } from '../data/images'; import { highlightTerms } from './highlightTerms'; -// In-content diagram: a plain string renders a single image, a themed pair swaps with the theme. -function ThemedDocImage({ image }: { image: string | ThemedImage }) { - if (typeof image === 'string') { - return ; - } - - return ( - <> - - - - ); -} - -// A documentation block with a visual on one side and copy on the other. Pass either -// `image` (rendered via ThemedDocImage) or an arbitrary `visual` node. `imageFirst` -// places the visual on the left, otherwise it sits on the right. export function AlternatingDocBlock({ - image, visual, title, children, action, imageFirst = false, }: { - image?: string | ThemedImage; - visual?: ReactNode; + visual: ReactNode; title: string; children: ReactNode; action?: ReactNode; @@ -36,7 +16,7 @@ export function AlternatingDocBlock({ }) { return (
-
{visual ?? (image && )}
+
{visual}

{title}

{highlightTerms(children)}

diff --git a/website/src/components/SiteFooter.tsx b/website/src/components/SiteFooter.tsx index dbb8c83a3a..d66a4dcd50 100644 --- a/website/src/components/SiteFooter.tsx +++ b/website/src/components/SiteFooter.tsx @@ -73,21 +73,15 @@ const footerColumns: FooterColumn[] = [ }, ]; -// Global footer: a brand block (logo + social) reserving the leading ~1.5 columns, -// then the multi-column link grid, with the theme toggle and copyright in a -// divider-separated bottom bar. On the home page it carries an extra gradient -// (.site-footer--home). export function SiteFooter({ - home, theme, onToggleTheme, }: { - home: boolean; theme: ThemeMode; onToggleTheme: () => void; }) { return ( -
+
diff --git a/website/src/components/SiteNavigation.tsx b/website/src/components/SiteNavigation.tsx index f9ebef6c49..6ef3b72c63 100644 --- a/website/src/components/SiteNavigation.tsx +++ b/website/src/components/SiteNavigation.tsx @@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import Button from '@cloudscape-design/components/button'; import SideNavigation, { SideNavigationProps } from '@cloudscape-design/components/side-navigation'; import SpaceBetween from '@cloudscape-design/components/space-between'; -import { getStartedButtonStyle, menuButtonStyle } from '../cloudscape-theme'; +import { menuButtonStyle } from '../cloudscape-theme'; import { ThemeToggle } from './ThemeToggle'; import { asset } from '../asset'; import { BLOG_URL, DOCS_URL, ROUTES } from '../routes'; @@ -31,9 +31,9 @@ const CloudGlyph = () => ( ); -const BoxGlyph = () => ( +const LayersGlyph = () => ( ); @@ -51,7 +51,6 @@ type ProductLink = { text: string; secondaryText: string; href: string; - external?: boolean; icon: ReactNode; badge: string; }; @@ -59,12 +58,12 @@ type ProductLink = { // The products. products[0] (open-source) is featured at the top of the "Products" menu; the rest // follow as rows. Reused by the standalone top-nav hover menu and the mobile nav's "Products" // section. -// NOTE: the descriptions are duplicated in GetStartedSection.tsx (the switcher rail) and +// NOTE: the descriptions are duplicated in GetStartedSection.tsx (the product list) and // mkdocs/overrides/header-2.html — keep all three in sync. const products: ProductLink[] = [ - { id: 'open-source', text: 'dstack', secondaryText: 'The open-source control plane for AI-native orchestration.', href: DOCS_URL, icon: , badge: 'Self-hosted' }, - { id: 'factory', text: 'dstack Factory', secondaryText: 'Extends dstack with a governance and metering layer, and a private preset registry.', href: 'https://calendly.com/dstackai/discovery-call', external: true, icon: , badge: 'Self-hosted' }, - { id: 'sky-product', text: 'dstack Sky', secondaryText: 'Everything in dstack Factory, plus the GPU marketplace and the public preset registry.', href: 'https://sky.dstack.ai', external: true, icon: , badge: 'Hosted by us' }, + { id: 'open-source', text: 'dstack', secondaryText: 'The open-source control plane for AI-native orchestration.', href: asset(ROUTES.HOME), icon: , badge: 'Self-hosted' }, + { id: 'factory', text: 'dstack Factory', secondaryText: 'A complete software stack for AI labs, inference providers, and data centers.', href: 'https://calendly.com/dstackai/discovery-call', icon: , badge: 'Self-hosted' }, + { id: 'sky-product', text: 'dstack Sky', secondaryText: 'An AI cloud with AI-native orchestration. Rent GPUs on demand or bring your own compute.', href: asset(ROUTES.SKY), icon: , badge: 'Hosted by us' }, ]; // Items for the mobile slide-out navigation. The blog categories are top-level links (mirroring @@ -78,32 +77,29 @@ const mobileNavigationItems: SideNavigationProps.Item[] = [ type: 'link', text: p.text, href: p.href, - ...(p.external ? { external: true, externalIconAriaLabel } : {}), + external: true, + externalIconAriaLabel, })), }, { type: 'link', text: 'Docs', href: DOCS_URL }, { type: 'link', text: 'Case studies', href: `${BLOG_URL}/case-studies/` }, { type: 'link', text: 'Blog', href: BLOG_URL }, - { type: 'link', text: 'GitHub', href: dstackGithubUrl, external: true, externalIconAriaLabel }, ]; -// Standalone "Products" top-nav menu. The popup opens on hover (and on keyboard focus) and -// closes once the pointer/focus leaves both the trigger and the popup. The trigger reads like -// the plain text links beside it — no dropdown caret. A short close delay (hover-intent, below) -// lets the pointer cross the gap from trigger to popup without the menu dropping. -function ProductsHoverMenu() { +function ProductsHoverMenu({ selectedProductId }: { selectedProductId: string }) { const [open, setOpen] = useState(false); - // Hover-intent: leaving the trigger schedules a close, but re-entering the wrapper (e.g. moving - // onto the popup, which is a child) cancels it — so the small gap between trigger and popup - // doesn't drop the menu. + const [hoveredProductId, setHoveredProductId] = useState(null); + const activeProductId = hoveredProductId ?? selectedProductId; const closeTimer = useRef(undefined); const openMenu = () => { window.clearTimeout(closeTimer.current); setOpen(true); }; - const scheduleClose = () => { + const scheduleClose = (menu: HTMLDivElement) => { window.clearTimeout(closeTimer.current); - closeTimer.current = window.setTimeout(() => setOpen(false), 150); + closeTimer.current = window.setTimeout(() => { + if (!menu.querySelector(':focus-visible')) setOpen(false); + }, 150); }; // GitHub star count for the open-source repo, fetched once the menu first opens. Best-effort: @@ -121,28 +117,14 @@ function ProductsHoverMenu() { .catch(() => {}); }, [open]); - // The popup renders the Get-started section's switcher rail verbatim. The white indicator card - // rests on the first row and slides to whichever row the pointer is over. - const [active, setActive] = useState(0); - const railRef = useRef(null); - const [indicator, setIndicator] = useState<{ top: number; height: number } | null>(null); - useEffect(() => { - if (!open) return; - const rail = railRef.current; - const selected = rail?.querySelector('.gs-opt--on'); - if (!rail || !selected) return; - const railBox = rail.getBoundingClientRect(); - const rowBox = selected.getBoundingClientRect(); - // Fractional rect math (offsetTop/offsetHeight round to integers and leave 1px slivers). - const borderTop = parseFloat(getComputedStyle(rail).borderTopWidth) || 0; - setIndicator({ top: rowBox.top - railBox.top - borderTop, height: rowBox.height }); - }, [open, active, stars]); - return (
{ + setHoveredProductId(null); + scheduleClose(event.currentTarget); + }} onFocus={openMenu} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { @@ -158,15 +140,15 @@ function ProductsHoverMenu() { {open && (
- {/* The Get-started section's switcher rail, verbatim (rows are links here). */} -
- {indicator &&
+ + + + + + + + + {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..b053b8203e 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. */ @@ -1472,10 +1200,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 +1271,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 +1350,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 +1377,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 +1398,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 +1424,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 +1492,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 +1540,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 +1695,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 +1703,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 +1786,6 @@ p { top: 12px; } - .feature-card-grid, - .stats-grid, - .start-grid, .doc-card-grid, .product-grid, .concept-grid, @@ -2113,21 +1793,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 +1809,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 +1843,6 @@ p { flex: 0 0 28px; } - .site-desktop-trigger { - display: none; - } - .site-logo { position: absolute; left: 50%; @@ -2282,10 +1920,7 @@ p { text-align: center !important; } - .home-hero h1 { - font-size: 36px; - } - + .home-hero h1, .home-hero h2 { font-size: 32px; } @@ -2294,11 +1929,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)), + }, + }, }, }); From 4514e5ef4d498ed18550b341414203bec8ab16e8 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 14 Sep 2026 13:47:03 +0200 Subject: [PATCH 6/8] [UI] Move preset deployment to a tab --- frontend/src/locale/en.json | 2 +- .../pages/Presets/Details/Deploy/index.tsx | 63 ++++++++++++++----- .../Presets/Details/VerifiedOn/index.tsx | 10 ++- frontend/src/pages/Presets/Details/index.tsx | 12 ++-- frontend/src/pages/Presets/index.ts | 2 +- frontend/src/router.tsx | 5 +- frontend/src/routes.ts | 6 +- 7 files changed, 65 insertions(+), 35 deletions(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index de0ddae44b..8ccc8346eb 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -888,7 +888,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 3b184d4e09..88538ffa2e 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', } @@ -123,9 +123,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'), @@ -215,7 +215,7 @@ export const PresetDetailsOverview: React.FC = () => { - + ); }; diff --git a/frontend/src/pages/Presets/index.ts b/frontend/src/pages/Presets/index.ts index bd68b4e0a1..3e2c70eb9a 100644 --- a/frontend/src/pages/Presets/index.ts +++ b/frontend/src/pages/Presets/index.ts @@ -1,4 +1,4 @@ export * from './List'; export * from './Details'; export * from './Details/Inspect'; -export * from './Details/VerifiedOn'; +export * from './Details/Deploy'; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index b12dbef02f..702bc6193d 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -36,7 +36,7 @@ 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'; @@ -65,7 +65,8 @@ export const router = createBrowserRouter([ element: , children: [ { index: true, element: }, - { path: ROUTES.PRESETS.DETAILS.VERIFIED_ON.TEMPLATE, element: }, + { path: ROUTES.PRESETS.DETAILS.DEPLOY.TEMPLATE, element: }, + { path: 'verified-on', element: }, { path: ROUTES.PRESETS.DETAILS.INSPECT.TEMPLATE, element: }, ], }, 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`, From 2f46f2200d9f39e21dcaa6701a671196db1b431b Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 14 Sep 2026 14:01:56 +0200 Subject: [PATCH 7/8] [UI] Add preset name links and project breadcrumbs --- frontend/src/locale/en.json | 1 - frontend/src/pages/Presets/Details/index.tsx | 44 +++++++------------- frontend/src/pages/Presets/List/hooks.tsx | 12 ++---- 3 files changed, 19 insertions(+), 38 deletions(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 8ccc8346eb..bbfafc0cb3 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -859,7 +859,6 @@ "name": "Name", "id": "ID", "project": "Project", - "base": "Base", "repo": "Repo", "user": "User", "created_at": "Created", diff --git a/frontend/src/pages/Presets/Details/index.tsx b/frontend/src/pages/Presets/Details/index.tsx index 88538ffa2e..6125128f88 100644 --- a/frontend/src/pages/Presets/Details/index.tsx +++ b/frontend/src/pages/Presets/Details/index.tsx @@ -61,6 +61,10 @@ export const PresetDetails: React.FC = () => { 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), @@ -166,43 +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.can_delete ? ( - {data.pushed_by} - ) : ( - data.pushed_by - )} -
+ {t('presets.repo')} +
{data.repo}
{t('presets.created_at')} diff --git a/frontend/src/pages/Presets/List/hooks.tsx b/frontend/src/pages/Presets/List/hooks.tsx index 6736052708..be009b1d85 100644 --- a/frontend/src/pages/Presets/List/hooks.tsx +++ b/frontend/src/pages/Presets/List/hooks.tsx @@ -92,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', @@ -118,11 +119,6 @@ export const useColumnsDefinitions = () => { ), }, - { - id: 'base', - header: t('presets.base'), - cell: (item: IPreset) => item.base, - }, { id: 'repo', header: t('presets.repo'), From d51b254ff7d7794e456f91dc3f0de9acabd044b4 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 14 Sep 2026 14:09:23 +0200 Subject: [PATCH 8/8] [Website] Use box icon for dstack and remove star counts --- mkdocs/assets/stylesheets/cloudscape-docs.css | 13 ------ mkdocs/overrides/header-2.html | 22 +--------- website/src/components/SiteNavigation.tsx | 41 +++--------------- website/src/pages/Home/GetStartedSection.tsx | 42 +++---------------- website/src/styles.css | 18 +------- 5 files changed, 14 insertions(+), 122 deletions(-) diff --git a/mkdocs/assets/stylesheets/cloudscape-docs.css b/mkdocs/assets/stylesheets/cloudscape-docs.css index da48eb415a..2a9caf2289 100644 --- a/mkdocs/assets/stylesheets/cloudscape-docs.css +++ b/mkdocs/assets/stylesheets/cloudscape-docs.css @@ -354,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; diff --git a/mkdocs/overrides/header-2.html b/mkdocs/overrides/header-2.html index 4fba0fd50f..cdea2fe8ab 100644 --- a/mkdocs/overrides/header-2.html +++ b/mkdocs/overrides/header-2.html @@ -84,11 +84,8 @@
Self-hosted
- - - + dstack @@ -192,21 +189,6 @@ } }); } - - var el = document.querySelector('[data-cs-gh-stars]'); - if (el) { - fetch('https://api.github.com/repos/dstackai/dstack') - .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (d) { - if (!d || typeof d.stargazers_count !== 'number') return; - var n = d.stargazers_count, label; - if (n < 1000) label = String(n); - else { var k = n / 1000; label = (k >= 10 ? Math.round(k) : Number(k.toFixed(1))) + 'k'; } - el.textContent = label; - el.hidden = false; - }) - .catch(function () {}); - } })(); diff --git a/website/src/components/SiteNavigation.tsx b/website/src/components/SiteNavigation.tsx index 6ef3b72c63..f11eb99736 100644 --- a/website/src/components/SiteNavigation.tsx +++ b/website/src/components/SiteNavigation.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode, useEffect, useRef, useState } from 'react'; +import { Fragment, ReactNode, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import Button from '@cloudscape-design/components/button'; import SideNavigation, { SideNavigationProps } from '@cloudscape-design/components/side-navigation'; @@ -10,20 +10,11 @@ import { BLOG_URL, DOCS_URL, ROUTES } from '../routes'; import { ThemeMode } from '../theme'; const dstackGithubUrl = 'https://github.com/dstackai/dstack'; -const dstackGithubApiUrl = 'https://api.github.com/repos/dstackai/dstack'; const externalIconAriaLabel = 'External link icon'; -// Compact star count: 1340 → "1.3k", 12000 → "12k", 980 → "980". -function formatStars(count: number): string { - if (count < 1000) return String(count); - const thousands = count / 1000; - return `${thousands >= 10 ? Math.round(thousands) : Number(thousands.toFixed(1))}k`; -} - -// Monochrome product glyphs for the "Products" menu (GitHub mark also doubles as the star badge). -const GithubGlyph = () => ( -
- - - {stars !== null && ( - {formatStars(stars)} - )} - + {products[0].icon} {products[0].text} {products[0].secondaryText} diff --git a/website/src/pages/Home/GetStartedSection.tsx b/website/src/pages/Home/GetStartedSection.tsx index 8d7e723f4c..edd2c35604 100644 --- a/website/src/pages/Home/GetStartedSection.tsx +++ b/website/src/pages/Home/GetStartedSection.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useState } from 'react'; +import { ReactNode, useState } from 'react'; import CodeView from '@cloudscape-design/code-view/code-view'; import shHighlight from '@cloudscape-design/code-view/highlight/sh'; import Button from '@cloudscape-design/components/button'; @@ -7,20 +7,9 @@ import { mainButtonStyle } from '../../cloudscape-theme'; import { installMethods, maxInstallLines, padYamlToLines } from '../../data/snippets'; import { DOCS_URL, ROUTES, docsUrl } from '../../routes'; -const GITHUB_API_URL = 'https://api.github.com/repos/dstackai/dstack'; - -// Compact star count: 1340 → "1.3k", 12000 → "12k", 980 → "980" (mirrors the Products menu). -function formatStars(count: number): string { - if (count < 1000) return String(count); - const thousands = count / 1000; - return `${thousands >= 10 ? Math.round(thousands) : Number(thousands.toFixed(1))}k`; -} - -// Product glyphs. GitHub mark doubles as the open-source star badge; cloud / layers mark the -// hosted / self-hosted rows (thin-line, matching the Products menu). -const GithubGlyph = () => ( -
Self-hosted
- - - {stars !== null && ( - {formatStars(stars)} - )} - + dstack The open-source control plane for AI-native orchestration. diff --git a/website/src/styles.css b/website/src/styles.css index b053b8203e..13d8fa9197 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -1132,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 {