mirror of
https://github.com/portainer/portainer.git
synced 2025-08-09 07:45:22 +02:00
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
This commit is contained in:
parent
c442d936d3
commit
0f3c7b1424
130 changed files with 2400 additions and 1078 deletions
|
@ -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,
|
||||
};
|
3
app/portainer/components/InformationPanel/index.ts
Normal file
3
app/portainer/components/InformationPanel/index.ts
Normal file
|
@ -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;
|
||||
}
|
29
app/portainer/environment-groups/queries.ts
Normal file
29
app/portainer/environment-groups/queries.ts
Normal file
|
@ -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;
|
||||
}
|
14
app/portainer/environment-groups/types.ts
Normal file
14
app/portainer/environment-groups/types.ts
Normal file
|
@ -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);
|
||||
}
|
||||
|
|
46
app/portainer/environments/queries.ts
Normal file
46
app/portainer/environments/queries.ts
Normal file
|
@ -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);
|
||||
|
|
7
app/portainer/helpers/strings.ts
Normal file
7
app/portainer/helpers/strings.ts
Normal file
|
@ -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;
|
||||
}
|
||||
|
|
38
app/portainer/home/BackupFailedPanel.test.tsx
Normal file
38
app/portainer/home/BackupFailedPanel.test.tsx
Normal file
|
@ -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();
|
||||
});
|
42
app/portainer/home/BackupFailedPanel.tsx
Normal file
42
app/portainer/home/BackupFailedPanel.tsx
Normal file
|
@ -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 };
|
||||
}
|
9
app/portainer/home/EdgeLoadingSpinner.module.css
Normal file
9
app/portainer/home/EdgeLoadingSpinner.module.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
12
app/portainer/home/EdgeLoadingSpinner.tsx
Normal file
12
app/portainer/home/EdgeLoadingSpinner.tsx
Normal file
|
@ -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;
|
||||
}
|
58
app/portainer/home/EnvironmentList/EnvironmentList.test.tsx
Normal file
58
app/portainer/home/EnvironmentList/EnvironmentList.test.tsx
Normal file
|
@ -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;
|
||||
}
|
159
app/portainer/home/EnvironmentList/EnvironmentList.tsx
Normal file
159
app/portainer/home/EnvironmentList/EnvironmentList.tsx
Normal file
|
@ -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;
|
||||
}
|
81
app/portainer/home/EnvironmentList/KubeconfigButton.tsx
Normal file
81
app/portainer/home/EnvironmentList/KubeconfigButton.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
13
app/portainer/home/EnvironmentList/index.ts
Normal file
13
app/portainer/home/EnvironmentList/index.ts
Normal file
|
@ -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',
|
||||
]);
|
88
app/portainer/home/HomeView.tsx
Normal file
88
app/portainer/home/HomeView.tsx
Normal file
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
48
app/portainer/home/LicenseNodePanel.test.tsx
Normal file
48
app/portainer/home/LicenseNodePanel.test.tsx
Normal file
|
@ -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();
|
||||
});
|
56
app/portainer/home/LicenseNodePanel.tsx
Normal file
56
app/portainer/home/LicenseNodePanel.tsx
Normal file
|
@ -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 };
|
||||
}
|
56
app/portainer/home/MotdPanel.tsx
Normal file
56
app/portainer/home/MotdPanel.tsx
Normal file
|
@ -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];
|
||||
})
|
||||
);
|
||||
}
|
15
app/portainer/home/home.service.ts
Normal file
15
app/portainer/home/home.service.ts
Normal file
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
9
app/portainer/home/index.ts
Normal file
9
app/portainer/home/index.ts
Normal file
|
@ -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;
|
7
app/portainer/home/types.ts
Normal file
7
app/portainer/home/types.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export interface Motd {
|
||||
Title: string;
|
||||
Message: string;
|
||||
Hash: string;
|
||||
Style?: string;
|
||||
ContentLayout?: Record<string, string>;
|
||||
}
|
28
app/portainer/hooks/UIStateProvider.tsx
Normal file
28
app/portainer/hooks/UIStateProvider.tsx
Normal file
|
@ -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>(
|
||||
|
|
14
app/portainer/hooks/usePaginationLimitState.ts
Normal file
14
app/portainer/hooks/usePaginationLimitState.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
export function usePaginationLimitState(
|
||||
key: string
|
||||
): [number, (value: number) => void] {
|
||||
const paginationKey = paginationKeyBuilder(key);
|
||||
const [pageLimit, setPageLimit] = useLocalStorage(paginationKey, 10);
|
||||
|
||||
return [pageLimit, setPageLimit];
|
||||
|
||||
function paginationKeyBuilder(key: string) {
|
||||
return `datatable_pagination_${key}`;
|
||||
}
|
||||
}
|
|
@ -123,3 +123,8 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||
export function isAdmin(user?: UserViewModel | null): boolean {
|
||||
return !!user && user.Role === 1;
|
||||
}
|
||||
|
||||
export function useIsAdmin() {
|
||||
const { user } = useUser();
|
||||
return !!user && isAdmin(user);
|
||||
}
|
||||
|
|
43
app/portainer/license-management/license.service.test.ts
Normal file
43
app/portainer/license-management/license.service.test.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { server, rest } from '@/setup-tests/server';
|
||||
|
||||
import { getLicenses } from './license.service';
|
||||
import type { License } from './types';
|
||||
|
||||
describe('getLicenses', () => {
|
||||
it('on success should return the server body', async () => {
|
||||
const catchFn = jest.fn();
|
||||
const thenFn = jest.fn();
|
||||
|
||||
const data: License[] = [];
|
||||
server.use(
|
||||
rest.get('/api/licenses', (req, res, ctx) => res(ctx.json(data)))
|
||||
);
|
||||
|
||||
const promise = getLicenses();
|
||||
|
||||
await promise.then(thenFn).catch(catchFn);
|
||||
|
||||
expect(catchFn).not.toHaveBeenCalled();
|
||||
expect(thenFn).toHaveBeenCalledWith(data);
|
||||
});
|
||||
|
||||
it('on failure should return the server message', async () => {
|
||||
const catchFn = jest.fn();
|
||||
const thenFn = jest.fn();
|
||||
|
||||
const message = 'message';
|
||||
const details = 'details';
|
||||
|
||||
server.use(
|
||||
rest.get('/api/licenses', (req, res, ctx) =>
|
||||
res(ctx.status(400), ctx.json({ message, details }))
|
||||
)
|
||||
);
|
||||
|
||||
const promise = getLicenses();
|
||||
await promise.then(thenFn, catchFn);
|
||||
|
||||
expect(catchFn).toHaveBeenCalledWith(new Error(message));
|
||||
expect(thenFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
130
app/portainer/license-management/license.service.ts
Normal file
130
app/portainer/license-management/license.service.ts
Normal file
|
@ -0,0 +1,130 @@
|
|||
import _ from 'lodash-es';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
|
||||
import { License, LicenseInfo } from './types';
|
||||
|
||||
type Listener = (info: LicenseInfo) => void;
|
||||
|
||||
interface Store {
|
||||
data?: LicenseInfo;
|
||||
lastLoaded?: number;
|
||||
invalidated: boolean;
|
||||
listeners: Listener[];
|
||||
}
|
||||
|
||||
const store: Store = {
|
||||
listeners: [],
|
||||
invalidated: true,
|
||||
};
|
||||
|
||||
export async function getLicenses() {
|
||||
try {
|
||||
const { data } = await axios.get<License[]>(buildUrl());
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
const axiosError = e as AxiosError;
|
||||
throw new Error(axiosError.response?.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
interface AttachResponse {
|
||||
licenses: License[];
|
||||
failedKeys: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function attachLicense(licenseKeys: string[]) {
|
||||
try {
|
||||
const { data } = await axios.post<AttachResponse>(buildUrl(), {
|
||||
licenseKeys,
|
||||
});
|
||||
|
||||
if (Object.keys(data.failedKeys).length === licenseKeys.length) {
|
||||
return data;
|
||||
}
|
||||
|
||||
store.invalidated = true;
|
||||
getLicenseInfo();
|
||||
return data;
|
||||
} catch (e) {
|
||||
const axiosError = e as AxiosError;
|
||||
throw new Error(axiosError.response?.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
interface RemoveResponse {
|
||||
failedKeys: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function removeLicense(licenseKeys: string[]) {
|
||||
try {
|
||||
const { data } = await axios.post<RemoveResponse>(buildUrl('remove'), {
|
||||
licenseKeys,
|
||||
});
|
||||
if (Object.keys(data.failedKeys).length === licenseKeys.length) {
|
||||
return data;
|
||||
}
|
||||
|
||||
store.invalidated = true;
|
||||
getLicenseInfo();
|
||||
return data;
|
||||
} catch (e) {
|
||||
const axiosError = e as AxiosError;
|
||||
throw new Error(axiosError.response?.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLicenseInfo() {
|
||||
try {
|
||||
if (
|
||||
store.data &&
|
||||
!store.invalidated &&
|
||||
store.lastLoaded &&
|
||||
Math.abs(store.lastLoaded - Date.now()) < 1000 * 30
|
||||
) {
|
||||
return store.data;
|
||||
}
|
||||
|
||||
const { data: info } = await axios.get<LicenseInfo>(buildUrl('info'));
|
||||
store.data = info;
|
||||
store.lastLoaded = Date.now();
|
||||
store.invalidated = false;
|
||||
store.listeners.forEach((listener) => listener(info));
|
||||
|
||||
return info;
|
||||
} catch (e) {
|
||||
const axiosError = e as AxiosError;
|
||||
throw new Error(axiosError.response?.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribe(listener: Listener) {
|
||||
store.listeners.push(listener);
|
||||
}
|
||||
|
||||
export function unsubscribe(listener: Listener) {
|
||||
_.remove<Listener>(store.listeners, listener);
|
||||
}
|
||||
|
||||
/* @ngInject */
|
||||
export function LicenseService() {
|
||||
return {
|
||||
licenses: getLicenses,
|
||||
attach: attachLicense,
|
||||
remove: removeLicense,
|
||||
info: getLicenseInfo,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUrl(action = '') {
|
||||
let url = 'licenses';
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
return url;
|
||||
}
|
43
app/portainer/license-management/types.ts
Normal file
43
app/portainer/license-management/types.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
// matches https://github.com/portainer/liblicense/blob/master/liblicense.go#L66-L74
|
||||
export enum Edition {
|
||||
CE = 1,
|
||||
BE,
|
||||
EE,
|
||||
}
|
||||
|
||||
// matches https://github.com/portainer/liblicense/blob/master/liblicense.go#L60-L64
|
||||
|
||||
export enum LicenseType {
|
||||
Trial = 1,
|
||||
Subscription,
|
||||
}
|
||||
|
||||
// matches https://github.com/portainer/liblicense/blob/master/liblicense.go#L35-L50
|
||||
export interface License {
|
||||
id: string;
|
||||
company: string;
|
||||
created: number;
|
||||
email: string;
|
||||
expiresAfter: number;
|
||||
licenseKey: string;
|
||||
nodes: number;
|
||||
productEdition: Edition;
|
||||
revoked: boolean;
|
||||
revokedAt: number;
|
||||
type: LicenseType;
|
||||
version: number;
|
||||
reference: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// matches https://github.com/portainer/portainer-ee/blob/c4575bf528583fe1682267db4ee40a11a905f611/api/portainer.go#L588-L597
|
||||
export interface LicenseInfo {
|
||||
productEdition: Edition;
|
||||
company: string;
|
||||
email: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
nodes: number;
|
||||
type: LicenseType;
|
||||
valid: boolean;
|
||||
}
|
20
app/portainer/license-management/use-license.service.ts
Normal file
20
app/portainer/license-management/use-license.service.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { getLicenseInfo } from './license.service';
|
||||
import { LicenseInfo } from './types';
|
||||
|
||||
export function useLicenseInfo() {
|
||||
const { isLoading, data: info } = useQuery<LicenseInfo, Error>(
|
||||
'licenseInfo',
|
||||
() => getLicenseInfo(),
|
||||
{
|
||||
onError(error) {
|
||||
notifyError('Failure', error as Error, 'Failed to get license info');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { isLoading, info };
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
export function MotdViewModel(data) {
|
||||
this.Title = data.Title;
|
||||
this.Message = data.Message;
|
||||
this.Hash = data.Hash;
|
||||
this.Style = data.Style;
|
||||
this.ContentLayout = data.ContentLayout;
|
||||
}
|
|
@ -20,7 +20,6 @@ angular.module('portainer.app').factory('Backup', [
|
|||
saveS3Settings: { method: 'POST', params: { subResource: 's3', action: 'settings' } },
|
||||
exportS3Backup: { method: 'POST', params: { subResource: 's3', action: 'execute' } },
|
||||
restoreS3Backup: { method: 'POST', params: { subResource: 's3', action: 'restore' } },
|
||||
getBackupStatus: { method: 'GET', params: { subResource: 's3', action: 'status' } },
|
||||
}
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
angular.module('portainer.app').factory('Motd', [
|
||||
'$resource',
|
||||
'API_ENDPOINT_MOTD',
|
||||
function MotdFactory($resource, API_ENDPOINT_MOTD) {
|
||||
'use strict';
|
||||
return $resource(
|
||||
API_ENDPOINT_MOTD,
|
||||
{},
|
||||
{
|
||||
get: {
|
||||
method: 'GET',
|
||||
ignoreLoadingBar: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
]);
|
29
app/portainer/services/api/backup.service.ts
Normal file
29
app/portainer/services/api/backup.service.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import axios, { parseAxiosError } from '../axios';
|
||||
|
||||
interface StatusResponse {
|
||||
Failed: boolean;
|
||||
TimestampUTC: string;
|
||||
}
|
||||
|
||||
export async function getBackupStatus() {
|
||||
try {
|
||||
const { data } = await axios.get<StatusResponse>(buildUrl('s3', 'status'));
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error, 'Unable to retrieve backup status');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(resource?: string, action?: string) {
|
||||
let url = '/backup';
|
||||
|
||||
if (resource) {
|
||||
url += `/${resource}`;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -71,20 +71,6 @@ angular.module('portainer.app').factory('BackupService', [
|
|||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.getBackupStatus = function () {
|
||||
var deferred = $q.defer();
|
||||
|
||||
Backup.getBackupStatus()
|
||||
.$promise.then(function success(data) {
|
||||
deferred.resolve(data);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve backup status', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import { MotdViewModel } from '../../models/motd';
|
||||
|
||||
angular.module('portainer.app').factory('MotdService', [
|
||||
'$q',
|
||||
'Motd',
|
||||
function MotdServiceFactory($q, Motd) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.motd = function () {
|
||||
var deferred = $q.defer();
|
||||
|
||||
Motd.get()
|
||||
.$promise.then(function success(data) {
|
||||
var motd = new MotdViewModel(data);
|
||||
deferred.resolve(motd);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve information message', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
24
app/portainer/services/api/status.service.ts
Normal file
24
app/portainer/services/api/status.service.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import axios, { parseAxiosError } from '../axios';
|
||||
|
||||
interface NodesCountResponse {
|
||||
nodes: number;
|
||||
}
|
||||
|
||||
export async function getNodesCount() {
|
||||
try {
|
||||
const { data } = await axios.get<NodesCountResponse>(buildUrl('nodes'));
|
||||
return data.nodes;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(action?: string) {
|
||||
let url = '/status';
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -1,10 +1,9 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
const BASE_URL = '/users';
|
||||
|
||||
import PortainerError from '@/portainer/error';
|
||||
import { filterNonAdministratorUsers } from '@/portainer/helpers/userHelper';
|
||||
import { UserViewModel, UserTokenModel } from '../../models/user';
|
||||
import { TeamMembershipModel } from '../../models/teamMembership';
|
||||
|
@ -21,12 +20,7 @@ export async function getUsers(includeAdministrators) {
|
|||
|
||||
return filterNonAdministratorUsers(users);
|
||||
} catch (e) {
|
||||
let err = e;
|
||||
if (err.isAxiosError) {
|
||||
err = new Error(e.response.data.message);
|
||||
}
|
||||
|
||||
throw new PortainerError('Unable to retrieve users', err);
|
||||
throw parseAxiosError(e, 'Unable to retrieve users');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -36,12 +30,7 @@ export async function getUser(id) {
|
|||
|
||||
return new UserViewModel(user);
|
||||
} catch (e) {
|
||||
let err = e;
|
||||
if (err.isAxiosError) {
|
||||
err = new Error(e.response.data.message);
|
||||
}
|
||||
|
||||
throw new PortainerError('Unable to retrieve user details', err);
|
||||
throw parseAxiosError(e, 'Unable to retrieve user details');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ import axiosOrigin, { AxiosError, AxiosRequestConfig } from 'axios';
|
|||
import { loadProgressBar } from 'axios-progress-bar';
|
||||
import 'axios-progress-bar/dist/nprogress.css';
|
||||
|
||||
import PortainerError from '../error';
|
||||
import { get as localStorageGet } from '../hooks/useLocalStorage';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import { get as localStorageGet } from '@/portainer/hooks/useLocalStorage';
|
||||
|
||||
import {
|
||||
portainerAgentManagerOperation,
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
angular.module('portainer.app').factory(
|
||||
'EndpointProvider',
|
||||
/* @ngInject */
|
||||
function EndpointProviderFactory(LocalStorage, $uiRouterGlobals) {
|
||||
'use strict';
|
||||
const state = {
|
||||
currentEndpoint: null,
|
||||
};
|
||||
var service = {};
|
||||
var endpoint = {};
|
||||
|
||||
|
@ -108,11 +108,11 @@ angular.module('portainer.app').factory(
|
|||
};
|
||||
|
||||
service.currentEndpoint = function () {
|
||||
var endpointId = endpoint.ID;
|
||||
var endpoints = LocalStorage.getEndpoints();
|
||||
return _.find(endpoints, function (item) {
|
||||
return item.Id === endpointId;
|
||||
});
|
||||
return state.currentEndpoint;
|
||||
};
|
||||
|
||||
service.setCurrentEndpoint = function (endpoint) {
|
||||
state.currentEndpoint = endpoint;
|
||||
};
|
||||
|
||||
return service;
|
||||
|
|
|
@ -189,21 +189,6 @@ export function confirmDeletionAsync(message: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export function confirmEndpointSnapshot(callback: ConfirmCallback) {
|
||||
confirm({
|
||||
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',
|
||||
},
|
||||
},
|
||||
callback,
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmImageExport(callback: ConfirmCallback) {
|
||||
confirm({
|
||||
title: 'Caution',
|
||||
|
|
|
@ -9,7 +9,6 @@ import {
|
|||
confirmDeletion,
|
||||
confirmDetachment,
|
||||
confirmDeletionAsync,
|
||||
confirmEndpointSnapshot,
|
||||
confirmChangePassword,
|
||||
confirmImageExport,
|
||||
confirmImageForceRemoval,
|
||||
|
@ -54,7 +53,6 @@ export function ModalServiceAngular() {
|
|||
confirmRedeploy,
|
||||
confirmDeletionAsync,
|
||||
confirmContainerRecreation,
|
||||
confirmEndpointSnapshot,
|
||||
confirmChangePassword,
|
||||
confirmImageExport,
|
||||
confirmServiceForceUpdate,
|
||||
|
|
|
@ -4,6 +4,10 @@ import { error, success, warning } from './notifications';
|
|||
|
||||
jest.mock('toastr');
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('calling success should show success message', () => {
|
||||
const title = 'title';
|
||||
const text = 'text';
|
||||
|
@ -14,6 +18,9 @@ it('calling success should show success message', () => {
|
|||
});
|
||||
|
||||
it('calling error with Error should show error message', () => {
|
||||
const consoleErrorFn = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => jest.fn());
|
||||
const title = 'title';
|
||||
const errorMessage = 'message';
|
||||
const fallback = 'fallback';
|
||||
|
@ -25,9 +32,14 @@ it('calling error with Error should show error message', () => {
|
|||
title,
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
consoleErrorFn.mockRestore();
|
||||
});
|
||||
|
||||
it('calling error without Error should show fallback message', () => {
|
||||
const consoleErrorFn = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => jest.fn());
|
||||
const title = 'title';
|
||||
|
||||
const fallback = 'fallback';
|
||||
|
@ -35,6 +47,7 @@ it('calling error without Error should show fallback message', () => {
|
|||
error(title, undefined, fallback);
|
||||
|
||||
expect(toastr.error).toHaveBeenCalledWith(fallback, title, expect.anything());
|
||||
consoleErrorFn.mockRestore();
|
||||
});
|
||||
|
||||
it('calling warning should show warning message', () => {
|
||||
|
|
28
app/portainer/settings/settings.service.ts
Normal file
28
app/portainer/settings/settings.service.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { PublicSettingsViewModel } from '@/portainer/models/settings';
|
||||
|
||||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
export async function publicSettings() {
|
||||
try {
|
||||
const { data } = await axios.get(buildUrl('public'));
|
||||
return new PublicSettingsViewModel(data);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve application settings'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(subResource?: string, action?: string) {
|
||||
let url = 'settings';
|
||||
if (subResource) {
|
||||
url += `/${subResource}`;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
18
app/portainer/tags/queries.ts
Normal file
18
app/portainer/tags/queries.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { getTags } from './tags.service';
|
||||
import { Tag } from './types';
|
||||
|
||||
export function useTags<T = Tag>(select?: (tags: Tag[]) => T[]) {
|
||||
const { data, isLoading } = useQuery('tags', () => getTags(), {
|
||||
staleTime: 50,
|
||||
select,
|
||||
onError(error) {
|
||||
notifyError('Failed loading tags', error as Error);
|
||||
},
|
||||
});
|
||||
|
||||
return { tags: data, isLoading };
|
||||
}
|
38
app/portainer/tags/tags.service.ts
Normal file
38
app/portainer/tags/tags.service.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
import { Tag, TagId } from './types';
|
||||
|
||||
export async function getTags() {
|
||||
try {
|
||||
const { data } = await axios.get<Tag[]>(buildUrl());
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Unable to retrieve tags');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTag(name: string) {
|
||||
try {
|
||||
const { data: tag } = await axios.post(buildUrl(), { name });
|
||||
return tag;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Unable to create tag');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTag(id: TagId) {
|
||||
try {
|
||||
await axios.delete(buildUrl(id));
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Unable to delte tag');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(id?: TagId) {
|
||||
let url = '/tags';
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
6
app/portainer/tags/types.ts
Normal file
6
app/portainer/tags/types.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export type TagId = number;
|
||||
|
||||
export interface Tag {
|
||||
ID: TagId;
|
||||
Name: string;
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
<!-- <react-example text="'text'"></react-example> -->
|
||||
|
||||
<rd-header>
|
||||
<rd-header-title title-text="Home">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.home" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Environments</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<motd-panel ng-if="motd && motd.Message !== '' && applicationState.UI.dismissedInfoHash !== motd.Hash" motd="motd" dismiss-action="dismissImportantInformation(motd.Hash)">
|
||||
</motd-panel>
|
||||
|
||||
<kubernetes-feedback-panel></kubernetes-feedback-panel>
|
||||
|
||||
<information-panel ng-if="!isAdmin && endpoints.length === 0" title-text="Information">
|
||||
<span class="small text-muted">
|
||||
<p>
|
||||
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
You do not have access to any environment. Please contact your administrator.
|
||||
</p>
|
||||
</span>
|
||||
</information-panel>
|
||||
|
||||
<div
|
||||
class="row"
|
||||
style="width: 100%; height: 100%; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center"
|
||||
ng-if="state.connectingToEdgeEndpoint"
|
||||
>
|
||||
Connecting to the Edge environment...
|
||||
<i class="fa fa-cog fa-spin" style="margin-left: 5px"></i>
|
||||
</div>
|
||||
|
||||
<information-panel ng-if="isAdmin && endpoints.length === 0" title-text="Information">
|
||||
<span class="small text-muted">
|
||||
<p>
|
||||
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
No environment available for management. Please head over the
|
||||
<a ui-sref="portainer.endpoints.new"> environments view </a>
|
||||
to add an environment.
|
||||
</p>
|
||||
</span>
|
||||
</information-panel>
|
||||
|
||||
<div class="row" ng-if="!state.connectingToEdgeEndpoint">
|
||||
<div class="col-sm-12">
|
||||
<endpoint-list
|
||||
title-text="Environments"
|
||||
title-icon="fa-plug"
|
||||
table-key="home_endpoints"
|
||||
tags="tags"
|
||||
dashboard-action="goToDashboard"
|
||||
show-snapshot-action="isAdmin"
|
||||
snapshot-action="triggerSnapshot"
|
||||
edit-action="goToEdit"
|
||||
is-admin="isAdmin"
|
||||
retrieve-page="getPaginatedEndpoints"
|
||||
endpoint-init-time="state.homepageLoadTime"
|
||||
></endpoint-list>
|
||||
</div>
|
||||
</div>
|
|
@ -1,102 +0,0 @@
|
|||
import EndpointHelper from 'Portainer/helpers/endpointHelper';
|
||||
import { getEndpoints } from 'Portainer/environments/environment.service';
|
||||
|
||||
angular
|
||||
.module('portainer.app')
|
||||
.controller(
|
||||
'HomeController',
|
||||
function ($q, $scope, $state, TagService, Authentication, EndpointService, GroupService, Notifications, EndpointProvider, StateManager, ModalService, MotdService) {
|
||||
$scope.state = {
|
||||
connectingToEdgeEndpoint: false,
|
||||
homepageLoadTime: '',
|
||||
};
|
||||
|
||||
$scope.goToEdit = function (id) {
|
||||
$state.go('portainer.endpoints.endpoint', { id: id });
|
||||
};
|
||||
|
||||
$scope.goToDashboard = function (endpoint) {
|
||||
if (endpoint.Type === 3) {
|
||||
$state.go('azure.dashboard', { endpointId: endpoint.Id });
|
||||
return;
|
||||
}
|
||||
if (endpoint.Type === 4 || endpoint.Type === 7) {
|
||||
if (!endpoint.EdgeID) {
|
||||
$state.go('portainer.endpoints.endpoint', { id: endpoint.Id });
|
||||
return;
|
||||
}
|
||||
$scope.state.connectingToEdgeEndpoint = true;
|
||||
}
|
||||
if (endpoint.Type === 5 || endpoint.Type === 6 || endpoint.Type === 7) {
|
||||
$state.go('kubernetes.dashboard', { endpointId: endpoint.Id });
|
||||
return;
|
||||
}
|
||||
$state.go('docker.dashboard', { endpointId: endpoint.Id });
|
||||
};
|
||||
|
||||
$scope.dismissImportantInformation = function (hash) {
|
||||
StateManager.dismissImportantInformation(hash);
|
||||
};
|
||||
|
||||
$scope.dismissInformationPanel = function (id) {
|
||||
StateManager.dismissInformationPanel(id);
|
||||
};
|
||||
|
||||
$scope.triggerSnapshot = function () {
|
||||
ModalService.confirmEndpointSnapshot(function (result) {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
triggerSnapshot();
|
||||
});
|
||||
};
|
||||
|
||||
function triggerSnapshot() {
|
||||
EndpointService.snapshotEndpoints()
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Environments updated');
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'An error occured during environment snapshot');
|
||||
});
|
||||
}
|
||||
|
||||
$scope.getPaginatedEndpoints = getPaginatedEndpoints;
|
||||
function getPaginatedEndpoints(lastId, limit, search) {
|
||||
const deferred = $q.defer();
|
||||
$q.all({
|
||||
endpoints: getEndpoints(lastId, limit, { search, edgeDeviceFilter: false }),
|
||||
groups: GroupService.groups(),
|
||||
})
|
||||
.then(function success(data) {
|
||||
var endpoints = data.endpoints.value;
|
||||
var groups = data.groups;
|
||||
EndpointHelper.mapGroupNameToEndpoint(endpoints, groups);
|
||||
EndpointProvider.setEndpoints(endpoints);
|
||||
deferred.resolve({ endpoints: endpoints, totalCount: data.endpoints.totalCount });
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve environment information');
|
||||
});
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
async function initView() {
|
||||
$scope.state.homepageLoadTime = Math.floor(Date.now() / 1000);
|
||||
$scope.isAdmin = Authentication.isAdmin();
|
||||
|
||||
MotdService.motd().then(function success(data) {
|
||||
$scope.motd = data;
|
||||
});
|
||||
|
||||
try {
|
||||
$scope.tags = await TagService.tags();
|
||||
} catch (err) {
|
||||
Notifications.error('Failed loading page data', err);
|
||||
}
|
||||
}
|
||||
|
||||
initView();
|
||||
}
|
||||
);
|
Loading…
Add table
Add a link
Reference in a new issue