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/no-unused-vars': '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/jsx-no-bind': off
'no-await-in-loop': 'off'

View File

@ -1,3 +1,4 @@
import clsx from 'clsx';
import { ComponentProps } from 'react';
import { Button } from './buttons';
@ -8,12 +9,14 @@ export function LinkButton({
params,
disabled,
children,
className,
...props
}: ComponentProps<typeof Button> & ComponentProps<typeof Link>) {
const button = (
<Button
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
className={clsx(className, '!m-0')}
size="medium"
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 {
getDashboardRoute,
@ -11,6 +10,7 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Icon } from '@@/Icon';
import { LinkButton } from '@@/LinkButton';
type BrowseStatus = 'snapshot' | 'connected' | 'disconnected';
export function EnvironmentBrowseButtons({
environment,
onClickBrowse,
@ -21,13 +21,13 @@ export function EnvironmentBrowseButtons({
isActive: boolean;
}) {
const isEdgeAsync = checkEdgeAsync(environment);
const browseStatus = getStatus(isActive, isEdgeAsync);
return (
<div className="flex flex-col gap-1 ml-auto [&>*]:flex-1">
{isBE && (
<LinkButton
icon={ClockRewind}
disabled={!isEdgeAsync}
icon={History}
disabled={!isEdgeAsync || browseStatus === 'snapshot'}
to="edge.browse.dashboard"
params={{
environmentId: environment.Id,
@ -41,7 +41,7 @@ export function EnvironmentBrowseButtons({
<LinkButton
icon={Wifi}
disabled={isEdgeAsync}
disabled={isEdgeAsync || browseStatus === 'connected'}
to={getDashboardRoute(environment)}
params={{
endpointId: environment.Id,
@ -53,14 +53,59 @@ export function EnvironmentBrowseButtons({
Live connect
</LinkButton>
{!isActive ? (
<div className="min-h-[30px] vertical-center justify-center">
<Icon icon={WifiOff} />
Disconnected
</div>
) : (
<div className="min-h-[30px]" />
)}
<BrowseStatusTag status={browseStatus} />
</div>
);
}
function getStatus(isActive: boolean, isEdgeAsync: boolean) {
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>
);
}

View File

@ -19,7 +19,15 @@ interface 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({});

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { ReactNode, useEffect, useState } from 'react';
import clsx from 'clsx';
import { HardDrive, RefreshCcw } from 'lucide-react';
import _ from 'lodash';
import { useStore } from 'zustand';
import { usePaginationLimitState } from '@/react/hooks/usePaginationLimitState';
import {
@ -23,6 +24,8 @@ import { useAgentVersionsList } from '@/react/portainer/environments/queries/use
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { useUser } from '@/react/hooks/useUser';
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 { TableActions, TableContainer, TableTitle } from '@@/datatables';
@ -43,7 +46,7 @@ import styles from './EnvironmentList.module.css';
import { UpdateBadge } from './UpdateBadge';
interface Props {
onClickItem(environment: Environment): void;
onClickBrowse(environment: Environment): void;
onRefresh(): void;
}
@ -67,8 +70,14 @@ enum ConnectionType {
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 { environmentId: currentEnvironmentId } = useStore(environmentStore);
const [platformTypes, setPlatformTypes] = useHomePageFilter<
Filter<PlatformType>[]
>('platformType', []);
@ -128,6 +137,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
status: statusFilter,
tagIds: tagFilter?.length ? tagFilter : undefined,
groupIds: groupFilter,
provisioned: true,
edgeDevice: false,
tagsPartialMatch: true,
agentVersions: agentVersions.map((a) => a.value),
@ -219,6 +229,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
sort: sortByFilter,
order: sortByDescending ? 'desc' : 'asc',
}}
selectedItems={selectedItems}
/>
</div>
<div className={clsx(styles.filterSearchbar, 'ml-3')}>
@ -315,7 +326,12 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
groupName={
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 {
environments: Environment[];
envQueryParams: Query;
selectedItems: Array<Environment>;
}
export function KubeconfigButton({ environments, envQueryParams }: Props) {
export function KubeconfigButton({
environments,
envQueryParams,
selectedItems,
}: Props) {
const [isOpen, setIsOpen] = useState(false);
if (!environments) {
return null;
}
if (!isKubeconfigButtonVisible(environments)) {
return null;
// return null;
}
return (
<>
<Button onClick={handleClick} size="medium" className="!ml-3">
<Download className="lucide icon-white" aria-hidden="true" /> Kubeconfig
<Button
onClick={handleClick}
size="medium"
className="!ml-3"
disabled={selectedItems.some(
(env) => !isKubernetesEnvironment(env.Type)
)}
icon={Download}
>
Kubeconfig
</Button>
{prompt()}
</>
@ -65,6 +74,11 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) {
<KubeconfigPrompt
envQueryParams={envQueryParams}
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 clsx from 'clsx';
import { useState } from 'react';
import { DialogOverlay } from '@reach/dialog';
import { DialogContent, DialogOverlay } from '@reach/dialog';
import { downloadKubeconfigFile } from '@/react/kubernetes/services/kubeconfig.service';
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 { usePublicSettings } from '@/react/portainer/settings/queries';
import {
Query,
useEnvironmentList,
} from '@/react/portainer/environments/queries/useEnvironmentList';
import { useListSelection } from '@/react/hooks/useListSelection';
import { PaginationControls } from '@@/PaginationControls';
import { Checkbox } from '@@/form-components/Checkbox';
import { Button } from '@@/buttons';
import { useSelection } from './KubeconfigSelection';
import styles from './KubeconfigPrompt.module.css';
import '@reach/dialog/styles.css';
export interface KubeconfigPromptProps {
envQueryParams: Query;
onClose: () => void;
selectedItems: Array<Environment['Id']>;
}
const storageKey = 'home_endpoints';
export function KubeconfigPrompt({
envQueryParams,
onClose,
selectedItems,
}: KubeconfigPromptProps) {
const [page, setPage] = useState(1);
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
@ -38,8 +43,10 @@ export function KubeconfigPrompt({
select: (settings) => expiryMessage(settings.KubeconfigExpiry),
});
const { selection, toggle: toggleSelection, selectionSize } = useSelection();
const { environments, totalCount } = useEnvironmentList({
const [selection, toggleSelection] =
useListSelection<Environment['Id']>(selectedItems);
const { environments, totalCount, isLoading } = useEnvironmentList({
...envQueryParams,
page,
pageLimit,
@ -49,84 +56,96 @@ export function KubeconfigPrompt({
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 (
<DialogOverlay
className={styles.dialog}
aria-label="Kubeconfig View"
role="dialog"
onDismiss={onClose}
>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" onClick={onClose}>
<X />
</button>
<h5 className="modal-title">Download kubeconfig file</h5>
</div>
<div className="modal-body">
<form className="bootbox-form">
<div className="bootbox-prompt-message">
<span>
Select the kubernetes environments to add to the kubeconfig
file. You may select across multiple pages.
</span>
<span className="space-left">{expiryQuery.data}</span>
</div>
</form>
<br />
<div className="h-8 flex items-center">
<Checkbox
id="settings-container-truncate-name"
label="Select all (in this page)"
checked={isAllPageSelected}
onChange={handleSelectAll}
/>
<DialogContent className="p-0 bg-transparent modal-dialog">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" onClick={onClose}>
<X />
</button>
<h5 className="modal-title">Download kubeconfig file</h5>
</div>
<div className="datatable">
<div className="bootbox-checkbox-list">
{environments.map((env) => (
<div
key={env.Id}
className={clsx(
styles.checkbox,
'h-8 flex items-center pt-1'
)}
>
<Checkbox
id={`${env.Id}`}
label={`${env.Name} (${env.URL})`}
checked={!!selection[env.Id]}
onChange={() =>
toggleSelection(env.Id, !selection[env.Id])
}
/>
</div>
))}
</div>
<div className="pt-3 flex justify-end w-full">
<PaginationControls
showAll={totalCount <= 100}
page={page}
onPageChange={setPage}
pageLimit={pageLimit}
onPageLimitChange={setPageLimit}
totalCount={totalCount}
<div className="modal-body">
<form className="bootbox-form">
<div className="bootbox-prompt-message">
<span>
Select the kubernetes environments to add to the kubeconfig
file. You may select across multiple pages.
</span>
<span className="space-left">{expiryQuery.data}</span>
</div>
</form>
<br />
<div className="h-8 flex items-center">
<Checkbox
id="settings-container-truncate-name"
label="Select all (in this page)"
checked={isAllPageSelected}
onChange={handleSelectAll}
/>
</div>
<div className="datatable">
<div className="bootbox-checkbox-list">
{environments
.filter((env) => env.Status <= 2)
.map((env) => (
<div
key={env.Id}
className={clsx(
styles.checkbox,
'h-8 flex items-center pt-1'
)}
>
<Checkbox
id={`${env.Id}`}
label={`${env.Name} (${env.URL})`}
checked={selection.includes(env.Id)}
onChange={() =>
toggleSelection(env.Id, !selection.includes(env.Id))
}
/>
</div>
))}
</div>
<div className="pt-3 flex justify-end w-full">
<PaginationControls
showAll={totalCount <= 100}
page={page}
onPageChange={setPage}
pageLimit={pageLimit}
onPageLimitChange={setPageLimit}
totalCount={totalCount}
/>
</div>
</div>
</div>
<div className="modal-footer">
<Button onClick={onClose} color="default">
Cancel
</Button>
<Button
onClick={handleDownload}
disabled={selection.length === 0}
>
Download File
</Button>
</div>
</div>
<div className="modal-footer">
<Button onClick={onClose} color="default">
Cancel
</Button>
<Button onClick={handleDownload} disabled={selectionSize < 1}>
Download File
</Button>
</div>
</div>
</div>
</DialogContent>
</DialogOverlay>
);
@ -139,12 +158,12 @@ export function KubeconfigPrompt({
}
async function confirmKubeconfigSelection() {
if (selectionSize === 0) {
if (selection.length === 0) {
notifications.warning('No environment was selected', '');
return;
}
try {
await downloadKubeconfigFile(Object.keys(selection).map(Number));
await downloadKubeconfigFile(selection);
onClose();
} catch (e) {
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';
import { EnvironmentList } from './EnvironmentList';
export { EnvironmentList };
export const EnvironmentListAngular = react2angular(EnvironmentList, [
'onClickItem',
'onRefresh',
]);
export { EnvironmentList } from './EnvironmentList';

View File

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

View File

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