mirror of https://github.com/portainer/portainer
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 buttonpull/6646/head
parent
c442d936d3
commit
0f3c7b1424
@ -1,11 +1,15 @@
|
||||
name: Test Frontend
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install modules
|
||||
run: yarn --frozen-lockfile
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: yarn test:client
|
||||
|
@ -0,0 +1 @@
|
||||
export function loadProgressBar() {}
|
@ -1,20 +0,0 @@
|
||||
import angular from 'angular';
|
||||
|
||||
angular.module('portainer.kubernetes').factory('KubernetesConfig', KubernetesConfigFactory);
|
||||
|
||||
/* @ngInject */
|
||||
function KubernetesConfigFactory($http, EndpointProvider, API_ENDPOINT_KUBERNETES) {
|
||||
return { get };
|
||||
|
||||
async function get(environmentIDs) {
|
||||
return $http({
|
||||
method: 'GET',
|
||||
url: `${API_ENDPOINT_KUBERNETES}/config`,
|
||||
params: { ids: JSON.stringify(environmentIDs.map((x) => parseInt(x))) },
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Accept: 'text/yaml',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import { publicSettings } from '@/portainer/settings/settings.service';
|
||||
|
||||
const baseUrl = 'kubernetes';
|
||||
|
||||
export async function downloadKubeconfigFile(environmentIds: EnvironmentId[]) {
|
||||
try {
|
||||
const { headers, data } = await axios.get<Blob>(`${baseUrl}/config`, {
|
||||
params: { ids: JSON.stringify(environmentIds) },
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Accept: 'text/yaml',
|
||||
},
|
||||
});
|
||||
const contentDispositionHeader = headers['content-disposition'];
|
||||
const filename = contentDispositionHeader.replace('attachment;', '').trim();
|
||||
saveAs(data, filename);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, '');
|
||||
}
|
||||
}
|
||||
|
||||
export async function expiryMessage() {
|
||||
const settings = await publicSettings();
|
||||
|
||||
const prefix = 'Kubeconfig file will';
|
||||
switch (settings.KubeconfigExpiry) {
|
||||
case '24h':
|
||||
return `${prefix} expire in 1 day.`;
|
||||
case '168h':
|
||||
return `${prefix} expire in 7 days.`;
|
||||
case '720h':
|
||||
return `${prefix} expire in 30 days.`;
|
||||
case '8640h':
|
||||
return `${prefix} expire in 1 year.`;
|
||||
case '0':
|
||||
default:
|
||||
return `${prefix} not expire.`;
|
||||
}
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import angular from 'angular';
|
||||
|
||||
class KubernetesConfigService {
|
||||
/* @ngInject */
|
||||
constructor(KubernetesConfig, FileSaver, SettingsService) {
|
||||
this.KubernetesConfig = KubernetesConfig;
|
||||
this.FileSaver = FileSaver;
|
||||
this.SettingsService = SettingsService;
|
||||
}
|
||||
|
||||
async downloadKubeconfigFile(environmentIDs) {
|
||||
const response = await this.KubernetesConfig.get(environmentIDs);
|
||||
const headers = response.headers();
|
||||
const contentDispositionHeader = headers['content-disposition'];
|
||||
const filename = contentDispositionHeader.replace('attachment;', '').trim();
|
||||
return this.FileSaver.saveAs(response.data, filename);
|
||||
}
|
||||
|
||||
async expiryMessage() {
|
||||
const settings = await this.SettingsService.publicSettings();
|
||||
const expiryDays = settings.KubeconfigExpiry;
|
||||
const prefix = 'Kubeconfig file will ';
|
||||
switch (expiryDays) {
|
||||
case '0':
|
||||
return prefix + 'not expire.';
|
||||
case '24h':
|
||||
return prefix + 'expire in 1 day.';
|
||||
case '168h':
|
||||
return prefix + 'expire in 7 days.';
|
||||
case '720h':
|
||||
return prefix + 'expire in 30 days.';
|
||||
case '8640h':
|
||||
return prefix + 'expire in 1 year.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesConfigService;
|
||||
angular.module('portainer.kubernetes').service('KubernetesConfigService', KubernetesConfigService);
|
@ -0,0 +1,47 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Button } from '../Button';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
onDismiss?(): void;
|
||||
bodyClassName?: string;
|
||||
wrapperStyle?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function InformationPanel({
|
||||
title,
|
||||
onDismiss,
|
||||
wrapperStyle,
|
||||
bodyClassName,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody className={bodyClassName}>
|
||||
<div style={wrapperStyle}>
|
||||
<div className="col-sm-12 form-section-title">
|
||||
<span style={{ float: 'left' }}>{title}</span>
|
||||
{!!onDismiss && (
|
||||
<span
|
||||
className="small"
|
||||
style={{ float: 'right' }}
|
||||
ng-if="dismissAction"
|
||||
>
|
||||
<Button color="link" onClick={() => onDismiss()}>
|
||||
<i className="fa fa-times" /> dismiss
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">{children}</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
export const InformationPanelAngular = {
|
||||
templateUrl: './InformationPanelAngular.html',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
dismissAction: '&?',
|
||||
},
|
||||
transclude: true,
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export { InformationPanel } from './InformationPanel';
|
||||
|
||||
export { InformationPanelAngular } from './InformationPanelAngular';
|
@ -0,0 +1,4 @@
|
||||
.reloadButton {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
@ -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>;
|
||||
}
|
||||
|
@ -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,8 +0,0 @@
|
||||
angular.module('portainer.app').component('informationPanel', {
|
||||
templateUrl: './informationPanel.html',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
dismissAction: '&?',
|
||||
},
|
||||
transclude: true,
|
||||
});
|
@ -1,8 +0,0 @@
|
||||
angular.module('portainer.app').component('motdPanel', {
|
||||
templateUrl: './motdPanel.html',
|
||||
bindings: {
|
||||
motd: '<',
|
||||
dismissAction: '&?',
|
||||
},
|
||||
transclude: true,
|
||||
});
|
@ -1,26 +0,0 @@
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="motd-body">
|
||||
<style ng-if="$ctrl.motd.Style">
|
||||
{{ $ctrl.motd.Style }}
|
||||
</style>
|
||||
<div ng-style="{{ $ctrl.motd.ContentLayout ? $ctrl.motd.ContentLayout : {} }}">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
<span style="float: left">
|
||||
{{ $ctrl.motd.Title }}
|
||||
</span>
|
||||
<span class="small" style="float: right" ng-if="$ctrl.dismissAction">
|
||||
<a ng-click="$ctrl.dismissAction()"><i class="fa fa-times"></i> dismiss</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="text-muted">
|
||||
<p ng-bind-html="$ctrl.motd.Message"></p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,35 @@
|
||||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
import { EnvironmentGroup, EnvironmentGroupId } from './types';
|
||||
|
||||
export async function getGroup(id: EnvironmentGroupId) {
|
||||
try {
|
||||
const { data: group } = await axios.get<EnvironmentGroup>(buildUrl(id));
|
||||
return group;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, '');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGroups() {
|
||||
try {
|
||||
const { data: groups } = await axios.get<EnvironmentGroup[]>(buildUrl());
|
||||
return groups;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, '');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(id?: EnvironmentGroupId, action?: string) {
|
||||
let url = '/endpoint_groups';
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { EnvironmentGroup, EnvironmentGroupId } from './types';
|
||||
import { getGroup, getGroups } from './environment-groups.service';
|
||||
|
||||
export function useGroups() {
|
||||
return useQuery<EnvironmentGroup[]>(['environment-groups'], getGroups);
|
||||
}
|
||||
|
||||
export function useGroup<T = EnvironmentGroup>(
|
||||
groupId: EnvironmentGroupId,
|
||||
select?: (group: EnvironmentGroup) => T
|
||||
) {
|
||||
const { data } = useQuery(
|
||||
['environment-groups', groupId],
|
||||
() => getGroup(groupId),
|
||||
{
|
||||
staleTime: 50,
|
||||
select,
|
||||
onError(error) {
|
||||
notifyError('Failed loading group', error as Error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import { TagId } from '@/portainer/tags/types';
|
||||
|
||||
export type EnvironmentGroupId = number;
|
||||
|
||||
export interface EnvironmentGroup {
|
||||
// Environment(Endpoint) group Identifier
|
||||
Id: EnvironmentGroupId;
|
||||
// Environment(Endpoint) group name
|
||||
Name: string;
|
||||
// Description associated to the environment(endpoint) group
|
||||
Description: string;
|
||||
// List of tags associated to this environment(endpoint) group
|
||||
TagIds: TagId[];
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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) {
|
||||
export function idsToTagNames(tags, ids) {
|
||||
const filteredTags = _.filter(tags, (tag) => _.includes(ids, tag.Id));
|
||||
const tagNames = _.map(filteredTags, 'Name');
|
||||
return tagNames;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,38 @@
|
||||
import { server, rest } from '@/setup-tests/server';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { BackupFailedPanel } from './BackupFailedPanel';
|
||||
|
||||
test('when backup failed, should show message', async () => {
|
||||
const timestamp = 1500;
|
||||
server.use(
|
||||
rest.get('/api/backup/s3/status', (req, res, ctx) =>
|
||||
res(ctx.json({ Failed: true, TimestampUTC: timestamp }))
|
||||
)
|
||||
);
|
||||
|
||||
const { findByText } = renderWithQueryClient(<BackupFailedPanel />);
|
||||
|
||||
await expect(
|
||||
findByText(
|
||||
`The latest automated backup has failed at ${isoDate(
|
||||
timestamp
|
||||
)}. For details please see the log files and have a look at the`,
|
||||
{ exact: false }
|
||||
)
|
||||
).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
test("when user is using less nodes then allowed he shouldn't see message", async () => {
|
||||
server.use(
|
||||
rest.get('/api/backup/s3/status', (req, res, ctx) =>
|
||||
res(ctx.json({ Failed: false }))
|
||||
)
|
||||
);
|
||||
const { findByText } = renderWithQueryClient(<BackupFailedPanel />);
|
||||
|
||||
await expect(
|
||||
findByText('The latest automated backup has failed at', { exact: false })
|
||||
).rejects.toBeTruthy();
|
||||
});
|
@ -0,0 +1,42 @@
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { InformationPanel } from '../components/InformationPanel';
|
||||
import { TextTip } from '../components/Tip/TextTip';
|
||||
import { getBackupStatus } from '../services/api/backup.service';
|
||||
import { isoDate } from '../filters/filters';
|
||||
import { Link } from '../components/Link';
|
||||
|
||||
export function BackupFailedPanel() {
|
||||
const { status, isLoading } = useBackupStatus();
|
||||
|
||||
if (isLoading || !status || !status.Failed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InformationPanel title="Information">
|
||||
<TextTip>
|
||||
The latest automated backup has failed at {isoDate(status.TimestampUTC)}
|
||||
. For details please see the log files and have a look at the{' '}
|
||||
<Link to="portainer.settings">settings</Link> to verify the backup
|
||||
configuration.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function useBackupStatus() {
|
||||
const { data, isLoading } = useQuery(
|
||||
['backup', 'status'],
|
||||
() => getBackupStatus(),
|
||||
{
|
||||
onError(error) {
|
||||
notifyError('Failure', error as Error, 'Failed to get license info');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { status: data, isLoading };
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './EdgeLoadingSpinner.module.css';
|
||||
|
||||
export function EdgeLoadingSpinner() {
|
||||
return (
|
||||
<div className={clsx('row', styles.root)}>
|
||||
Connecting to the Edge environment...
|
||||
<i className="fa fa-cog fa-spin space-left" />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
import { render } from '@/react-tools/test-utils';
|
||||
|
||||
import { EdgeIndicator } from './EdgeIndicator';
|
||||
|
||||
test('when edge id is not set, should show unassociated label', () => {
|
||||
const { queryByLabelText } = renderComponent();
|
||||
|
||||
const unassociatedLabel = queryByLabelText('unassociated');
|
||||
|
||||
expect(unassociatedLabel).toBeVisible();
|
||||
});
|
||||
|
||||
test('given edge id and last checkin is set, should show heartbeat', () => {
|
||||
const { queryByLabelText } = renderComponent('id', 1);
|
||||
|
||||
expect(queryByLabelText('edge-heartbeat')).toBeVisible();
|
||||
expect(queryByLabelText('edge-last-checkin')).toBeVisible();
|
||||
});
|
||||
|
||||
function renderComponent(
|
||||
edgeId = '',
|
||||
lastCheckInDate = 0,
|
||||
checkInInterval = 0,
|
||||
homepageLoadTime = 0
|
||||
) {
|
||||
return render(
|
||||
<EdgeIndicator
|
||||
edgeId={edgeId}
|
||||
lastCheckInDate={lastCheckInDate}
|
||||
checkInInterval={checkInInterval}
|
||||
homepageLoadTime={homepageLoadTime}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
|
||||
interface Props {
|
||||
checkInInterval?: number;
|
||||
edgeId?: string;
|
||||
homepageLoadTime?: number;
|
||||
lastCheckInDate?: number;
|
||||
}
|
||||
|
||||
export function EdgeIndicator({
|
||||
edgeId,
|
||||
lastCheckInDate,
|
||||
checkInInterval,
|
||||
homepageLoadTime,
|
||||
}: Props) {
|
||||
if (!edgeId) {
|
||||
return (
|
||||
<span className="label label-default" aria-label="unassociated">
|
||||
<s>associated</s>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// give checkIn some wiggle room
|
||||
let isCheckValid = false;
|
||||
if (checkInInterval && homepageLoadTime && lastCheckInDate) {
|
||||
isCheckValid =
|
||||
homepageLoadTime - lastCheckInDate <= checkInInterval * 2 + 20;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span
|
||||
className={clsx('label', {
|
||||
'label-danger': !isCheckValid,
|
||||
'label-success': isCheckValid,
|
||||
})}
|
||||
aria-label="edge-heartbeat"
|
||||
>
|
||||
heartbeat
|
||||
</span>
|
||||
|
||||
{!!lastCheckInDate && (
|
||||
<span
|
||||
className="space-left small text-muted"
|
||||
aria-label="edge-last-checkin"
|
||||
>
|
||||
{isoDateFromTimestamp(lastCheckInDate)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { environmentTypeIcon } from '@/portainer/filters/filters';
|
||||
import dockerEdge from '@/assets/images/edge_endpoint.png';
|
||||
import kube from '@/assets/images/kubernetes_endpoint.png';
|
||||
import kubeEdge from '@/assets/images/kubernetes_edge_endpoint.png';
|
||||
import { EnvironmentType } from '@/portainer/environments/types';
|
||||
|
||||
interface Props {
|
||||
type: EnvironmentType;
|
||||
}
|
||||
|
||||
export function EnvironmentIcon({ type }: Props) {
|
||||
switch (type) {
|
||||
case EnvironmentType.EdgeAgentOnDocker:
|
||||
return (
|
||||
<img src={dockerEdge} alt="docker edge endpoint" aria-hidden="true" />
|
||||
);
|
||||
case EnvironmentType.KubernetesLocal:
|
||||
case EnvironmentType.AgentOnKubernetes:
|
||||
return <img src={kube} alt="kubernetes endpoint" aria-hidden="true" />;
|
||||
case EnvironmentType.EdgeAgentOnKubernetes:
|
||||
return (
|
||||
<img src={kubeEdge} alt="kubernetes edge endpoint" aria-hidden="true" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<i
|
||||
className={clsx('fa-4x', 'blue-icon', environmentTypeIcon(type))}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrapperButton {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
outline: initial;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 7px;
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
import { Story } from '@storybook/react';
|
||||
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentStatus,
|
||||
EnvironmentType,
|
||||
} from '@/portainer/environments/types';
|
||||
|
||||
import { EnvironmentItem } from './EnvironmentItem';
|
||||
|
||||
export default {
|
||||
component: EnvironmentItem,
|
||||
title: 'Home/EnvironmentList/EnvironmentItem',
|
||||
};
|
||||
|
||||
interface Args {
|
||||
environment: Environment;
|
||||
homepageLoadTime: number;
|
||||
}
|
||||
|
||||
function Template({ environment, homepageLoadTime = 1 }: Args) {
|
||||
return (
|
||||
<EnvironmentItem
|
||||
environment={environment}
|
||||
homepageLoadTime={homepageLoadTime}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DockerEnvironment: Story<Args> = Template.bind({});
|
||||
DockerEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.Docker),
|
||||
};
|
||||
|
||||
export const DockerAgentEnvironment: Story<Args> = Template.bind({});
|
||||
DockerAgentEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.AgentOnDocker),
|
||||
};
|
||||
|
||||
export const DockerEdgeEnvironment: Story<Args> = Template.bind({});
|
||||
DockerEdgeEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.EdgeAgentOnDocker),
|
||||
};
|
||||
|
||||
export const AzureEnvironment: Story<Args> = Template.bind({});
|
||||
AzureEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.Azure),
|
||||
};
|
||||
|
||||
export const KubernetesLocalEnvironment: Story<Args> = Template.bind({});
|
||||
KubernetesLocalEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.KubernetesLocal),
|
||||
};
|
||||
|
||||
export const KubernetesAgentEnvironment: Story<Args> = Template.bind({});
|
||||
KubernetesAgentEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.AgentOnKubernetes),
|
||||
};
|
||||
|
||||
export const KubernetesEdgeEnvironment: Story<Args> = Template.bind({});
|
||||
KubernetesEdgeEnvironment.args = {
|
||||
environment: mockEnvironment(EnvironmentType.EdgeAgentOnKubernetes),
|
||||
};
|
||||
|
||||
function mockEnvironment(type: EnvironmentType): Environment {
|
||||
return {
|
||||
Id: 1,
|
||||
Name: 'environment',
|
||||
GroupId: 1,
|
||||
Snapshots: [],
|
||||
Status: EnvironmentStatus.Up,
|
||||
TagIds: [],
|
||||
Type: type,
|
||||
Kubernetes: {
|
||||
Snapshots: [],
|
||||
},
|
||||
URL: 'url',
|
||||
UserTrusted: false,
|
||||
};
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
import {
|
||||
EnvironmentGroup,
|
||||
EnvironmentGroupId,
|
||||
} from '@/portainer/environment-groups/types';
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { Tag } from '@/portainer/tags/types';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { server, rest } from '@/setup-tests/server';
|
||||
|
||||
import { EnvironmentItem } from './EnvironmentItem';
|
||||
|
||||
test('loads component', async () => {
|
||||
const env: Environment = {
|
||||
TagIds: [],
|
||||
GroupId: 1,
|
||||
Type: 1,
|
||||
Name: 'environment',
|
||||
Status: 1,
|
||||
URL: 'url',
|
||||
Snapshots: [],
|
||||
Kubernetes: { Snapshots: [] },
|
||||
Id: 3,
|
||||
UserTrusted: false,
|
||||
};
|
||||
const { getByText } = renderComponent(env);
|
||||
|
||||
expect(getByText(env.Name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows group name', async () => {
|
||||
const groupName = 'group-name';
|
||||
const groupId: EnvironmentGroupId = 14;
|
||||
|
||||
const env: Environment = {
|
||||
TagIds: [],
|
||||
GroupId: groupId,
|
||||
Type: 1,
|
||||
Name: 'environment',
|
||||
Status: 1,
|
||||
URL: 'url',
|
||||
Snapshots: [],
|
||||
Kubernetes: { Snapshots: [] },
|
||||
Id: 3,
|
||||
UserTrusted: false,
|
||||
};
|
||||
|
||||
const { findByText } = renderComponent(env, { Name: groupName });
|
||||
|
||||
await expect(findByText(groupName)).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
function renderComponent(
|
||||
env: Environment,
|
||||
group: Partial<EnvironmentGroup> = { Name: 'group' },
|
||||
isAdmin = false,
|
||||
tags: Tag[] = []
|
||||
) {
|
||||
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
|
||||
|
||||
server.use(rest.get('/api/tags', (req, res, ctx) => res(ctx.json(tags))));
|
||||
|
||||
return renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<EnvironmentItem
|
||||
onClick={() => {}}
|
||||
environment={env}
|
||||
groupName={group.Name}
|
||||
homepageLoadTime={0}
|
||||
/>
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
@ -0,0 +1,203 @@
|
||||
import clsx from 'clsx';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {
|
||||
isoDateFromTimestamp,
|
||||
humanize,
|
||||
stripProtocol,
|
||||
} from '@/portainer/filters/filters';
|
||||
import { type Environment, PlatformType } from '@/portainer/environments/types';
|
||||
import {
|
||||
getPlatformType,
|
||||
isDockerEnvironment,
|
||||
isEdgeEnvironment,
|
||||
} from '@/portainer/environments/utils';
|
||||
import type { TagId } from '@/portainer/tags/types';
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
import { Link } from '@/portainer/components/Link';
|
||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
||||
import { useTags } from '@/portainer/tags/queries';
|
||||
|
||||
import { EnvironmentIcon } from './EnvironmentIcon';
|
||||
import { EdgeIndicator } from './EdgeIndicator';
|
||||
import { EnvironmentStats } from './EnvironmentStats';
|
||||
import styles from './EnvironmentItem.module.css';
|
||||
import { EnvironmentStatusBadge } from './EnvironmentStatusBadge';
|
||||
|
||||
interface Props {
|
||||
homepageLoadTime?: number;
|
||||
environment: Environment;
|
||||
groupName?: string;
|
||||
onClick(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function EnvironmentItem({
|
||||
environment,
|
||||
onClick,
|
||||
homepageLoadTime,
|
||||
groupName,
|
||||
}: Props) {
|
||||
const isAdmin = useIsAdmin();
|
||||
const isEdge = isEdgeEnvironment(environment.Type);
|
||||
|
||||
const snapshotTime = getSnapshotTime(environment);
|
||||
|
||||
const tags = useEnvironmentTagNames(environment.TagIds);
|
||||
const route = getRoute(environment);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<button
|
||||
type="button"
|
||||
color="link"
|
||||
onClick={() => onClick(environment)}
|
||||
className={styles.wrapperButton}
|
||||
>
|
||||
<Link
|
||||
className={clsx('blocklist-item', styles.item)}
|
||||
to={route}
|
||||
params={{
|
||||
endpointId: environment.Id,
|
||||
id: environment.Id,
|
||||
}}
|
||||
>
|
||||
<div className="blocklist-item-box">
|
||||
<span className={clsx('blocklist-item-logo', 'endpoint-item')}>
|
||||
<EnvironmentIcon type={environment.Type} />
|
||||
</span>
|
||||
<span className="col-sm-12">
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span>
|
||||
<span className="blocklist-item-title endpoint-item">
|
||||
{environment.Name}
|
||||
</span>
|
||||
<span className="space-left blocklist-item-subtitle">
|
||||
{isEdge ? (
|
||||
<EdgeIndicator
|
||||
edgeId={environment.EdgeID}
|
||||
checkInInterval={environment.EdgeCheckinInterval}
|
||||
lastCheckInDate={environment.LastCheckInDate}
|
||||
homepageLoadTime={homepageLoadTime}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<EnvironmentStatusBadge status={environment.Status} />
|
||||
<span className="space-left small text-muted">
|
||||
{snapshotTime}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{groupName && (
|
||||
<span className="small">
|
||||
<span>Group: </span>
|
||||
<span>{groupName}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<EnvironmentStats environment={environment} />
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="small text-muted">
|
||||
{isDockerEnvironment(environment.Type) && (
|
||||
<span>
|
||||
{environment.Snapshots.length > 0 && (
|
||||
<span className="small text-muted">
|
||||
<i className="fa fa-microchip space-right" />
|
||||
{environment.Snapshots[0].TotalCPU}
|
||||
<i className="fa fa-memory space-left space-right" />
|
||||
{humanize(environment.Snapshots[0].TotalMemory)}
|
||||
</span>
|
||||
)}
|
||||
<span className="space-left space-right">-</span>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<i className="fa fa-tags space-right" aria-hidden="true" />
|
||||
{tags}
|
||||
</span>
|
||||
</span>
|
||||
{!isEdge && (
|
||||
<span className="small text-muted">
|
||||
{stripProtocol(environment.URL)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="portainer.endpoints.endpoint"
|
||||
params={{ id: environment.Id }}
|
||||
className={styles.editButton}
|
||||
>
|
||||
<Button color="link">
|
||||
<i className="fa fa-pencil-alt" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useEnvironmentTagNames(tagIds?: TagId[]) {
|
||||
const { tags, isLoading } = useTags((tags) => {
|
||||
if (!tagIds) {
|
||||
return [];
|
||||
}
|
||||
return _.compact(
|
||||
tagIds
|
||||
.map((id) => tags.find((tag) => tag.ID === id))
|
||||
.map((tag) => tag?.Name)
|
||||
);
|
||||
});
|
||||
|
||||
if (tags) {
|
||||
return tags.join(', ');
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return 'Loading tags...';
|
||||
}
|
||||
|
||||
return 'No tags';
|
||||
}
|
||||
|
||||
function getSnapshotTime(environment: Environment) {
|
||||
const platform = getPlatformType(environment.Type);
|
||||
|
||||
switch (platform) {
|
||||
case PlatformType.Docker:
|
||||
return environment.Snapshots.length > 0
|
||||
? isoDateFromTimestamp(environment.Snapshots[0].Time)
|
||||
: null;
|
||||
case PlatformType.Kubernetes:
|
||||
return environment.Kubernetes.Snapshots &&
|
||||
environment.Kubernetes.Snapshots.length > 0
|
||||
? isoDateFromTimestamp(environment.Kubernetes.Snapshots[0].Time)
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRoute(environment: Environment) {
|
||||
if (isEdgeEnvironment(environment.Type) && !environment.EdgeID) {
|
||||
return 'portainer.endpoints.endpoint';
|
||||
}
|
||||
|
||||
const platform = getPlatformType(environment.Type);
|
||||
|
||||
switch (platform) {
|
||||
case PlatformType.Azure:
|
||||
return 'azure.dashboard';
|
||||
case PlatformType.Docker:
|
||||
return 'docker.dashboard';
|
||||
case PlatformType.Kubernetes:
|
||||
return 'kubernetes.dashboard';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
import { Environment, PlatformType } from '@/portainer/environments/types';
|
||||
import { getPlatformType } from '@/portainer/environments/utils';
|
||||
|
||||
import { EnvironmentStatsDocker } from './EnvironmentStatsDocker';
|
||||
import { EnvironmentStatsKubernetes } from './EnvironmentStatsKubernetes';
|
||||
|
||||
interface Props {
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
export function EnvironmentStats({ environment }: Props) {
|
||||
const platform = getPlatformType(environment.Type);
|
||||
switch (platform) {
|
||||
case PlatformType.Kubernetes:
|
||||
return (
|
||||
<EnvironmentStatsKubernetes
|
||||
snapshots={environment.Kubernetes.Snapshots || []}
|
||||
/>
|
||||
);
|
||||
case PlatformType.Docker:
|
||||
return (
|
||||
<EnvironmentStatsDocker
|
||||
snapshots={environment.Snapshots}
|
||||
type={environment.Type}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc">-</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
import {
|
||||
DockerSnapshot,
|
||||
EnvironmentType,
|
||||
} from '@/portainer/environments/types';
|
||||
import { addPlural } from '@/portainer/helpers/strings';
|
||||
|
||||
import { Stat } from './EnvironmentStatsItem';
|
||||
|
||||
interface Props {
|
||||
snapshots: DockerSnapshot[];
|
||||
type: EnvironmentType;
|
||||
}
|
||||
|
||||
export function EnvironmentStatsDocker({ snapshots = [], type }: Props) {
|
||||
if (snapshots.length === 0) {
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc">No snapshot available</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = snapshots[0];
|
||||
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc space-x-4">
|
||||
<Stat
|
||||
value={addPlural(snapshot.StackCount, 'stack')}
|
||||
icon="fa-th-list"
|
||||
/>
|
||||
|
||||
{!!snapshot.Swarm && (
|
||||
<Stat
|
||||
value={addPlural(snapshot.ServiceCount, 'service')}
|
||||
icon="fa-list-alt"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContainerStats
|
||||
running={snapshot.RunningContainerCount}
|
||||
stopped={snapshot.StoppedContainerCount}
|
||||
healthy={snapshot.HealthyContainerCount}
|
||||
unhealthy={snapshot.UnhealthyContainerCount}
|
||||
/>
|
||||
|
||||
<Stat value={addPlural(snapshot.VolumeCount, 'volume')} icon="fa-hdd" />
|
||||
<Stat value={addPlural(snapshot.ImageCount, 'image')} icon="fa-clone" />
|
||||
</span>
|
||||
|
||||
<span className="small text-muted space-x-3">
|
||||
<span>{snapshot.Swarm ? 'Swarm' : 'Standalone'}</span>
|
||||
<span>{snapshot.DockerVersion}</span>
|
||||
{type === EnvironmentType.AgentOnDocker && (
|
||||
<span>
|
||||
+ <i className="fa fa-bolt" aria-hidden="true" /> Agent
|
||||
</span>
|
||||
)}
|
||||
{snapshot.Swarm && (
|
||||
<Stat value={addPlural(snapshot.NodeCount, 'node')} icon="fa-hdd" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContainerStatsProps {
|
||||
running: number;
|
||||
stopped: number;
|
||||
healthy: number;
|
||||
unhealthy: number;
|
||||
}
|
||||
|
||||
function ContainerStats({
|
||||
running,
|
||||
stopped,
|
||||
healthy,
|
||||
unhealthy,
|
||||
}: ContainerStatsProps) {
|
||||
const containersCount = running + stopped;
|
||||
|
||||
return (
|
||||
<Stat value={addPlural(containersCount, 'container')} icon="fa-cubes">
|
||||
{containersCount > 0 && (
|
||||
<span className="space-x-2">
|
||||
<span>-</span>
|
||||
<Stat value={running} icon="fa-power-off green-icon" />
|
||||
<Stat value={stopped} icon="fa-power-off red-icon" />
|
||||
<span>/</span>
|
||||
<Stat value={healthy} icon="fa-heartbeat green-icon" />
|
||||
<Stat value={unhealthy} icon="fa-heartbeat orange-icon" />
|
||||
</span>
|
||||
)}
|
||||
</Stat>
|
||||
);
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: string | number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export function Stat({ value, icon, children }: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<span>
|
||||
<i className={clsx('fa space-right', icon)} aria-hidden="true" />
|
||||
<span>{value}</span>
|
||||
{children && <span className="space-left">{children}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
import { KubernetesSnapshot } from '@/portainer/environments/types';
|
||||
import { humanize } from '@/portainer/filters/filters';
|
||||
import { addPlural } from '@/portainer/helpers/strings';
|
||||
|
||||
import { Stat } from './EnvironmentStatsItem';
|
||||
|
||||
interface Props {
|
||||
snapshots?: KubernetesSnapshot[];
|
||||
}
|
||||
|
||||
export function EnvironmentStatsKubernetes({ snapshots = [] }: Props) {
|
||||
if (snapshots.length === 0) {
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc">No snapshot available</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshot = snapshots[0];
|
||||
|
||||
return (
|
||||
<div className="blocklist-item-line endpoint-item">
|
||||
<span className="blocklist-item-desc space-x-4">
|
||||
<Stat icon="fa-microchip" value={`${snapshot.TotalCPU} CPU`} />
|
||||
<Stat
|
||||
icon="fa-memory"
|
||||
value={`${humanize(snapshot.TotalMemory)} RAM`}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="small text-muted space-x-3">
|
||||
<span>Kubernetes {snapshot.KubernetesVersion}</span>
|
||||
<Stat value={addPlural(snapshot.NodeCount, 'node')} icon="fa-hdd" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { EnvironmentStatus } from '@/portainer/environments/types';
|
||||
|
||||
interface Props {
|
||||
status: EnvironmentStatus;
|
||||
}
|
||||
|
||||
export function EnvironmentStatusBadge({ status }: Props) {
|
||||
return (
|
||||
<span className={clsx('label', `label-${environmentStatusBadge(status)}`)}>
|
||||
{status === EnvironmentStatus.Up ? 'up' : 'down'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function environmentStatusBadge(status: EnvironmentStatus) {
|
||||
if (status === EnvironmentStatus.Down) {
|
||||
return 'danger';
|
||||
}
|
||||
return 'success';
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { EnvironmentItem } from './EnvironmentItem';
|
@ -0,0 +1,7 @@
|
||||
.actionBar .description {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.refresh-environments-button {
|
||||
margin-left: 0 !important;
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { rest, server } from '@/setup-tests/server';
|
||||
|
||||
import { EnvironmentList } from './EnvironmentList';
|
||||
|
||||
test('when no environments for query should show empty list message', async () => {
|
||||
const { findByText } = await renderComponent(false, []);
|
||||
|
||||
await expect(findByText('No environments available.')).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not admin and no environments at all should show empty list info message', async () => {
|
||||
const { findByText } = await renderComponent(false, []);
|
||||
|
||||
await expect(
|
||||
findByText(
|
||||
'You do not have access to any environment. Please contact your administrator.'
|
||||
)
|
||||
).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is an admin and no environments at all should show empty list info message', async () => {
|
||||
const { findByText } = await renderComponent(true);
|
||||
|
||||
await expect(
|
||||
findByText(/No environment available for management. Please head over the/)
|
||||
).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
async function renderComponent(
|
||||
isAdmin = false,
|
||||
environments: Environment[] = []
|
||||
) {
|
||||
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
|
||||
|
||||
server.use(
|
||||
rest.get('/api/endpoints', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.set('x-total-available', environments.length.toString()),
|
||||
ctx.set('x-total-count', environments.length.toString()),
|
||||
ctx.json(environments)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const queries = renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<EnvironmentList onClickItem={jest.fn()} onRefresh={jest.fn()} />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
await expect(queries.findByText('Environments')).resolves.toBeVisible();
|
||||
|
||||
return queries;
|
||||
}
|
@ -0,0 +1,159 @@
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { PaginationControls } from '@/portainer/components/pagination-controls';
|
||||
import { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState';
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
||||
import {
|
||||
SearchBar,
|
||||
useSearchBarState,
|
||||
} from '@/portainer/components/datatables/components/SearchBar';
|
||||
import {
|
||||
TableActions,
|
||||
TableContainer,
|
||||
TableTitle,
|
||||
} from '@/portainer/components/datatables/components';
|
||||
import { TableFooter } from '@/portainer/components/datatables/components/TableFooter';
|
||||
import { useDebounce } from '@/portainer/hooks/useDebounce';
|
||||
import { useEnvironmentList } from '@/portainer/environments/queries';
|
||||
import { useGroups } from '@/portainer/environment-groups/queries';
|
||||
|
||||
import { EnvironmentItem } from './EnvironmentItem';
|
||||
import { KubeconfigButton } from './KubeconfigButton';
|
||||
import styles from './EnvironmentList.module.css';
|
||||
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
|
||||
|
||||
interface Props {
|
||||
onClickItem(environment: Environment): void;
|
||||
onRefresh(): void;
|
||||
}
|
||||
|
||||
export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||
const homepageLoadTime = usePageLoadingTime();
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
const storageKey = 'home_endpoints';
|
||||
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
||||
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const debouncedTextFilter = useDebounce(searchBarValue);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [searchBarValue]);
|
||||
|
||||
const groupsQuery = useGroups();
|
||||
|
||||
const { isLoading, environments, totalCount, totalAvailable } =
|
||||
useEnvironmentList(page, pageLimit, debouncedTextFilter, true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TableContainer>
|
||||
<TableTitle icon="fa-plug" label="Environments" />
|
||||
|
||||
<TableActions className={styles.actionBar}>
|
||||
<div className={styles.description}>
|
||||
<i className="fa fa-exclamation-circle blue-icon space-right" />
|
||||
Click on an environment to manage
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
data-cy="home-refreshEndpointsButton"
|
||||
className={clsx(styles.refreshEnvironmentsButton)}
|
||||
>
|
||||
<i className="fa fa-sync space-right" aria-hidden="true" />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<KubeconfigButton environments={environments} />
|
||||
</TableActions>
|
||||
|
||||
<SearchBar
|
||||
value={searchBarValue}
|
||||
onChange={setSearchBarValue}
|
||||
placeholder="Search by name, group, tag, status, URL..."
|
||||
data-cy="home-endpointsSearchInput"
|
||||
/>
|
||||
|
||||
<div className="blocklist" data-cy="home-endpointList">
|
||||
{renderItems(
|
||||
isLoading,
|
||||
totalCount,
|
||||
environments.map((env) => (
|
||||
<EnvironmentItem
|
||||
key={env.Id}
|
||||
environment={env}
|
||||
groupName={
|
||||
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
|
||||
}
|
||||
onClick={onClickItem}
|
||||
homepageLoadTime={homepageLoadTime}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TableFooter>
|
||||
<PaginationControls
|
||||
showAll={totalCount <= 100}
|
||||
pageLimit={pageLimit}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
totalCount={totalCount}
|
||||
onPageLimitChange={setPageLimit}
|
||||
/>
|
||||
</TableFooter>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderItems(
|
||||
isLoading: boolean,
|
||||
totalCount: number,
|
||||
|
||||
items: ReactNode
|
||||
) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center text-muted" data-cy="home-loadingEndpoints">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!totalCount) {
|
||||
return (
|
||||
<div className="text-center text-muted" data-cy="home-noEndpoints">
|
||||
No environments available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function usePageLoadingTime() {
|
||||
const [homepageLoadTime, setHomepageLoadTime] = useState<
|
||||
number | undefined
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
setHomepageLoadTime(Math.floor(Date.now() / 1000));
|
||||
}, []);
|
||||
|
||||
return homepageLoadTime;
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
import * as kcService from '@/kubernetes/services/kubeconfig.service';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { confirmKubeconfigSelection } from '@/portainer/services/modal.service/prompt';
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
import { isKubernetesEnvironment } from '@/portainer/environments/utils';
|
||||
import { trackEvent } from '@/angulartics.matomo/analytics-services';
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
|
||||
interface Props {
|
||||
environments?: Environment[];
|
||||
}
|
||||
|
||||
export function KubeconfigButton({ environments }: Props) {
|
||||
if (!environments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isKubeconfigButtonVisible(environments)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick}>
|
||||
<i className="fas fa-download space-right" /> kubeconfig
|
||||
</Button>
|
||||
);
|
||||
|
||||
function handleClick() {
|
||||
if (!environments) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent('kubernetes-kubectl-kubeconfig-multi', {
|
||||
category: 'kubernetes',
|
||||
});
|
||||
|
||||
showKubeconfigModal(environments);
|
||||
}
|
||||
}
|
||||
|
||||
function isKubeconfigButtonVisible(environments: Environment[]) {
|
||||
if (window.location.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
return environments.some((env) => isKubernetesEnvironment(env.Type));
|
||||
}
|
||||
|
||||
async function showKubeconfigModal(environments: Environment[]) {
|
||||
const kubeEnvironments = environments.filter((env) =>
|
||||
isKubernetesEnvironment(env.Type)
|
||||
);
|
||||
const options = kubeEnvironments.map((environment) => ({
|
||||
text: `${environment.Name} (${environment.URL})`,
|
||||
value: `${environment.Id}`,
|
||||
}));
|
||||
|
||||
let expiryMessage = '';
|
||||
try {
|
||||
expiryMessage = await kcService.expiryMessage();
|
||||
} catch (e) {
|
||||
notifications.error('Failed fetching kubeconfig expiry time', e as Error);
|
||||
}
|
||||
|
||||
confirmKubeconfigSelection(
|
||||
options,
|
||||
expiryMessage,
|
||||
async (selectedEnvironmentIDs: string[]) => {
|
||||
if (selectedEnvironmentIDs.length === 0) {
|
||||
notifications.warning('No environment was selected', '');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await kcService.downloadKubeconfigFile(
|
||||
selectedEnvironmentIDs.map((id) => parseInt(id, 10))
|
||||
);
|
||||
} catch (e) {
|
||||
notifications.error('Failed downloading kubeconfig file', e as Error);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import { InformationPanel } from '@/portainer/components/InformationPanel';
|
||||
import { Link } from '@/portainer/components/Link';
|
||||
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
||||
|
||||
export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
return (
|
||||
<InformationPanel title="Information">
|
||||
<TextTip>
|
||||
{isAdmin ? (
|
||||
<span>
|
||||
No environment available for management. Please head over the
|
||||
<Link to="portainer.endpoints.new"> environments view </Link>
|
||||
to add an environment.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
You do not have access to any environment. Please contact your
|
||||
administrator.
|
||||
</span>
|
||||
)}
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
);
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { react2angular } from '@/react-tools/react2angular';
|
||||
|
||||
import { EnvironmentList } from './EnvironmentList';
|
||||
|
||||
export { EnvironmentList };
|
||||
|
||||
export const EnvironmentListAngular = react2angular(EnvironmentList, [
|
||||
'tags',
|
||||
'onClickItem',
|
||||
'onRefresh',
|
||||
'homepageLoadTime',
|
||||
'groups',
|
||||
]);
|
@ -0,0 +1,88 @@
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import * as notifications from '../services/notifications';
|
||||
import { Environment } from '../environments/types';
|
||||
import { snapshotEndpoints } from '../environments/environment.service';
|
||||
import { isEdgeEnvironment } from '../environments/utils';
|
||||
import { confirmAsync } from '../services/modal.service/confirm';
|
||||
|
||||
import { EnvironmentList } from './EnvironmentList';
|
||||
import { EdgeLoadingSpinner } from './EdgeLoadingSpinner';
|
||||
import { MotdPanel } from './MotdPanel';
|
||||
import { LicenseNodePanel } from './LicenseNodePanel';
|
||||
import { BackupFailedPanel } from './BackupFailedPanel';
|
||||
|
||||
export function HomeView() {
|
||||
const [connectingToEdgeEndpoint, setConnectingToEdgeEndpoint] =
|
||||
useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
reload
|
||||
title="Home"
|
||||
breadcrumbs={[{ label: 'Environments' }]}
|
||||
/>
|
||||
|
||||
{process.env.PORTAINER_EDITION !== 'CE' && <LicenseNodePanel />}
|
||||
|
||||
<MotdPanel />
|
||||
|
||||
{process.env.PORTAINER_EDITION !== 'CE' && <BackupFailedPanel />}
|
||||
|
||||
{connectingToEdgeEndpoint ? (
|
||||
<EdgeLoadingSpinner />
|
||||
) : (
|
||||
<EnvironmentList
|
||||
onClickItem={handleClickItem}
|
||||
onRefresh={confirmTriggerSnapshot}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
async function confirmTriggerSnapshot() {
|
||||
const result = await confirmEndpointSnapshot();
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await snapshotEndpoints();
|
||||
notifications.success('Success', 'Environments updated');
|
||||
router.stateService.reload();
|
||||
} catch (err) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
err as Error,
|
||||
'An error occurred during environment snapshot'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickItem(environment: Environment) {
|
||||
if (isEdgeEnvironment(environment.Type)) {
|
||||
setConnectingToEdgeEndpoint(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const HomeViewAngular = r2a(HomeView, []);
|
||||
|
||||
async function confirmEndpointSnapshot() {
|
||||
return confirmAsync({
|
||||
title: 'Are you sure?',
|
||||
message:
|
||||
'Triggering a manual refresh will poll each environment to retrieve its information, this may take a few moments.',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Continue',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import { server, rest } from '@/setup-tests/server';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
|
||||
import { LicenseType } from '../license-management/types';
|
||||
|
||||
import { LicenseNodePanel } from './LicenseNodePanel';
|
||||
|
||||
test('when user is using more nodes then allowed he should see message', async () => {
|
||||
const allowed = 2;
|
||||
const used = 5;
|
||||
server.use(
|
||||
rest.get('/api/licenses/info', (req, res, ctx) =>
|
||||
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
|
||||
),
|
||||
rest.get('/api/status/nodes', (req, res, ctx) =>
|
||||
res(ctx.json({ nodes: used }))
|
||||
)
|
||||
);
|
||||
|
||||
const { findByText } = renderWithQueryClient(<LicenseNodePanel />);
|
||||
|
||||
await expect(
|
||||
findByText(
|
||||
/The number of nodes for your license has been exceeded. Please contact your administrator./
|
||||
)
|
||||
).resolves.toBeVisible();
|
||||
});
|
||||
|
||||
test("when user is using less nodes then allowed he shouldn't see message", async () => {
|
||||
const allowed = 5;
|
||||
const used = 2;
|
||||
server.use(
|
||||
rest.get('/api/licenses/info', (req, res, ctx) =>
|
||||
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
|
||||
),
|
||||
rest.get('/api/status/nodes', (req, res, ctx) =>
|
||||
res(ctx.json({ nodes: used }))
|
||||
)
|
||||
);
|
||||
|
||||
const { findByText } = renderWithQueryClient(<LicenseNodePanel />);
|
||||
|
||||
await expect(
|
||||
findByText(
|
||||
/The number of nodes for your license has been exceeded. Please contact your administrator./
|
||||
)
|
||||
).rejects.toBeTruthy();
|
||||
});
|
@ -0,0 +1,56 @@
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { InformationPanel } from '../components/InformationPanel';
|
||||
import { TextTip } from '../components/Tip/TextTip';
|
||||
import { LicenseType } from '../license-management/types';
|
||||
import { useLicenseInfo } from '../license-management/use-license.service';
|
||||
import { getNodesCount } from '../services/api/status.service';
|
||||
|
||||
export function LicenseNodePanel() {
|
||||
const nodesValid = useNodesValid();
|
||||
|
||||
if (nodesValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InformationPanel title="License node allowance exceeded">
|
||||
<TextTip>
|
||||
The number of nodes for your license has been exceeded. Please contact
|
||||
your administrator.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function useNodesValid() {
|
||||
const { isLoading: isLoadingNodes, nodesCount } = useNodesCounts();
|
||||
|
||||
const { isLoading: isLoadingLicense, info } = useLicenseInfo();
|
||||
if (
|
||||
isLoadingLicense ||
|
||||
isLoadingNodes ||
|
||||
!info ||
|
||||
info.type === LicenseType.Trial
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return nodesCount <= info.nodes;
|
||||
}
|
||||
|
||||
function useNodesCounts() {
|
||||
const { isLoading, data } = useQuery(
|
||||
['status', 'nodes'],
|
||||
() => getNodesCount(),
|
||||
{
|
||||
onError(error) {
|
||||
notifyError('Failure', error as Error, 'Failed to get nodes count');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { nodesCount: data || 0, isLoading };
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import { useUIState } from '@/portainer/hooks/UIStateProvider';
|
||||
|
||||
import { InformationPanel } from '../components/InformationPanel/InformationPanel';
|
||||
|
||||
import { getMotd } from './home.service';
|
||||
|
||||
export function MotdPanel() {
|
||||
const motd = useMotd();
|
||||
|
||||
const [uiState, setUIState] = useUIState();
|
||||
|
||||
if (!motd || motd.Message === '' || motd.Hash === uiState.dismissedInfoHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!motd.Style && <style>{motd.Style}</style>}
|
||||
<InformationPanel
|
||||
onDismiss={() => onDismiss(motd.Hash)}
|
||||
title={motd.Title}
|
||||
wrapperStyle={camelCaseKeys(motd.ContentLayout)}
|
||||
bodyClassName="motd-body"
|
||||
>
|
||||
<span className="text-muted">
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<p dangerouslySetInnerHTML={{ __html: motd.Message }} />
|
||||
</span>
|
||||
</InformationPanel>
|
||||
</>
|
||||
);
|
||||
|
||||
function onDismiss(hash: string) {
|
||||
setUIState({
|
||||
...uiState,
|
||||
dismissedInfoHash: hash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function useMotd() {
|
||||
const { data } = useQuery('motd', () => getMotd());
|
||||
return data;
|
||||
}
|
||||
|
||||
function camelCaseKeys(obj: Record<string, string> = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => {
|
||||
const camelCased = _.camelCase(key);
|
||||
return [camelCased, value];
|
||||
})
|
||||
);
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
import { Motd } from './types';
|
||||
|
||||
export async function getMotd() {
|
||||
try {
|
||||
const { data } = await axios.get<Motd>('/motd');
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
err as Error,
|
||||
'Unable to retrieve information message'
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { EnvironmentListAngular } from './EnvironmentList';
|
||||
import { HomeViewAngular } from './HomeView';
|
||||
|
||||
export default angular
|
||||
.module('portainer.app.home', [])
|
||||
.component('homeView', HomeViewAngular)
|
||||
.component('environmentList', EnvironmentListAngular).name;
|
@ -0,0 +1,7 @@
|
||||
export interface Motd {
|
||||
Title: string;
|
||||
Message: string;
|
||||
Hash: string;
|
||||
Style?: string;
|
||||
ContentLayout?: Record<string, string>;
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { createContext, PropsWithChildren, useContext } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
|
||||
|
||||
interface UIState {
|
||||
dismissedInfoPanels: Record<string, string>;
|
||||
dismissedInfoHash: string;
|
||||
}
|
||||
|
||||
type UIStateService = [UIState, (state: UIState) => void];
|
||||
|
||||
const Context = createContext<null | UIStateService>(null);
|
||||
|
||||
export function useUIState() {
|
||||
const context = useContext(Context);
|
||||
|
||||
if (context == null) {
|
||||
throw new Error('Should be nested under a UIStateProvider component');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function UIStateProvider({ children }: PropsWithChildren<unknown>) {
|
||||
const service = useLocalStorage<UIState>('UI_STATE', {} as UIState);
|
||||
|
||||
return <Context.Provider value={service}>{children}</Context.Provider>;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue