diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 89451648d4..58507d8477 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -106,6 +106,10 @@ export const API = { // Fleets VOLUMES_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/volumes/delete`, + // PRESETS + PRESETS_GET: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/presets/get`, + PRESETS_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/presets/delete`, + // METRICS JOB_METRICS: (projectName: IProject['project_name'], runName: IRun['run_spec']['run_name']) => `${API.BASE()}/project/${projectName}/metrics/job/${runName}`, @@ -183,6 +187,11 @@ export const API = { LIST: () => `${API.VOLUME.BASE()}/list`, }, + PRESET: { + BASE: () => `${API.BASE()}/presets`, + LIST: () => `${API.PRESET.BASE()}/list`, + }, + USER_PUBLIC_KEYS: { BASE: () => `${API.BASE()}/users/public_keys`, LIST: () => `${API.USER_PUBLIC_KEYS.BASE()}/list`, diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 6acd2a2b05..1cdef08c1b 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -64,6 +64,7 @@ export type { ModalProps } from '@cloudscape-design/components/modal'; export { default as AnchorNavigation } from '@cloudscape-design/components/anchor-navigation'; export { default as ExpandableSection } from '@cloudscape-design/components/expandable-section'; export { default as KeyValuePairs } from '@cloudscape-design/components/key-value-pairs'; +export { default as TreeView } from '@cloudscape-design/components/tree-view'; export { I18nProvider } from '@cloudscape-design/components/i18n'; export { default as Wizard } from '@cloudscape-design/components/wizard'; export { default as SegmentedControl } from '@cloudscape-design/components/segmented-control'; diff --git a/frontend/src/consts.ts b/frontend/src/consts.ts index 06715082d1..579238f187 100644 --- a/frontend/src/consts.ts +++ b/frontend/src/consts.ts @@ -3,4 +3,6 @@ export const DISCORD_URL = 'https://discord.gg/u8SmfwPpMd'; export const QUICK_START_URL = 'https://dstack.ai/docs/quickstart/'; export const TALLY_FORM_ID = '3xYlYG'; export const DOCS_URL = 'https://dstack.ai/docs/'; +export const PRESETS_DOCS_URL = 'https://dstack.ai/docs/concepts/presets/'; +export const FLEETS_DOCS_URL = 'https://dstack.ai/docs/concepts/fleets/'; export const DEFAULT_TABLE_PAGE_SIZE = 20; diff --git a/frontend/src/layouts/AppLayout/hooks.ts b/frontend/src/layouts/AppLayout/hooks.ts index f46366fcd6..52e31db286 100644 --- a/frontend/src/layouts/AppLayout/hooks.ts +++ b/frontend/src/layouts/AppLayout/hooks.ts @@ -28,8 +28,9 @@ export const useSideNavigation = () => { { type: 'link', text: t('navigation.fleets'), href: ROUTES.FLEETS.LIST }, { type: 'link', text: t('navigation.instances'), href: ROUTES.INSTANCES.LIST }, { type: 'link', text: t('navigation.volumes'), href: ROUTES.VOLUMES.LIST }, - { type: 'link', text: t('navigation.events'), href: ROUTES.EVENTS.LIST }, { type: 'link', text: t('navigation.models'), href: ROUTES.MODELS.LIST }, + process.env.UI_VERSION === 'sky' && { type: 'link', text: t('navigation.presets'), href: ROUTES.PRESETS.LIST }, + { type: 'link', text: t('navigation.events'), href: ROUTES.EVENTS.LIST }, { type: 'link', text: t('navigation.project_other'), href: ROUTES.PROJECT.LIST }, isGlobalAdmin && { diff --git a/frontend/src/libs/presets.ts b/frontend/src/libs/presets.ts new file mode 100644 index 0000000000..0153fb2a83 --- /dev/null +++ b/frontend/src/libs/presets.ts @@ -0,0 +1,94 @@ +/** + * Formats a token count the way the CLI does: exact binary multiples keep + * binary names (32768 is "32K"), anything else rounds as decimal (1500 is + * "1.5K"). + */ +export const formatTokenCount = (value: number): string => { + for (const [divisor, suffix] of [ + [1024 * 1024, 'M'], + [1024, 'K'], + ] as const) { + if (value >= divisor && value % divisor === 0) { + return `${value / divisor}${suffix}`; + } + } + + if (value >= 999950) { + return `${trimZero((value / 1_000_000).toFixed(1))}M`; + } + + if (value >= 1000) { + return `${trimZero((value / 1000).toFixed(1))}K`; + } + + return String(value); +}; + +const trimZero = (value: string): string => (value.endsWith('.0') ? value.slice(0, -2) : value); + +/** As the CLI prints durations: 999.6 reads as 1s rather than 1000ms. */ +const formatDurationMs = (value: number): string => (value < 999.5 ? `${round(value)}ms` : `${round(value / 1000)}s`); + +const round = (value: number): string => String(Math.round(value * 100) / 100); + +/** + * Request counts, wall time, and token totals say nothing about how the preset + * performs: the totals are the workload multiplied by the request count, which + * the constraints already state. + */ +const HIDDEN_METRICS = new Set([ + 'successful_requests', + 'failed_requests', + 'duration_seconds', + 'total_input_tokens', + 'total_output_tokens', +]); + +const METRIC_LABELS: Record = { + output_tok_per_s: 'TPS', + per_user_tok_per_s: 'TPS/user', + total_input_tokens: 'Input tokens', + total_output_tokens: 'Output tokens', + ttft_ms: 'TTFT', + tpot_ms: 'TPOT', +}; + +const TOKEN_COUNT_METRICS = new Set(['total_input_tokens', 'total_output_tokens']); + +export type BenchmarkMetric = { label: string; value: string }; + +const formatMetricValue = (key: string, value: number): string => { + if (TOKEN_COUNT_METRICS.has(key)) return formatTokenCount(value); + if (key.endsWith('_ms')) return formatDurationMs(value); + return round(value); +}; + +/** + * The metrics worth showing, flattened: a metric measured as a distribution + * becomes one entry per statistic. + */ +export const getBenchmarkMetrics = (metrics: HashMap): BenchmarkMetric[] => { + const entries: BenchmarkMetric[] = []; + + Object.entries(metrics ?? {}).forEach(([key, value]) => { + if (HIDDEN_METRICS.has(key)) return; + const label = METRIC_LABELS[key] ?? key; + + if (typeof value === 'number') { + entries.push({ label, value: formatMetricValue(key, value) }); + return; + } + + if (value && typeof value === 'object') { + Object.entries(value as HashMap).forEach(([statistic, statisticValue]) => { + if (typeof statisticValue !== 'number') return; + entries.push({ + label: `${label} ${statistic}`, + value: formatMetricValue(key, statisticValue), + }); + }); + } + }); + + return entries; +}; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 804134f3d1..a06f08c4de 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -87,7 +87,8 @@ "volumes": "Volumes", "instances": "Instances", "offers": "Offers", - "events": "Events" + "events": "Events", + "presets": "Presets" }, "backend": { @@ -834,5 +835,49 @@ "confirm_dialog": { "title": "Confirm delete", "message": "Are you sure you want to delete?" + }, + "presets": { + "list_page_title": "Presets", + "empty_message_title": "No presets", + "empty_message_text": "Presets are created and pushed with the CLI.", + "documentation": "Documentation", + "nomatch_message_title": "No matches", + "nomatch_message_text": "We can't find a match. Try to change project or clear filter", + "filter_property_placeholder": "Filter by properties", + "name": "Name", + "id": "ID", + "project": "Project", + "base": "Base", + "repo": "Repo", + "user": "User", + "created_at": "Created", + "context_length": "Context length", + "benchmark": "Benchmark", + "details": "Details", + "inspect": "Inspect", + "delete_confirm_title": "Delete presets", + "delete_confirm_message": "Are you sure you want to delete these presets?", + "verified_on": "Verified on", + "replica_group": "Replica group {{name}}", + "step_pull": "Pull", + "step_pull_description": "Pull the preset to your machine.", + "step_export": "Export", + "step_export_description": "Export it as a service configuration, along with the files it references.", + "step_apply": "Apply", + "step_apply_description": "Deploy the service to any cloud, Kubernetes cluster, or on-prem fleet.", + "replica": "Replica {{index}}", + "deploy": "Deploy", + "no_cli": "No CLI installed?", + "no_cli_description": "To use dstack, install the CLI on your local machine.", + "constraints": "Constraints", + "concurrency": "Concurrency", + "dataset": "Dataset", + "input_tokens": "Input tokens", + "output_tokens": "Output tokens", + "shared_prefix": "Shared prefix", + "no_fleet_description": "Deploying the service requires a fleet with matching resources.", + "fleets": "Fleets", + "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/Events/List/hooks/useColumnDefinitions.tsx b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx index be4ec19a5e..d41e3af6f1 100644 --- a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx +++ b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx @@ -154,6 +154,19 @@ export const useColumnsDefinitions = () => { ); + case 'preset': + return ( +
+ Preset{' '} + {target.project_name && ( + + {target.project_name} + + )} + /{target.name} +
+ ); + default: return '---'; } diff --git a/frontend/src/pages/Events/List/hooks/useFilters.ts b/frontend/src/pages/Events/List/hooks/useFilters.ts index d160caa17f..066f178288 100644 --- a/frontend/src/pages/Events/List/hooks/useFilters.ts +++ b/frontend/src/pages/Events/List/hooks/useFilters.ts @@ -28,6 +28,7 @@ const filterKeys: Record = { TARGET_VOLUMES: 'target_volumes', TARGET_GATEWAYS: 'target_gateways', TARGET_SECRETS: 'target_secrets', + TARGET_PRESETS: 'target_presets', WITHIN_PROJECTS: 'within_projects', WITHIN_FLEETS: 'within_fleets', WITHIN_RUNS: 'within_runs', @@ -47,6 +48,7 @@ const multipleChoiseKeys: RequestParamsKeys[] = [ 'target_volumes', 'target_gateways', 'target_secrets', + 'target_presets', 'within_projects', 'within_fleets', 'within_runs', @@ -64,6 +66,7 @@ const targetTypes = [ { label: 'Volume', value: 'volume' }, { label: 'Gateway', value: 'gateway' }, { label: 'Secret', value: 'secret' }, + { label: 'Preset', value: 'preset' }, ]; const baseFilteringProperties = [ @@ -121,6 +124,12 @@ const baseFilteringProperties = [ propertyLabel: 'Target secret IDs', groupValuesLabel: 'Secret ids', }, + { + key: filterKeys.TARGET_PRESETS, + operators: ['='], + propertyLabel: 'Target preset IDs', + groupValuesLabel: 'Preset ids', + }, { key: filterKeys.WITHIN_PROJECTS, diff --git a/frontend/src/pages/Presets/Details/Benchmark/index.tsx b/frontend/src/pages/Presets/Details/Benchmark/index.tsx new file mode 100644 index 0000000000..fd0e18648c --- /dev/null +++ b/frontend/src/pages/Presets/Details/Benchmark/index.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +import { Box, ColumnLayout, Container, Header, Loader } from 'components'; + +import { formatTokenCount, getBenchmarkMetrics } from 'libs/presets'; +import { useGetPresetQuery } from 'services/preset'; + +export const PresetBenchmark: FC = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramProjectName = params.projectName ?? ''; + const paramPresetId = params.presetId ?? ''; + + const { data, isLoading } = useGetPresetQuery({ + project_name: paramProjectName, + id: paramPresetId, + }); + + if (isLoading || !data) + return ( + + + + ); + + const metrics = getBenchmarkMetrics(data.spec.preset.benchmark.metrics as HashMap); + + return ( + {t('presets.benchmark')}}> + +
+ {t('presets.context_length')} +
{formatTokenCount(data.spec.preset.context_length)}
+
+
+ {t('presets.concurrency')} +
{String((data.spec.preset.benchmark.workload as HashMap)?.concurrency)}
+
+ {metrics.map(({ label, value }) => ( +
+ {label} +
{value}
+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/pages/Presets/Details/Constraints/index.tsx b/frontend/src/pages/Presets/Details/Constraints/index.tsx new file mode 100644 index 0000000000..5d01286e1d --- /dev/null +++ b/frontend/src/pages/Presets/Details/Constraints/index.tsx @@ -0,0 +1,63 @@ +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +import { Box, ColumnLayout, Container, Header, Loader } from 'components'; + +import { formatTokenCount } from 'libs/presets'; +import { useGetPresetQuery } from 'services/preset'; + +const DEFAULT_DATASET = 'random'; + +export const PresetConstraints: FC = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramProjectName = params.projectName ?? ''; + const paramPresetId = params.presetId ?? ''; + + const { data, isLoading } = useGetPresetQuery({ + project_name: paramProjectName, + id: paramPresetId, + }); + + if (isLoading || !data) + return ( + + + + ); + + // The conditions the benchmark holds for: the workload it measured and the + // context the service was verified to serve. + const workload = (data.spec.preset.benchmark.workload ?? {}) as HashMap; + const dataset = workload.dataset as string | undefined; + const inputTokens = workload.input_tokens as number; + const sharedPrefix = (workload.shared_prefix_tokens as number) ?? 0; + + return ( + {t('presets.constraints')}}> + +
+ {t('presets.dataset')} +
{dataset ?? DEFAULT_DATASET}
+
+
+ {t('presets.input_tokens')} +
{formatTokenCount(inputTokens)}
+
+
+ {t('presets.output_tokens')} +
{formatTokenCount(workload.output_tokens as number)}
+
+ {sharedPrefix > 0 && ( +
+ {t('presets.shared_prefix')} +
+ {formatTokenCount(sharedPrefix)} ({Math.round((100 * sharedPrefix) / inputTokens)}%) +
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/Presets/Details/Deploy/index.tsx b/frontend/src/pages/Presets/Details/Deploy/index.tsx new file mode 100644 index 0000000000..ec6fb2719e --- /dev/null +++ b/frontend/src/pages/Presets/Details/Deploy/index.tsx @@ -0,0 +1,125 @@ +import React, { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Button, ExpandableSection, Link, Popover, SpaceBetween, StatusIndicator, Tabs, Wizard } from 'components'; + +import { FLEETS_DOCS_URL } from 'consts'; +import { copyToClipboard } from 'libs'; + +const UV_INSTALL_COMMAND = 'uv tool install dstack -U'; +const PIP_INSTALL_COMMAND = 'pip install dstack -U'; + +const CopyableCommand: FC<{ command: string }> = ({ command }) => { + const { t } = useTranslation(); + + return ( + + {command} + {t('common.copied')}} + > + + + ); + } + + return ( + + + + ); + }; + + const renderNoMatchMessage = (): React.ReactNode => { + return ( + + + + ); + }; + + return { renderEmptyMessage, renderNoMatchMessage } as const; +}; + +export const useColumnsDefinitions = () => { + const { t } = useTranslation(); + + const columns = [ + { + 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, + }, + { + id: 'id', + header: t('presets.id'), + cell: (item: IPreset) => ( + {item.id} + ), + }, + { + id: 'project', + header: t('presets.project'), + cell: (item: IPreset) => ( + {item.project_name} + ), + }, + { + id: 'base', + header: t('presets.base'), + cell: (item: IPreset) => item.base, + }, + { + id: 'repo', + header: t('presets.repo'), + cell: (item: IPreset) => item.repo, + }, + { + id: 'user', + header: t('presets.user'), + cell: (item: IPreset) => ( + {item.pushed_by} + ), + }, + { + id: 'created', + header: t('presets.created_at'), + cell: (item: IPreset) => format(new Date(item.created_at), DATE_TIME_FORMAT), + }, + ]; + + return { columns } as const; +}; + +export const usePresetsDelete = () => { + const { t } = useTranslation(); + const [request, { isLoading: isDeleting }] = useDeletePresetMutation(); + const [pushNotification] = useNotifications(); + + const deletePresets = (presets: IPreset[]) => { + return Promise.all( + presets.map((preset) => request({ project_name: preset.project_name, id: preset.id }).unwrap()), + ).catch((error) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: getServerError(error) }), + }); + }); + }; + + return { isDeleting, deletePresets } as const; +}; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const [propertyFilterQuery, setPropertyFilterQuery] = useState(() => + requestParamsToTokens({ searchParams, filterKeys }), + ); + const [filteringOptions, setFilteringOptions] = useState([]); + const [filteringStatusType, setFilteringStatusType] = useState(); + const [getProjects] = useLazyGetProjectsQuery(); + const [getUsers] = useLazyGetUserListQuery(); + + const filteringProperties = [ + { + key: filterKeys.PROJECT_NAME, + operators: ['='], + propertyLabel: 'Project', + groupValuesLabel: 'Project values', + }, + { + key: filterKeys.USERNAME, + operators: ['='], + propertyLabel: 'User', + groupValuesLabel: 'User values', + }, + { + key: filterKeys.BASE, + operators: ['='], + propertyLabel: 'Base', + groupValuesLabel: 'Base values', + }, + ]; + + // Projects and users are suggested by the same name-pattern lookups the + // other list pages use; a base model is typed in, as no API enumerates one. + const handleLoadItems: PropertyFilterProps['onLoadItems'] = async ({ detail: { filteringProperty, filteringText } }) => { + setFilteringOptions([]); + 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); + } + + 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); + } + + setFilteringStatusType(undefined); + }; + + const onChangePropertyFilter: PropertyFilterProps['onChange'] = ({ detail }) => { + const filteredTokens = detail.tokens.filter((token, tokenIndex) => { + if (!token.propertyKey) return true; + return !detail.tokens.some((item, index) => tokenIndex < index && item.propertyKey === token.propertyKey); + }); + + setSearchParams(tokensToSearchParams(filteredTokens)); + setPropertyFilterQuery({ ...detail, tokens: filteredTokens }); + }; + + const clearFilter = () => { + setSearchParams({}); + setPropertyFilterQuery(EMPTY_QUERY); + }; + + const filteringRequestParams = useMemo(() => { + return tokensToRequestParams({ tokens: propertyFilterQuery.tokens }); + }, [propertyFilterQuery]); + + const isDisabledClearFilter = !propertyFilterQuery.tokens.length; + + return { + filteringRequestParams, + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + isDisabledClearFilter, + filteringStatusType, + handleLoadItems, + } as const; +}; diff --git a/frontend/src/pages/Presets/List/index.tsx b/frontend/src/pages/Presets/List/index.tsx new file mode 100644 index 0000000000..67646fcfd3 --- /dev/null +++ b/frontend/src/pages/Presets/List/index.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, ButtonWithConfirmation, Header, Loader, PropertyFilter, SpaceBetween, Table } from 'components'; + +import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; +import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; +import { ROUTES } from 'routes'; +import { useLazyGetAllPresetsQuery } from 'services/preset'; + +import { useColumnsDefinitions, useFilters, usePresetsDelete, usePresetsTableEmptyMessages } from './hooks'; + +export const PresetList: React.FC = () => { + const { t } = useTranslation(); + + const { + clearFilter, + propertyFilterQuery, + onChangePropertyFilter, + filteringOptions, + filteringProperties, + filteringRequestParams, + isDisabledClearFilter, + filteringStatusType, + handleLoadItems, + } = useFilters(); + + const { isDeleting, deletePresets } = usePresetsDelete(); + + const { renderEmptyMessage, renderNoMatchMessage } = usePresetsTableEmptyMessages({ + clearFilter, + isDisabledClearFilter, + }); + + const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ + useLazyQuery: useLazyGetAllPresetsQuery, + args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE } as TPresetsListRequestParams, + + getPaginationParams: (lastPreset) => ({ + prev_created_at: lastPreset.created_at, + prev_id: lastPreset.id, + }), + }); + + useBreadcrumbs([ + { + text: t('navigation.presets'), + href: ROUTES.PRESETS.LIST, + }, + ]); + + const { columns } = useColumnsDefinitions(); + + const { items, actions, collectionProps } = useCollection(data ?? [], { + filtering: { + empty: renderEmptyMessage(), + noMatch: renderNoMatchMessage(), + }, + selection: {}, + }); + + const { selectedItems } = collectionProps; + + const deleteSelected = () => { + if (!selectedItems?.length) return; + + deletePresets([...selectedItems]).then(() => { + actions.setSelectedItems([]); + refreshList(); + }); + }; + + const isDisabledDelete = isDeleting || !selectedItems?.length; + + return ( + + + {t('common.delete')} + + +