From 8b924e77e81ebf774dab671b15011c673bb908bb Mon Sep 17 00:00:00 2001 From: Dmitry Meyer Date: Mon, 30 Mar 2026 08:45:32 +0000 Subject: [PATCH 1/2] Add web UI for user public keys Generated by Claude Part-of: https://github.com/dstackai/dstack/issues/3644 --- frontend/src/api.ts | 10 +- frontend/src/libs/instance.ts | 6 +- frontend/src/locale/en.json | 19 ++ .../List/hooks/useColumnDefinitions.tsx | 4 +- .../pages/Instances/Details/Inspect/index.tsx | 7 +- .../pages/Runs/Details/RunDetails/index.tsx | 7 +- frontend/src/pages/Runs/List/helpers.ts | 3 +- .../pages/User/Details/PublicKeys/index.tsx | 216 ++++++++++++++++++ frontend/src/pages/User/Details/index.tsx | 6 + frontend/src/pages/User/Details/types.ts | 1 + frontend/src/router.tsx | 18 +- frontend/src/routes.ts | 5 + frontend/src/services/publicKeys.ts | 53 +++++ frontend/src/services/templates.ts | 4 +- frontend/src/store.ts | 3 + 15 files changed, 327 insertions(+), 35 deletions(-) create mode 100644 frontend/src/pages/User/Details/PublicKeys/index.tsx create mode 100644 frontend/src/services/publicKeys.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 661538126..78fcd72c3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -171,8 +171,7 @@ export const API = { INSTANCES: { BASE: () => `${API.BASE()}/instances`, LIST: () => `${API.INSTANCES.BASE()}/list`, - DETAILS: (projectName: IProject['project_name']) => - `${API.BASE()}/project/${projectName}/instances/get`, + DETAILS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/instances/get`, }, SERVER: { @@ -184,4 +183,11 @@ export const API = { BASE: () => `${API.BASE()}/volumes`, LIST: () => `${API.VOLUME.BASE()}/list`, }, + + USER_PUBLIC_KEYS: { + BASE: () => `${API.BASE()}/users/public_keys`, + LIST: () => `${API.USER_PUBLIC_KEYS.BASE()}/list`, + ADD: () => `${API.USER_PUBLIC_KEYS.BASE()}/add`, + DELETE: () => `${API.USER_PUBLIC_KEYS.BASE()}/delete`, + }, }; diff --git a/frontend/src/libs/instance.ts b/frontend/src/libs/instance.ts index fae5c60b7..614081569 100644 --- a/frontend/src/libs/instance.ts +++ b/frontend/src/libs/instance.ts @@ -20,11 +20,7 @@ export const getHealthStatusIconType = (healthStatus: THealthStatus): StatusIndi export const formatInstanceStatusText = (instance: IInstance): string => { const status = instance.status; - if ( - (status === 'idle' || status === 'busy') && - instance.total_blocks !== null && - instance.total_blocks > 1 - ) { + if ((status === 'idle' || status === 'busy') && instance.total_blocks !== null && instance.total_blocks > 1) { return `${instance.busy_blocks}/${instance.total_blocks} Busy`; } diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index ae5144acc..9acedfdb7 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -742,6 +742,25 @@ "settings": "Settings", "projects": "Projects", "events": "Events", + "public_keys": { + "title": "SSH Keys", + "add_key": "Add SSH key", + "name": "Title", + "fingerprint": "Fingerprint", + "key_type": "Key type", + "added": "Added", + "empty_title": "No SSH keys", + "empty_message": "You haven't added any SSH keys yet.", + "key_name_label": "Title", + "key_name_description": "A label to identify this key", + "key_name_placeholder": "My SSH key", + "key_label": "Key", + "key_description": "Paste your public key content (e.g. the contents of ~/.ssh/id_ed25519.pub)", + "key_required": "Key content is required", + "key_already_exists": "This public key is already added to your account", + "delete_confirm_title": "Delete SSH key", + "delete_confirm_message": "Are you sure you want to delete the selected SSH key(s)?" + }, "create": { "page_title": "Create user", "error_notification": "Create user error", diff --git a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx index fce75101f..be4ec19a5 100644 --- a/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx +++ b/frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx @@ -78,9 +78,7 @@ export const useColumnsDefinitions = () => { )} / - + {target.name} diff --git a/frontend/src/pages/Instances/Details/Inspect/index.tsx b/frontend/src/pages/Instances/Details/Inspect/index.tsx index a9c9ac259..09205dad7 100644 --- a/frontend/src/pages/Instances/Details/Inspect/index.tsx +++ b/frontend/src/pages/Instances/Details/Inspect/index.tsx @@ -58,12 +58,7 @@ export const InstanceInspect = () => { return ( {t('fleets.instances.inspect')}}> - {}} - /> + {}} /> ); }; diff --git a/frontend/src/pages/Runs/Details/RunDetails/index.tsx b/frontend/src/pages/Runs/Details/RunDetails/index.tsx index 6eb80db63..aff1fced7 100644 --- a/frontend/src/pages/Runs/Details/RunDetails/index.tsx +++ b/frontend/src/pages/Runs/Details/RunDetails/index.tsx @@ -212,10 +212,9 @@ export const RunDetails = () => { )} - {runData.run_spec.configuration.type === 'task' && !runIsStopped(runData.status) && - (runData.jobs[0]?.job_spec?.app_specs?.length ?? 0) > 0 && ( - - )} + {runData.run_spec.configuration.type === 'task' && + !runIsStopped(runData.status) && + (runData.jobs[0]?.job_spec?.app_specs?.length ?? 0) > 0 && } {runData.jobs.length > 1 && ( { + const { t } = useTranslation(); + const params = useParams(); + const paramUserName = params.userName ?? ''; + const [pushNotification] = useNotifications(); + + const [showAddModal, setShowAddModal] = useState(false); + const [keyValue, setKeyValue] = useState(''); + const [keyName, setKeyName] = useState(''); + const [addError, setAddError] = useState(''); + + const { data: publicKeys = [], isLoading } = useListPublicKeysQuery(); + const [addPublicKey, { isLoading: isAdding }] = useAddPublicKeyMutation(); + const [deletePublicKeys, { isLoading: isDeleting }] = useDeletePublicKeysMutation(); + + useBreadcrumbs([ + { + text: t('navigation.account'), + href: ROUTES.USER.LIST, + }, + { + text: paramUserName, + href: ROUTES.USER.DETAILS.FORMAT(paramUserName), + }, + { + text: t('users.public_keys.title'), + href: ROUTES.USER.PUBLIC_KEYS.FORMAT(paramUserName), + }, + ]); + + const { items, collectionProps } = useCollection(publicKeys, { + selection: {}, + }); + + const { selectedItems = [] } = collectionProps; + + const openAddModal = () => { + setKeyValue(''); + setKeyName(''); + setAddError(''); + setShowAddModal(true); + }; + + const closeAddModal = () => { + setShowAddModal(false); + }; + + const handleAdd = () => { + if (!keyValue.trim()) { + setAddError(t('users.public_keys.key_required')); + return; + } + + addPublicKey({ key: keyValue.trim(), name: keyName.trim() || undefined }) + .unwrap() + .then(() => { + setShowAddModal(false); + }) + .catch((error) => { + const detail = (error?.data?.detail ?? []) as { msg: string; code: string }[]; + const isKeyExists = detail.some(({ code }) => code === 'resource_exists'); + setAddError(isKeyExists ? t('users.public_keys.key_already_exists') : getServerError(error)); + }); + }; + + const handleDelete = () => { + deletePublicKeys(selectedItems.map((k) => k.id)) + .unwrap() + .catch((error) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: getServerError(error) }), + }); + }); + }; + + const formatDate = (iso: string) => { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const columns = [ + { + id: 'name', + header: t('users.public_keys.name'), + cell: (item: IPublicKey) => item.name, + }, + { + id: 'fingerprint', + header: t('users.public_keys.fingerprint'), + cell: (item: IPublicKey) => ( + + {item.fingerprint} + + ), + }, + { + id: 'type', + header: t('users.public_keys.key_type'), + cell: (item: IPublicKey) => item.type, + }, + { + id: 'added_at', + header: t('users.public_keys.added'), + cell: (item: IPublicKey) => formatDate(item.added_at), + }, + ]; + + return ( + <> + + + {t('common.delete')} + + + + + } + > + {t('users.public_keys.title')} + + } + empty={ + + {t('users.public_keys.empty_title')} + + {t('users.public_keys.empty_message')} + + + + } + /> + + + + + + + + } + > + + + setKeyName(detail.value)} + placeholder={t('users.public_keys.key_name_placeholder')} + /> + + + + { + setKeyValue(detail.value); + setAddError(''); + }} + placeholder="ssh-ed25519 AAAA..." + rows={5} + /> + + + + + ); +}; diff --git a/frontend/src/pages/User/Details/index.tsx b/frontend/src/pages/User/Details/index.tsx index 3236d9aca..87d66a9fb 100644 --- a/frontend/src/pages/User/Details/index.tsx +++ b/frontend/src/pages/User/Details/index.tsx @@ -19,6 +19,7 @@ export { Settings as UserSettings } from './Settings'; export { Billing as UserBilling } from './Billing'; export { Events as UserEvents } from './Events'; export { UserProjectList as UserProjects } from './Projects'; +export { PublicKeys as UserPublicKeys } from './PublicKeys'; export const UserDetails: React.FC = () => { const { t } = useTranslation(); @@ -77,6 +78,11 @@ export const UserDetails: React.FC = () => { id: UserDetailsTabTypeEnum.EVENTS, href: ROUTES.USER.EVENTS.FORMAT(paramUserName), }, + { + label: t('users.public_keys.title'), + id: UserDetailsTabTypeEnum.PUBLIC_KEYS, + href: ROUTES.USER.PUBLIC_KEYS.FORMAT(paramUserName), + }, process.env.UI_VERSION === 'sky' && { label: t('billing.title'), id: UserDetailsTabTypeEnum.BILLING, diff --git a/frontend/src/pages/User/Details/types.ts b/frontend/src/pages/User/Details/types.ts index 9f2a0680a..ea0f69431 100644 --- a/frontend/src/pages/User/Details/types.ts +++ b/frontend/src/pages/User/Details/types.ts @@ -3,5 +3,6 @@ export enum UserDetailsTabTypeEnum { PROJECTS = 'projects', EVENTS = 'events', ACTIVITY = 'activity', + PUBLIC_KEYS = 'public-keys', BILLING = 'billing', } diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 1ee08eec9..cb36ab9bf 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -15,28 +15,20 @@ import { EventsList as FleetEventsList } from 'pages/Fleets/Details/Events'; import { FleetDetails as FleetDetailsGeneral } from 'pages/Fleets/Details/FleetDetails'; import { FleetInspect } from 'pages/Fleets/Details/Inspect'; import { InstanceDetailsPage, InstanceList } from 'pages/Instances'; -import { InstanceDetails } from 'pages/Instances/Details/InstanceDetails'; import { EventsList as InstanceEventsList } from 'pages/Instances/Details/Events'; import { InstanceInspect } from 'pages/Instances/Details/Inspect'; +import { InstanceDetails } from 'pages/Instances/Details/InstanceDetails'; import { ModelsList } from 'pages/Models'; import { ModelDetails } from 'pages/Models/Details'; import { CreateProjectWizard, ProjectAdd, ProjectDetails, ProjectEvents, ProjectList, ProjectSettings } from 'pages/Project'; import { BackendAdd, BackendEdit } from 'pages/Project/Backends'; import { AddGateway, EditGateway } from 'pages/Project/Gateways'; -import { - Launch, - EventsList as RunEvents, - JobLogs, - JobMetrics, - RunDetails, - RunDetailsPage, - RunList, -} from 'pages/Runs'; +import { EventsList as RunEvents, JobLogs, JobMetrics, Launch, RunDetails, RunDetailsPage, RunList } from 'pages/Runs'; import { RunInspect } from 'pages/Runs/Details/Inspect'; import { JobDetailsPage } from 'pages/Runs/Details/Jobs/Details'; import { EventsList as JobEvents } from 'pages/Runs/Details/Jobs/Events'; import { CreditsHistoryAdd, UserAdd, UserDetails, UserEdit, UserList } from 'pages/User'; -import { UserBilling, UserEvents, UserProjects, UserSettings } from 'pages/User/Details'; +import { UserBilling, UserEvents, UserProjects, UserPublicKeys, UserSettings } from 'pages/User/Details'; import { AuthErrorMessage } from './App/AuthErrorMessage'; import { EventList } from './pages/Events'; @@ -287,6 +279,10 @@ export const router = createBrowserRouter([ path: ROUTES.USER.EVENTS.TEMPLATE, element: , }, + { + path: ROUTES.USER.PUBLIC_KEYS.TEMPLATE, + element: , + }, process.env.UI_VERSION === 'sky' && { path: ROUTES.USER.BILLING.LIST.TEMPLATE, element: , diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index e5d06ef94..03e0e3d7f 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -205,6 +205,11 @@ export const ROUTES = { TEMPLATE: `/users/:userName/events`, FORMAT: (userName: string) => buildRoute(ROUTES.USER.EVENTS.TEMPLATE, { userName }), }, + PUBLIC_KEYS: { + TEMPLATE: `/users/:userName/public-keys`, + FORMAT: (userName: string) => buildRoute(ROUTES.USER.PUBLIC_KEYS.TEMPLATE, { userName }), + }, + BILLING: { LIST: { TEMPLATE: `/users/:userName/billing`, diff --git a/frontend/src/services/publicKeys.ts b/frontend/src/services/publicKeys.ts new file mode 100644 index 000000000..d8d3c9b98 --- /dev/null +++ b/frontend/src/services/publicKeys.ts @@ -0,0 +1,53 @@ +import { API } from 'api'; +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; + +import fetchBaseQueryHeaders from 'libs/fetchBaseQueryHeaders'; + +export interface IPublicKey { + id: string; + added_at: string; + name: string; + type: string; + fingerprint: string; +} + +export const publicKeysApi = createApi({ + reducerPath: 'publicKeysApi', + refetchOnMountOrArgChange: true, + baseQuery: fetchBaseQuery({ + prepareHeaders: fetchBaseQueryHeaders, + }), + + tagTypes: ['PublicKey'], + + endpoints: (builder) => ({ + listPublicKeys: builder.query({ + query: () => ({ + url: API.USER_PUBLIC_KEYS.LIST(), + method: 'POST', + }), + providesTags: (result) => + result ? [...result.map(({ id }) => ({ type: 'PublicKey' as const, id })), 'PublicKey'] : ['PublicKey'], + }), + + addPublicKey: builder.mutation({ + query: (body) => ({ + url: API.USER_PUBLIC_KEYS.ADD(), + method: 'POST', + body, + }), + invalidatesTags: ['PublicKey'], + }), + + deletePublicKeys: builder.mutation({ + query: (ids) => ({ + url: API.USER_PUBLIC_KEYS.DELETE(), + method: 'POST', + body: { ids }, + }), + invalidatesTags: ['PublicKey'], + }), + }), +}); + +export const { useListPublicKeysQuery, useAddPublicKeyMutation, useDeletePublicKeysMutation } = publicKeysApi; diff --git a/frontend/src/services/templates.ts b/frontend/src/services/templates.ts index d1d2028d8..08f1fe781 100644 --- a/frontend/src/services/templates.ts +++ b/frontend/src/services/templates.ts @@ -22,9 +22,7 @@ export const templateApi = createApi({ }, providesTags: (result) => - result - ? [...result.map(({ name }) => ({ type: 'Templates' as const, id: name })), 'Templates'] - : ['Templates'], + result ? [...result.map(({ name }) => ({ type: 'Templates' as const, id: name })), 'Templates'] : ['Templates'], }), }), }); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index e314eff99..f6dd63606 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -11,6 +11,7 @@ import { gatewayApi } from 'services/gateway'; import { instanceApi } from 'services/instance'; import { mainApi } from 'services/mainApi'; import { projectApi } from 'services/project'; +import { publicKeysApi } from 'services/publicKeys'; import { repoApi } from 'services/repo'; import { runApi } from 'services/run'; import { secretApi } from 'services/secrets'; @@ -42,6 +43,7 @@ export const store = configureStore({ [gpuApi.reducerPath]: gpuApi.reducer, [repoApi.reducerPath]: repoApi.reducer, [mainApi.reducerPath]: mainApi.reducer, + [publicKeysApi.reducerPath]: publicKeysApi.reducer, [eventApi.reducerPath]: eventApi.reducer, [templateApi.reducerPath]: templateApi.reducer, }, @@ -62,6 +64,7 @@ export const store = configureStore({ .concat(volumeApi.middleware) .concat(secretApi.middleware) .concat(gpuApi.middleware) + .concat(publicKeysApi.middleware) .concat(eventApi.middleware) .concat(repoApi.middleware) .concat(templateApi.middleware) From 2318abaf8534ff37b62cbd4b387264f5f4df3aef Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 30 Mar 2026 12:16:30 +0200 Subject: [PATCH 2/2] [UI] Added Info to `SSH keys` section; plus minor edits in UI titles --- frontend/src/locale/en.json | 2 +- .../User/Details/PublicKeys/constants.tsx | 28 +++++++++++++++++++ .../pages/User/Details/PublicKeys/index.tsx | 14 ++++++---- frontend/src/pages/User/Details/index.tsx | 10 +++---- 4 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/User/Details/PublicKeys/constants.tsx diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 9acedfdb7..e4a61fa2c 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -743,7 +743,7 @@ "projects": "Projects", "events": "Events", "public_keys": { - "title": "SSH Keys", + "title": "SSH keys", "add_key": "Add SSH key", "name": "Title", "fingerprint": "Fingerprint", diff --git a/frontend/src/pages/User/Details/PublicKeys/constants.tsx b/frontend/src/pages/User/Details/PublicKeys/constants.tsx new file mode 100644 index 000000000..1b7025b09 --- /dev/null +++ b/frontend/src/pages/User/Details/PublicKeys/constants.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +export const SSH_KEYS_INFO = { + header:

SSH Keys

, + body: ( + <> +

+ These SSH keys are for direct SSH access to runs from your local client without running{' '} + dstack attach. +

+

+ If you use dstack attach (or attached dstack apply), dstack manages a + client SSH key and local SSH shortcut automatically. In that workflow, you usually don't need to upload + additional keys. +

+

+ Without dstack attach, {'ssh '} is not configured on your machine. Use the + full proxied SSH connection details from run details instead. This requires SSH proxy to be enabled on the + server. +

+

+ To authorize this direct path, upload your public key (for example, ~/.ssh/id_ed25519.pub), and + keep the matching private key on your client. Uploaded keys are additional and do not replace the system-managed + key used by dstack attach/dstack apply. +

+ + ), +}; diff --git a/frontend/src/pages/User/Details/PublicKeys/index.tsx b/frontend/src/pages/User/Details/PublicKeys/index.tsx index 9887348a5..b6ce794d3 100644 --- a/frontend/src/pages/User/Details/PublicKeys/index.tsx +++ b/frontend/src/pages/User/Details/PublicKeys/index.tsx @@ -4,18 +4,21 @@ import { useParams } from 'react-router-dom'; import CloudscapeInput from '@cloudscape-design/components/input'; import CloudscapeTextarea from '@cloudscape-design/components/textarea'; -import { Box, Button, ButtonWithConfirmation, FormField, Header, Modal, SpaceBetween, Table } from 'components'; +import { Box, Button, ButtonWithConfirmation, FormField, Header, InfoLink, Modal, SpaceBetween, Table } from 'components'; -import { useBreadcrumbs, useCollection, useNotifications } from 'hooks'; +import { useBreadcrumbs, useCollection, useHelpPanel, useNotifications } from 'hooks'; import { getServerError } from 'libs'; import { ROUTES } from 'routes'; import { IPublicKey, useAddPublicKeyMutation, useDeletePublicKeysMutation, useListPublicKeysQuery } from 'services/publicKeys'; +import { SSH_KEYS_INFO } from './constants'; + export const PublicKeys: React.FC = () => { const { t } = useTranslation(); const params = useParams(); const paramUserName = params.userName ?? ''; const [pushNotification] = useNotifications(); + const [openHelpPanel] = useHelpPanel(); const [showAddModal, setShowAddModal] = useState(false); const [keyValue, setKeyValue] = useState(''); @@ -134,6 +137,7 @@ export const PublicKeys: React.FC = () => { header={
openHelpPanel(SSH_KEYS_INFO)} />} actions={ { } @@ -160,7 +164,7 @@ export const PublicKeys: React.FC = () => { {t('users.public_keys.empty_message')} - + } /> @@ -176,7 +180,7 @@ export const PublicKeys: React.FC = () => { {t('common.cancel')} diff --git a/frontend/src/pages/User/Details/index.tsx b/frontend/src/pages/User/Details/index.tsx index 87d66a9fb..ee878a442 100644 --- a/frontend/src/pages/User/Details/index.tsx +++ b/frontend/src/pages/User/Details/index.tsx @@ -68,6 +68,11 @@ export const UserDetails: React.FC = () => { id: UserDetailsTabTypeEnum.SETTINGS, href: ROUTES.USER.DETAILS.FORMAT(paramUserName), }, + { + label: t('users.public_keys.title'), + id: UserDetailsTabTypeEnum.PUBLIC_KEYS, + href: ROUTES.USER.PUBLIC_KEYS.FORMAT(paramUserName), + }, { label: t('users.projects'), id: UserDetailsTabTypeEnum.PROJECTS, @@ -78,11 +83,6 @@ export const UserDetails: React.FC = () => { id: UserDetailsTabTypeEnum.EVENTS, href: ROUTES.USER.EVENTS.FORMAT(paramUserName), }, - { - label: t('users.public_keys.title'), - id: UserDetailsTabTypeEnum.PUBLIC_KEYS, - href: ROUTES.USER.PUBLIC_KEYS.FORMAT(paramUserName), - }, process.env.UI_VERSION === 'sky' && { label: t('billing.title'), id: UserDetailsTabTypeEnum.BILLING,