Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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`,
},
};
6 changes: 1 addition & 5 deletions frontend/src/libs/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}

Expand Down
19 changes: 19 additions & 0 deletions frontend/src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ export const useColumnsDefinitions = () => {
</NavigateLink>
)}
/
<NavigateLink
href={ROUTES.INSTANCES.DETAILS.FORMAT(target.project_name ?? '', target.id)}
>
<NavigateLink href={ROUTES.INSTANCES.DETAILS.FORMAT(target.project_name ?? '', target.id)}>
{target.name}
</NavigateLink>
</div>
Expand Down
7 changes: 1 addition & 6 deletions frontend/src/pages/Instances/Details/Inspect/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,7 @@ export const InstanceInspect = () => {

return (
<Container header={<Header variant="h2">{t('fleets.instances.inspect')}</Header>}>
<CodeEditor
value={jsonContent}
language="json"
editorContentHeight={600}
onChange={() => {}}
/>
<CodeEditor value={jsonContent} language="json" editorContentHeight={600} onChange={() => {}} />
</Container>
);
};
7 changes: 3 additions & 4 deletions frontend/src/pages/Runs/Details/RunDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,9 @@ export const RunDetails = () => {
<ConnectToServiceRun run={runData} />
)}

{runData.run_spec.configuration.type === 'task' && !runIsStopped(runData.status) &&
(runData.jobs[0]?.job_spec?.app_specs?.length ?? 0) > 0 && (
<ConnectToTaskRun run={runData} />
)}
{runData.run_spec.configuration.type === 'task' &&
!runIsStopped(runData.status) &&
(runData.jobs[0]?.job_spec?.app_specs?.length ?? 0) > 0 && <ConnectToTaskRun run={runData} />}

{runData.jobs.length > 1 && (
<JobList
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/Runs/List/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { groupBy as _groupBy } from 'lodash';

import { getBaseUrl } from 'App/helpers';
import { formatBackend } from 'libs/fleet';
import { formatResources } from 'libs/resources';

import { getBaseUrl } from 'App/helpers';

import { finishedJobs, finishedRunStatuses } from '../constants';
import { getJobStatus } from '../Details/Jobs/List/helpers';

Expand Down
28 changes: 28 additions & 0 deletions frontend/src/pages/User/Details/PublicKeys/constants.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';

export const SSH_KEYS_INFO = {
header: <h2>SSH Keys</h2>,
body: (
<>
<p>
These SSH keys are for direct SSH access to runs from your local client without running{' '}
<code>dstack attach</code>.
</p>
<p>
If you use <code>dstack attach</code> (or attached <code>dstack apply</code>), <code>dstack</code> manages a
client SSH key and local SSH shortcut automatically. In that workflow, you usually don&apos;t need to upload
additional keys.
</p>
<p>
Without <code>dstack attach</code>, <code>{'ssh <run-name>'}</code> 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.
</p>
<p>
To authorize this direct path, upload your public key (for example, <code>~/.ssh/id_ed25519.pub</code>), and
keep the matching private key on your client. Uploaded keys are additional and do not replace the system-managed
key used by <code>dstack attach</code>/<code>dstack apply</code>.
</p>
</>
),
};
220 changes: 220 additions & 0 deletions frontend/src/pages/User/Details/PublicKeys/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
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, InfoLink, Modal, SpaceBetween, Table } from 'components';

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('');
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) => (
<Box fontWeight="normal" variant="code">
{item.fingerprint}
</Box>
),
},
{
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 (
<>
<Table
{...collectionProps}
loading={isLoading}
columnDefinitions={columns}
items={items}
selectionType="multi"
trackBy="id"
header={
<Header
counter={publicKeys.length ? `(${publicKeys.length})` : undefined}
info={<InfoLink onFollow={() => openHelpPanel(SSH_KEYS_INFO)} />}
actions={
<SpaceBetween size="xs" direction="horizontal">
<ButtonWithConfirmation
disabled={!selectedItems.length || isDeleting}
onClick={handleDelete}
confirmTitle={t('users.public_keys.delete_confirm_title')}
confirmContent={t('users.public_keys.delete_confirm_message')}
>
{t('common.delete')}
</ButtonWithConfirmation>

<Button variant="primary" onClick={openAddModal}>
{t('common.add')}
</Button>
</SpaceBetween>
}
>
{t('users.public_keys.title')}
</Header>
}
empty={
<Box textAlign="center" color="inherit">
<b>{t('users.public_keys.empty_title')}</b>
<Box padding={{ bottom: 's' }} variant="p" color="inherit">
{t('users.public_keys.empty_message')}
</Box>
<Button onClick={openAddModal}>{t('common.add')}</Button>
</Box>
}
/>

<Modal
visible={showAddModal}
onDismiss={closeAddModal}
header={t('users.public_keys.add_key')}
footer={
<Box float="right">
<SpaceBetween direction="horizontal" size="xs">
<Button variant="link" onClick={closeAddModal}>
{t('common.cancel')}
</Button>
<Button variant="primary" loading={isAdding} onClick={handleAdd}>
{t('common.add')}
</Button>
</SpaceBetween>
</Box>
}
>
<SpaceBetween size="m">
<FormField
label={t('users.public_keys.key_name_label')}
description={t('users.public_keys.key_name_description')}
>
<CloudscapeInput
value={keyName}
onChange={({ detail }) => setKeyName(detail.value)}
placeholder={t('users.public_keys.key_name_placeholder')}
/>
</FormField>

<FormField
label={t('users.public_keys.key_label')}
description={t('users.public_keys.key_description')}
errorText={addError}
>
<CloudscapeTextarea
value={keyValue}
onChange={({ detail }) => {
setKeyValue(detail.value);
setAddError('');
}}
placeholder="ssh-ed25519 AAAA..."
rows={5}
/>
</FormField>
</SpaceBetween>
</Modal>
</>
);
};
6 changes: 6 additions & 0 deletions frontend/src/pages/User/Details/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -67,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,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/User/Details/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export enum UserDetailsTabTypeEnum {
PROJECTS = 'projects',
EVENTS = 'events',
ACTIVITY = 'activity',
PUBLIC_KEYS = 'public-keys',
BILLING = 'billing',
}
Loading
Loading