mirror of https://github.com/portainer/portainer
feat(home): add connect and browse buttons [EE-4182] (#8175)
parent
db9d87c918
commit
8936ae9b7a
|
@ -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'
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({});
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,84 +56,96 @@ 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}
|
||||||
>
|
>
|
||||||
<div className="modal-dialog">
|
<DialogContent className="p-0 bg-transparent modal-dialog">
|
||||||
<div className="modal-content">
|
<div className="modal-dialog">
|
||||||
<div className="modal-header">
|
<div className="modal-content">
|
||||||
<button type="button" className="close" onClick={onClose}>
|
<div className="modal-header">
|
||||||
<X />
|
<button type="button" className="close" onClick={onClose}>
|
||||||
</button>
|
<X />
|
||||||
<h5 className="modal-title">Download kubeconfig file</h5>
|
</button>
|
||||||
</div>
|
<h5 className="modal-title">Download kubeconfig file</h5>
|
||||||
<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>
|
||||||
<div className="datatable">
|
<div className="modal-body">
|
||||||
<div className="bootbox-checkbox-list">
|
<form className="bootbox-form">
|
||||||
{environments.map((env) => (
|
<div className="bootbox-prompt-message">
|
||||||
<div
|
<span>
|
||||||
key={env.Id}
|
Select the kubernetes environments to add to the kubeconfig
|
||||||
className={clsx(
|
file. You may select across multiple pages.
|
||||||
styles.checkbox,
|
</span>
|
||||||
'h-8 flex items-center pt-1'
|
<span className="space-left">{expiryQuery.data}</span>
|
||||||
)}
|
</div>
|
||||||
>
|
</form>
|
||||||
<Checkbox
|
<br />
|
||||||
id={`${env.Id}`}
|
<div className="h-8 flex items-center">
|
||||||
label={`${env.Name} (${env.URL})`}
|
<Checkbox
|
||||||
checked={!!selection[env.Id]}
|
id="settings-container-truncate-name"
|
||||||
onChange={() =>
|
label="Select all (in this page)"
|
||||||
toggleSelection(env.Id, !selection[env.Id])
|
checked={isAllPageSelected}
|
||||||
}
|
onChange={handleSelectAll}
|
||||||
/>
|
|
||||||
</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 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>
|
</div>
|
||||||
<div className="modal-footer">
|
|
||||||
<Button onClick={onClose} color="default">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleDownload} disabled={selectionSize < 1}>
|
|
||||||
Download File
|
|
||||||
</Button>
|
|
||||||
</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);
|
||||||
|
|
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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',
|
|
||||||
]);
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue