Skip to content

Commit 5eb5945

Browse files
committed
Add details to option descriptions to include mounts and other specs
1 parent 06e1ca2 commit 5eb5945

File tree

4 files changed

+169
-70
lines changed

4 files changed

+169
-70
lines changed

workspaces/frontend/src/app/pages/Workspaces/Form/properties/WorkspaceFormPropertiesSecrets.tsx

Lines changed: 29 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,7 @@ export const WorkspaceFormPropertiesSecrets: React.FC<WorkspaceFormPropertiesSec
4545
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
4646
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
4747
const [availableSecrets, setAvailableSecrets] = useState<SecretsSecretListItem[]>([]);
48-
const [attachedSecrets, setAttachedSecrets] = useState<WorkspacesPodSecretMount[]>([]);
49-
const [attachedMountPath, setAttachedMountPath] = useState('');
50-
const [attachedDefaultMode, setAttachedDefaultMode] = useState(DEFAULT_MODE_OCTAL);
48+
const [attachedSecretKeys, setAttachedSecretKeys] = useState<Set<string>>(new Set());
5149

5250
const { api } = useNotebookAPI();
5351
const { selectedNamespace } = useNamespaceContext();
@@ -60,6 +58,9 @@ export const WorkspaceFormPropertiesSecrets: React.FC<WorkspaceFormPropertiesSec
6058
fetchSecrets();
6159
}, [api.secrets, selectedNamespace]);
6260

61+
const getSecretKey = (secret: WorkspacesPodSecretMount): string =>
62+
`${secret.secretName}:${secret.mountPath}:${secret.defaultMode}`;
63+
6364
const openDeleteModal = useCallback((i: number) => {
6465
setIsDeleteModalOpen(true);
6566
setDeleteIndex(i);
@@ -76,26 +77,23 @@ export const WorkspaceFormPropertiesSecrets: React.FC<WorkspaceFormPropertiesSec
7677

7778
const handleAttachSecrets = useCallback(
7879
(newSecrets: SecretsSecretListItem[], mountPath: string, mode: number) => {
79-
const newAttachedSecrets = newSecrets.map((secret) => ({
80+
const newSecretMounts = newSecrets.map((secret) => ({
8081
secretName: secret.name,
8182
mountPath,
8283
defaultMode: mode,
8384
}));
84-
const oldAttachedNames = new Set(attachedSecrets.map((s) => s.secretName));
85-
const secretsWithoutOldAttached = secrets.filter((s) => !oldAttachedNames.has(s.secretName));
86-
const manualSecretNames = new Set(secretsWithoutOldAttached.map((s) => s.secretName));
87-
const filteredNewAttached = newAttachedSecrets.filter(
88-
(s) => !manualSecretNames.has(s.secretName),
89-
);
90-
91-
// Update both states
92-
setAttachedSecrets(filteredNewAttached);
93-
setSecrets([...secretsWithoutOldAttached, ...filteredNewAttached]);
94-
setAttachedMountPath(mountPath);
95-
setAttachedDefaultMode(mode.toString(8));
85+
86+
// Track the keys of attached secrets
87+
const newKeys = new Set(attachedSecretKeys);
88+
newSecretMounts.forEach((mount) => {
89+
newKeys.add(getSecretKey(mount));
90+
});
91+
setAttachedSecretKeys(newKeys);
92+
93+
setSecrets([...secrets, ...newSecretMounts]);
9694
setIsAttachModalOpen(false);
9795
},
98-
[attachedSecrets, secrets, setSecrets],
96+
[secrets, setSecrets, attachedSecretKeys],
9997
);
10098

10199
const handleCreateOrEditSubmit = useCallback(
@@ -121,32 +119,30 @@ export const WorkspaceFormPropertiesSecrets: React.FC<WorkspaceFormPropertiesSec
121119
}, []);
122120

123121
const isAttachedSecret = useCallback(
124-
(secretName: string) => attachedSecrets.some((s) => s.secretName === secretName),
125-
[attachedSecrets],
122+
(index: number): boolean => {
123+
const secret = secrets[index];
124+
return attachedSecretKeys.has(getSecretKey(secret));
125+
},
126+
[secrets, attachedSecretKeys],
126127
);
127128

128129
const handleDelete = useCallback(() => {
129130
if (deleteIndex === null) {
130131
return;
131132
}
132133
const secretToDelete = secrets[deleteIndex];
133-
setSecrets(secrets.filter((_, i) => i !== deleteIndex));
134134

135-
// If it's an attached secret, also remove from attachedSecrets
136-
if (isAttachedSecret(secretToDelete.secretName)) {
137-
const updatedAttachedSecrets = attachedSecrets.filter(
138-
(s) => s.secretName !== secretToDelete.secretName,
139-
);
140-
setAttachedSecrets(updatedAttachedSecrets);
141-
if (updatedAttachedSecrets.length === 0) {
142-
setAttachedMountPath('');
143-
setAttachedDefaultMode(DEFAULT_MODE_OCTAL);
144-
}
135+
// Remove from attached keys if it was attached
136+
if (attachedSecretKeys.has(getSecretKey(secretToDelete))) {
137+
const newKeys = new Set(attachedSecretKeys);
138+
newKeys.delete(getSecretKey(secretToDelete));
139+
setAttachedSecretKeys(newKeys);
145140
}
146141

142+
setSecrets(secrets.filter((_, i) => i !== deleteIndex));
147143
setDeleteIndex(null);
148144
setIsDeleteModalOpen(false);
149-
}, [deleteIndex, secrets, setSecrets, attachedSecrets, isAttachedSecret]);
145+
}, [deleteIndex, secrets, setSecrets, attachedSecretKeys]);
150146

151147
return (
152148
<>
@@ -183,7 +179,7 @@ export const WorkspaceFormPropertiesSecrets: React.FC<WorkspaceFormPropertiesSec
183179
onSelect={() => setDropdownOpen(null)}
184180
popperProps={{ position: 'right' }}
185181
>
186-
{!isAttachedSecret(secret.secretName) && (
182+
{!isAttachedSecret(index) && (
187183
<DropdownItem onClick={() => handleEdit(index)}>Edit</DropdownItem>
188184
)}
189185
<DropdownItem onClick={() => openDeleteModal(index)}>Remove</DropdownItem>
@@ -212,10 +208,8 @@ export const WorkspaceFormPropertiesSecrets: React.FC<WorkspaceFormPropertiesSec
212208
availableSecrets={availableSecrets}
213209
isOpen={isAttachModalOpen}
214210
setIsOpen={setIsAttachModalOpen}
215-
selectedSecrets={attachedSecrets.map((secret) => secret.secretName)}
216211
onClose={handleAttachSecrets}
217-
initialMountPath={attachedMountPath}
218-
initialDefaultMode={attachedDefaultMode}
212+
existingSecretKeys={attachedSecretKeys}
219213
/>
220214
<SecretsCreateModal
221215
isOpen={isCreateModalOpen}

workspaces/frontend/src/app/pages/Workspaces/Form/properties/secrets/SecretsAttachModal.tsx

Lines changed: 122 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,65 +8,149 @@ import {
88
ModalVariant,
99
} from '@patternfly/react-core/dist/esm/components/Modal';
1010
import { MultiTypeaheadSelect, MultiTypeaheadSelectOption } from '@patternfly/react-templates';
11-
import { Form, FormGroup } from '@patternfly/react-core/dist/esm/components/Form';
11+
import { Form } from '@patternfly/react-core/dist/esm/components/Form';
1212
import { HelperText, HelperTextItem } from '@patternfly/react-core/dist/esm/components/HelperText';
1313
import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput';
1414
import { ValidatedOptions } from '@patternfly/react-core/helpers';
15+
import { Flex, FlexItem } from '@patternfly/react-core/dist/esm/layouts/Flex';
16+
import { Tooltip } from '@patternfly/react-core/dist/esm/components/Tooltip';
17+
import { InfoCircleIcon } from '@patternfly/react-icons/dist/esm/icons/info-circle-icon';
18+
import { Truncate } from '@patternfly/react-core/dist/esm/components/Truncate';
19+
import { Stack, StackItem } from '@patternfly/react-core/dist/esm/layouts/Stack';
1520
import { SecretsSecretListItem } from '~/generated/data-contracts';
1621
import { isValidDefaultMode } from '~/app/pages/Workspaces/Form/helpers';
22+
import ThemeAwareFormGroupWrapper from '~/shared/components/ThemeAwareFormGroupWrapper';
1723

1824
export interface SecretsAttachModalProps {
1925
isOpen: boolean;
2026
setIsOpen: (isOpen: boolean) => void;
2127
onClose: (secrets: SecretsSecretListItem[], mountPath: string, mode: number) => void;
22-
selectedSecrets: string[];
2328
availableSecrets: SecretsSecretListItem[];
24-
initialMountPath?: string;
25-
initialDefaultMode?: string;
29+
existingSecretKeys: Set<string>;
2630
}
2731

32+
const DEFAULT_MODE_OCTAL = (420).toString(8);
33+
2834
export const SecretsAttachModal: React.FC<SecretsAttachModalProps> = ({
2935
isOpen,
3036
setIsOpen,
3137
onClose,
32-
selectedSecrets,
3338
availableSecrets,
34-
initialMountPath = '',
35-
initialDefaultMode = '',
39+
existingSecretKeys,
3640
}) => {
37-
const [selected, setSelected] = useState<string[]>(selectedSecrets);
38-
const [mountPath, setMountPath] = useState(initialMountPath);
39-
const [defaultMode, setDefaultMode] = useState(initialDefaultMode);
41+
const [selected, setSelected] = useState<string[]>([]);
42+
const [mountPath, setMountPath] = useState('');
43+
const [defaultMode, setDefaultMode] = useState(DEFAULT_MODE_OCTAL);
4044
const [isDefaultModeValid, setIsDefaultModeValid] = useState(true);
45+
const [error, setError] = useState<string>('');
4146

42-
// Sync state with props when modal opens or props change
47+
// Reset state when modal opens
4348
useEffect(() => {
4449
if (isOpen) {
45-
setSelected(selectedSecrets);
46-
setMountPath(initialMountPath);
47-
setDefaultMode(initialDefaultMode);
50+
setSelected([]);
51+
setMountPath('');
52+
setDefaultMode(DEFAULT_MODE_OCTAL);
4853
setIsDefaultModeValid(true);
54+
setError('');
4955
}
50-
}, [isOpen, selectedSecrets, initialMountPath, initialDefaultMode]);
56+
}, [isOpen]);
57+
58+
const getSecretKey = (secretName: string, path: string, mode: number): string =>
59+
`${secretName}:${path}:${mode}`;
5160

5261
const handleDefaultModeChange = (val: string) => {
5362
if (val.length <= 3) {
5463
setDefaultMode(val);
5564
const isValid = isValidDefaultMode(val);
5665
setIsDefaultModeValid(val.length === 3 && isValid);
66+
setError(''); // Clear error when user modifies input
67+
}
68+
};
69+
70+
const handleAttach = () => {
71+
const mode = parseInt(defaultMode, 8);
72+
73+
// Check for duplicates
74+
const duplicates: string[] = [];
75+
selected.forEach((secretName) => {
76+
const key = getSecretKey(secretName, mountPath.trim(), mode);
77+
if (existingSecretKeys.has(key)) {
78+
duplicates.push(secretName);
79+
}
80+
});
81+
82+
if (duplicates.length > 0) {
83+
const secretList = duplicates.join(', ');
84+
setError(
85+
`The following secret${duplicates.length > 1 ? 's are' : ' is'} already mounted to "${mountPath.trim()}" with mode ${defaultMode}: ${secretList}`,
86+
);
87+
return;
5788
}
89+
90+
// No duplicates, proceed with attaching
91+
onClose(
92+
availableSecrets.filter((secret) => selected.includes(secret.name)),
93+
mountPath.trim(),
94+
mode,
95+
);
5896
};
5997

6098
const initialOptions = useMemo<MultiTypeaheadSelectOption[]>(
6199
() =>
62100
availableSecrets.map((secret) => ({
63101
content: secret.name,
64102
value: secret.name,
65-
selected: selectedSecrets.includes(secret.name),
66103
isDisabled: !secret.canMount,
67-
description: `Type: ${secret.type}`,
104+
description: (
105+
// <Grid style={{ maxWidth: '45vw' }}>
106+
<Flex justifyContent={{ default: 'justifyContentSpaceBetween' }}>
107+
<FlexItem style={{ maxWidth: '35vw' }}>
108+
<Stack>
109+
<StackItem>
110+
Type: {secret.type}
111+
{secret.immutable && '. Immutable'}
112+
</StackItem>
113+
{secret.mounts && (
114+
<StackItem>
115+
{`Mounted to: `}
116+
<Truncate
117+
content={secret.mounts.map((mount) => mount.name).join(', ')}
118+
position="middle"
119+
/>
120+
</StackItem>
121+
)}
122+
</Stack>
123+
</FlexItem>
124+
<FlexItem>
125+
{secret.canMount && (
126+
<Tooltip
127+
aria="none"
128+
aria-live="polite"
129+
content=<Stack>
130+
<StackItem>
131+
Created at: {new Date(secret.audit.createdAt).toLocaleString()} {`by `}
132+
{secret.audit.createdBy}
133+
</StackItem>
134+
<StackItem>
135+
Updated at: {new Date(secret.audit.updatedAt).toLocaleString()} {`by `}
136+
{secret.audit.updatedBy}
137+
</StackItem>
138+
</Stack>
139+
>
140+
<Button
141+
aria-label="Show secret details"
142+
variant="plain"
143+
id="tt-ref"
144+
icon={<InfoCircleIcon />}
145+
/>
146+
</Tooltip>
147+
)}
148+
</FlexItem>
149+
</Flex>
150+
// </Grid>
151+
),
68152
})),
69-
[availableSecrets, selectedSecrets],
153+
[availableSecrets],
70154
);
71155

72156
return (
@@ -81,26 +165,32 @@ export const SecretsAttachModal: React.FC<SecretsAttachModalProps> = ({
81165
<ModalHeader title="Attach Existing Secrets" labelId="basic-modal-title" />
82166
<ModalBody id="modal-box-body-basic">
83167
<Form>
84-
<FormGroup label="Secret" fieldId="secret-select">
168+
<ThemeAwareFormGroupWrapper label="Secret" fieldId="secret-select">
85169
<MultiTypeaheadSelect
86170
initialOptions={initialOptions}
87171
id="secret-select"
88172
placeholder="Select a secret"
89173
noOptionsFoundMessage={(filter) => `No secret was found for "${filter}"`}
90-
onSelectionChange={(_ev, selections) => setSelected(selections as string[])}
174+
onSelectionChange={(_ev, selections) => {
175+
setSelected(selections as string[]);
176+
setError('');
177+
}}
91178
/>
92-
</FormGroup>
93-
<FormGroup label="Mount Path" isRequired fieldId="mount-path">
179+
</ThemeAwareFormGroupWrapper>
180+
<ThemeAwareFormGroupWrapper label="Mount Path" isRequired fieldId="mount-path">
94181
<TextInput
95182
name="mountPath"
96183
isRequired
97184
type="text"
98185
value={mountPath}
99-
onChange={(_, val) => setMountPath(val)}
186+
onChange={(_, val) => {
187+
setMountPath(val);
188+
setError('');
189+
}}
100190
id="mount-path"
101191
/>
102-
</FormGroup>
103-
<FormGroup label="Default Mode" isRequired fieldId="default-mode">
192+
</ThemeAwareFormGroupWrapper>
193+
<ThemeAwareFormGroupWrapper label="Default Mode" isRequired fieldId="default-mode">
104194
<TextInput
105195
name="defaultMode"
106196
isRequired
@@ -117,21 +207,20 @@ export const SecretsAttachModal: React.FC<SecretsAttachModalProps> = ({
117207
</HelperTextItem>
118208
</HelperText>
119209
)}
120-
</FormGroup>
210+
</ThemeAwareFormGroupWrapper>
121211
</Form>
212+
{error && (
213+
<HelperText>
214+
<HelperTextItem variant="error">{error}</HelperTextItem>
215+
</HelperText>
216+
)}
122217
</ModalBody>
123218
<ModalFooter>
124219
<Button
125220
key="attach"
126221
variant="primary"
127222
isDisabled={!isDefaultModeValid || !mountPath || selected.length === 0}
128-
onClick={() =>
129-
onClose(
130-
availableSecrets.filter((secret) => selected.includes(secret.name)),
131-
mountPath,
132-
parseInt(defaultMode, 8),
133-
)
134-
}
223+
onClick={handleAttach}
135224
>
136225
Attach
137226
</Button>

workspaces/frontend/src/shared/mock/mockBuilder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,9 +487,9 @@ export const buildMockSecret = (
487487
canUpdate: true,
488488
audit: {
489489
createdAt: new Date(2025, 4, 1).toISOString(),
490-
createdBy: 'test',
490+
createdBy: 'admin1',
491491
updatedAt: new Date(2025, 4, 1).toISOString(),
492-
updatedBy: 'test',
492+
updatedBy: 'user1',
493493
},
494494
...secret,
495495
});

workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,25 @@ export const mockSecretCreate: SecretsSecretCreate = {
188188
export const mockSecretsList = [
189189
buildMockSecret({
190190
name: 'secret-1',
191+
immutable: true,
191192
}),
192193
buildMockSecret({
193194
name: 'secret-2',
194195
canMount: false,
195196
}),
197+
buildMockSecret({
198+
name: 'secret-3',
199+
mounts: [
200+
{
201+
name: 'workspace-1',
202+
group: 'group-1',
203+
kind: 'kind-1',
204+
},
205+
{
206+
name: 'workspace-2',
207+
group: 'group-2',
208+
kind: 'kind-2',
209+
},
210+
],
211+
}),
196212
];

0 commit comments

Comments
 (0)