refactor(home): migrate view to react [EE-1810] (#6314)

* refactor(http): parse axios errors (#6325)

* refactor(home): use endpoint-list as react component [EE-1814] (#6060)

* refactor(home): use endpoint-list as react component

fix(home): add missing features and refactors

- kubebutton
- group name
- poll when endpoint is off
- state management

refactor(endpoints): use stat component

fix(endpoints): add space between items

refactor(endpoints): move stats to components

refactor(endpoints): fetch time

refactor(home): move logic

refactor(home): move fe render logic

refactor(settings): use vanilla js for publicSettings

refactor(kube): remove angular from kube config service

feat(home): add kubeconfig button

feat(home): send analytics when opening kubeconfig modal

fix(home): memoize footer

refactor(home): use react-query for loading

fix(home): show correct control for kubeconfig modal

refactor(home): use debounce

refactor(home): use new components

refactor(home): replace endpoints with environments

refactor(home): move endpoint-list component to home

fix(home): show group name

refactor(home): use switch for environment icon

fix(kubeconfig): fix default case

refactor(axios): use parse axios error

refactor(home): use link components for navigate

fix(home): align azure icon

refactor(home): refactor stats

refactor(home): export envstatusbadge

refactor(home): remove unused bindings

* chore(home): write tests for edge indicator

* chore(home): basic stories for environment item

* style(settings): reformat

* fix(environments): add publicurl

* refactor(home): use table components

* refactor(datatables): merge useSearchBarState

* refactor(home): fetch group in env item

* chore(tests): basic tests

* chore(home): test when no envs

* refactor(tags): use axios for tagService

* refactor(env-groups): use axios for getGroups

* feat(app): ui-state context provider

* refactor(home): create MotdPanel

* refactor(app): create InformationPanel

* feat(endpoints): fetch number of total endpoints

* refactor(app): merge hooks

* refactor(home): migrate view to react [EE-1810]

fixes [EE-1810]

refactor(home): wip use react view

feat(home): show message if no endpoints

refactor(home): show endpoint list

refactor(home): don't use home to manage link

refactor(home): move state

refactor(home): check if edge using util

refactor(home): move inf panels

chore(home): tests

refactor(home): load groups and tags in env-item

refactor(settings): revert publicSettings change

refactor(home): move confirm snapshot method

* fix(home): show tags

* fix(environments): handle missing snapshots

* fix(kube/volumes): fetch pesistent volume claims

* refactor(kube): remove use of endpointProvider

* refactor(endpoints): set current endpoint

* chore(home): add data-cy for tests

* chore(tests): mock axios-progress-bar

* refactor(home): move use env list to env module

* feat(app): sync home view changes with ee

* fix(home): sort page header

* fix(app): fix tests

* chore(github): use yarn cache

* refactor(environments): load list of groups

* chore(babel): remove auto 18n keys extraction

* chore(environments): fix tests

* refactor(k8s/application): use current endpoint

* fix(app/header): add margin to header

* refactor(app): remove unused types

* refactor(app): use rq onError handler

* refactor(home): wrap element with button
pull/6646/head
Chaim Lev-Ari 3 years ago committed by GitHub
parent c442d936d3
commit 0f3c7b1424
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -18,17 +18,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v1
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 12
# ESLint and Prettier must be in `package.json`
- name: Install Node.js dependencies
run: yarn --frozen-lockfile
node-version: '14'
cache: 'yarn'
- run: yarn --frozen-lockfile
- name: Run linters
uses: wearerequired/lint-action@v1

@ -1,11 +1,15 @@
name: Test Frontend
on: push
jobs:
build:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install modules
run: yarn --frozen-lockfile
- uses: actions/setup-node@v2
with:
node-version: '14'
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Run tests
run: yarn test:client

@ -80,6 +80,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
}
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
totalAvailableEndpoints := len(filteredEndpoints)
if endpointIDs != nil {
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
@ -127,6 +128,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
}
w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount))
w.Header().Set("X-Total-Available", strconv.Itoa(totalAvailableEndpoints))
return response.JSON(w, paginatedEndpoints)
}

@ -0,0 +1 @@
export function loadProgressBar() {}

@ -38,7 +38,7 @@ export function onStartupAngular($rootScope, $state, $interval, LocalStorage, En
function ping(EndpointProvider, SystemService) {
const endpoint = EndpointProvider.currentEndpoint();
if (endpoint !== undefined && endpoint.Type == PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) {
if (endpoint && endpoint.Type == PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) {
SystemService.ping(endpoint.Id);
}
}

@ -375,10 +375,6 @@ a[ng-click] {
background-color: var(--white-color) fff;
}
.pagination-controls {
margin-left: 10px;
}
.user-box {
margin-right: 25px;
}
@ -832,6 +828,18 @@ json-tree .branch-preview {
align-items: center;
}
.space-x-2 > * + * {
margin-left: 0.5rem;
}
.space-x-3 > * + * {
margin-left: 0.75rem;
}
.space-x-4 > * + * {
margin-left: 1rem;
}
.space-y-8 > * + * {
margin-top: 2rem;
}

@ -222,33 +222,6 @@ json-tree .branch-preview {
background-color: var(--bg-progress-color);
}
.pagination > .disabled > span,
.pagination > .disabled > span:hover,
.pagination > .disabled > span:focus,
.pagination > .disabled > a,
.pagination > .disabled > a:hover,
.pagination > .disabled > a:focus {
color: var(--text-pagination-color);
background-color: var(--bg-pagination-color);
border-color: var(--border-pagination-color);
}
.pagination > li > a,
.pagination > li > span {
background-color: var(--bg-pagination-span-color);
border-color: var(--border-pagination-span-color);
color: var(--text-pagination-span-color);
}
.pagination > li > a:hover,
.pagination > li > span:hover,
.pagination > li > a:focus,
.pagination > li > span:focus {
background-color: var(--bg-pagination-hover-color);
border-color: var(--border-pagination-hover-color);
color: var(--text-pagination-span-hover-color);
}
.ui-select-bootstrap .ui-select-choices-row > span {
color: var(--text-ui-select-color);
}

@ -30,8 +30,8 @@ import { ColumnVisibilityMenu } from '@/portainer/components/datatables/componen
import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
import { useDebounce } from '@/portainer/hooks/useDebounce';
import {
useSearchBarContext,
SearchBar,
useSearchBarState,
} from '@/portainer/components/datatables/components/SearchBar';
import type {
ContainersTableSettings,
@ -63,7 +63,7 @@ export function ContainersDatatable({
}: ContainerTableProps) {
const { settings, setTableSettings } =
useTableSettings<ContainersTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarContext();
const [searchBarValue, setSearchBarValue] = useSearchBarState('containers');
const columns = useColumns();

@ -1,7 +1,6 @@
import { react2angular } from '@/react-tools/react2angular';
import { EnvironmentProvider } from '@/portainer/environments/useEnvironment';
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
import { SearchBarProvider } from '@/portainer/components/datatables/components/SearchBar';
import type { Environment } from '@/portainer/environments/types';
import {
@ -30,10 +29,8 @@ export function ContainersDatatableContainer({
return (
<EnvironmentProvider environment={endpoint}>
<TableSettingsProvider defaults={defaultSettings} storageKey={tableKey}>
<SearchBarProvider storageKey={tableKey}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<ContainersDatatable {...props} />
</SearchBarProvider>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<ContainersDatatable {...props} />
</TableSettingsProvider>
</EnvironmentProvider>
);

@ -8,6 +8,7 @@ import {
usePagination,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import _ from 'lodash';
import { Environment } from '@/portainer/environments/types';
import { PaginationControls } from '@/portainer/components/pagination-controls';
@ -27,7 +28,7 @@ import { ColumnVisibilityMenu } from '@/portainer/components/datatables/componen
import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
import { useDebounce } from '@/portainer/hooks/useDebounce';
import {
useSearchBarContext,
useSearchBarState,
SearchBar,
} from '@/portainer/components/datatables/components/SearchBar';
import { useRowSelect } from '@/portainer/components/datatables/components/useRowSelect';
@ -38,34 +39,39 @@ import { EdgeDevicesDatatableSettings } from '@/edge/devices/components/EdgeDevi
import { EdgeDevicesDatatableActions } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions';
import { AMTDevicesDatatable } from '@/edge/devices/components/AMTDevicesDatatable/AMTDevicesDatatable';
import { TextTip } from '@/portainer/components/Tip/TextTip';
import { EnvironmentGroup } from '@/portainer/environment-groups/types';
import { RowProvider } from './columns/RowContext';
import { useColumns } from './columns';
import styles from './EdgeDevicesDatatable.module.css';
export interface EdgeDevicesTableProps {
storageKey: string;
isEnabled: boolean;
isFdoEnabled: boolean;
isOpenAmtEnabled: boolean;
disableTrustOnFirstConnect: boolean;
mpsServer: string;
dataset: Environment[];
groups: EnvironmentGroup[];
onRefresh(): Promise<void>;
setLoadingMessage(message: string): void;
}
export function EdgeDevicesDatatable({
storageKey,
isFdoEnabled,
isOpenAmtEnabled,
disableTrustOnFirstConnect,
mpsServer,
dataset,
groups,
onRefresh,
setLoadingMessage,
}: EdgeDevicesTableProps) {
const { settings, setTableSettings } =
useTableSettings<EdgeDeviceTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarContext();
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
const columns = useColumns();
@ -131,6 +137,8 @@ export function EdgeDevicesDatatable({
environment.AMTDeviceGUID && environment.AMTDeviceGUID !== ''
);
const groupsById = _.groupBy(groups, 'Id');
return (
<TableContainer>
<TableTitle icon="fa-plug" label="Edge Devices">
@ -201,12 +209,13 @@ export function EdgeDevicesDatatable({
{page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
const group = groupsById[row.original.GroupId];
return (
<RowProvider
key={key}
disableTrustOnFirstConnect={disableTrustOnFirstConnect}
isOpenAmtEnabled={isOpenAmtEnabled}
groupName={group[0]?.Name}
>
<TableRow<Environment>
cells={row.cells}

@ -1,6 +1,5 @@
import { react2angular } from '@/react-tools/react2angular';
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
import { SearchBarProvider } from '@/portainer/components/datatables/components/SearchBar';
import {
EdgeDevicesDatatable,
@ -18,12 +17,12 @@ export function EdgeDevicesDatatableContainer({
sortBy: { id: 'state', desc: false },
};
const storageKey = 'edgeDevices';
return (
<TableSettingsProvider defaults={defaultSettings} storageKey="edgeDevices">
<SearchBarProvider storageKey="edgeDevices">
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<EdgeDevicesDatatable {...props} />
</SearchBarProvider>
<TableSettingsProvider defaults={defaultSettings} storageKey={storageKey}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<EdgeDevicesDatatable {...props} storageKey={storageKey} />
</TableSettingsProvider>
);
}
@ -31,6 +30,7 @@ export function EdgeDevicesDatatableContainer({
export const EdgeDevicesDatatableAngular = react2angular(
EdgeDevicesDatatableContainer,
[
'groups',
'dataset',
'onRefresh',
'setLoadingMessage',

@ -3,23 +3,26 @@ import { createContext, useContext, useMemo, PropsWithChildren } from 'react';
interface RowContextState {
disableTrustOnFirstConnect: boolean;
isOpenAmtEnabled: boolean;
groupName?: string;
}
const RowContext = createContext<RowContextState | null>(null);
export interface RowProviderProps {
disableTrustOnFirstConnect: boolean;
groupName?: string;
isOpenAmtEnabled: boolean;
}
export function RowProvider({
disableTrustOnFirstConnect,
groupName,
isOpenAmtEnabled,
children,
}: PropsWithChildren<RowProviderProps>) {
const state = useMemo(
() => ({ disableTrustOnFirstConnect, isOpenAmtEnabled }),
[disableTrustOnFirstConnect, isOpenAmtEnabled]
() => ({ disableTrustOnFirstConnect, groupName, isOpenAmtEnabled }),
[disableTrustOnFirstConnect, groupName, isOpenAmtEnabled]
);
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;

@ -3,10 +3,19 @@ import { Column } from 'react-table';
import { Environment } from '@/portainer/environments/types';
import { DefaultFilter } from '@/portainer/components/datatables/components/Filter';
import { useRowContext } from './RowContext';
export const group: Column<Environment> = {
Header: 'Group',
accessor: (row) => row.GroupName || '-',
accessor: (row) => row.GroupId,
Cell: GroupCell,
id: 'groupName',
Filter: DefaultFilter,
canHide: true,
};
function GroupCell() {
const { groupName } = useRowContext();
return groupName;
}

@ -28,6 +28,7 @@
<div class="col-sm-12">
<edge-devices-datatable
dataset="($ctrl.edgeDevices)"
groups="($ctrl.groups)"
is-fdo-enabled="($ctrl.isFDOEnabled)"
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
disable-trust-on-first-connect="($ctrl.disableTrustOnFirstConnect)"

@ -1,4 +1,3 @@
import EndpointHelper from 'Portainer/helpers/endpointHelper';
import { getEndpoints } from 'Portainer/environments/environment.service';
import { EnvironmentType } from 'Portainer/environments/types';
@ -13,7 +12,7 @@ export function EdgeDevicesViewController($q, $async, EndpointService, GroupServ
return $async(async () => {
try {
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, { types: [EnvironmentType.EdgeAgentOnDocker] }), GroupService.groups()]);
EndpointHelper.mapGroupNameToEndpoint(endpointsResponse.value, groups);
ctrl.groups = groups;
ctrl.edgeDevices = endpointsResponse.value;
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve edge devices');

@ -2,6 +2,7 @@ angular.module('portainer.kubernetes').component('kubernetesResourcePoolsDatatab
templateUrl: './resourcePoolsDatatable.html',
controller: 'KubernetesResourcePoolsDatatableController',
bindings: {
endpoint: '<',
titleText: '@',
titleIcon: '@',
dataset: '<',
@ -10,6 +11,5 @@ angular.module('portainer.kubernetes').component('kubernetesResourcePoolsDatatab
reverseOrder: '<',
removeAction: '<',
refreshCallback: '<',
endpoint: '<',
},
});

@ -1,11 +1,10 @@
export default class HelmAddRepositoryController {
/* @ngInject */
constructor($state, $async, HelmService, Notifications, EndpointProvider) {
constructor($state, $async, HelmService, Notifications) {
this.$state = $state;
this.$async = $async;
this.HelmService = HelmService;
this.Notifications = Notifications;
this.EndpointProvider = EndpointProvider;
}
doesRepoExist() {
@ -19,7 +18,7 @@ export default class HelmAddRepositoryController {
async addRepository() {
this.state.isAddingRepo = true;
try {
await this.HelmService.addHelmRepository(this.EndpointProvider.currentEndpoint().Id, { url: this.state.repository });
await this.HelmService.addHelmRepository(this.endpoint.Id, { url: this.state.repository });
this.Notifications.success('Helm repository added successfully');
this.$state.reload(this.$state.current);
} catch (err) {

@ -146,7 +146,7 @@
<div class="row">
<div class="col-sm-12">
<helm-add-repository repos="$ctrl.state.repos"></helm-add-repository>
<helm-add-repository repos="$ctrl.state.repos" endpoint="$ctrl.endpoint"></helm-add-repository>
</div>
</div>

@ -1,20 +0,0 @@
import angular from 'angular';
angular.module('portainer.kubernetes').factory('KubernetesConfig', KubernetesConfigFactory);
/* @ngInject */
function KubernetesConfigFactory($http, EndpointProvider, API_ENDPOINT_KUBERNETES) {
return { get };
async function get(environmentIDs) {
return $http({
method: 'GET',
url: `${API_ENDPOINT_KUBERNETES}/config`,
params: { ids: JSON.stringify(environmentIDs.map((x) => parseInt(x))) },
responseType: 'blob',
headers: {
Accept: 'text/yaml',
},
});
}
}

@ -0,0 +1,43 @@
import { saveAs } from 'file-saver';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/portainer/environments/types';
import { publicSettings } from '@/portainer/settings/settings.service';
const baseUrl = 'kubernetes';
export async function downloadKubeconfigFile(environmentIds: EnvironmentId[]) {
try {
const { headers, data } = await axios.get<Blob>(`${baseUrl}/config`, {
params: { ids: JSON.stringify(environmentIds) },
responseType: 'blob',
headers: {
Accept: 'text/yaml',
},
});
const contentDispositionHeader = headers['content-disposition'];
const filename = contentDispositionHeader.replace('attachment;', '').trim();
saveAs(data, filename);
} catch (e) {
throw parseAxiosError(e as Error, '');
}
}
export async function expiryMessage() {
const settings = await publicSettings();
const prefix = 'Kubeconfig file will';
switch (settings.KubeconfigExpiry) {
case '24h':
return `${prefix} expire in 1 day.`;
case '168h':
return `${prefix} expire in 7 days.`;
case '720h':
return `${prefix} expire in 30 days.`;
case '8640h':
return `${prefix} expire in 1 year.`;
case '0':
default:
return `${prefix} not expire.`;
}
}

@ -1,40 +0,0 @@
import angular from 'angular';
class KubernetesConfigService {
/* @ngInject */
constructor(KubernetesConfig, FileSaver, SettingsService) {
this.KubernetesConfig = KubernetesConfig;
this.FileSaver = FileSaver;
this.SettingsService = SettingsService;
}
async downloadKubeconfigFile(environmentIDs) {
const response = await this.KubernetesConfig.get(environmentIDs);
const headers = response.headers();
const contentDispositionHeader = headers['content-disposition'];
const filename = contentDispositionHeader.replace('attachment;', '').trim();
return this.FileSaver.saveAs(response.data, filename);
}
async expiryMessage() {
const settings = await this.SettingsService.publicSettings();
const expiryDays = settings.KubeconfigExpiry;
const prefix = 'Kubeconfig file will ';
switch (expiryDays) {
case '0':
return prefix + 'not expire.';
case '24h':
return prefix + 'expire in 1 day.';
case '168h':
return prefix + 'expire in 7 days.';
case '720h':
return prefix + 'expire in 30 days.';
case '8640h':
return prefix + 'expire in 1 year.';
}
return '';
}
}
export default KubernetesConfigService;
angular.module('portainer.kubernetes').service('KubernetesConfigService', KubernetesConfigService);

@ -6,9 +6,8 @@ import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
class KubernetesPersistentVolumeClaimService {
/* @ngInject */
constructor($async, EndpointProvider, KubernetesPersistentVolumeClaims) {
constructor($async, KubernetesPersistentVolumeClaims) {
this.$async = $async;
this.EndpointProvider = EndpointProvider;
this.KubernetesPersistentVolumeClaims = KubernetesPersistentVolumeClaims;
this.getAsync = this.getAsync.bind(this);
@ -18,7 +17,7 @@ class KubernetesPersistentVolumeClaimService {
this.deleteAsync = this.deleteAsync.bind(this);
}
async getAsync(namespace, name) {
async getAsync(namespace, storageClasses, name) {
try {
const params = new KubernetesCommonParams();
params.id = name;
@ -26,28 +25,28 @@ class KubernetesPersistentVolumeClaimService {
this.KubernetesPersistentVolumeClaims(namespace).get(params).$promise,
this.KubernetesPersistentVolumeClaims(namespace).getYaml(params).$promise,
]);
const storageClasses = this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.StorageClasses;
return KubernetesPersistentVolumeClaimConverter.apiToPersistentVolumeClaim(raw, storageClasses, yaml);
} catch (err) {
throw new PortainerError('Unable to retrieve persistent volume claim', err);
}
}
async getAllAsync(namespace) {
async getAllAsync(namespace, storageClasses) {
try {
const data = await this.KubernetesPersistentVolumeClaims(namespace).get().$promise;
const storageClasses = this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.StorageClasses;
return _.map(data.items, (item) => KubernetesPersistentVolumeClaimConverter.apiToPersistentVolumeClaim(item, storageClasses));
} catch (err) {
throw new PortainerError('Unable to retrieve persistent volume claims', err);
}
}
get(namespace, name) {
get(namespace, storageClasses, name) {
if (name) {
return this.$async(this.getAsync, namespace, name);
return this.$async(this.getAsync, namespace, storageClasses, name);
}
return this.$async(this.getAllAsync, namespace);
return this.$async(this.getAllAsync, namespace, storageClasses);
}
/**

@ -19,28 +19,28 @@ class KubernetesVolumeService {
/**
* GET
*/
async getAsync(namespace, name) {
const [pvc, pool] = await Promise.all([this.KubernetesPersistentVolumeClaimService.get(namespace, name), this.KubernetesResourcePoolService.get(namespace)]);
async getAsync(namespace, storageClasses, name) {
const [pvc, pool] = await Promise.all([this.KubernetesPersistentVolumeClaimService.get(namespace, storageClasses, name), this.KubernetesResourcePoolService.get(namespace)]);
return KubernetesVolumeConverter.pvcToVolume(pvc, pool);
}
async getAllAsync(namespace) {
async getAllAsync(namespace, storageClasses) {
const data = await this.KubernetesResourcePoolService.get(namespace);
const pools = data instanceof Array ? data : [data];
const res = await Promise.all(
_.map(pools, async (pool) => {
const pvcs = await this.KubernetesPersistentVolumeClaimService.get(pool.Namespace.Name);
const pvcs = await this.KubernetesPersistentVolumeClaimService.get(pool.Namespace.Name, storageClasses);
return _.map(pvcs, (pvc) => KubernetesVolumeConverter.pvcToVolume(pvc, pool));
})
);
return _.flatten(res);
}
get(namespace, name) {
get(namespace, storageClasses, name) {
if (name) {
return this.$async(this.getAsync, namespace, name);
return this.$async(this.getAsync, namespace, storageClasses, name);
}
return this.$async(this.getAllAsync, namespace);
return this.$async(this.getAllAsync, namespace, storageClasses);
}
/**

@ -932,7 +932,8 @@ class KubernetesCreateApplicationController {
refreshVolumes(namespace) {
return this.$async(async () => {
try {
const volumes = await this.KubernetesVolumeService.get(namespace);
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
const volumes = await this.KubernetesVolumeService.get(namespace, storageClasses);
_.forEach(volumes, (volume) => {
volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, this.applications);
});
@ -1045,9 +1046,11 @@ class KubernetesCreateApplicationController {
return this.$async(async () => {
try {
const namespace = this.$state.params.namespace;
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
[this.application, this.persistentVolumeClaims] = await Promise.all([
this.KubernetesApplicationService.get(namespace, this.$state.params.name),
this.KubernetesPersistentVolumeClaimService.get(namespace),
this.KubernetesPersistentVolumeClaimService.get(namespace, storageClasses),
]);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve application details');

@ -348,12 +348,6 @@ class KubernetesApplicationController {
}
async onInit() {
const endpointId = this.LocalStorage.getEndpointID();
const endpoints = this.LocalStorage.getEndpoints();
const endpoint = _.find(endpoints, function (item) {
return item.Id === endpointId;
});
this.state = {
activeTab: 0,
currentName: this.$state.$current.name,
@ -372,7 +366,7 @@ class KubernetesApplicationController {
expandedNote: false,
useIngress: false,
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
publicUrl: endpoint.PublicURL,
publicUrl: this.endpoint.PublicURL,
};
this.state.activeTab = this.LocalStorage.getActiveTab('application');

@ -33,13 +33,14 @@ class KubernetesDashboardController {
async getAllAsync() {
const isAdmin = this.Authentication.isAdmin();
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
try {
const [pools, applications, configurations, volumes, tags] = await Promise.all([
this.KubernetesResourcePoolService.get(),
this.KubernetesApplicationService.get(),
this.KubernetesConfigurationService.get(),
this.KubernetesVolumeService.get(),
this.KubernetesVolumeService.get(undefined, storageClasses),
this.TagService.tags(),
]);
this.applications = applications;

@ -6,6 +6,7 @@
<div class="row">
<div class="col-sm-12">
<kubernetes-resource-pools-datatable
endpoint="ctrl.endpoint"
dataset="ctrl.resourcePools"
table-key="kubernetes.resourcePools"
order-by="Namespace.Name"

@ -4,5 +4,6 @@ angular.module('portainer.kubernetes').component('kubernetesVolumeView', {
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
endpoint: '<',
},
});

@ -116,9 +116,10 @@ class KubernetesVolumeController {
}
async getVolumeAsync() {
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
try {
const [volume, applications] = await Promise.all([
this.KubernetesVolumeService.get(this.state.namespace, this.state.name),
this.KubernetesVolumeService.get(this.state.namespace, storageClasses, this.state.name),
this.KubernetesApplicationService.get(this.state.namespace),
]);
volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, applications);

@ -71,9 +71,10 @@ class KubernetesVolumesController {
}
async getVolumesAsync() {
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
try {
const [volumes, applications, storages] = await Promise.all([
this.KubernetesVolumeService.get(),
this.KubernetesVolumeService.get(undefined, storageClasses),
this.KubernetesApplicationService.get(),
this.KubernetesStorageService.get(this.endpoint.Id),
]);

@ -7,6 +7,7 @@ import featureFlagModule from './feature-flags';
import userActivityModule from './user-activity';
import servicesModule from './services';
import teamsModule from './teams';
import homeModule from './home';
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
@ -25,6 +26,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat
angular
.module('portainer.app', [
homeModule,
'portainer.oauth',
'portainer.rbac',
componentsModule,
@ -74,7 +76,7 @@ angular
parent: 'root',
abstract: true,
resolve: {
endpoint: /* @ngInject */ function endpoint($async, $state, $transition$, EndpointService, Notifications) {
endpoint: /* @ngInject */ function endpoint($async, $state, $transition$, EndpointProvider, EndpointService, Notifications) {
return $async(async () => {
try {
const endpointId = +$transition$.params().endpointId;
@ -85,6 +87,8 @@ angular
return;
}
EndpointProvider.setCurrentEndpoint(endpoint);
return endpoint;
} catch (e) {
Notifications.error('Failed loading environment', e);
@ -322,8 +326,7 @@ angular
url: '/home',
views: {
'content@': {
templateUrl: './views/home/home.html',
controller: 'HomeController',
component: 'homeView',
},
},
};

@ -1,4 +1,4 @@
import { PropsWithChildren } from 'react';
import { MouseEventHandler, PropsWithChildren } from 'react';
import clsx from 'clsx';
type Type = 'submit' | 'button' | 'reset';
@ -13,7 +13,7 @@ export interface Props {
className?: string;
dataCy?: string;
type?: Type;
onClick?: () => void;
onClick?: MouseEventHandler<HTMLButtonElement>;
}
export function Button({

@ -3,5 +3,3 @@ import { AddButton } from './AddButton';
import { ButtonGroup } from './ButtonGroup';
export { Button, AddButton, ButtonGroup };
export default Button;

@ -0,0 +1,47 @@
import { PropsWithChildren } from 'react';
import { Button } from '../Button';
import { Widget, WidgetBody } from '../widget';
interface Props {
title: string;
onDismiss?(): void;
bodyClassName?: string;
wrapperStyle?: Record<string, string>;
}
export function InformationPanel({
title,
onDismiss,
wrapperStyle,
bodyClassName,
children,
}: PropsWithChildren<Props>) {
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody className={bodyClassName}>
<div style={wrapperStyle}>
<div className="col-sm-12 form-section-title">
<span style={{ float: 'left' }}>{title}</span>
{!!onDismiss && (
<span
className="small"
style={{ float: 'right' }}
ng-if="dismissAction"
>
<Button color="link" onClick={() => onDismiss()}>
<i className="fa fa-times" /> dismiss
</Button>
</span>
)}
</div>
<div className="form-group">{children}</div>
</div>
</WidgetBody>
</Widget>
</div>
</div>
);
}

@ -0,0 +1,8 @@
export const InformationPanelAngular = {
templateUrl: './InformationPanelAngular.html',
bindings: {
titleText: '@',
dismissAction: '&?',
},
transclude: true,
};

@ -0,0 +1,3 @@
export { InformationPanel } from './InformationPanel';
export { InformationPanelAngular } from './InformationPanelAngular';

@ -7,7 +7,7 @@ body.hamburg .row.header .meta {
}
.row.header {
height: 60px;
min-height: 60px;
background: var(--bg-row-header-color);
margin-bottom: 15px;
}

@ -6,11 +6,17 @@ import { HeaderContainer } from './HeaderContainer';
import { HeaderContent } from './HeaderContent';
test('should not render without a wrapping HeaderContainer', async () => {
const consoleErrorFn = jest
.spyOn(console, 'error')
.mockImplementation(() => jest.fn());
function renderComponent() {
return render(<HeaderContent />);
}
expect(renderComponent).toThrowErrorMatchingSnapshot();
consoleErrorFn.mockRestore();
});
test('should display a HeaderContent', async () => {

@ -6,12 +6,18 @@ import { HeaderContainer } from './HeaderContainer';
import { HeaderTitle } from './HeaderTitle';
test('should not render without a wrapping HeaderContainer', async () => {
const consoleErrorFn = jest
.spyOn(console, 'error')
.mockImplementation(() => jest.fn());
const title = 'title';
function renderComponent() {
return render(<HeaderTitle title={title} />);
}
expect(renderComponent).toThrowErrorMatchingSnapshot();
consoleErrorFn.mockRestore();
});
test('should display a HeaderTitle', async () => {

@ -0,0 +1,4 @@
.reloadButton {
padding: 0;
margin: 0;
}

@ -7,6 +7,7 @@ import { Crumb } from './Breadcrumbs/Breadcrumbs';
import { HeaderContainer } from './HeaderContainer';
import { HeaderContent } from './HeaderContent';
import { HeaderTitle } from './HeaderTitle';
import styles from './PageHeader.module.css';
interface Props {
reload?: boolean;
@ -20,7 +21,12 @@ export function PageHeader({ title, breadcrumbs = [], reload }: Props) {
<HeaderContainer>
<HeaderTitle title={title}>
{reload && (
<Button color="link" onClick={() => router.stateService.reload()}>
<Button
color="link"
size="medium"
onClick={() => router.stateService.reload()}
className={styles.reloadButton}
>
<i className="fa fa-sync" aria-hidden="true" />
</Button>
)}

@ -1,13 +1,16 @@
import { useContext, createContext, PropsWithChildren } from 'react';
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
interface Props {
value: string;
placeholder?: string;
onChange(value: string): void;
}
export function SearchBar({ value, onChange }: Props) {
export function SearchBar({
value,
placeholder = 'Search...',
onChange,
}: Props) {
return (
<div className="searchBar">
<i className="fa fa-search searchIcon" aria-hidden="true" />
@ -16,44 +19,21 @@ export function SearchBar({ value, onChange }: Props) {
className="searchInput"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search..."
placeholder={placeholder}
/>
</div>
);
}
const SearchBarContext = createContext<
[string, (value: string) => void] | null
>(null);
export function useSearchBarState(
key: string
): [string, (value: string) => void] {
const filterKey = keyBuilder(key);
const [value, setValue] = useLocalStorage(filterKey, '', sessionStorage);
interface SearchBarProviderProps {
defaultValue?: string;
storageKey: string;
}
return [value, setValue];
export function SearchBarProvider({
children,
storageKey,
defaultValue = '',
}: PropsWithChildren<SearchBarProviderProps>) {
const state = useLocalStorage(
`datatable_text_filter_${storageKey}`,
defaultValue,
sessionStorage
);
return (
<SearchBarContext.Provider value={state}>
{children}
</SearchBarContext.Provider>
);
}
export function useSearchBarContext() {
const context = useContext(SearchBarContext);
if (context === null) {
throw new Error('should be used under SearchBarProvider');
function keyBuilder(key: string) {
return `datatable_text_filter_${key}`;
}
return context;
}

@ -1,9 +1,17 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { useTableContext } from './TableContainer';
export function TableActions({ children }: PropsWithChildren<unknown>) {
interface Props {
className?: string;
}
export function TableActions({
children,
className,
}: PropsWithChildren<Props>) {
useTableContext();
return <div className="actionBar">{children}</div>;
return <div className={clsx('actionBar', className)}>{children}</div>;
}

@ -87,8 +87,20 @@
margin: 0;
}
.datatable .pagination > li.disabled > a,
.datatable .pagination > li.disabled > button {
pointer-events: none;
cursor: not-allowed;
}
.datatable .pagination > li.disabled {
cursor: not-allowed;
}
.datatable .pagination > li > a,
.datatable .pagination > li > button,
.pagination > li > span {
user-select: none;
float: none;
}

@ -1,61 +0,0 @@
import angular from 'angular';
import _ from 'lodash-es';
import PortainerEndpointTagHelper from 'Portainer/helpers/tagHelper';
class EndpointItemController {
/* @ngInject */
constructor() {
this.editEndpoint = this.editEndpoint.bind(this);
}
editEndpoint(event) {
event.stopPropagation();
this.onEdit(this.model.Id);
}
joinTags() {
if (!this.tags) {
return 'Loading tags...';
}
if (!this.model.TagIds || !this.model.TagIds.length) {
return '';
}
const tagNames = PortainerEndpointTagHelper.idsToTagNames(this.tags, this.model.TagIds);
return _.join(tagNames, ',');
}
isEdgeEndpoint() {
return this.model.Type === 4 || this.model.Type === 7;
}
calcIsCheckInValid() {
if (!this.isEdgeEndpoint()) {
return false;
}
const checkInInterval = this.model.EdgeCheckinInterval;
// give checkIn some wiggle room
return this.endpointInitTime - this.model.LastCheckInDate <= checkInInterval * 2 + 20;
}
$onInit() {
this.endpointTags = this.joinTags();
this.isCheckInValid = this.calcIsCheckInValid();
}
$onChanges({ tags, model }) {
if ((!tags && !model) || (!tags.currentValue && !model.currentValue)) {
return;
}
this.endpointTags = this.joinTags();
if (model) {
this.isCheckInValid = this.calcIsCheckInValid();
}
}
}
angular.module('portainer.app').controller('EndpointItemController', EndpointItemController);
export default EndpointItemController;

@ -1,140 +0,0 @@
<div class="blocklist-item" ng-click="$ctrl.onSelect($ctrl.model)">
<div class="blocklist-item-box">
<span ng-class="['blocklist-item-logo', 'endpoint-item', { azure: $ctrl.model.Type === 3 }]">
<i
ng-if="$ctrl.model.Type !== 4 && $ctrl.model.Type !== 5 && $ctrl.model.Type !== 6 && $ctrl.model.Type !== 7"
ng-class="$ctrl.model.Type | endpointtypeicon"
class="fa-4x blue-icon"
aria-hidden="true"
></i>
<img ng-if="$ctrl.model.Type === 4" src="~@/assets/images/edge_endpoint.png" />
<img ng-if="$ctrl.model.Type === 5 || $ctrl.model.Type === 6" src="~@/assets/images/kubernetes_endpoint.png" />
<img ng-if="$ctrl.model.Type === 7" src="~@/assets/images/kubernetes_edge_endpoint.png" />
</span>
<span class="col-sm-12">
<div class="blocklist-item-line endpoint-item">
<span>
<span class="blocklist-item-title endpoint-item">
{{ $ctrl.model.Name }}
</span>
<span class="space-left blocklist-item-subtitle">
<span ng-if="$ctrl.isEdgeEndpoint()">
<span ng-if="!$ctrl.model.EdgeID" class="label label-default"><s>associated</s></span>
<span ng-if="$ctrl.model.EdgeID">
<span class="label" ng-class="{ 'label-danger': !$ctrl.isCheckInValid, 'label-success': $ctrl.isCheckInValid }">heartbeat</span>
<span class="space-left small text-muted" ng-if="$ctrl.model.LastCheckInDate">
{{ $ctrl.model.LastCheckInDate | getisodatefromtimestamp }}
</span>
</span>
</span>
<span ng-if="!$ctrl.isEdgeEndpoint()">
<span class="label label-{{ $ctrl.model.Status | endpointstatusbadge }}">
{{ $ctrl.model.Status === 1 ? 'up' : 'down' }}
</span>
<span class="space-left small text-muted" ng-if="$ctrl.model.Snapshots[0]">
{{ $ctrl.model.Snapshots[0].Time | getisodatefromtimestamp }}
</span>
<span class="space-left small text-muted" ng-if="$ctrl.model.Kubernetes.Snapshots[0]">
{{ $ctrl.model.Kubernetes.Snapshots[0].Time | getisodatefromtimestamp }}
</span>
</span>
</span>
</span>
<span>
<span class="small" ng-if="$ctrl.model.GroupName"> Group: {{ $ctrl.model.GroupName }} </span>
<button ng-if="$ctrl.isAdmin" class="btn btn-link btn-xs" ng-click="$ctrl.editEndpoint($event)"><i class="fa fa-pencil-alt"></i> </button>
</span>
</div>
<div class="blocklist-item-line endpoint-item" ng-if="$ctrl.model.Snapshots[0]">
<span class="blocklist-item-desc">
<span>
<span style="padding: 0 7px 0 0">
<i class="fa fa-th-list space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].StackCount }}
{{ $ctrl.model.Snapshots[0].StackCount === 1 ? 'stack' : 'stacks' }}
</span>
<span style="padding: 0 7px 0 7px" ng-if="$ctrl.model.Snapshots[0].Swarm">
<i class="fa fa-list-alt space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].ServiceCount }}
{{ $ctrl.model.Snapshots[0].ServiceCount === 1 ? 'service' : 'services' }}
</span>
<span style="padding: 0 7px 0 7px">
<i class="fa fa-cubes space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount }}
{{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount === 1 ? 'container' : 'containers' }}
<span ng-if="$ctrl.model.Snapshots[0].RunningContainerCount > 0 || $ctrl.model.Snapshots[0].StoppedContainerCount > 0">
-
<i class="fa fa-power-off green-icon" aria-hidden="true"></i> {{ $ctrl.model.Snapshots[0].RunningContainerCount }}
<i class="fa fa-power-off red-icon" aria-hidden="true"></i> {{ $ctrl.model.Snapshots[0].StoppedContainerCount }}
/
<i class="fa fa-heartbeat green-icon" aria-hidden="true"></i> {{ $ctrl.model.Snapshots[0].HealthyContainerCount }}
<i class="fa fa-heartbeat orange-icon" aria-hidden="true"></i> {{ $ctrl.model.Snapshots[0].UnhealthyContainerCount }}
</span>
</span>
<span style="padding: 0 7px 0 7px">
<i class="fa fa-hdd space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].VolumeCount }}
{{ $ctrl.model.Snapshots[0].VolumeCount === 1 ? 'volume' : 'volumes' }}
</span>
<span style="padding: 0 7px 0 7px">
<i class="fa fa-clone space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].ImageCount }}
{{ $ctrl.model.Snapshots[0].ImageCount === 1 ? 'image' : 'images' }}
</span>
</span>
</span>
<span class="small text-muted">
{{ $ctrl.model.Snapshots[0].Swarm ? 'Swarm' : 'Standalone' }} {{ $ctrl.model.Snapshots[0].DockerVersion }}
<span ng-if="$ctrl.model.Type === 2">+ <i class="fa fa-bolt" aria-hidden="true"></i> Agent</span>
<span style="padding: 0 7px 0 0" ng-if="$ctrl.model.Snapshots[0].Swarm">
<i class="fa fa-hdd space-left space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].NodeCount }}
{{ $ctrl.model.Snapshots[0].NodeCount === 1 ? 'node' : 'nodes' }}
</span>
</span>
</div>
<div class="blocklist-item-line endpoint-item" ng-if="!$ctrl.model.Snapshots[0] && $ctrl.model.Type !== 5 && $ctrl.model.Type !== 6 && $ctrl.model.Type !== 7">
<span class="blocklist-item-desc"> No snapshot available </span>
</div>
<div class="blocklist-item-line endpoint-item" ng-if="$ctrl.model.Kubernetes.Snapshots[0] && ($ctrl.model.Type === 5 || $ctrl.model.Type === 6 || $ctrl.model.Type === 7)">
<span class="blocklist-item-desc">
<span>
<span style="padding: 0 7px 0 0"> <i class="fa fa-microchip space-right" aria-hidden="true"></i>{{ $ctrl.model.Kubernetes.Snapshots[0].TotalCPU }} CPU </span>
<span style="padding: 0 7px 0 7px">
<i class="fa fa-memory space-right" aria-hidden="true"></i>{{ $ctrl.model.Kubernetes.Snapshots[0].TotalMemory | humansize }} RAM
</span>
</span>
</span>
<span class="small text-muted">
Kubernetes {{ $ctrl.model.Kubernetes.Snapshots[0].KubernetesVersion }}
<span style="padding: 0 0 0 7px">
<i class="fa fa-hdd space-left space-right" aria-hidden="true"></i>
{{ $ctrl.model.Kubernetes.Snapshots[0].NodeCount }} {{ $ctrl.model.Kubernetes.Snapshots[0].NodeCount === 1 ? 'node' : 'nodes' }}
</span>
</span>
</div>
<div class="blocklist-item-line endpoint-item" ng-if="!$ctrl.model.Kubernetes.Snapshots[0] && ($ctrl.model.Type === 5 || $ctrl.model.Type === 6 || $ctrl.model.Type === 7)">
<span class="blocklist-item-desc"> - </span>
</div>
<div class="blocklist-item-line endpoint-item">
<span class="small text-muted">
<span ng-if="$ctrl.model.Type === 1 || $ctrl.model.Type === 2 || $ctrl.model.Type === 4">
<span class="small text-muted">
<i class="fa fa-microchip"></i> {{ $ctrl.model.Snapshots[0].TotalCPU }}<i class="fa fa-memory space-left"></i> {{ $ctrl.model.Snapshots[0].TotalMemory | humansize }}
</span>
<span class="space-left space-right">-</span>
</span>
<span ng-if="$ctrl.endpointTags.length === 0"> <i class="fa fa-tags" aria-hidden="true"></i> No tags </span>
<span ng-if="$ctrl.endpointTags.length > 0">
<i class="fa fa-tags" aria-hidden="true"></i>
{{ $ctrl.endpointTags }}
</span>
</span>
<span class="small text-muted" ng-if="$ctrl.model.Type !== 4 && $ctrl.model.Type !== 7">
{{ $ctrl.model.URL | stripprotocol }}
</span>
</div>
</span>
</div>
</div>

@ -1,16 +0,0 @@
import angular from 'angular';
import EndpointItemController from './endpoint-item-controller';
angular.module('portainer.app').component('endpointItem', {
templateUrl: './endpointItem.html',
bindings: {
model: '<',
onSelect: '<',
onEdit: '<',
isAdmin: '<',
tags: '<',
endpointInitTime: '<',
},
controller: EndpointItemController,
});

@ -1,185 +0,0 @@
import _ from 'lodash-es';
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
const ENDPOINTS_POLLING_INTERVAL = 30000; // in ms
const ENDPOINTS_CACHE_SIZE = 100;
angular.module('portainer.app').controller('EndpointListController', [
'DatatableService',
'PaginationService',
'ModalService',
'KubernetesConfigService',
'Notifications',
function EndpointListController(DatatableService, PaginationService, ModalService, KubernetesConfigService, Notifications) {
this.state = {
totalFilteredEndpoints: null,
textFilter: '',
filteredEndpoints: [],
paginatedItemLimit: '10',
pageNumber: 1,
loading: true,
pollingTimeout: null,
};
this.onTextFilterChange = function (init = false) {
this.state.loading = true;
var filterValue = this.state.textFilter;
DatatableService.setDataTableTextFilters(this.tableKey, filterValue);
if (!init && this.hasBackendPagination()) {
this.paginationChangedAction();
} else {
this.state.filteredEndpoints = frontEndpointFilter(this.endpoints, this.tags, filterValue);
this.state.loading = false;
if (filterValue) {
this.state.totalFilteredEndpoints = this.state.filteredEndpoints.length;
} else {
this.state.totalFilteredEndpoints = this.endpoints.length;
}
}
};
function frontEndpointFilter(endpoints, tags, filterValue) {
if (!endpoints || !endpoints.length || !filterValue) {
return endpoints;
}
var keywords = filterValue.split(' ');
return _.filter(endpoints, function (endpoint) {
var statusString = convertStatusToString(endpoint.Status);
return _.every(keywords, function (keyword) {
var lowerCaseKeyword = keyword.toLowerCase();
return (
_.includes(endpoint.Name.toLowerCase(), lowerCaseKeyword) ||
_.includes(endpoint.GroupName.toLowerCase(), lowerCaseKeyword) ||
_.includes(endpoint.URL.toLowerCase(), lowerCaseKeyword) ||
_.some(endpoint.TagIds, (tagId) => {
const tag = tags.find((t) => t.Id === tagId);
if (!tag) {
return false;
}
return _.includes(tag.Name.toLowerCase(), lowerCaseKeyword);
}) ||
_.includes(statusString, keyword)
);
});
});
}
this.hasBackendPagination = function () {
return this.totalCount && this.totalCount > ENDPOINTS_CACHE_SIZE;
};
this.clearPollTimeout = function () {
if (this.state.pollingTimeout) {
clearTimeout(this.state.pollingTimeout);
this.state.pollingTimeout = 0;
}
};
this.$onDestory = function () {
this.clearPollTimeout();
};
this.getCurrentPage = async function (start, paginatedItemLimit, textFilter, init = false) {
try {
const { totalCount, endpoints } = await this.retrievePage(start, paginatedItemLimit, textFilter);
if (init) {
this.totalCount = totalCount;
this.endpoints = endpoints;
this.onTextFilterChange(init);
} else {
this.state.filteredEndpoints = endpoints;
this.state.totalFilteredEndpoints = totalCount;
}
this.state.loading = false;
const hasOfflineEndpoint = endpoints.some((e) => e.Status !== 1);
if (hasOfflineEndpoint) {
this.state.pollingTimeout = setTimeout(() => this.getCurrentPage(start, paginatedItemLimit, textFilter), ENDPOINTS_POLLING_INTERVAL);
}
} catch (err) {
Notifications.error('Failed loading page data', err);
}
};
this.paginationChangedAction = async function (init = false) {
this.clearPollTimeout();
if (init || this.hasBackendPagination()) {
this.state.loading = true;
this.state.filteredEndpoints = [];
const start = (this.state.pageNumber - 1) * this.state.paginatedItemLimit + 1;
if (init) {
await this.getCurrentPage(start, ENDPOINTS_CACHE_SIZE, null, init);
} else {
await this.getCurrentPage(start, this.state.paginatedItemLimit, this.state.textFilter);
}
}
};
this.pageChangeHandler = function (newPageNumber) {
this.state.pageNumber = newPageNumber;
this.paginationChangedAction();
};
this.changePaginationLimit = function () {
PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
this.paginationChangedAction();
};
function convertStatusToString(status) {
return status === 1 ? 'up' : 'down';
}
this.showKubeconfigButton = function () {
if (window.location.protocol !== 'https:') {
return false;
}
return _.some(this.endpoints, (endpoint) => isKubernetesMode(endpoint));
};
function isKubernetesMode(endpoint) {
return [
PortainerEndpointTypes.KubernetesLocalEnvironment,
PortainerEndpointTypes.AgentOnKubernetesEnvironment,
PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment,
].includes(endpoint.Type);
}
this.showKubeconfigModal = async function () {
const kubeEnvironments = _.filter(this.endpoints, (endpoint) => isKubernetesMode(endpoint));
const options = kubeEnvironments.map(function (environment) {
return {
text: `${environment.Name} (${environment.URL})`,
value: environment.Id,
};
});
let expiryMessage = '';
try {
expiryMessage = await KubernetesConfigService.expiryMessage();
} catch (e) {
Notifications.error('Failed fetching kubeconfig expiry time', e);
}
ModalService.confirmKubeconfigSelection(options, expiryMessage, async function (selectedEnvironmentIDs) {
if (selectedEnvironmentIDs.length === 0) {
Notifications.warning('No environment was selected');
return;
}
try {
await KubernetesConfigService.downloadKubeconfigFile(selectedEnvironmentIDs);
} catch (e) {
Notifications.error('Failed downloading kubeconfig file', e);
}
});
};
this.$onInit = function () {
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
this.state.paginatedItemLimit = PaginationService.getPaginationLimit(this.tableKey);
if (textFilter) {
this.state.textFilter = textFilter;
}
this.paginationChangedAction(true);
};
},
]);

@ -1,17 +0,0 @@
angular.module('portainer.app').component('endpointList', {
templateUrl: './endpointList.html',
controller: 'EndpointListController',
bindings: {
titleText: '@',
titleIcon: '@',
tags: '<',
tableKey: '@',
dashboardAction: '<',
snapshotAction: '<',
showSnapshotAction: '<',
editAction: '<',
isAdmin: '<',
retrievePage: '<',
endpointInitTime: '<',
},
});

@ -1,87 +0,0 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="actionBar" ng-if="$ctrl.showSnapshotAction || $ctrl.showKubeconfigButton()">
<div style="margin-bottom: 10px" ng-if="$ctrl.endpoints.length">
<i class="fa fa-exclamation-circle blue-icon" style="margin-right: 5px"></i>Click on an environment to manage
</div>
<button type="button" ng-if="$ctrl.showSnapshotAction" class="btn btn-sm btn-primary" ng-click="$ctrl.snapshotAction()" data-cy="home-refreshEndpointsButton">
<i class="fa fa-sync space-right" aria-hidden="true"></i>Refresh
</button>
<button
ng-if="$ctrl.showKubeconfigButton()"
type="button"
class="btn btn-sm btn-primary"
ng-click="$ctrl.showKubeconfigModal()"
analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-kubectl-kubeconfig-multi"
>
<i class="fas fa-download space-right"></i> kubeconfig
</button>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
ng-model-options="{ debounce: 300 }"
placeholder="Search by name, group, tag, status, URL..."
auto-focus
data-cy="home-endpointsSearchInput"
/>
</div>
<div class="blocklist" data-cy="home-endpointList">
<endpoint-item
ng-if="$ctrl.hasBackendPagination()"
dir-paginate="endpoint in $ctrl.state.filteredEndpoints | itemsPerPage: $ctrl.state.paginatedItemLimit"
model="endpoint"
total-items="$ctrl.state.totalFilteredEndpoints"
on-select="($ctrl.dashboardAction)"
on-edit="($ctrl.editAction)"
is-admin="$ctrl.isAdmin"
tags="$ctrl.tags"
endpoint-init-time="$ctrl.endpointInitTime"
></endpoint-item>
<endpoint-item
ng-if="!$ctrl.hasBackendPagination()"
dir-paginate="endpoint in $ctrl.state.filteredEndpoints | itemsPerPage: $ctrl.state.paginatedItemLimit"
model="endpoint"
on-select="($ctrl.dashboardAction)"
on-edit="($ctrl.editAction)"
is-admin="$ctrl.isAdmin"
tags="$ctrl.tags"
endpoint-init-time="$ctrl.endpointInitTime"
></endpoint-item>
<div ng-if="$ctrl.state.loading" class="text-center text-muted" data-cy="home-loadingEndpoints"> Loading... </div>
<div ng-if="!$ctrl.state.loading && !$ctrl.state.filteredEndpoints.length" class="text-center text-muted" data-cy="home-noEndpoints"> No environment available. </div>
</div>
<div class="footer" ng-if="$ctrl.endpoints">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="home-paginationSelect">
<option value="0" ng-if="!$ctrl.hasBackendPagination()">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5" on-page-change="$ctrl.pageChangeHandler(newPageNumber, oldPageNumber)"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

@ -1,5 +1,5 @@
import _ from 'lodash-es';
import PortainerEndpointTagHelper from 'Portainer/helpers/tagHelper';
import { idsToTagNames } from 'Portainer/helpers/tagHelper';
angular.module('portainer.app').component('groupAssociationTable', {
templateUrl: './groupAssociationTable.html',
@ -42,7 +42,7 @@ angular.module('portainer.app').component('groupAssociationTable', {
};
this.tagIdsToTagNames = function tagIdsToTagNames(tagIds) {
return PortainerEndpointTagHelper.idsToTagNames(this.tags, tagIds).join(', ') || '-';
return idsToTagNames(this.tags, tagIds).join(', ') || '-';
};
this.groupIdToGroupName = function groupIdToGroupName(groupId) {

@ -13,9 +13,11 @@ import headerModule from './PageHeader';
import { ReactExampleAngular } from './ReactExample';
import { TooltipAngular } from './Tip/Tooltip';
import { beFeatureIndicatorAngular } from './BEFeatureIndicator';
import { InformationPanelAngular } from './InformationPanel';
export default angular
.module('portainer.app.components', [headerModule, boxSelectorModule, widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
.component('informationPanel', InformationPanelAngular)
.component('portainerTooltip', TooltipAngular)
.component('reactExample', ReactExampleAngular)
.component('beFeatureIndicator', beFeatureIndicatorAngular)

@ -1,8 +0,0 @@
angular.module('portainer.app').component('informationPanel', {
templateUrl: './informationPanel.html',
bindings: {
titleText: '@',
dismissAction: '&?',
},
transclude: true,
});

@ -1,8 +0,0 @@
angular.module('portainer.app').component('motdPanel', {
templateUrl: './motdPanel.html',
bindings: {
motd: '<',
dismissAction: '&?',
},
transclude: true,
});

@ -1,26 +0,0 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body classes="motd-body">
<style ng-if="$ctrl.motd.Style">
{{ $ctrl.motd.Style }}
</style>
<div ng-style="{{ $ctrl.motd.ContentLayout ? $ctrl.motd.ContentLayout : {} }}">
<div class="col-sm-12 form-section-title">
<span style="float: left">
{{ $ctrl.motd.Title }}
</span>
<span class="small" style="float: right" ng-if="$ctrl.dismissAction">
<a ng-click="$ctrl.dismissAction()"><i class="fa fa-times"></i> dismiss</a>
</span>
</div>
<div class="form-group">
<span class="text-muted">
<p ng-bind-html="$ctrl.motd.Message"></p>
</span>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

@ -0,0 +1,35 @@
import axios, { parseAxiosError } from '../services/axios';
import { EnvironmentGroup, EnvironmentGroupId } from './types';
export async function getGroup(id: EnvironmentGroupId) {
try {
const { data: group } = await axios.get<EnvironmentGroup>(buildUrl(id));
return group;
} catch (e) {
throw parseAxiosError(e as Error, '');
}
}
export async function getGroups() {
try {
const { data: groups } = await axios.get<EnvironmentGroup[]>(buildUrl());
return groups;
} catch (e) {
throw parseAxiosError(e as Error, '');
}
}
function buildUrl(id?: EnvironmentGroupId, action?: string) {
let url = '/endpoint_groups';
if (id) {
url += `/${id}`;
}
if (action) {
url += `/${action}`;
}
return url;
}

@ -0,0 +1,29 @@
import { useQuery } from 'react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import { EnvironmentGroup, EnvironmentGroupId } from './types';
import { getGroup, getGroups } from './environment-groups.service';
export function useGroups() {
return useQuery<EnvironmentGroup[]>(['environment-groups'], getGroups);
}
export function useGroup<T = EnvironmentGroup>(
groupId: EnvironmentGroupId,
select?: (group: EnvironmentGroup) => T
) {
const { data } = useQuery(
['environment-groups', groupId],
() => getGroup(groupId),
{
staleTime: 50,
select,
onError(error) {
notifyError('Failed loading group', error as Error);
},
}
);
return data;
}

@ -0,0 +1,14 @@
import { TagId } from '@/portainer/tags/types';
export type EnvironmentGroupId = number;
export interface EnvironmentGroup {
// Environment(Endpoint) group Identifier
Id: EnvironmentGroupId;
// Environment(Endpoint) group name
Name: string;
// Description associated to the environment(endpoint) group
Description: string;
// List of tags associated to this environment(endpoint) group
TagIds: TagId[];
}

@ -1,12 +1,9 @@
import PortainerError from '@/portainer/error';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { type EnvironmentGroupId } from '@/portainer/environment-groups/types';
import { type TagId } from '@/portainer/tags/types';
import {
Environment,
EnvironmentGroupId,
EnvironmentCreationTypes,
TagId,
} from '../types';
import { type Environment, EnvironmentCreationTypes } from '../types';
import { arrayToJson, buildUrl, json2formData } from './utils';

@ -1,14 +1,14 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { type EnvironmentGroupId } from '@/portainer/environment-groups/types';
import { type TagId } from '@/portainer/tags/types';
import { UserId } from '@/portainer/users/types';
import { TeamId } from '@/portainer/teams/types';
import {
import type {
Environment,
EnvironmentGroupId,
EnvironmentId,
EnvironmentType,
EnvironmentSettings,
TagId,
TeamId,
UserId,
} from '../types';
import { arrayToJson, buildUrl } from './utils';
@ -51,9 +51,14 @@ export async function getEndpoints(
try {
const response = await axios.get<Environment[]>(url, { params });
const totalCount = response.headers['X-Total-Count'];
const totalCount = response.headers['x-total-count'];
const totalAvailable = response.headers['x-total-available'];
return { totalCount: parseInt(totalCount, 10), value: response.data };
return {
totalCount: parseInt(totalCount, 10),
value: response.data,
totalAvailable: parseInt(totalAvailable, 10),
};
} catch (e) {
throw parseAxiosError(e as Error);
}

@ -0,0 +1,46 @@
import { useQuery } from 'react-query';
import { getEndpoints } from '@/portainer/environments/environment.service';
import { EnvironmentStatus } from '@/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
export function useEnvironmentList(
page: number,
pageLimit: number,
textFilter: string,
refetchOffline = false
) {
const { isLoading, data } = useQuery(
['environments', page, pageLimit, textFilter],
async () => {
const start = (page - 1) * pageLimit + 1;
return getEndpoints(start, pageLimit, { search: textFilter });
},
{
keepPreviousData: true,
refetchInterval: (data) => {
if (!data || !refetchOffline) {
return false;
}
const hasOfflineEnvironment = data.value.some(
(env) => env.Status === EnvironmentStatus.Down
);
return hasOfflineEnvironment && ENVIRONMENTS_POLLING_INTERVAL;
},
onError(error) {
notifyError('Failed loading environments', error as Error);
},
}
);
return {
isLoading,
environments: data ? data.value : [],
totalCount: data ? data.totalCount : 0,
totalAvailable: data ? data.totalAvailable : 0,
};
}

@ -1,3 +1,8 @@
import { TagId } from '@/portainer/tags/types';
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
import { UserId } from '@/portainer/users/types';
import { TeamId } from '@/portainer/teams/types';
export type EnvironmentId = number;
export enum EnvironmentType {
@ -17,13 +22,6 @@ export enum EnvironmentType {
EdgeAgentOnKubernetes,
}
export type TagId = number;
export interface Tag {
Id: TagId;
Name: string;
}
export enum EnvironmentStatus {
Up = 1,
Down,
@ -55,14 +53,14 @@ export interface KubernetesSnapshot {
}
export interface KubernetesSettings {
Snapshots: KubernetesSnapshot[];
Snapshots?: KubernetesSnapshot[] | null;
}
export type Environment = {
Id: EnvironmentId;
Type: EnvironmentType;
TagIds: TagId[];
GroupName: string;
GroupId: EnvironmentGroupId;
EdgeID?: string;
EdgeCheckinInterval?: number;
LastCheckInDate?: number;
@ -88,8 +86,6 @@ export enum EnvironmentCreationTypes {
LocalKubernetesEnvironment,
}
export type EnvironmentGroupId = number;
export enum PlatformType {
Docker,
Kubernetes,
@ -117,11 +113,9 @@ export interface EnvironmentSettings {
enableHostManagementFeatures: boolean;
}
export type UserId = number;
export type TeamId = number;
export type RoleId = number;
interface AccessPolicy {
RoleId: RoleId;
}
export type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
export type UserAccessPolicies = Record<UserId, AccessPolicy>;
export type TeamAccessPolicies = Record<TeamId, AccessPolicy>;

@ -17,6 +17,14 @@ export function getPlatformType(envType: EnvironmentType) {
}
}
export function isDockerEnvironment(envType: EnvironmentType) {
return getPlatformType(envType) === PlatformType.Docker;
}
export function isKubernetesEnvironment(envType: EnvironmentType) {
return getPlatformType(envType) === PlatformType.Kubernetes;
}
export function isEdgeEnvironment(envType: EnvironmentType) {
return [
EnvironmentType.EdgeAgentOnDocker,

@ -92,7 +92,7 @@ export function endpointTypeName(type) {
return '';
}
export function endpointTypeIcon(type) {
export function environmentTypeIcon(type) {
if (type === 3) {
return 'fab fa-microsoft';
} else if (type === 4) {
@ -131,10 +131,3 @@ export function truncate(text, length, end) {
return String(text).substring(0, length - end.length) + end;
}
}
export function endpointStatusBadge(status) {
if (status === 2) {
return 'danger';
}
return 'success';
}

@ -3,8 +3,7 @@ import _ from 'lodash-es';
import {
arrayToStr,
endpointStatusBadge,
endpointTypeIcon,
environmentTypeIcon,
endpointTypeName,
getPairKey,
getPairValue,
@ -35,6 +34,5 @@ angular
.filter('arraytostr', () => arrayToStr)
.filter('labelsToStr', () => labelsToStr)
.filter('endpointtypename', () => endpointTypeName)
.filter('endpointtypeicon', () => endpointTypeIcon)
.filter('ownershipicon', () => ownershipIcon)
.filter('endpointstatusbadge', () => endpointStatusBadge);
.filter('endpointtypeicon', () => environmentTypeIcon)
.filter('ownershipicon', () => ownershipIcon);

@ -0,0 +1,7 @@
export function pluralize(val: number, word: string, plural = `${word}s`) {
return [1, -1].includes(Number(val)) ? word : plural;
}
export function addPlural(value: number, word: string, plural = `${word}s`) {
return `${value} ${pluralize(value, word, plural)}`;
}

@ -1,9 +1,7 @@
import _ from 'lodash';
export default class PortainerEndpointTagHelper {
static idsToTagNames(tags, ids) {
const filteredTags = _.filter(tags, (tag) => _.includes(ids, tag.Id));
const tagNames = _.map(filteredTags, 'Name');
return tagNames;
}
export function idsToTagNames(tags, ids) {
const filteredTags = _.filter(tags, (tag) => _.includes(ids, tag.Id));
const tagNames = _.map(filteredTags, 'Name');
return tagNames;
}

@ -0,0 +1,38 @@
import { server, rest } from '@/setup-tests/server';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { isoDate } from '@/portainer/filters/filters';
import { BackupFailedPanel } from './BackupFailedPanel';
test('when backup failed, should show message', async () => {
const timestamp = 1500;
server.use(
rest.get('/api/backup/s3/status', (req, res, ctx) =>
res(ctx.json({ Failed: true, TimestampUTC: timestamp }))
)
);
const { findByText } = renderWithQueryClient(<BackupFailedPanel />);
await expect(
findByText(
`The latest automated backup has failed at ${isoDate(
timestamp
)}. For details please see the log files and have a look at the`,
{ exact: false }
)
).resolves.toBeVisible();
});
test("when user is using less nodes then allowed he shouldn't see message", async () => {
server.use(
rest.get('/api/backup/s3/status', (req, res, ctx) =>
res(ctx.json({ Failed: false }))
)
);
const { findByText } = renderWithQueryClient(<BackupFailedPanel />);
await expect(
findByText('The latest automated backup has failed at', { exact: false })
).rejects.toBeTruthy();
});

@ -0,0 +1,42 @@
import { useQuery } from 'react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import { InformationPanel } from '../components/InformationPanel';
import { TextTip } from '../components/Tip/TextTip';
import { getBackupStatus } from '../services/api/backup.service';
import { isoDate } from '../filters/filters';
import { Link } from '../components/Link';
export function BackupFailedPanel() {
const { status, isLoading } = useBackupStatus();
if (isLoading || !status || !status.Failed) {
return null;
}
return (
<InformationPanel title="Information">
<TextTip>
The latest automated backup has failed at {isoDate(status.TimestampUTC)}
. For details please see the log files and have a look at the{' '}
<Link to="portainer.settings">settings</Link> to verify the backup
configuration.
</TextTip>
</InformationPanel>
);
}
function useBackupStatus() {
const { data, isLoading } = useQuery(
['backup', 'status'],
() => getBackupStatus(),
{
onError(error) {
notifyError('Failure', error as Error, 'Failed to get license info');
},
}
);
return { status: data, isLoading };
}

@ -0,0 +1,9 @@
.root {
width: 100%;
height: 100%;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

@ -0,0 +1,12 @@
import clsx from 'clsx';
import styles from './EdgeLoadingSpinner.module.css';
export function EdgeLoadingSpinner() {
return (
<div className={clsx('row', styles.root)}>
Connecting to the Edge environment...
<i className="fa fa-cog fa-spin space-left" />
</div>
);
}

@ -0,0 +1,34 @@
import { render } from '@/react-tools/test-utils';
import { EdgeIndicator } from './EdgeIndicator';
test('when edge id is not set, should show unassociated label', () => {
const { queryByLabelText } = renderComponent();
const unassociatedLabel = queryByLabelText('unassociated');
expect(unassociatedLabel).toBeVisible();
});
test('given edge id and last checkin is set, should show heartbeat', () => {
const { queryByLabelText } = renderComponent('id', 1);
expect(queryByLabelText('edge-heartbeat')).toBeVisible();
expect(queryByLabelText('edge-last-checkin')).toBeVisible();
});
function renderComponent(
edgeId = '',
lastCheckInDate = 0,
checkInInterval = 0,
homepageLoadTime = 0
) {
return render(
<EdgeIndicator
edgeId={edgeId}
lastCheckInDate={lastCheckInDate}
checkInInterval={checkInInterval}
homepageLoadTime={homepageLoadTime}
/>
);
}

@ -0,0 +1,55 @@
import clsx from 'clsx';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
interface Props {
checkInInterval?: number;
edgeId?: string;
homepageLoadTime?: number;
lastCheckInDate?: number;
}
export function EdgeIndicator({
edgeId,
lastCheckInDate,
checkInInterval,
homepageLoadTime,
}: Props) {
if (!edgeId) {
return (
<span className="label label-default" aria-label="unassociated">
<s>associated</s>
</span>
);
}
// give checkIn some wiggle room
let isCheckValid = false;
if (checkInInterval && homepageLoadTime && lastCheckInDate) {
isCheckValid =
homepageLoadTime - lastCheckInDate <= checkInInterval * 2 + 20;
}
return (
<span>
<span
className={clsx('label', {
'label-danger': !isCheckValid,
'label-success': isCheckValid,
})}
aria-label="edge-heartbeat"
>
heartbeat
</span>
{!!lastCheckInDate && (
<span
className="space-left small text-muted"
aria-label="edge-last-checkin"
>
{isoDateFromTimestamp(lastCheckInDate)}
</span>
)}
</span>
);
}

@ -0,0 +1,34 @@
import clsx from 'clsx';
import { environmentTypeIcon } from '@/portainer/filters/filters';
import dockerEdge from '@/assets/images/edge_endpoint.png';
import kube from '@/assets/images/kubernetes_endpoint.png';
import kubeEdge from '@/assets/images/kubernetes_edge_endpoint.png';
import { EnvironmentType } from '@/portainer/environments/types';
interface Props {
type: EnvironmentType;
}
export function EnvironmentIcon({ type }: Props) {
switch (type) {
case EnvironmentType.EdgeAgentOnDocker:
return (
<img src={dockerEdge} alt="docker edge endpoint" aria-hidden="true" />
);
case EnvironmentType.KubernetesLocal:
case EnvironmentType.AgentOnKubernetes:
return <img src={kube} alt="kubernetes endpoint" aria-hidden="true" />;
case EnvironmentType.EdgeAgentOnKubernetes:
return (
<img src={kubeEdge} alt="kubernetes edge endpoint" aria-hidden="true" />
);
default:
return (
<i
className={clsx('fa-4x', 'blue-icon', environmentTypeIcon(type))}
aria-hidden="true"
/>
);
}
}

@ -0,0 +1,23 @@
.root {
position: relative;
}
.wrapperButton {
width: 100%;
border: 0;
margin: 0;
padding: 0;
}
.item {
display: block;
text-decoration: none;
outline: initial;
color: inherit;
}
.edit-button {
position: absolute;
right: 0;
top: 7px;
}

@ -0,0 +1,81 @@
import { Story } from '@storybook/react';
import {
Environment,
EnvironmentStatus,
EnvironmentType,
} from '@/portainer/environments/types';
import { EnvironmentItem } from './EnvironmentItem';
export default {
component: EnvironmentItem,
title: 'Home/EnvironmentList/EnvironmentItem',
};
interface Args {
environment: Environment;
homepageLoadTime: number;
}
function Template({ environment, homepageLoadTime = 1 }: Args) {
return (
<EnvironmentItem
environment={environment}
homepageLoadTime={homepageLoadTime}
onClick={() => {}}
/>
);
}
export const DockerEnvironment: Story<Args> = Template.bind({});
DockerEnvironment.args = {
environment: mockEnvironment(EnvironmentType.Docker),
};
export const DockerAgentEnvironment: Story<Args> = Template.bind({});
DockerAgentEnvironment.args = {
environment: mockEnvironment(EnvironmentType.AgentOnDocker),
};
export const DockerEdgeEnvironment: Story<Args> = Template.bind({});
DockerEdgeEnvironment.args = {
environment: mockEnvironment(EnvironmentType.EdgeAgentOnDocker),
};
export const AzureEnvironment: Story<Args> = Template.bind({});
AzureEnvironment.args = {
environment: mockEnvironment(EnvironmentType.Azure),
};
export const KubernetesLocalEnvironment: Story<Args> = Template.bind({});
KubernetesLocalEnvironment.args = {
environment: mockEnvironment(EnvironmentType.KubernetesLocal),
};
export const KubernetesAgentEnvironment: Story<Args> = Template.bind({});
KubernetesAgentEnvironment.args = {
environment: mockEnvironment(EnvironmentType.AgentOnKubernetes),
};
export const KubernetesEdgeEnvironment: Story<Args> = Template.bind({});
KubernetesEdgeEnvironment.args = {
environment: mockEnvironment(EnvironmentType.EdgeAgentOnKubernetes),
};
function mockEnvironment(type: EnvironmentType): Environment {
return {
Id: 1,
Name: 'environment',
GroupId: 1,
Snapshots: [],
Status: EnvironmentStatus.Up,
TagIds: [],
Type: type,
Kubernetes: {
Snapshots: [],
},
URL: 'url',
UserTrusted: false,
};
}

@ -0,0 +1,74 @@
import {
EnvironmentGroup,
EnvironmentGroupId,
} from '@/portainer/environment-groups/types';
import { Environment } from '@/portainer/environments/types';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { Tag } from '@/portainer/tags/types';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { server, rest } from '@/setup-tests/server';
import { EnvironmentItem } from './EnvironmentItem';
test('loads component', async () => {
const env: Environment = {
TagIds: [],
GroupId: 1,
Type: 1,
Name: 'environment',
Status: 1,
URL: 'url',
Snapshots: [],
Kubernetes: { Snapshots: [] },
Id: 3,
UserTrusted: false,
};
const { getByText } = renderComponent(env);
expect(getByText(env.Name)).toBeInTheDocument();
});
test('shows group name', async () => {
const groupName = 'group-name';
const groupId: EnvironmentGroupId = 14;
const env: Environment = {
TagIds: [],
GroupId: groupId,
Type: 1,
Name: 'environment',
Status: 1,
URL: 'url',
Snapshots: [],
Kubernetes: { Snapshots: [] },
Id: 3,
UserTrusted: false,
};
const { findByText } = renderComponent(env, { Name: groupName });
await expect(findByText(groupName)).resolves.toBeVisible();
});
function renderComponent(
env: Environment,
group: Partial<EnvironmentGroup> = { Name: 'group' },
isAdmin = false,
tags: Tag[] = []
) {
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
server.use(rest.get('/api/tags', (req, res, ctx) => res(ctx.json(tags))));
return renderWithQueryClient(
<UserContext.Provider value={{ user }}>
<EnvironmentItem
onClick={() => {}}
environment={env}
groupName={group.Name}
homepageLoadTime={0}
/>
</UserContext.Provider>
);
}

@ -0,0 +1,203 @@
import clsx from 'clsx';
import _ from 'lodash';
import {
isoDateFromTimestamp,
humanize,
stripProtocol,
} from '@/portainer/filters/filters';
import { type Environment, PlatformType } from '@/portainer/environments/types';
import {
getPlatformType,
isDockerEnvironment,
isEdgeEnvironment,
} from '@/portainer/environments/utils';
import type { TagId } from '@/portainer/tags/types';
import { Button } from '@/portainer/components/Button';
import { Link } from '@/portainer/components/Link';
import { useIsAdmin } from '@/portainer/hooks/useUser';
import { useTags } from '@/portainer/tags/queries';
import { EnvironmentIcon } from './EnvironmentIcon';
import { EdgeIndicator } from './EdgeIndicator';
import { EnvironmentStats } from './EnvironmentStats';
import styles from './EnvironmentItem.module.css';
import { EnvironmentStatusBadge } from './EnvironmentStatusBadge';
interface Props {
homepageLoadTime?: number;
environment: Environment;
groupName?: string;
onClick(environment: Environment): void;
}
export function EnvironmentItem({
environment,
onClick,
homepageLoadTime,
groupName,
}: Props) {
const isAdmin = useIsAdmin();
const isEdge = isEdgeEnvironment(environment.Type);
const snapshotTime = getSnapshotTime(environment);
const tags = useEnvironmentTagNames(environment.TagIds);
const route = getRoute(environment);
return (
<div className={styles.root}>
<button
type="button"
color="link"
onClick={() => onClick(environment)}
className={styles.wrapperButton}
>
<Link
className={clsx('blocklist-item', styles.item)}
to={route}
params={{
endpointId: environment.Id,
id: environment.Id,
}}
>
<div className="blocklist-item-box">
<span className={clsx('blocklist-item-logo', 'endpoint-item')}>
<EnvironmentIcon type={environment.Type} />
</span>
<span className="col-sm-12">
<div className="blocklist-item-line endpoint-item">
<span>
<span className="blocklist-item-title endpoint-item">
{environment.Name}
</span>
<span className="space-left blocklist-item-subtitle">
{isEdge ? (
<EdgeIndicator
edgeId={environment.EdgeID}
checkInInterval={environment.EdgeCheckinInterval}
lastCheckInDate={environment.LastCheckInDate}
homepageLoadTime={homepageLoadTime}
/>
) : (
<>
<EnvironmentStatusBadge status={environment.Status} />
<span className="space-left small text-muted">
{snapshotTime}
</span>
</>
)}
</span>
</span>
{groupName && (
<span className="small">
<span>Group: </span>
<span>{groupName}</span>
</span>
)}
</div>
<EnvironmentStats environment={environment} />
<div className="blocklist-item-line endpoint-item">
<span className="small text-muted">
{isDockerEnvironment(environment.Type) && (
<span>
{environment.Snapshots.length > 0 && (
<span className="small text-muted">
<i className="fa fa-microchip space-right" />
{environment.Snapshots[0].TotalCPU}
<i className="fa fa-memory space-left space-right" />
{humanize(environment.Snapshots[0].TotalMemory)}
</span>
)}
<span className="space-left space-right">-</span>
</span>
)}
<span>
<i className="fa fa-tags space-right" aria-hidden="true" />
{tags}
</span>
</span>
{!isEdge && (
<span className="small text-muted">
{stripProtocol(environment.URL)}
</span>
)}
</div>
</span>
</div>
</Link>
</button>
{isAdmin && (
<Link
to="portainer.endpoints.endpoint"
params={{ id: environment.Id }}
className={styles.editButton}
>
<Button color="link">
<i className="fa fa-pencil-alt" />
</Button>
</Link>
)}
</div>
);
}
function useEnvironmentTagNames(tagIds?: TagId[]) {
const { tags, isLoading } = useTags((tags) => {
if (!tagIds) {
return [];
}
return _.compact(
tagIds
.map((id) => tags.find((tag) => tag.ID === id))
.map((tag) => tag?.Name)
);
});
if (tags) {
return tags.join(', ');
}
if (isLoading) {
return 'Loading tags...';
}
return 'No tags';
}
function getSnapshotTime(environment: Environment) {
const platform = getPlatformType(environment.Type);
switch (platform) {
case PlatformType.Docker:
return environment.Snapshots.length > 0
? isoDateFromTimestamp(environment.Snapshots[0].Time)
: null;
case PlatformType.Kubernetes:
return environment.Kubernetes.Snapshots &&
environment.Kubernetes.Snapshots.length > 0
? isoDateFromTimestamp(environment.Kubernetes.Snapshots[0].Time)
: null;
default:
return null;
}
}
function getRoute(environment: Environment) {
if (isEdgeEnvironment(environment.Type) && !environment.EdgeID) {
return 'portainer.endpoints.endpoint';
}
const platform = getPlatformType(environment.Type);
switch (platform) {
case PlatformType.Azure:
return 'azure.dashboard';
case PlatformType.Docker:
return 'docker.dashboard';
case PlatformType.Kubernetes:
return 'kubernetes.dashboard';
default:
return '';
}
}

@ -0,0 +1,34 @@
import { Environment, PlatformType } from '@/portainer/environments/types';
import { getPlatformType } from '@/portainer/environments/utils';
import { EnvironmentStatsDocker } from './EnvironmentStatsDocker';
import { EnvironmentStatsKubernetes } from './EnvironmentStatsKubernetes';
interface Props {
environment: Environment;
}
export function EnvironmentStats({ environment }: Props) {
const platform = getPlatformType(environment.Type);
switch (platform) {
case PlatformType.Kubernetes:
return (
<EnvironmentStatsKubernetes
snapshots={environment.Kubernetes.Snapshots || []}
/>
);
case PlatformType.Docker:
return (
<EnvironmentStatsDocker
snapshots={environment.Snapshots}
type={environment.Type}
/>
);
default:
return (
<div className="blocklist-item-line endpoint-item">
<span className="blocklist-item-desc">-</span>
</div>
);
}
}

@ -0,0 +1,96 @@
import {
DockerSnapshot,
EnvironmentType,
} from '@/portainer/environments/types';
import { addPlural } from '@/portainer/helpers/strings';
import { Stat } from './EnvironmentStatsItem';
interface Props {
snapshots: DockerSnapshot[];
type: EnvironmentType;
}
export function EnvironmentStatsDocker({ snapshots = [], type }: Props) {
if (snapshots.length === 0) {
return (
<div className="blocklist-item-line endpoint-item">
<span className="blocklist-item-desc">No snapshot available</span>
</div>
);
}
const snapshot = snapshots[0];
return (
<div className="blocklist-item-line endpoint-item">
<span className="blocklist-item-desc space-x-4">
<Stat
value={addPlural(snapshot.StackCount, 'stack')}
icon="fa-th-list"
/>
{!!snapshot.Swarm && (
<Stat
value={addPlural(snapshot.ServiceCount, 'service')}
icon="fa-list-alt"
/>
)}
<ContainerStats
running={snapshot.RunningContainerCount}
stopped={snapshot.StoppedContainerCount}
healthy={snapshot.HealthyContainerCount}
unhealthy={snapshot.UnhealthyContainerCount}
/>
<Stat value={addPlural(snapshot.VolumeCount, 'volume')} icon="fa-hdd" />
<Stat value={addPlural(snapshot.ImageCount, 'image')} icon="fa-clone" />
</span>
<span className="small text-muted space-x-3">
<span>{snapshot.Swarm ? 'Swarm' : 'Standalone'}</span>
<span>{snapshot.DockerVersion}</span>
{type === EnvironmentType.AgentOnDocker && (
<span>
+ <i className="fa fa-bolt" aria-hidden="true" /> Agent
</span>
)}
{snapshot.Swarm && (
<Stat value={addPlural(snapshot.NodeCount, 'node')} icon="fa-hdd" />
)}
</span>
</div>
);
}
interface ContainerStatsProps {
running: number;
stopped: number;
healthy: number;
unhealthy: number;
}
function ContainerStats({
running,
stopped,
healthy,
unhealthy,
}: ContainerStatsProps) {
const containersCount = running + stopped;
return (
<Stat value={addPlural(containersCount, 'container')} icon="fa-cubes">
{containersCount > 0 && (
<span className="space-x-2">
<span>-</span>
<Stat value={running} icon="fa-power-off green-icon" />
<Stat value={stopped} icon="fa-power-off red-icon" />
<span>/</span>
<Stat value={healthy} icon="fa-heartbeat green-icon" />
<Stat value={unhealthy} icon="fa-heartbeat orange-icon" />
</span>
)}
</Stat>
);
}

@ -0,0 +1,17 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
interface Props {
value: string | number;
icon: string;
}
export function Stat({ value, icon, children }: PropsWithChildren<Props>) {
return (
<span>
<i className={clsx('fa space-right', icon)} aria-hidden="true" />
<span>{value}</span>
{children && <span className="space-left">{children}</span>}
</span>
);
}

@ -0,0 +1,38 @@
import { KubernetesSnapshot } from '@/portainer/environments/types';
import { humanize } from '@/portainer/filters/filters';
import { addPlural } from '@/portainer/helpers/strings';
import { Stat } from './EnvironmentStatsItem';
interface Props {
snapshots?: KubernetesSnapshot[];
}
export function EnvironmentStatsKubernetes({ snapshots = [] }: Props) {
if (snapshots.length === 0) {
return (
<div className="blocklist-item-line endpoint-item">
<span className="blocklist-item-desc">No snapshot available</span>
</div>
);
}
const snapshot = snapshots[0];
return (
<div className="blocklist-item-line endpoint-item">
<span className="blocklist-item-desc space-x-4">
<Stat icon="fa-microchip" value={`${snapshot.TotalCPU} CPU`} />
<Stat
icon="fa-memory"
value={`${humanize(snapshot.TotalMemory)} RAM`}
/>
</span>
<span className="small text-muted space-x-3">
<span>Kubernetes {snapshot.KubernetesVersion}</span>
<Stat value={addPlural(snapshot.NodeCount, 'node')} icon="fa-hdd" />
</span>
</div>
);
}

@ -0,0 +1,22 @@
import clsx from 'clsx';
import { EnvironmentStatus } from '@/portainer/environments/types';
interface Props {
status: EnvironmentStatus;
}
export function EnvironmentStatusBadge({ status }: Props) {
return (
<span className={clsx('label', `label-${environmentStatusBadge(status)}`)}>
{status === EnvironmentStatus.Up ? 'up' : 'down'}
</span>
);
}
function environmentStatusBadge(status: EnvironmentStatus) {
if (status === EnvironmentStatus.Down) {
return 'danger';
}
return 'success';
}

@ -0,0 +1 @@
export { EnvironmentItem } from './EnvironmentItem';

@ -0,0 +1,7 @@
.actionBar .description {
margin-bottom: 10px;
}
.refresh-environments-button {
margin-left: 0 !important;
}

@ -0,0 +1,58 @@
import { Environment } from '@/portainer/environments/types';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { rest, server } from '@/setup-tests/server';
import { EnvironmentList } from './EnvironmentList';
test('when no environments for query should show empty list message', async () => {
const { findByText } = await renderComponent(false, []);
await expect(findByText('No environments available.')).resolves.toBeVisible();
});
test('when user is not admin and no environments at all should show empty list info message', async () => {
const { findByText } = await renderComponent(false, []);
await expect(
findByText(
'You do not have access to any environment. Please contact your administrator.'
)
).resolves.toBeVisible();
});
test('when user is an admin and no environments at all should show empty list info message', async () => {
const { findByText } = await renderComponent(true);
await expect(
findByText(/No environment available for management. Please head over the/)
).resolves.toBeVisible();
});
async function renderComponent(
isAdmin = false,
environments: Environment[] = []
) {
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
server.use(
rest.get('/api/endpoints', (req, res, ctx) =>
res(
ctx.set('x-total-available', environments.length.toString()),
ctx.set('x-total-count', environments.length.toString()),
ctx.json(environments)
)
)
);
const queries = renderWithQueryClient(
<UserContext.Provider value={{ user }}>
<EnvironmentList onClickItem={jest.fn()} onRefresh={jest.fn()} />
</UserContext.Provider>
);
await expect(queries.findByText('Environments')).resolves.toBeVisible();
return queries;
}

@ -0,0 +1,159 @@
import { ReactNode, useEffect, useState } from 'react';
import clsx from 'clsx';
import { PaginationControls } from '@/portainer/components/pagination-controls';
import { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState';
import { Environment } from '@/portainer/environments/types';
import { Button } from '@/portainer/components/Button';
import { useIsAdmin } from '@/portainer/hooks/useUser';
import {
SearchBar,
useSearchBarState,
} from '@/portainer/components/datatables/components/SearchBar';
import {
TableActions,
TableContainer,
TableTitle,
} from '@/portainer/components/datatables/components';
import { TableFooter } from '@/portainer/components/datatables/components/TableFooter';
import { useDebounce } from '@/portainer/hooks/useDebounce';
import { useEnvironmentList } from '@/portainer/environments/queries';
import { useGroups } from '@/portainer/environment-groups/queries';
import { EnvironmentItem } from './EnvironmentItem';
import { KubeconfigButton } from './KubeconfigButton';
import styles from './EnvironmentList.module.css';
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
interface Props {
onClickItem(environment: Environment): void;
onRefresh(): void;
}
export function EnvironmentList({ onClickItem, onRefresh }: Props) {
const homepageLoadTime = usePageLoadingTime();
const isAdmin = useIsAdmin();
const storageKey = 'home_endpoints';
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
const [page, setPage] = useState(1);
const debouncedTextFilter = useDebounce(searchBarValue);
useEffect(() => {
setPage(1);
}, [searchBarValue]);
const groupsQuery = useGroups();
const { isLoading, environments, totalCount, totalAvailable } =
useEnvironmentList(page, pageLimit, debouncedTextFilter, true);
return (
<>
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
<div className="row">
<div className="col-sm-12">
<TableContainer>
<TableTitle icon="fa-plug" label="Environments" />
<TableActions className={styles.actionBar}>
<div className={styles.description}>
<i className="fa fa-exclamation-circle blue-icon space-right" />
Click on an environment to manage
</div>
{isAdmin && (
<Button
onClick={onRefresh}
data-cy="home-refreshEndpointsButton"
className={clsx(styles.refreshEnvironmentsButton)}
>
<i className="fa fa-sync space-right" aria-hidden="true" />
Refresh
</Button>
)}
<KubeconfigButton environments={environments} />
</TableActions>
<SearchBar
value={searchBarValue}
onChange={setSearchBarValue}
placeholder="Search by name, group, tag, status, URL..."
data-cy="home-endpointsSearchInput"
/>
<div className="blocklist" data-cy="home-endpointList">
{renderItems(
isLoading,
totalCount,
environments.map((env) => (
<EnvironmentItem
key={env.Id}
environment={env}
groupName={
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
}
onClick={onClickItem}
homepageLoadTime={homepageLoadTime}
/>
))
)}
</div>
<TableFooter>
<PaginationControls
showAll={totalCount <= 100}
pageLimit={pageLimit}
page={page}
onPageChange={setPage}
totalCount={totalCount}
onPageLimitChange={setPageLimit}
/>
</TableFooter>
</TableContainer>
</div>
</div>
</>
);
}
function renderItems(
isLoading: boolean,
totalCount: number,
items: ReactNode
) {
if (isLoading) {
return (
<div className="text-center text-muted" data-cy="home-loadingEndpoints">
Loading...
</div>
);
}
if (!totalCount) {
return (
<div className="text-center text-muted" data-cy="home-noEndpoints">
No environments available.
</div>
);
}
return items;
}
function usePageLoadingTime() {
const [homepageLoadTime, setHomepageLoadTime] = useState<
number | undefined
>();
useEffect(() => {
setHomepageLoadTime(Math.floor(Date.now() / 1000));
}, []);
return homepageLoadTime;
}

@ -0,0 +1,81 @@
import * as kcService from '@/kubernetes/services/kubeconfig.service';
import * as notifications from '@/portainer/services/notifications';
import { confirmKubeconfigSelection } from '@/portainer/services/modal.service/prompt';
import { Environment } from '@/portainer/environments/types';
import { isKubernetesEnvironment } from '@/portainer/environments/utils';
import { trackEvent } from '@/angulartics.matomo/analytics-services';
import { Button } from '@/portainer/components/Button';
interface Props {
environments?: Environment[];
}
export function KubeconfigButton({ environments }: Props) {
if (!environments) {
return null;
}
if (!isKubeconfigButtonVisible(environments)) {
return null;
}
return (
<Button onClick={handleClick}>
<i className="fas fa-download space-right" /> kubeconfig
</Button>
);
function handleClick() {
if (!environments) {
return;
}
trackEvent('kubernetes-kubectl-kubeconfig-multi', {
category: 'kubernetes',
});
showKubeconfigModal(environments);
}
}
function isKubeconfigButtonVisible(environments: Environment[]) {
if (window.location.protocol !== 'https:') {
return false;
}
return environments.some((env) => isKubernetesEnvironment(env.Type));
}
async function showKubeconfigModal(environments: Environment[]) {
const kubeEnvironments = environments.filter((env) =>
isKubernetesEnvironment(env.Type)
);
const options = kubeEnvironments.map((environment) => ({
text: `${environment.Name} (${environment.URL})`,
value: `${environment.Id}`,
}));
let expiryMessage = '';
try {
expiryMessage = await kcService.expiryMessage();
} catch (e) {
notifications.error('Failed fetching kubeconfig expiry time', e as Error);
}
confirmKubeconfigSelection(
options,
expiryMessage,
async (selectedEnvironmentIDs: string[]) => {
if (selectedEnvironmentIDs.length === 0) {
notifications.warning('No environment was selected', '');
return;
}
try {
await kcService.downloadKubeconfigFile(
selectedEnvironmentIDs.map((id) => parseInt(id, 10))
);
} catch (e) {
notifications.error('Failed downloading kubeconfig file', e as Error);
}
}
);
}

@ -0,0 +1,24 @@
import { InformationPanel } from '@/portainer/components/InformationPanel';
import { Link } from '@/portainer/components/Link';
import { TextTip } from '@/portainer/components/Tip/TextTip';
export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) {
return (
<InformationPanel title="Information">
<TextTip>
{isAdmin ? (
<span>
No environment available for management. Please head over the
<Link to="portainer.endpoints.new"> environments view </Link>
to add an environment.
</span>
) : (
<span>
You do not have access to any environment. Please contact your
administrator.
</span>
)}
</TextTip>
</InformationPanel>
);
}

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

@ -0,0 +1,88 @@
import { useRouter } from '@uirouter/react';
import { useState } from 'react';
import { r2a } from '@/react-tools/react2angular';
import { PageHeader } from '../components/PageHeader';
import * as notifications from '../services/notifications';
import { Environment } from '../environments/types';
import { snapshotEndpoints } from '../environments/environment.service';
import { isEdgeEnvironment } from '../environments/utils';
import { confirmAsync } from '../services/modal.service/confirm';
import { EnvironmentList } from './EnvironmentList';
import { EdgeLoadingSpinner } from './EdgeLoadingSpinner';
import { MotdPanel } from './MotdPanel';
import { LicenseNodePanel } from './LicenseNodePanel';
import { BackupFailedPanel } from './BackupFailedPanel';
export function HomeView() {
const [connectingToEdgeEndpoint, setConnectingToEdgeEndpoint] =
useState(false);
const router = useRouter();
return (
<>
<PageHeader
reload
title="Home"
breadcrumbs={[{ label: 'Environments' }]}
/>
{process.env.PORTAINER_EDITION !== 'CE' && <LicenseNodePanel />}
<MotdPanel />
{process.env.PORTAINER_EDITION !== 'CE' && <BackupFailedPanel />}
{connectingToEdgeEndpoint ? (
<EdgeLoadingSpinner />
) : (
<EnvironmentList
onClickItem={handleClickItem}
onRefresh={confirmTriggerSnapshot}
/>
)}
</>
);
async function confirmTriggerSnapshot() {
const result = await confirmEndpointSnapshot();
if (!result) {
return;
}
try {
await snapshotEndpoints();
notifications.success('Success', 'Environments updated');
router.stateService.reload();
} catch (err) {
notifications.error(
'Failure',
err as Error,
'An error occurred during environment snapshot'
);
}
}
function handleClickItem(environment: Environment) {
if (isEdgeEnvironment(environment.Type)) {
setConnectingToEdgeEndpoint(true);
}
}
}
export const HomeViewAngular = r2a(HomeView, []);
async function confirmEndpointSnapshot() {
return confirmAsync({
title: 'Are you sure?',
message:
'Triggering a manual refresh will poll each environment to retrieve its information, this may take a few moments.',
buttons: {
confirm: {
label: 'Continue',
className: 'btn-primary',
},
},
});
}

@ -0,0 +1,48 @@
import { server, rest } from '@/setup-tests/server';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { LicenseType } from '../license-management/types';
import { LicenseNodePanel } from './LicenseNodePanel';
test('when user is using more nodes then allowed he should see message', async () => {
const allowed = 2;
const used = 5;
server.use(
rest.get('/api/licenses/info', (req, res, ctx) =>
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
),
rest.get('/api/status/nodes', (req, res, ctx) =>
res(ctx.json({ nodes: used }))
)
);
const { findByText } = renderWithQueryClient(<LicenseNodePanel />);
await expect(
findByText(
/The number of nodes for your license has been exceeded. Please contact your administrator./
)
).resolves.toBeVisible();
});
test("when user is using less nodes then allowed he shouldn't see message", async () => {
const allowed = 5;
const used = 2;
server.use(
rest.get('/api/licenses/info', (req, res, ctx) =>
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
),
rest.get('/api/status/nodes', (req, res, ctx) =>
res(ctx.json({ nodes: used }))
)
);
const { findByText } = renderWithQueryClient(<LicenseNodePanel />);
await expect(
findByText(
/The number of nodes for your license has been exceeded. Please contact your administrator./
)
).rejects.toBeTruthy();
});

@ -0,0 +1,56 @@
import { useQuery } from 'react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import { InformationPanel } from '../components/InformationPanel';
import { TextTip } from '../components/Tip/TextTip';
import { LicenseType } from '../license-management/types';
import { useLicenseInfo } from '../license-management/use-license.service';
import { getNodesCount } from '../services/api/status.service';
export function LicenseNodePanel() {
const nodesValid = useNodesValid();
if (nodesValid) {
return null;
}
return (
<InformationPanel title="License node allowance exceeded">
<TextTip>
The number of nodes for your license has been exceeded. Please contact
your administrator.
</TextTip>
</InformationPanel>
);
}
function useNodesValid() {
const { isLoading: isLoadingNodes, nodesCount } = useNodesCounts();
const { isLoading: isLoadingLicense, info } = useLicenseInfo();
if (
isLoadingLicense ||
isLoadingNodes ||
!info ||
info.type === LicenseType.Trial
) {
return true;
}
return nodesCount <= info.nodes;
}
function useNodesCounts() {
const { isLoading, data } = useQuery(
['status', 'nodes'],
() => getNodesCount(),
{
onError(error) {
notifyError('Failure', error as Error, 'Failed to get nodes count');
},
}
);
return { nodesCount: data || 0, isLoading };
}

@ -0,0 +1,56 @@
import { useQuery } from 'react-query';
import _ from 'lodash-es';
import { useUIState } from '@/portainer/hooks/UIStateProvider';
import { InformationPanel } from '../components/InformationPanel/InformationPanel';
import { getMotd } from './home.service';
export function MotdPanel() {
const motd = useMotd();
const [uiState, setUIState] = useUIState();
if (!motd || motd.Message === '' || motd.Hash === uiState.dismissedInfoHash) {
return null;
}
return (
<>
{!!motd.Style && <style>{motd.Style}</style>}
<InformationPanel
onDismiss={() => onDismiss(motd.Hash)}
title={motd.Title}
wrapperStyle={camelCaseKeys(motd.ContentLayout)}
bodyClassName="motd-body"
>
<span className="text-muted">
{/* eslint-disable-next-line react/no-danger */}
<p dangerouslySetInnerHTML={{ __html: motd.Message }} />
</span>
</InformationPanel>
</>
);
function onDismiss(hash: string) {
setUIState({
...uiState,
dismissedInfoHash: hash,
});
}
}
function useMotd() {
const { data } = useQuery('motd', () => getMotd());
return data;
}
function camelCaseKeys(obj: Record<string, string> = {}) {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
const camelCased = _.camelCase(key);
return [camelCased, value];
})
);
}

@ -0,0 +1,15 @@
import axios, { parseAxiosError } from '../services/axios';
import { Motd } from './types';
export async function getMotd() {
try {
const { data } = await axios.get<Motd>('/motd');
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Unable to retrieve information message'
);
}
}

@ -0,0 +1,9 @@
import angular from 'angular';
import { EnvironmentListAngular } from './EnvironmentList';
import { HomeViewAngular } from './HomeView';
export default angular
.module('portainer.app.home', [])
.component('homeView', HomeViewAngular)
.component('environmentList', EnvironmentListAngular).name;

@ -0,0 +1,7 @@
export interface Motd {
Title: string;
Message: string;
Hash: string;
Style?: string;
ContentLayout?: Record<string, string>;
}

@ -0,0 +1,28 @@
import { createContext, PropsWithChildren, useContext } from 'react';
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
interface UIState {
dismissedInfoPanels: Record<string, string>;
dismissedInfoHash: string;
}
type UIStateService = [UIState, (state: UIState) => void];
const Context = createContext<null | UIStateService>(null);
export function useUIState() {
const context = useContext(Context);
if (context == null) {
throw new Error('Should be nested under a UIStateProvider component');
}
return context;
}
export function UIStateProvider({ children }: PropsWithChildren<unknown>) {
const service = useLocalStorage<UIState>('UI_STATE', {} as UIState);
return <Context.Provider value={service}>{children}</Context.Provider>;
}

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
const localStoragePrefix = 'portainer';
@ -21,7 +21,7 @@ export function useLocalStorage<T>(
[key, storage]
);
return [value, handleChange];
return useMemo(() => [value, handleChange], [value, handleChange]);
}
export function get<T>(

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save