Skip to content

Commit 8b924e7

Browse files
committed
Add web UI for user public keys
Generated by Claude Part-of: #3644
1 parent 83cad55 commit 8b924e7

File tree

15 files changed

+327
-35
lines changed

15 files changed

+327
-35
lines changed

frontend/src/api.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,7 @@ export const API = {
171171
INSTANCES: {
172172
BASE: () => `${API.BASE()}/instances`,
173173
LIST: () => `${API.INSTANCES.BASE()}/list`,
174-
DETAILS: (projectName: IProject['project_name']) =>
175-
`${API.BASE()}/project/${projectName}/instances/get`,
174+
DETAILS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/instances/get`,
176175
},
177176

178177
SERVER: {
@@ -184,4 +183,11 @@ export const API = {
184183
BASE: () => `${API.BASE()}/volumes`,
185184
LIST: () => `${API.VOLUME.BASE()}/list`,
186185
},
186+
187+
USER_PUBLIC_KEYS: {
188+
BASE: () => `${API.BASE()}/users/public_keys`,
189+
LIST: () => `${API.USER_PUBLIC_KEYS.BASE()}/list`,
190+
ADD: () => `${API.USER_PUBLIC_KEYS.BASE()}/add`,
191+
DELETE: () => `${API.USER_PUBLIC_KEYS.BASE()}/delete`,
192+
},
187193
};

frontend/src/libs/instance.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,7 @@ export const getHealthStatusIconType = (healthStatus: THealthStatus): StatusIndi
2020
export const formatInstanceStatusText = (instance: IInstance): string => {
2121
const status = instance.status;
2222

23-
if (
24-
(status === 'idle' || status === 'busy') &&
25-
instance.total_blocks !== null &&
26-
instance.total_blocks > 1
27-
) {
23+
if ((status === 'idle' || status === 'busy') && instance.total_blocks !== null && instance.total_blocks > 1) {
2824
return `${instance.busy_blocks}/${instance.total_blocks} Busy`;
2925
}
3026

frontend/src/locale/en.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,25 @@
742742
"settings": "Settings",
743743
"projects": "Projects",
744744
"events": "Events",
745+
"public_keys": {
746+
"title": "SSH Keys",
747+
"add_key": "Add SSH key",
748+
"name": "Title",
749+
"fingerprint": "Fingerprint",
750+
"key_type": "Key type",
751+
"added": "Added",
752+
"empty_title": "No SSH keys",
753+
"empty_message": "You haven't added any SSH keys yet.",
754+
"key_name_label": "Title",
755+
"key_name_description": "A label to identify this key",
756+
"key_name_placeholder": "My SSH key",
757+
"key_label": "Key",
758+
"key_description": "Paste your public key content (e.g. the contents of ~/.ssh/id_ed25519.pub)",
759+
"key_required": "Key content is required",
760+
"key_already_exists": "This public key is already added to your account",
761+
"delete_confirm_title": "Delete SSH key",
762+
"delete_confirm_message": "Are you sure you want to delete the selected SSH key(s)?"
763+
},
745764
"create": {
746765
"page_title": "Create user",
747766
"error_notification": "Create user error",

frontend/src/pages/Events/List/hooks/useColumnDefinitions.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,7 @@ export const useColumnsDefinitions = () => {
7878
</NavigateLink>
7979
)}
8080
/
81-
<NavigateLink
82-
href={ROUTES.INSTANCES.DETAILS.FORMAT(target.project_name ?? '', target.id)}
83-
>
81+
<NavigateLink href={ROUTES.INSTANCES.DETAILS.FORMAT(target.project_name ?? '', target.id)}>
8482
{target.name}
8583
</NavigateLink>
8684
</div>

frontend/src/pages/Instances/Details/Inspect/index.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,7 @@ export const InstanceInspect = () => {
5858

5959
return (
6060
<Container header={<Header variant="h2">{t('fleets.instances.inspect')}</Header>}>
61-
<CodeEditor
62-
value={jsonContent}
63-
language="json"
64-
editorContentHeight={600}
65-
onChange={() => {}}
66-
/>
61+
<CodeEditor value={jsonContent} language="json" editorContentHeight={600} onChange={() => {}} />
6762
</Container>
6863
);
6964
};

frontend/src/pages/Runs/Details/RunDetails/index.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,9 @@ export const RunDetails = () => {
212212
<ConnectToServiceRun run={runData} />
213213
)}
214214

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

220219
{runData.jobs.length > 1 && (
221220
<JobList

frontend/src/pages/Runs/List/helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { groupBy as _groupBy } from 'lodash';
22

3-
import { getBaseUrl } from 'App/helpers';
43
import { formatBackend } from 'libs/fleet';
54
import { formatResources } from 'libs/resources';
65

6+
import { getBaseUrl } from 'App/helpers';
7+
78
import { finishedJobs, finishedRunStatuses } from '../constants';
89
import { getJobStatus } from '../Details/Jobs/List/helpers';
910

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import React, { useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { useParams } from 'react-router-dom';
4+
import CloudscapeInput from '@cloudscape-design/components/input';
5+
import CloudscapeTextarea from '@cloudscape-design/components/textarea';
6+
7+
import { Box, Button, ButtonWithConfirmation, FormField, Header, Modal, SpaceBetween, Table } from 'components';
8+
9+
import { useBreadcrumbs, useCollection, useNotifications } from 'hooks';
10+
import { getServerError } from 'libs';
11+
import { ROUTES } from 'routes';
12+
import { IPublicKey, useAddPublicKeyMutation, useDeletePublicKeysMutation, useListPublicKeysQuery } from 'services/publicKeys';
13+
14+
export const PublicKeys: React.FC = () => {
15+
const { t } = useTranslation();
16+
const params = useParams();
17+
const paramUserName = params.userName ?? '';
18+
const [pushNotification] = useNotifications();
19+
20+
const [showAddModal, setShowAddModal] = useState(false);
21+
const [keyValue, setKeyValue] = useState('');
22+
const [keyName, setKeyName] = useState('');
23+
const [addError, setAddError] = useState('');
24+
25+
const { data: publicKeys = [], isLoading } = useListPublicKeysQuery();
26+
const [addPublicKey, { isLoading: isAdding }] = useAddPublicKeyMutation();
27+
const [deletePublicKeys, { isLoading: isDeleting }] = useDeletePublicKeysMutation();
28+
29+
useBreadcrumbs([
30+
{
31+
text: t('navigation.account'),
32+
href: ROUTES.USER.LIST,
33+
},
34+
{
35+
text: paramUserName,
36+
href: ROUTES.USER.DETAILS.FORMAT(paramUserName),
37+
},
38+
{
39+
text: t('users.public_keys.title'),
40+
href: ROUTES.USER.PUBLIC_KEYS.FORMAT(paramUserName),
41+
},
42+
]);
43+
44+
const { items, collectionProps } = useCollection(publicKeys, {
45+
selection: {},
46+
});
47+
48+
const { selectedItems = [] } = collectionProps;
49+
50+
const openAddModal = () => {
51+
setKeyValue('');
52+
setKeyName('');
53+
setAddError('');
54+
setShowAddModal(true);
55+
};
56+
57+
const closeAddModal = () => {
58+
setShowAddModal(false);
59+
};
60+
61+
const handleAdd = () => {
62+
if (!keyValue.trim()) {
63+
setAddError(t('users.public_keys.key_required'));
64+
return;
65+
}
66+
67+
addPublicKey({ key: keyValue.trim(), name: keyName.trim() || undefined })
68+
.unwrap()
69+
.then(() => {
70+
setShowAddModal(false);
71+
})
72+
.catch((error) => {
73+
const detail = (error?.data?.detail ?? []) as { msg: string; code: string }[];
74+
const isKeyExists = detail.some(({ code }) => code === 'resource_exists');
75+
setAddError(isKeyExists ? t('users.public_keys.key_already_exists') : getServerError(error));
76+
});
77+
};
78+
79+
const handleDelete = () => {
80+
deletePublicKeys(selectedItems.map((k) => k.id))
81+
.unwrap()
82+
.catch((error) => {
83+
pushNotification({
84+
type: 'error',
85+
content: t('common.server_error', { error: getServerError(error) }),
86+
});
87+
});
88+
};
89+
90+
const formatDate = (iso: string) => {
91+
return new Date(iso).toLocaleDateString(undefined, {
92+
year: 'numeric',
93+
month: 'short',
94+
day: 'numeric',
95+
});
96+
};
97+
98+
const columns = [
99+
{
100+
id: 'name',
101+
header: t('users.public_keys.name'),
102+
cell: (item: IPublicKey) => item.name,
103+
},
104+
{
105+
id: 'fingerprint',
106+
header: t('users.public_keys.fingerprint'),
107+
cell: (item: IPublicKey) => (
108+
<Box fontWeight="normal" variant="code">
109+
{item.fingerprint}
110+
</Box>
111+
),
112+
},
113+
{
114+
id: 'type',
115+
header: t('users.public_keys.key_type'),
116+
cell: (item: IPublicKey) => item.type,
117+
},
118+
{
119+
id: 'added_at',
120+
header: t('users.public_keys.added'),
121+
cell: (item: IPublicKey) => formatDate(item.added_at),
122+
},
123+
];
124+
125+
return (
126+
<>
127+
<Table
128+
{...collectionProps}
129+
loading={isLoading}
130+
columnDefinitions={columns}
131+
items={items}
132+
selectionType="multi"
133+
trackBy="id"
134+
header={
135+
<Header
136+
counter={publicKeys.length ? `(${publicKeys.length})` : undefined}
137+
actions={
138+
<SpaceBetween size="xs" direction="horizontal">
139+
<ButtonWithConfirmation
140+
disabled={!selectedItems.length || isDeleting}
141+
onClick={handleDelete}
142+
confirmTitle={t('users.public_keys.delete_confirm_title')}
143+
confirmContent={t('users.public_keys.delete_confirm_message')}
144+
>
145+
{t('common.delete')}
146+
</ButtonWithConfirmation>
147+
148+
<Button variant="primary" onClick={openAddModal}>
149+
{t('users.public_keys.add_key')}
150+
</Button>
151+
</SpaceBetween>
152+
}
153+
>
154+
{t('users.public_keys.title')}
155+
</Header>
156+
}
157+
empty={
158+
<Box textAlign="center" color="inherit">
159+
<b>{t('users.public_keys.empty_title')}</b>
160+
<Box padding={{ bottom: 's' }} variant="p" color="inherit">
161+
{t('users.public_keys.empty_message')}
162+
</Box>
163+
<Button onClick={openAddModal}>{t('users.public_keys.add_key')}</Button>
164+
</Box>
165+
}
166+
/>
167+
168+
<Modal
169+
visible={showAddModal}
170+
onDismiss={closeAddModal}
171+
header={t('users.public_keys.add_key')}
172+
footer={
173+
<Box float="right">
174+
<SpaceBetween direction="horizontal" size="xs">
175+
<Button variant="link" onClick={closeAddModal}>
176+
{t('common.cancel')}
177+
</Button>
178+
<Button variant="primary" loading={isAdding} onClick={handleAdd}>
179+
{t('users.public_keys.add_key')}
180+
</Button>
181+
</SpaceBetween>
182+
</Box>
183+
}
184+
>
185+
<SpaceBetween size="m">
186+
<FormField
187+
label={t('users.public_keys.key_name_label')}
188+
description={t('users.public_keys.key_name_description')}
189+
>
190+
<CloudscapeInput
191+
value={keyName}
192+
onChange={({ detail }) => setKeyName(detail.value)}
193+
placeholder={t('users.public_keys.key_name_placeholder')}
194+
/>
195+
</FormField>
196+
197+
<FormField
198+
label={t('users.public_keys.key_label')}
199+
description={t('users.public_keys.key_description')}
200+
errorText={addError}
201+
>
202+
<CloudscapeTextarea
203+
value={keyValue}
204+
onChange={({ detail }) => {
205+
setKeyValue(detail.value);
206+
setAddError('');
207+
}}
208+
placeholder="ssh-ed25519 AAAA..."
209+
rows={5}
210+
/>
211+
</FormField>
212+
</SpaceBetween>
213+
</Modal>
214+
</>
215+
);
216+
};

frontend/src/pages/User/Details/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export { Settings as UserSettings } from './Settings';
1919
export { Billing as UserBilling } from './Billing';
2020
export { Events as UserEvents } from './Events';
2121
export { UserProjectList as UserProjects } from './Projects';
22+
export { PublicKeys as UserPublicKeys } from './PublicKeys';
2223

2324
export const UserDetails: React.FC = () => {
2425
const { t } = useTranslation();
@@ -77,6 +78,11 @@ export const UserDetails: React.FC = () => {
7778
id: UserDetailsTabTypeEnum.EVENTS,
7879
href: ROUTES.USER.EVENTS.FORMAT(paramUserName),
7980
},
81+
{
82+
label: t('users.public_keys.title'),
83+
id: UserDetailsTabTypeEnum.PUBLIC_KEYS,
84+
href: ROUTES.USER.PUBLIC_KEYS.FORMAT(paramUserName),
85+
},
8086
process.env.UI_VERSION === 'sky' && {
8187
label: t('billing.title'),
8288
id: UserDetailsTabTypeEnum.BILLING,

frontend/src/pages/User/Details/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ export enum UserDetailsTabTypeEnum {
33
PROJECTS = 'projects',
44
EVENTS = 'events',
55
ACTIVITY = 'activity',
6+
PUBLIC_KEYS = 'public-keys',
67
BILLING = 'billing',
78
}

0 commit comments

Comments
 (0)