Skip to content

Commit 5d68b26

Browse files
committed
frontend: Activity: Improve panel UX with close options and pin feature
- Add ESC key handler to close/minimize active panels - Add click-outside detection with smart exception handling - Add toggle behavior when re-clicking the same resource - Add pin functionality to keep important resources in ActivityBar - Add pin/unpin button with visual indicator (outline/filled icon) - Pinned activities minimize instead of closing completely - Extract closeOrMinimize helper to reduce code duplication - Prevent panels from closing when clicking other activity panels - Prevent panels from closing when using table controls (pagination, sort, filter, search, show/hide, namespace selector) - Replace setTimeout delay with timestamp comparison for better UX Signed-off-by: jaehanbyun <[email protected]>
1 parent 87d749c commit 5d68b26

File tree

5 files changed

+269
-28
lines changed

5 files changed

+269
-28
lines changed

frontend/src/components/App/icons.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,12 @@ const mdiIcons = {
438438
'select-group': {
439439
body: '\u003Cpath fill="currentColor" d="M5 3a2 2 0 0 0-2 2h2m2-2v2h2V3m2 0v2h2V3m2 0v2h2V3m2 0v2h2a2 2 0 0 0-2-2M3 7v2h2V7m2 0v4h4V7m2 0v4h4V7m2 0v2h2V7M3 11v2h2v-2m14 0v2h2v-2M7 13v4h4v-4m2 0v4h4v-4M3 15v2h2v-2m14 0v2h2v-2M3 19a2 2 0 0 0 2 2v-2m2 0v2h2v-2m2 0v2h2v-2m2 0v2h2v-2m2 0v2a2 2 0 0 0 2-2Z"/\u003E',
440440
},
441+
pin: {
442+
body: '\u003Cpath fill="currentColor" d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2z"/\u003E',
443+
},
444+
'pin-outline': {
445+
body: '\u003Cpath fill="currentColor" d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2zm-6 2H8v-.5l2-2V4h4v7.5l2 2v.5z"/\u003E',
446+
},
441447
},
442448
aliases: {
443449
'more-vert': {

frontend/src/components/activity/Activity.tsx

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export interface Activity {
7272
temporary?: boolean;
7373
/** Cluster of the launched activity */
7474
cluster?: string;
75+
/** Whether this activity is pinned (won't close on click-outside) */
76+
pinned?: boolean;
7577
}
7678

7779
export interface ActivityState {
@@ -178,6 +180,21 @@ export const Activity = {
178180
update(id: string, diff: Partial<Activity>) {
179181
store.dispatch(activitySlice.actions.update({ ...diff, id }));
180182
},
183+
/**
184+
* Closes or minimizes activity based on pinned state
185+
* - Pinned activities: minimized (kept in ActivityBar)
186+
* - Regular activities: closed completely
187+
*/
188+
closeOrMinimize(id: string) {
189+
const state = store.getState();
190+
const activity = state.activity.activities[id];
191+
192+
if (activity?.pinned) {
193+
this.update(id, { minimized: true });
194+
} else {
195+
this.close(id);
196+
}
197+
},
181198
reset() {
182199
store.dispatch(activitySlice.actions.reset());
183200
},
@@ -335,6 +352,125 @@ export function SingleActivityRenderer({
335352
};
336353
}, [location]);
337354

355+
// Close or minimize activity when clicking outside (only for split modes)
356+
useEffect(() => {
357+
if (isOverview || minimized || (location !== 'split-left' && location !== 'split-right')) {
358+
return;
359+
}
360+
361+
// Record when the listener is registered to ignore immediate clicks
362+
const listenerRegistrationTime = Date.now();
363+
364+
const handleClickOutside = (event: MouseEvent) => {
365+
// Ignore clicks that happened within 100ms of listener registration
366+
// This prevents the click that opened the activity from closing it
367+
if (event.timeStamp && Date.now() - listenerRegistrationTime < 100) {
368+
return;
369+
}
370+
371+
const activityElement = activityElementRef.current;
372+
if (!activityElement) return;
373+
374+
// Check if click is outside the activity panel
375+
if (!activityElement.contains(event.target as Node)) {
376+
const target = event.target as HTMLElement;
377+
378+
// Don't close if clicking on:
379+
// 1. Another activity panel (let that activity handle it)
380+
const isAnotherActivity = !!target.closest('[role="complementary"]');
381+
382+
// 2. Resource links (let Link.tsx handle the transition)
383+
const isResourceLink = target.closest('a[href*="/"], a[role="button"]');
384+
385+
// 3. ActivityBar (taskbar at the bottom)
386+
const isInActivityBar = !!target.closest('[data-activity-bar="true"]');
387+
388+
// 4. Pagination buttons and table controls
389+
const isPaginationButton = !!target.closest(
390+
'button[aria-label*="page"], button[aria-label*="Page"], ' +
391+
'button[title*="page"], button[title*="Page"], ' +
392+
'.MuiPagination-root, .MuiPagination-root *, ' +
393+
'[role="navigation"], [role="navigation"] *, ' +
394+
'button[aria-label*="next"], button[aria-label*="previous"], ' +
395+
'button[aria-label*="first"], button[aria-label*="last"]'
396+
);
397+
398+
// 5. Table control buttons (sort, filter, search, etc.)
399+
const isTableControl = !!target.closest(
400+
// Table header and controls
401+
'thead, thead *, ' +
402+
'.MuiTableHead-root, .MuiTableHead-root *, ' +
403+
// Sort buttons
404+
'button[aria-label*="sort"], button[aria-label*="Sort"], ' +
405+
'[role="columnheader"], [role="columnheader"] *, ' +
406+
// Filter buttons and inputs
407+
'button[aria-label*="filter"], button[aria-label*="Filter"], ' +
408+
'button[title*="filter"], button[title*="Filter"], ' +
409+
'[aria-label*="filter"], [aria-label*="Filter"], ' +
410+
'[aria-label*="Namespace"], [aria-label*="namespace"], ' +
411+
// Search inputs and toggle buttons
412+
'input[type="search"], input[type="text"][placeholder*="Search"], ' +
413+
'input[type="text"][placeholder*="search"], ' +
414+
'input[type="text"][aria-label*="Search"], ' +
415+
'input[type="text"][aria-label*="search"], ' +
416+
'button[aria-label*="Search"], button[aria-label*="search"], ' +
417+
'button[title*="Search"], button[title*="search"], ' +
418+
// Show/Hide buttons (columns, search, etc.)
419+
'button[aria-label*="show"], button[aria-label*="Show"], ' +
420+
'button[aria-label*="hide"], button[aria-label*="Hide"], ' +
421+
'button[title*="show"], button[title*="Show"], ' +
422+
'button[title*="hide"], button[title*="Hide"], ' +
423+
'button[aria-label*="column"], button[aria-label*="Column"], ' +
424+
// MUI Select and Autocomplete components
425+
'.MuiSelect-root, .MuiSelect-root *, ' +
426+
'.MuiAutocomplete-root, .MuiAutocomplete-root *, ' +
427+
'.MuiAutocomplete-popper, .MuiAutocomplete-popper *, ' +
428+
'.MuiAutocomplete-listbox, .MuiAutocomplete-listbox *, ' +
429+
'[role="combobox"], [role="combobox"] *, ' +
430+
'[role="listbox"], [role="listbox"] *, ' +
431+
'[role="option"], [role="option"] *, ' +
432+
// MUI Input and FormControl
433+
'.MuiInputBase-root, .MuiInputBase-root *, ' +
434+
'.MuiFormControl-root, .MuiFormControl-root *, ' +
435+
'.MuiOutlinedInput-root, .MuiOutlinedInput-root *, ' +
436+
// MUI Table components
437+
'.MuiTablePagination-root, .MuiTablePagination-root *, ' +
438+
'.MuiTableSortLabel-root, .MuiTableSortLabel-root *, ' +
439+
// Toolbar and action areas
440+
'.MuiToolbar-root, .MuiToolbar-root *, ' +
441+
'[role="toolbar"], [role="toolbar"] *, ' +
442+
// Popover, Menu, Dialog (for filters, column selection, etc.)
443+
'.MuiPopover-root, .MuiPopover-root *, ' +
444+
'.MuiMenu-root, .MuiMenu-root *, ' +
445+
'.MuiDialog-root, .MuiDialog-root *, ' +
446+
'.MuiPaper-root[role="dialog"], .MuiPaper-root[role="dialog"] *, ' +
447+
'[role="menu"], [role="menu"] *, ' +
448+
'[role="dialog"], [role="dialog"] *, ' +
449+
'[role="presentation"], [role="presentation"] *'
450+
);
451+
452+
// If clicking UI controls or another activity panel, don't close
453+
// Otherwise, close or minimize based on pinned state
454+
if (
455+
!isAnotherActivity &&
456+
!isResourceLink &&
457+
!isInActivityBar &&
458+
!isPaginationButton &&
459+
!isTableControl
460+
) {
461+
Activity.closeOrMinimize(id);
462+
}
463+
}
464+
};
465+
466+
// Add listener immediately (no delay)
467+
document.addEventListener('mousedown', handleClickOutside);
468+
469+
return () => {
470+
document.removeEventListener('mousedown', handleClickOutside);
471+
};
472+
}, [id, isOverview, minimized, location, activity.pinned]);
473+
338474
return (
339475
<ActivityContext.Provider value={activity}>
340476
<Box
@@ -850,8 +986,16 @@ export const ActivitiesRenderer = React.memo(function ActivitiesRenderer() {
850986
}
851987
});
852988

989+
// Close or minimize the last activity when ESC is pressed
990+
useHotkeys('Escape', () => {
991+
if (lastElement && !isOverview) {
992+
Activity.closeOrMinimize(lastElement);
993+
}
994+
});
995+
853996
return (
854997
<>
998+
{/* Backdrop for overview mode */}
855999
<Box
8561000
sx={{
8571001
background: 'rgba(0,0,0,0.1)',
@@ -927,6 +1071,7 @@ export const ActivityBar = React.memo(function ({
9271071

9281072
return (
9291073
<Box
1074+
data-activity-bar="true"
9301075
sx={theme => ({
9311076
background: theme.palette.background.muted,
9321077
borderTop: '1px solid',
@@ -1004,6 +1149,26 @@ export const ActivityBar = React.memo(function ({
10041149
</Box>
10051150
</Box>
10061151
</Button>
1152+
<Tooltip title={it.pinned ? t('Unpin') : t('Pin')}>
1153+
<IconButton
1154+
size="small"
1155+
onClick={e => {
1156+
e.preventDefault();
1157+
e.stopPropagation();
1158+
Activity.update(it.id, { pinned: !it.pinned });
1159+
}}
1160+
sx={{
1161+
width: '42px',
1162+
height: '100%',
1163+
borderRadius: 1,
1164+
flexShrink: 0,
1165+
color: it.pinned ? 'primary.main' : undefined,
1166+
}}
1167+
aria-label={it.pinned ? t('Unpin') : t('Pin')}
1168+
>
1169+
<Icon icon={it.pinned ? 'mdi:pin' : 'mdi:pin-outline'} />
1170+
</IconButton>
1171+
</Tooltip>
10071172
<IconButton
10081173
size="small"
10091174
onClick={e => {

frontend/src/components/activity/__snapshots__/Activity.Basic.stories.storyshot

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@
166166
</div>
167167
<div
168168
class="MuiBox-root css-3ykv34"
169+
data-activity-bar="true"
169170
>
170171
<div
171172
class="MuiBox-root css-16dgwon"
@@ -192,6 +193,17 @@
192193
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
193194
/>
194195
</button>
196+
<button
197+
aria-label="Pin"
198+
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-182yqqz-MuiButtonBase-root-MuiIconButton-root"
199+
data-mui-internal-clone-element="true"
200+
tabindex="0"
201+
type="button"
202+
>
203+
<span
204+
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
205+
/>
206+
</button>
195207
<button
196208
aria-label="Close"
197209
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-182yqqz-MuiButtonBase-root-MuiIconButton-root"
@@ -228,6 +240,17 @@
228240
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
229241
/>
230242
</button>
243+
<button
244+
aria-label="Pin"
245+
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-182yqqz-MuiButtonBase-root-MuiIconButton-root"
246+
data-mui-internal-clone-element="true"
247+
tabindex="0"
248+
type="button"
249+
>
250+
<span
251+
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
252+
/>
253+
</button>
231254
<button
232255
aria-label="Close"
233256
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-182yqqz-MuiButtonBase-root-MuiIconButton-root"

frontend/src/components/common/Link.tsx

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ function PureLink(
143143

144144
export default function Link(props: React.PropsWithChildren<LinkProps | LinkObjectProps>) {
145145
const drawerEnabled = useTypedSelector(state => state.drawerMode.isDetailDrawerEnabled);
146+
const activities = useTypedSelector(state => state.activity?.activities || {});
147+
const activityHistory = useTypedSelector(state => state.activity?.history || []);
146148

147149
const { tooltip, ...propsRest } = props as LinkObjectProps;
148150

@@ -175,33 +177,50 @@ export default function Link(props: React.PropsWithChildren<LinkProps | LinkObje
175177
}
176178
: { kind, metadata: { name, namespace }, cluster };
177179

178-
Activity.launch({
179-
id:
180-
'details' +
181-
selectedResource.kind +
182-
' ' +
183-
selectedResource.metadata.name +
184-
selectedResource.cluster,
185-
title: selectedResource.kind + ' ' + selectedResource.metadata.name,
186-
hideTitleInHeader: true,
187-
location: 'split-right',
188-
cluster: selectedResource.cluster,
189-
temporary: true,
190-
content: (
191-
<KubeObjectDetails
192-
resource={{
193-
kind: selectedResource.kind,
194-
metadata: {
195-
name: selectedResource.metadata.name,
196-
namespace: selectedResource.metadata.namespace,
197-
},
198-
cluster: selectedResource.cluster,
199-
}}
200-
customResourceDefinition={selectedResource.customResourceDefinition}
201-
/>
202-
),
203-
icon: <KubeIcon kind={selectedResource.kind} width="100%" height="100%" />,
204-
});
180+
const activityId =
181+
'details' +
182+
selectedResource.kind +
183+
' ' +
184+
selectedResource.metadata.name +
185+
selectedResource.cluster;
186+
187+
// Get the currently active (last) activity
188+
const currentActivityId = activityHistory[activityHistory.length - 1];
189+
const currentActivity = activities[activityId];
190+
191+
// Check if clicking the same resource that's currently open
192+
if (currentActivityId === activityId && currentActivity) {
193+
// Same resource clicked again - close or minimize based on pinned state
194+
Activity.closeOrMinimize(activityId);
195+
} else if (currentActivity && currentActivity.minimized) {
196+
// If the activity exists but is minimized, restore it
197+
Activity.update(activityId, { minimized: false });
198+
} else {
199+
// Different resource or not open - launch it
200+
// (Activity.launch will automatically close other temporary activities)
201+
Activity.launch({
202+
id: activityId,
203+
title: selectedResource.kind + ' ' + selectedResource.metadata.name,
204+
hideTitleInHeader: true,
205+
location: 'split-right',
206+
cluster: selectedResource.cluster,
207+
temporary: true,
208+
content: (
209+
<KubeObjectDetails
210+
resource={{
211+
kind: selectedResource.kind,
212+
metadata: {
213+
name: selectedResource.metadata.name,
214+
namespace: selectedResource.metadata.namespace,
215+
},
216+
cluster: selectedResource.cluster,
217+
}}
218+
customResourceDefinition={selectedResource.customResourceDefinition}
219+
/>
220+
),
221+
icon: <KubeIcon kind={selectedResource.kind} width="100%" height="100%" />,
222+
});
223+
}
205224
}
206225
: undefined;
207226

frontend/src/lib/k8s/service.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,35 @@ class Service extends KubeObject<KubeService> {
101101
}
102102

103103
getPorts() {
104-
return this.spec?.ports?.map(port => port.port);
104+
const ports = this.spec?.ports ?? [];
105+
const serviceType = this.spec?.type;
106+
const lbIngresses = this.status?.loadBalancer?.ingress ?? [];
107+
const lbPorts = lbIngresses.flatMap(ing => ing?.ports ?? []);
108+
109+
return ports.map(p => {
110+
const protocol = (p.protocol || 'TCP').toUpperCase();
111+
112+
// NodePort: show <port>:<nodePort>/<protocol>
113+
if (serviceType === 'NodePort' && p.nodePort) {
114+
return `${p.port}:${p.nodePort}/${protocol}`;
115+
}
116+
117+
// LoadBalancer: prefer status.loadBalancer.ingress[].ports if available
118+
if (serviceType === 'LoadBalancer') {
119+
const matchingLbPort =
120+
lbPorts.find(lp => (lp?.protocol || '').toUpperCase() === protocol) || lbPorts[0];
121+
if (matchingLbPort?.port) {
122+
return `${p.port}:${matchingLbPort.port}/${protocol}`;
123+
}
124+
if (p.nodePort) {
125+
return `${p.port}:${p.nodePort}/${protocol}`;
126+
}
127+
return `${p.port}/${protocol}`;
128+
}
129+
130+
// Default: keep previous behavior (just service port number)
131+
return p.port;
132+
});
105133
}
106134

107135
getSelector() {

0 commit comments

Comments
 (0)