feat(home): add connect and browse buttons [EE-4182] (#8175)

pull/8197/head
Chaim Lev-Ari 2022-12-13 22:56:09 +02:00 committed by GitHub
parent db9d87c918
commit 8936ae9b7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 296 additions and 179 deletions

View File

@ -99,7 +99,7 @@ overrides:
'@typescript-eslint/explicit-module-boundary-types': off '@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-unused-vars': 'error' '@typescript-eslint/no-unused-vars': 'error'
'@typescript-eslint/no-explicit-any': 'error' '@typescript-eslint/no-explicit-any': 'error'
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either' }] 'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }]
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }] 'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
'react/jsx-no-bind': off 'react/jsx-no-bind': off
'no-await-in-loop': 'off' 'no-await-in-loop': 'off'

View File

@ -1,3 +1,4 @@
import clsx from 'clsx';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import { Button } from './buttons'; import { Button } from './buttons';
@ -8,12 +9,14 @@ export function LinkButton({
params, params,
disabled, disabled,
children, children,
className,
...props ...props
}: ComponentProps<typeof Button> & ComponentProps<typeof Link>) { }: ComponentProps<typeof Button> & ComponentProps<typeof Link>) {
const button = ( const button = (
<Button <Button
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...props} {...props}
className={clsx(className, '!m-0')}
size="medium" size="medium"
disabled={disabled} disabled={disabled}
> >

View File

@ -0,0 +1,24 @@
import { createStore } from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
import { EnvironmentId } from '../portainer/environments/types';
export const environmentStore = createStore<{
environmentId?: number;
setEnvironmentId(id: EnvironmentId): void;
clear(): void;
}>()(
persist(
(set) => ({
environmentId: undefined,
setEnvironmentId: (id: EnvironmentId) => set({ environmentId: id }),
clear: () => set({ environmentId: undefined }),
}),
{
name: keyBuilder('environmentId'),
getStorage: () => sessionStorage,
}
)
);

View File

@ -0,0 +1,20 @@
import { useState } from 'react';
export function useListSelection<T>(
initialValue: Array<T> = [],
compareFn: (a: T, b: T) => boolean = (a, b) => a === b
) {
const [selectedItems, setSelectedItems] = useState<Array<T>>(initialValue);
function handleChangeSelect(currentItem: T, selected: boolean) {
if (selected) {
setSelectedItems((items) => [...items, currentItem]);
} else {
setSelectedItems((items) =>
items.filter((item) => !compareFn(item, currentItem))
);
}
}
return [selectedItems, handleChangeSelect] as const;
}

View File

@ -1,6 +1,5 @@
import { Wifi, WifiOff } from 'lucide-react'; import { History, Wifi, WifiOff } from 'lucide-react';
import ClockRewind from '@/assets/ico/clock-rewind.svg?c';
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
import { import {
getDashboardRoute, getDashboardRoute,
@ -11,6 +10,7 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
import { LinkButton } from '@@/LinkButton'; import { LinkButton } from '@@/LinkButton';
type BrowseStatus = 'snapshot' | 'connected' | 'disconnected';
export function EnvironmentBrowseButtons({ export function EnvironmentBrowseButtons({
environment, environment,
onClickBrowse, onClickBrowse,
@ -21,13 +21,13 @@ export function EnvironmentBrowseButtons({
isActive: boolean; isActive: boolean;
}) { }) {
const isEdgeAsync = checkEdgeAsync(environment); const isEdgeAsync = checkEdgeAsync(environment);
const browseStatus = getStatus(isActive, isEdgeAsync);
return ( return (
<div className="flex flex-col gap-1 ml-auto [&>*]:flex-1"> <div className="flex flex-col gap-1 ml-auto [&>*]:flex-1">
{isBE && ( {isBE && (
<LinkButton <LinkButton
icon={ClockRewind} icon={History}
disabled={!isEdgeAsync} disabled={!isEdgeAsync || browseStatus === 'snapshot'}
to="edge.browse.dashboard" to="edge.browse.dashboard"
params={{ params={{
environmentId: environment.Id, environmentId: environment.Id,
@ -41,7 +41,7 @@ export function EnvironmentBrowseButtons({
<LinkButton <LinkButton
icon={Wifi} icon={Wifi}
disabled={isEdgeAsync} disabled={isEdgeAsync || browseStatus === 'connected'}
to={getDashboardRoute(environment)} to={getDashboardRoute(environment)}
params={{ params={{
endpointId: environment.Id, endpointId: environment.Id,
@ -53,14 +53,59 @@ export function EnvironmentBrowseButtons({
Live connect Live connect
</LinkButton> </LinkButton>
{!isActive ? ( <BrowseStatusTag status={browseStatus} />
<div className="min-h-[30px] vertical-center justify-center"> </div>
<Icon icon={WifiOff} /> );
Disconnected }
</div>
) : ( function getStatus(isActive: boolean, isEdgeAsync: boolean) {
<div className="min-h-[30px]" /> if (!isActive) {
)} return 'disconnected';
}
if (isEdgeAsync) {
return 'snapshot';
}
return 'connected';
}
function BrowseStatusTag({ status }: { status: BrowseStatus }) {
switch (status) {
case 'snapshot':
return <Snapshot />;
case 'connected':
return <Connected />;
case 'disconnected':
return <Disconnected />;
default:
return null;
}
}
function Disconnected() {
return (
<div className="min-h-[30px] vertical-center justify-center opacity-50">
<Icon icon={WifiOff} />
Disconnected
</div>
);
}
function Connected() {
return (
<div className="min-h-[30px] vertical-center gap-2 justify-center text-green-8 bg-green-3 rounded-lg">
<div className="rounded-full h-2 w-2 bg-green-8" />
Connected
</div>
);
}
function Snapshot() {
return (
<div className="min-h-[30px] vertical-center gap-2 justify-center text-warning-7 bg-warning-3 rounded-lg">
<div className="rounded-full h-2 w-2 bg-warning-7" />
Browsing Snapshot
</div> </div>
); );
} }

View File

@ -19,7 +19,15 @@ interface Args {
} }
function Template({ environment }: Args) { function Template({ environment }: Args) {
return <EnvironmentItem environment={environment} onClick={() => {}} />; return (
<EnvironmentItem
environment={environment}
onClickBrowse={() => {}}
isActive={false}
onSelect={() => {}}
isSelected={false}
/>
);
} }
export const DockerEnvironment: Story<Args> = Template.bind({}); export const DockerEnvironment: Story<Args> = Template.bind({});

View File

@ -44,7 +44,10 @@ function renderComponent(
return renderWithQueryClient( return renderWithQueryClient(
<UserContext.Provider value={{ user }}> <UserContext.Provider value={{ user }}>
<EnvironmentItem <EnvironmentItem
onClick={() => {}} onSelect={jest.fn()}
isSelected={false}
isActive={false}
onClickBrowse={() => {}}
environment={env} environment={env}
groupName={group.Name} groupName={group.Name}
/> />

View File

@ -10,7 +10,6 @@ import {
PlatformType, PlatformType,
} from '@/react/portainer/environments/types'; } from '@/react/portainer/environments/types';
import { import {
getDashboardRoute,
getPlatformType, getPlatformType,
isEdgeEnvironment, isEdgeEnvironment,
} from '@/react/portainer/environments/utils'; } from '@/react/portainer/environments/utils';
@ -19,49 +18,54 @@ import { useTags } from '@/portainer/tags/queries';
import { EdgeIndicator } from '@@/EdgeIndicator'; import { EdgeIndicator } from '@@/EdgeIndicator';
import { EnvironmentStatusBadge } from '@@/EnvironmentStatusBadge'; import { EnvironmentStatusBadge } from '@@/EnvironmentStatusBadge';
import { Link } from '@@/Link'; import { Checkbox } from '@@/form-components/Checkbox';
import { EnvironmentIcon } from './EnvironmentIcon'; import { EnvironmentIcon } from './EnvironmentIcon';
import { EnvironmentStats } from './EnvironmentStats'; import { EnvironmentStats } from './EnvironmentStats';
import { EngineVersion } from './EngineVersion'; import { EngineVersion } from './EngineVersion';
import { AgentVersionTag } from './AgentVersionTag'; import { AgentVersionTag } from './AgentVersionTag';
import { EnvironmentBrowseButtons } from './EnvironmentBrowseButtons';
import { EditButtons } from './EditButtons'; import { EditButtons } from './EditButtons';
interface Props { interface Props {
environment: Environment; environment: Environment;
groupName?: string; groupName?: string;
onClick(environment: Environment): void; onClickBrowse(): void;
onSelect(isSelected: boolean): void;
isSelected: boolean;
isActive: boolean;
} }
export function EnvironmentItem({ environment, onClick, groupName }: Props) { export function EnvironmentItem({
environment,
onClickBrowse,
groupName,
isActive,
isSelected,
onSelect,
}: Props) {
const isEdge = isEdgeEnvironment(environment.Type); const isEdge = isEdgeEnvironment(environment.Type);
const snapshotTime = getSnapshotTime(environment); const snapshotTime = getSnapshotTime(environment);
const tags = useEnvironmentTagNames(environment.TagIds); const tags = useEnvironmentTagNames(environment.TagIds);
const route = getDashboardRoute(environment);
return ( return (
<button <label className="relative">
type="button" <div className="absolute top-2 left-2">
onClick={() => onClick(environment)} <Checkbox
className="bg-transparent border-0 !p-0 !m-0" id={`environment-select-${environment.Id}`}
> checked={isSelected}
<Link onChange={() => onSelect(!isSelected)}
className="blocklist-item flex no-link overflow-hidden min-h-[100px]" />
to={route} </div>
params={{ <div className="blocklist-item flex overflow-hidden min-h-[100px]">
endpointId: environment.Id,
id: environment.Id,
}}
>
<div className="ml-2 self-center flex justify-center"> <div className="ml-2 self-center flex justify-center">
<EnvironmentIcon type={environment.Type} /> <EnvironmentIcon type={environment.Type} />
</div> </div>
<div className="ml-3 mr-auto flex justify-center gap-3 flex-col items-start"> <div className="ml-3 mr-auto flex justify-center gap-3 flex-col items-start">
<div className="space-x-3 flex items-center"> <div className="space-x-3 flex items-center">
<span className="font-bold">{environment.Name}</span> <span className="font-bold">{environment.Name}</span>
{isEdge ? ( {isEdge ? (
<EdgeIndicator environment={environment} showLastCheckInDate /> <EdgeIndicator environment={environment} showLastCheckInDate />
) : ( ) : (
@ -81,16 +85,13 @@ export function EnvironmentItem({ environment, onClick, groupName }: Props) {
)} )}
</> </>
)} )}
<EngineVersion environment={environment} /> <EngineVersion environment={environment} />
{!isEdge && ( {!isEdge && (
<span className="text-muted small vertical-center"> <span className="text-muted small vertical-center">
{stripProtocol(environment.URL)} {stripProtocol(environment.URL)}
</span> </span>
)} )}
</div> </div>
<div className="small text-muted space-x-2 vertical-center"> <div className="small text-muted space-x-2 vertical-center">
{groupName && ( {groupName && (
<span className="font-semibold"> <span className="font-semibold">
@ -98,19 +99,16 @@ export function EnvironmentItem({ environment, onClick, groupName }: Props) {
<span>{groupName}</span> <span>{groupName}</span>
</span> </span>
)} )}
<span className="vertical-center"> <span className="vertical-center">
<Tag className="icon icon-sm space-right" aria-hidden="true" /> <Tag className="icon icon-sm space-right" aria-hidden="true" />
{tags} {tags}
</span> </span>
{isEdge && ( {isEdge && (
<> <>
<AgentVersionTag <AgentVersionTag
type={environment.Type} type={environment.Type}
version={environment.Agent.Version} version={environment.Agent.Version}
/> />
{environment.Edge.AsyncMode && ( {environment.Edge.AsyncMode && (
<span className="vertical-center gap-1"> <span className="vertical-center gap-1">
<Globe <Globe
@ -123,13 +121,16 @@ export function EnvironmentItem({ environment, onClick, groupName }: Props) {
</> </>
)} )}
</div> </div>
<EnvironmentStats environment={environment} /> <EnvironmentStats environment={environment} />
</div> </div>
<EnvironmentBrowseButtons
environment={environment}
onClickBrowse={onClickBrowse}
isActive={isActive}
/>
<EditButtons environment={environment} /> <EditButtons environment={environment} />
</Link> </div>
</button> </label>
); );
} }

View File

@ -48,7 +48,7 @@ async function renderComponent(
const queries = renderWithQueryClient( const queries = renderWithQueryClient(
<UserContext.Provider value={{ user }}> <UserContext.Provider value={{ user }}>
<EnvironmentList onClickItem={jest.fn()} onRefresh={jest.fn()} /> <EnvironmentList onClickBrowse={jest.fn()} onRefresh={jest.fn()} />
</UserContext.Provider> </UserContext.Provider>
); );

View File

@ -2,6 +2,7 @@ import { ReactNode, useEffect, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { HardDrive, RefreshCcw } from 'lucide-react'; import { HardDrive, RefreshCcw } from 'lucide-react';
import _ from 'lodash'; import _ from 'lodash';
import { useStore } from 'zustand';
import { usePaginationLimitState } from '@/react/hooks/usePaginationLimitState'; import { usePaginationLimitState } from '@/react/hooks/usePaginationLimitState';
import { import {
@ -23,6 +24,8 @@ import { useAgentVersionsList } from '@/react/portainer/environments/queries/use
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service'; import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { useUser } from '@/react/hooks/useUser'; import { useUser } from '@/react/hooks/useUser';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { environmentStore } from '@/react/hooks/current-environment-store';
import { useListSelection } from '@/react/hooks/useListSelection';
import { TableFooter } from '@@/datatables/TableFooter'; import { TableFooter } from '@@/datatables/TableFooter';
import { TableActions, TableContainer, TableTitle } from '@@/datatables'; import { TableActions, TableContainer, TableTitle } from '@@/datatables';
@ -43,7 +46,7 @@ import styles from './EnvironmentList.module.css';
import { UpdateBadge } from './UpdateBadge'; import { UpdateBadge } from './UpdateBadge';
interface Props { interface Props {
onClickItem(environment: Environment): void; onClickBrowse(environment: Environment): void;
onRefresh(): void; onRefresh(): void;
} }
@ -67,8 +70,14 @@ enum ConnectionType {
const storageKey = 'home_endpoints'; const storageKey = 'home_endpoints';
export function EnvironmentList({ onClickItem, onRefresh }: Props) { export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
const [selectedItems, handleChangeSelect] = useListSelection<Environment>(
[],
(a, b) => a.Id === b.Id
);
const { isAdmin } = useUser(); const { isAdmin } = useUser();
const { environmentId: currentEnvironmentId } = useStore(environmentStore);
const [platformTypes, setPlatformTypes] = useHomePageFilter< const [platformTypes, setPlatformTypes] = useHomePageFilter<
Filter<PlatformType>[] Filter<PlatformType>[]
>('platformType', []); >('platformType', []);
@ -128,6 +137,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
status: statusFilter, status: statusFilter,
tagIds: tagFilter?.length ? tagFilter : undefined, tagIds: tagFilter?.length ? tagFilter : undefined,
groupIds: groupFilter, groupIds: groupFilter,
provisioned: true,
edgeDevice: false, edgeDevice: false,
tagsPartialMatch: true, tagsPartialMatch: true,
agentVersions: agentVersions.map((a) => a.value), agentVersions: agentVersions.map((a) => a.value),
@ -219,6 +229,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
sort: sortByFilter, sort: sortByFilter,
order: sortByDescending ? 'desc' : 'asc', order: sortByDescending ? 'desc' : 'asc',
}} }}
selectedItems={selectedItems}
/> />
</div> </div>
<div className={clsx(styles.filterSearchbar, 'ml-3')}> <div className={clsx(styles.filterSearchbar, 'ml-3')}>
@ -315,7 +326,12 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
groupName={ groupName={
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
} }
onClick={onClickItem} onClickBrowse={() => onClickBrowse(env)}
isActive={env.Id === currentEnvironmentId}
isSelected={selectedItems.some(
(selectedEnv) => selectedEnv.Id === env.Id
)}
onSelect={(selected) => handleChangeSelect(env, selected)}
/> />
)) ))
)} )}

View File

@ -15,22 +15,31 @@ import '@reach/dialog/styles.css';
export interface Props { export interface Props {
environments: Environment[]; environments: Environment[];
envQueryParams: Query; envQueryParams: Query;
selectedItems: Array<Environment>;
} }
export function KubeconfigButton({ environments, envQueryParams }: Props) { export function KubeconfigButton({
environments,
envQueryParams,
selectedItems,
}: Props) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
if (!environments) {
return null;
}
if (!isKubeconfigButtonVisible(environments)) { if (!isKubeconfigButtonVisible(environments)) {
return null; // return null;
} }
return ( return (
<> <>
<Button onClick={handleClick} size="medium" className="!ml-3"> <Button
<Download className="lucide icon-white" aria-hidden="true" /> Kubeconfig onClick={handleClick}
size="medium"
className="!ml-3"
disabled={selectedItems.some(
(env) => !isKubernetesEnvironment(env.Type)
)}
icon={Download}
>
Kubeconfig
</Button> </Button>
{prompt()} {prompt()}
</> </>
@ -65,6 +74,11 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) {
<KubeconfigPrompt <KubeconfigPrompt
envQueryParams={envQueryParams} envQueryParams={envQueryParams}
onClose={handleClose} onClose={handleClose}
selectedItems={
selectedItems.length
? selectedItems.map((env) => env.Id)
: environments.map((env) => env.Id)
}
/> />
) )
); );

View File

@ -1,35 +1,40 @@
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
import { DialogOverlay } from '@reach/dialog'; import { DialogContent, DialogOverlay } from '@reach/dialog';
import { downloadKubeconfigFile } from '@/react/kubernetes/services/kubeconfig.service'; import { downloadKubeconfigFile } from '@/react/kubernetes/services/kubeconfig.service';
import * as notifications from '@/portainer/services/notifications'; import * as notifications from '@/portainer/services/notifications';
import { EnvironmentType } from '@/react/portainer/environments/types'; import {
Environment,
EnvironmentType,
} from '@/react/portainer/environments/types';
import { usePaginationLimitState } from '@/react/hooks/usePaginationLimitState'; import { usePaginationLimitState } from '@/react/hooks/usePaginationLimitState';
import { usePublicSettings } from '@/react/portainer/settings/queries'; import { usePublicSettings } from '@/react/portainer/settings/queries';
import { import {
Query, Query,
useEnvironmentList, useEnvironmentList,
} from '@/react/portainer/environments/queries/useEnvironmentList'; } from '@/react/portainer/environments/queries/useEnvironmentList';
import { useListSelection } from '@/react/hooks/useListSelection';
import { PaginationControls } from '@@/PaginationControls'; import { PaginationControls } from '@@/PaginationControls';
import { Checkbox } from '@@/form-components/Checkbox'; import { Checkbox } from '@@/form-components/Checkbox';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { useSelection } from './KubeconfigSelection';
import styles from './KubeconfigPrompt.module.css'; import styles from './KubeconfigPrompt.module.css';
import '@reach/dialog/styles.css'; import '@reach/dialog/styles.css';
export interface KubeconfigPromptProps { export interface KubeconfigPromptProps {
envQueryParams: Query; envQueryParams: Query;
onClose: () => void; onClose: () => void;
selectedItems: Array<Environment['Id']>;
} }
const storageKey = 'home_endpoints'; const storageKey = 'home_endpoints';
export function KubeconfigPrompt({ export function KubeconfigPrompt({
envQueryParams, envQueryParams,
onClose, onClose,
selectedItems,
}: KubeconfigPromptProps) { }: KubeconfigPromptProps) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey); const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
@ -38,8 +43,10 @@ export function KubeconfigPrompt({
select: (settings) => expiryMessage(settings.KubeconfigExpiry), select: (settings) => expiryMessage(settings.KubeconfigExpiry),
}); });
const { selection, toggle: toggleSelection, selectionSize } = useSelection(); const [selection, toggleSelection] =
const { environments, totalCount } = useEnvironmentList({ useListSelection<Environment['Id']>(selectedItems);
const { environments, totalCount, isLoading } = useEnvironmentList({
...envQueryParams, ...envQueryParams,
page, page,
pageLimit, pageLimit,
@ -49,14 +56,20 @@ export function KubeconfigPrompt({
EnvironmentType.EdgeAgentOnKubernetes, EnvironmentType.EdgeAgentOnKubernetes,
], ],
}); });
const isAllPageSelected = environments.every((env) => selection[env.Id]); const isAllPageSelected =
!isLoading &&
environments
.filter((env) => env.Status <= 2)
.every((env) => selection.includes(env.Id));
return ( return (
<DialogOverlay <DialogOverlay
className={styles.dialog} className={styles.dialog}
aria-label="Kubeconfig View" aria-label="Kubeconfig View"
role="dialog" role="dialog"
onDismiss={onClose}
> >
<DialogContent className="p-0 bg-transparent modal-dialog">
<div className="modal-dialog"> <div className="modal-dialog">
<div className="modal-content"> <div className="modal-content">
<div className="modal-header"> <div className="modal-header">
@ -86,7 +99,9 @@ export function KubeconfigPrompt({
</div> </div>
<div className="datatable"> <div className="datatable">
<div className="bootbox-checkbox-list"> <div className="bootbox-checkbox-list">
{environments.map((env) => ( {environments
.filter((env) => env.Status <= 2)
.map((env) => (
<div <div
key={env.Id} key={env.Id}
className={clsx( className={clsx(
@ -97,9 +112,9 @@ export function KubeconfigPrompt({
<Checkbox <Checkbox
id={`${env.Id}`} id={`${env.Id}`}
label={`${env.Name} (${env.URL})`} label={`${env.Name} (${env.URL})`}
checked={!!selection[env.Id]} checked={selection.includes(env.Id)}
onChange={() => onChange={() =>
toggleSelection(env.Id, !selection[env.Id]) toggleSelection(env.Id, !selection.includes(env.Id))
} }
/> />
</div> </div>
@ -121,12 +136,16 @@ export function KubeconfigPrompt({
<Button onClick={onClose} color="default"> <Button onClick={onClose} color="default">
Cancel Cancel
</Button> </Button>
<Button onClick={handleDownload} disabled={selectionSize < 1}> <Button
onClick={handleDownload}
disabled={selection.length === 0}
>
Download File Download File
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</DialogContent>
</DialogOverlay> </DialogOverlay>
); );
@ -139,12 +158,12 @@ export function KubeconfigPrompt({
} }
async function confirmKubeconfigSelection() { async function confirmKubeconfigSelection() {
if (selectionSize === 0) { if (selection.length === 0) {
notifications.warning('No environment was selected', ''); notifications.warning('No environment was selected', '');
return; return;
} }
try { try {
await downloadKubeconfigFile(Object.keys(selection).map(Number)); await downloadKubeconfigFile(selection);
onClose(); onClose();
} catch (e) { } catch (e) {
notifications.error('Failed downloading kubeconfig file', e as Error); notifications.error('Failed downloading kubeconfig file', e as Error);

View File

@ -1,27 +0,0 @@
import { useState } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types';
export function useSelection() {
const [selection, setSelection] = useState<Record<EnvironmentId, boolean>>(
{}
);
const selectionSize = Object.keys(selection).length;
return { selection, toggle, selectionSize };
function toggle(id: EnvironmentId, selected: boolean) {
setSelection((prevSelection) => {
const newSelection = { ...prevSelection };
if (!selected) {
delete newSelection[id];
} else {
newSelection[id] = true;
}
return newSelection;
});
}
}

View File

@ -1,10 +1 @@
import { react2angular } from '@/react-tools/react2angular'; export { EnvironmentList } from './EnvironmentList';
import { EnvironmentList } from './EnvironmentList';
export { EnvironmentList };
export const EnvironmentListAngular = react2angular(EnvironmentList, [
'onClickItem',
'onRefresh',
]);

View File

@ -39,7 +39,7 @@ export function HomeView() {
<EdgeLoadingSpinner /> <EdgeLoadingSpinner />
) : ( ) : (
<EnvironmentList <EnvironmentList
onClickItem={handleClickItem} onClickBrowse={handleBrowseClick}
onRefresh={confirmTriggerSnapshot} onRefresh={confirmTriggerSnapshot}
/> />
)} )}
@ -64,7 +64,7 @@ export function HomeView() {
} }
} }
function handleClickItem(environment: Environment) { function handleBrowseClick(environment: Environment) {
if (isEdgeEnvironment(environment.Type)) { if (isEdgeEnvironment(environment.Type)) {
setConnectingToEdgeEndpoint(true); setConnectingToEdgeEndpoint(true);
} }

View File

@ -3,6 +3,7 @@ import { useEffect } from 'react';
import { X, Slash } from 'lucide-react'; import { X, Slash } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import angular from 'angular'; import angular from 'angular';
import { useStore } from 'zustand';
import { import {
PlatformType, PlatformType,
@ -11,9 +12,9 @@ import {
} from '@/react/portainer/environments/types'; } from '@/react/portainer/environments/types';
import { getPlatformType } from '@/react/portainer/environments/utils'; import { getPlatformType } from '@/react/portainer/environments/utils';
import { useEnvironment } from '@/react/portainer/environments/queries/useEnvironment'; import { useEnvironment } from '@/react/portainer/environments/queries/useEnvironment';
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
import { EndpointProviderInterface } from '@/portainer/services/endpointProvider';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { EndpointProviderInterface } from '@/portainer/services/endpointProvider';
import { environmentStore } from '@/react/hooks/current-environment-store';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
@ -98,35 +99,34 @@ function Content({ environment, onClear }: ContentProps) {
function useCurrentEnvironment() { function useCurrentEnvironment() {
const { params } = useCurrentStateAndParams(); const { params } = useCurrentStateAndParams();
const router = useRouter(); const router = useRouter();
const [environmentId, setEnvironmentId] = useLocalStorage<
EnvironmentId | undefined
>('environmentId', undefined, sessionStorage);
const envStore = useStore(environmentStore);
const { setEnvironmentId } = envStore;
useEffect(() => { useEffect(() => {
const environmentId = parseInt(params.endpointId, 10); const environmentId = parseInt(params.endpointId, 10);
if (params.endpointId && !Number.isNaN(environmentId)) { if (params.endpointId && !Number.isNaN(environmentId)) {
setEnvironmentId(environmentId); setEnvironmentId(environmentId);
} }
}, [params.endpointId, setEnvironmentId]); }, [setEnvironmentId, params.endpointId, params.environmentId]);
return { query: useEnvironment(environmentId), clearEnvironment }; return { query: useEnvironment(envStore.environmentId), clearEnvironment };
function clearEnvironment() { function clearEnvironment() {
const $injector = angular.element(document).injector(); const $injector = angular.element(document).injector();
$injector.invoke( $injector.invoke(
/* @ngInject */ (EndpointProvider: EndpointProviderInterface) => { /* @ngInject */ (EndpointProvider: EndpointProviderInterface) => {
EndpointProvider.setCurrentEndpoint(null); EndpointProvider.setCurrentEndpoint(null);
if (!params.endpointId) { if (!params.endpointId && !params.environmentId) {
document.title = 'Portainer'; document.title = 'Portainer';
} }
} }
); );
if (params.endpointId) { if (params.endpointId || params.environmentId) {
router.stateService.go('portainer.home'); router.stateService.go('portainer.home');
} }
setEnvironmentId(undefined); envStore.clear();
} }
} }