mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 21:39:40 +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
15
.github/workflows/lint.yml
vendored
15
.github/workflows/lint.yml
vendored
|
@ -18,17 +18,12 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- uses: actions/checkout@v2
|
||||||
uses: actions/checkout@v2
|
- uses: actions/setup-node@v2
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
with:
|
||||||
node-version: 12
|
node-version: '14'
|
||||||
|
cache: 'yarn'
|
||||||
# ESLint and Prettier must be in `package.json`
|
- run: yarn --frozen-lockfile
|
||||||
- name: Install Node.js dependencies
|
|
||||||
run: yarn --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Run linters
|
- name: Run linters
|
||||||
uses: wearerequired/lint-action@v1
|
uses: wearerequired/lint-action@v1
|
||||||
|
|
10
.github/workflows/test-client.yaml
vendored
10
.github/workflows/test-client.yaml
vendored
|
@ -1,11 +1,15 @@
|
||||||
name: Test Frontend
|
name: Test Frontend
|
||||||
on: push
|
on: push
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Install modules
|
- uses: actions/setup-node@v2
|
||||||
run: yarn --frozen-lockfile
|
with:
|
||||||
|
node-version: '14'
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: yarn test:client
|
run: yarn test:client
|
||||||
|
|
|
@ -80,6 +80,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
|
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
|
||||||
|
totalAvailableEndpoints := len(filteredEndpoints)
|
||||||
|
|
||||||
if endpointIDs != nil {
|
if endpointIDs != nil {
|
||||||
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
|
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
|
||||||
|
@ -127,6 +128,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount))
|
w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount))
|
||||||
|
w.Header().Set("X-Total-Available", strconv.Itoa(totalAvailableEndpoints))
|
||||||
return response.JSON(w, paginatedEndpoints)
|
return response.JSON(w, paginatedEndpoints)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
1
app/__mocks__/axios-progress-bar.ts
Normal file
1
app/__mocks__/axios-progress-bar.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export function loadProgressBar() {}
|
|
@ -38,7 +38,7 @@ export function onStartupAngular($rootScope, $state, $interval, LocalStorage, En
|
||||||
|
|
||||||
function ping(EndpointProvider, SystemService) {
|
function ping(EndpointProvider, SystemService) {
|
||||||
const endpoint = EndpointProvider.currentEndpoint();
|
const endpoint = EndpointProvider.currentEndpoint();
|
||||||
if (endpoint !== undefined && endpoint.Type == PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) {
|
if (endpoint && endpoint.Type == PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) {
|
||||||
SystemService.ping(endpoint.Id);
|
SystemService.ping(endpoint.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -375,10 +375,6 @@ a[ng-click] {
|
||||||
background-color: var(--white-color) fff;
|
background-color: var(--white-color) fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-controls {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-box {
|
.user-box {
|
||||||
margin-right: 25px;
|
margin-right: 25px;
|
||||||
}
|
}
|
||||||
|
@ -832,6 +828,18 @@ json-tree .branch-preview {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.space-x-2 > * + * {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-x-3 > * + * {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-x-4 > * + * {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.space-y-8 > * + * {
|
.space-y-8 > * + * {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -222,33 +222,6 @@ json-tree .branch-preview {
|
||||||
background-color: var(--bg-progress-color);
|
background-color: var(--bg-progress-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination > .disabled > span,
|
|
||||||
.pagination > .disabled > span:hover,
|
|
||||||
.pagination > .disabled > span:focus,
|
|
||||||
.pagination > .disabled > a,
|
|
||||||
.pagination > .disabled > a:hover,
|
|
||||||
.pagination > .disabled > a:focus {
|
|
||||||
color: var(--text-pagination-color);
|
|
||||||
background-color: var(--bg-pagination-color);
|
|
||||||
border-color: var(--border-pagination-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination > li > a,
|
|
||||||
.pagination > li > span {
|
|
||||||
background-color: var(--bg-pagination-span-color);
|
|
||||||
border-color: var(--border-pagination-span-color);
|
|
||||||
color: var(--text-pagination-span-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination > li > a:hover,
|
|
||||||
.pagination > li > span:hover,
|
|
||||||
.pagination > li > a:focus,
|
|
||||||
.pagination > li > span:focus {
|
|
||||||
background-color: var(--bg-pagination-hover-color);
|
|
||||||
border-color: var(--border-pagination-hover-color);
|
|
||||||
color: var(--text-pagination-span-hover-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-select-bootstrap .ui-select-choices-row > span {
|
.ui-select-bootstrap .ui-select-choices-row > span {
|
||||||
color: var(--text-ui-select-color);
|
color: var(--text-ui-select-color);
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,8 +30,8 @@ import { ColumnVisibilityMenu } from '@/portainer/components/datatables/componen
|
||||||
import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
|
import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
|
||||||
import { useDebounce } from '@/portainer/hooks/useDebounce';
|
import { useDebounce } from '@/portainer/hooks/useDebounce';
|
||||||
import {
|
import {
|
||||||
useSearchBarContext,
|
|
||||||
SearchBar,
|
SearchBar,
|
||||||
|
useSearchBarState,
|
||||||
} from '@/portainer/components/datatables/components/SearchBar';
|
} from '@/portainer/components/datatables/components/SearchBar';
|
||||||
import type {
|
import type {
|
||||||
ContainersTableSettings,
|
ContainersTableSettings,
|
||||||
|
@ -63,7 +63,7 @@ export function ContainersDatatable({
|
||||||
}: ContainerTableProps) {
|
}: ContainerTableProps) {
|
||||||
const { settings, setTableSettings } =
|
const { settings, setTableSettings } =
|
||||||
useTableSettings<ContainersTableSettings>();
|
useTableSettings<ContainersTableSettings>();
|
||||||
const [searchBarValue, setSearchBarValue] = useSearchBarContext();
|
const [searchBarValue, setSearchBarValue] = useSearchBarState('containers');
|
||||||
|
|
||||||
const columns = useColumns();
|
const columns = useColumns();
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { react2angular } from '@/react-tools/react2angular';
|
import { react2angular } from '@/react-tools/react2angular';
|
||||||
import { EnvironmentProvider } from '@/portainer/environments/useEnvironment';
|
import { EnvironmentProvider } from '@/portainer/environments/useEnvironment';
|
||||||
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
|
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
|
||||||
import { SearchBarProvider } from '@/portainer/components/datatables/components/SearchBar';
|
|
||||||
import type { Environment } from '@/portainer/environments/types';
|
import type { Environment } from '@/portainer/environments/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -30,10 +29,8 @@ export function ContainersDatatableContainer({
|
||||||
return (
|
return (
|
||||||
<EnvironmentProvider environment={endpoint}>
|
<EnvironmentProvider environment={endpoint}>
|
||||||
<TableSettingsProvider defaults={defaultSettings} storageKey={tableKey}>
|
<TableSettingsProvider defaults={defaultSettings} storageKey={tableKey}>
|
||||||
<SearchBarProvider storageKey={tableKey}>
|
|
||||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
<ContainersDatatable {...props} />
|
<ContainersDatatable {...props} />
|
||||||
</SearchBarProvider>
|
|
||||||
</TableSettingsProvider>
|
</TableSettingsProvider>
|
||||||
</EnvironmentProvider>
|
</EnvironmentProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
usePagination,
|
usePagination,
|
||||||
} from 'react-table';
|
} from 'react-table';
|
||||||
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
import { Environment } from '@/portainer/environments/types';
|
import { Environment } from '@/portainer/environments/types';
|
||||||
import { PaginationControls } from '@/portainer/components/pagination-controls';
|
import { PaginationControls } from '@/portainer/components/pagination-controls';
|
||||||
|
@ -27,7 +28,7 @@ import { ColumnVisibilityMenu } from '@/portainer/components/datatables/componen
|
||||||
import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
|
import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
|
||||||
import { useDebounce } from '@/portainer/hooks/useDebounce';
|
import { useDebounce } from '@/portainer/hooks/useDebounce';
|
||||||
import {
|
import {
|
||||||
useSearchBarContext,
|
useSearchBarState,
|
||||||
SearchBar,
|
SearchBar,
|
||||||
} from '@/portainer/components/datatables/components/SearchBar';
|
} from '@/portainer/components/datatables/components/SearchBar';
|
||||||
import { useRowSelect } from '@/portainer/components/datatables/components/useRowSelect';
|
import { useRowSelect } from '@/portainer/components/datatables/components/useRowSelect';
|
||||||
|
@ -38,34 +39,39 @@ import { EdgeDevicesDatatableSettings } from '@/edge/devices/components/EdgeDevi
|
||||||
import { EdgeDevicesDatatableActions } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions';
|
import { EdgeDevicesDatatableActions } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions';
|
||||||
import { AMTDevicesDatatable } from '@/edge/devices/components/AMTDevicesDatatable/AMTDevicesDatatable';
|
import { AMTDevicesDatatable } from '@/edge/devices/components/AMTDevicesDatatable/AMTDevicesDatatable';
|
||||||
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
||||||
|
import { EnvironmentGroup } from '@/portainer/environment-groups/types';
|
||||||
|
|
||||||
import { RowProvider } from './columns/RowContext';
|
import { RowProvider } from './columns/RowContext';
|
||||||
import { useColumns } from './columns';
|
import { useColumns } from './columns';
|
||||||
import styles from './EdgeDevicesDatatable.module.css';
|
import styles from './EdgeDevicesDatatable.module.css';
|
||||||
|
|
||||||
export interface EdgeDevicesTableProps {
|
export interface EdgeDevicesTableProps {
|
||||||
|
storageKey: string;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
isFdoEnabled: boolean;
|
isFdoEnabled: boolean;
|
||||||
isOpenAmtEnabled: boolean;
|
isOpenAmtEnabled: boolean;
|
||||||
disableTrustOnFirstConnect: boolean;
|
disableTrustOnFirstConnect: boolean;
|
||||||
mpsServer: string;
|
mpsServer: string;
|
||||||
dataset: Environment[];
|
dataset: Environment[];
|
||||||
|
groups: EnvironmentGroup[];
|
||||||
onRefresh(): Promise<void>;
|
onRefresh(): Promise<void>;
|
||||||
setLoadingMessage(message: string): void;
|
setLoadingMessage(message: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EdgeDevicesDatatable({
|
export function EdgeDevicesDatatable({
|
||||||
|
storageKey,
|
||||||
isFdoEnabled,
|
isFdoEnabled,
|
||||||
isOpenAmtEnabled,
|
isOpenAmtEnabled,
|
||||||
disableTrustOnFirstConnect,
|
disableTrustOnFirstConnect,
|
||||||
mpsServer,
|
mpsServer,
|
||||||
dataset,
|
dataset,
|
||||||
|
groups,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
setLoadingMessage,
|
setLoadingMessage,
|
||||||
}: EdgeDevicesTableProps) {
|
}: EdgeDevicesTableProps) {
|
||||||
const { settings, setTableSettings } =
|
const { settings, setTableSettings } =
|
||||||
useTableSettings<EdgeDeviceTableSettings>();
|
useTableSettings<EdgeDeviceTableSettings>();
|
||||||
const [searchBarValue, setSearchBarValue] = useSearchBarContext();
|
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
||||||
|
|
||||||
const columns = useColumns();
|
const columns = useColumns();
|
||||||
|
|
||||||
|
@ -131,6 +137,8 @@ export function EdgeDevicesDatatable({
|
||||||
environment.AMTDeviceGUID && environment.AMTDeviceGUID !== ''
|
environment.AMTDeviceGUID && environment.AMTDeviceGUID !== ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const groupsById = _.groupBy(groups, 'Id');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<TableTitle icon="fa-plug" label="Edge Devices">
|
<TableTitle icon="fa-plug" label="Edge Devices">
|
||||||
|
@ -201,12 +209,13 @@ export function EdgeDevicesDatatable({
|
||||||
{page.map((row) => {
|
{page.map((row) => {
|
||||||
prepareRow(row);
|
prepareRow(row);
|
||||||
const { key, className, role, style } = row.getRowProps();
|
const { key, className, role, style } = row.getRowProps();
|
||||||
|
const group = groupsById[row.original.GroupId];
|
||||||
return (
|
return (
|
||||||
<RowProvider
|
<RowProvider
|
||||||
key={key}
|
key={key}
|
||||||
disableTrustOnFirstConnect={disableTrustOnFirstConnect}
|
disableTrustOnFirstConnect={disableTrustOnFirstConnect}
|
||||||
isOpenAmtEnabled={isOpenAmtEnabled}
|
isOpenAmtEnabled={isOpenAmtEnabled}
|
||||||
|
groupName={group[0]?.Name}
|
||||||
>
|
>
|
||||||
<TableRow<Environment>
|
<TableRow<Environment>
|
||||||
cells={row.cells}
|
cells={row.cells}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { react2angular } from '@/react-tools/react2angular';
|
import { react2angular } from '@/react-tools/react2angular';
|
||||||
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
|
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
|
||||||
import { SearchBarProvider } from '@/portainer/components/datatables/components/SearchBar';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EdgeDevicesDatatable,
|
EdgeDevicesDatatable,
|
||||||
|
@ -18,12 +17,12 @@ export function EdgeDevicesDatatableContainer({
|
||||||
sortBy: { id: 'state', desc: false },
|
sortBy: { id: 'state', desc: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const storageKey = 'edgeDevices';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableSettingsProvider defaults={defaultSettings} storageKey="edgeDevices">
|
<TableSettingsProvider defaults={defaultSettings} storageKey={storageKey}>
|
||||||
<SearchBarProvider storageKey="edgeDevices">
|
|
||||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
<EdgeDevicesDatatable {...props} />
|
<EdgeDevicesDatatable {...props} storageKey={storageKey} />
|
||||||
</SearchBarProvider>
|
|
||||||
</TableSettingsProvider>
|
</TableSettingsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -31,6 +30,7 @@ export function EdgeDevicesDatatableContainer({
|
||||||
export const EdgeDevicesDatatableAngular = react2angular(
|
export const EdgeDevicesDatatableAngular = react2angular(
|
||||||
EdgeDevicesDatatableContainer,
|
EdgeDevicesDatatableContainer,
|
||||||
[
|
[
|
||||||
|
'groups',
|
||||||
'dataset',
|
'dataset',
|
||||||
'onRefresh',
|
'onRefresh',
|
||||||
'setLoadingMessage',
|
'setLoadingMessage',
|
||||||
|
|
|
@ -3,23 +3,26 @@ import { createContext, useContext, useMemo, PropsWithChildren } from 'react';
|
||||||
interface RowContextState {
|
interface RowContextState {
|
||||||
disableTrustOnFirstConnect: boolean;
|
disableTrustOnFirstConnect: boolean;
|
||||||
isOpenAmtEnabled: boolean;
|
isOpenAmtEnabled: boolean;
|
||||||
|
groupName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RowContext = createContext<RowContextState | null>(null);
|
const RowContext = createContext<RowContextState | null>(null);
|
||||||
|
|
||||||
export interface RowProviderProps {
|
export interface RowProviderProps {
|
||||||
disableTrustOnFirstConnect: boolean;
|
disableTrustOnFirstConnect: boolean;
|
||||||
|
groupName?: string;
|
||||||
isOpenAmtEnabled: boolean;
|
isOpenAmtEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RowProvider({
|
export function RowProvider({
|
||||||
disableTrustOnFirstConnect,
|
disableTrustOnFirstConnect,
|
||||||
|
groupName,
|
||||||
isOpenAmtEnabled,
|
isOpenAmtEnabled,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<RowProviderProps>) {
|
}: PropsWithChildren<RowProviderProps>) {
|
||||||
const state = useMemo(
|
const state = useMemo(
|
||||||
() => ({ disableTrustOnFirstConnect, isOpenAmtEnabled }),
|
() => ({ disableTrustOnFirstConnect, groupName, isOpenAmtEnabled }),
|
||||||
[disableTrustOnFirstConnect, isOpenAmtEnabled]
|
[disableTrustOnFirstConnect, groupName, isOpenAmtEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
|
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
|
||||||
|
|
|
@ -3,10 +3,19 @@ import { Column } from 'react-table';
|
||||||
import { Environment } from '@/portainer/environments/types';
|
import { Environment } from '@/portainer/environments/types';
|
||||||
import { DefaultFilter } from '@/portainer/components/datatables/components/Filter';
|
import { DefaultFilter } from '@/portainer/components/datatables/components/Filter';
|
||||||
|
|
||||||
|
import { useRowContext } from './RowContext';
|
||||||
|
|
||||||
export const group: Column<Environment> = {
|
export const group: Column<Environment> = {
|
||||||
Header: 'Group',
|
Header: 'Group',
|
||||||
accessor: (row) => row.GroupName || '-',
|
accessor: (row) => row.GroupId,
|
||||||
|
Cell: GroupCell,
|
||||||
id: 'groupName',
|
id: 'groupName',
|
||||||
Filter: DefaultFilter,
|
Filter: DefaultFilter,
|
||||||
canHide: true,
|
canHide: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function GroupCell() {
|
||||||
|
const { groupName } = useRowContext();
|
||||||
|
|
||||||
|
return groupName;
|
||||||
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<edge-devices-datatable
|
<edge-devices-datatable
|
||||||
dataset="($ctrl.edgeDevices)"
|
dataset="($ctrl.edgeDevices)"
|
||||||
|
groups="($ctrl.groups)"
|
||||||
is-fdo-enabled="($ctrl.isFDOEnabled)"
|
is-fdo-enabled="($ctrl.isFDOEnabled)"
|
||||||
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
|
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
|
||||||
disable-trust-on-first-connect="($ctrl.disableTrustOnFirstConnect)"
|
disable-trust-on-first-connect="($ctrl.disableTrustOnFirstConnect)"
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import EndpointHelper from 'Portainer/helpers/endpointHelper';
|
|
||||||
import { getEndpoints } from 'Portainer/environments/environment.service';
|
import { getEndpoints } from 'Portainer/environments/environment.service';
|
||||||
import { EnvironmentType } from 'Portainer/environments/types';
|
import { EnvironmentType } from 'Portainer/environments/types';
|
||||||
|
|
||||||
|
@ -13,7 +12,7 @@ export function EdgeDevicesViewController($q, $async, EndpointService, GroupServ
|
||||||
return $async(async () => {
|
return $async(async () => {
|
||||||
try {
|
try {
|
||||||
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, { types: [EnvironmentType.EdgeAgentOnDocker] }), GroupService.groups()]);
|
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, { types: [EnvironmentType.EdgeAgentOnDocker] }), GroupService.groups()]);
|
||||||
EndpointHelper.mapGroupNameToEndpoint(endpointsResponse.value, groups);
|
ctrl.groups = groups;
|
||||||
ctrl.edgeDevices = endpointsResponse.value;
|
ctrl.edgeDevices = endpointsResponse.value;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve edge devices');
|
Notifications.error('Failure', err, 'Unable to retrieve edge devices');
|
||||||
|
|
|
@ -2,6 +2,7 @@ angular.module('portainer.kubernetes').component('kubernetesResourcePoolsDatatab
|
||||||
templateUrl: './resourcePoolsDatatable.html',
|
templateUrl: './resourcePoolsDatatable.html',
|
||||||
controller: 'KubernetesResourcePoolsDatatableController',
|
controller: 'KubernetesResourcePoolsDatatableController',
|
||||||
bindings: {
|
bindings: {
|
||||||
|
endpoint: '<',
|
||||||
titleText: '@',
|
titleText: '@',
|
||||||
titleIcon: '@',
|
titleIcon: '@',
|
||||||
dataset: '<',
|
dataset: '<',
|
||||||
|
@ -10,6 +11,5 @@ angular.module('portainer.kubernetes').component('kubernetesResourcePoolsDatatab
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
removeAction: '<',
|
removeAction: '<',
|
||||||
refreshCallback: '<',
|
refreshCallback: '<',
|
||||||
endpoint: '<',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
export default class HelmAddRepositoryController {
|
export default class HelmAddRepositoryController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor($state, $async, HelmService, Notifications, EndpointProvider) {
|
constructor($state, $async, HelmService, Notifications) {
|
||||||
this.$state = $state;
|
this.$state = $state;
|
||||||
this.$async = $async;
|
this.$async = $async;
|
||||||
this.HelmService = HelmService;
|
this.HelmService = HelmService;
|
||||||
this.Notifications = Notifications;
|
this.Notifications = Notifications;
|
||||||
this.EndpointProvider = EndpointProvider;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
doesRepoExist() {
|
doesRepoExist() {
|
||||||
|
@ -19,7 +18,7 @@ export default class HelmAddRepositoryController {
|
||||||
async addRepository() {
|
async addRepository() {
|
||||||
this.state.isAddingRepo = true;
|
this.state.isAddingRepo = true;
|
||||||
try {
|
try {
|
||||||
await this.HelmService.addHelmRepository(this.EndpointProvider.currentEndpoint().Id, { url: this.state.repository });
|
await this.HelmService.addHelmRepository(this.endpoint.Id, { url: this.state.repository });
|
||||||
this.Notifications.success('Helm repository added successfully');
|
this.Notifications.success('Helm repository added successfully');
|
||||||
this.$state.reload(this.$state.current);
|
this.$state.reload(this.$state.current);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -146,7 +146,7 @@
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<helm-add-repository repos="$ctrl.state.repos"></helm-add-repository>
|
<helm-add-repository repos="$ctrl.state.repos" endpoint="$ctrl.endpoint"></helm-add-repository>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
|
|
||||||
angular.module('portainer.kubernetes').factory('KubernetesConfig', KubernetesConfigFactory);
|
|
||||||
|
|
||||||
/* @ngInject */
|
|
||||||
function KubernetesConfigFactory($http, EndpointProvider, API_ENDPOINT_KUBERNETES) {
|
|
||||||
return { get };
|
|
||||||
|
|
||||||
async function get(environmentIDs) {
|
|
||||||
return $http({
|
|
||||||
method: 'GET',
|
|
||||||
url: `${API_ENDPOINT_KUBERNETES}/config`,
|
|
||||||
params: { ids: JSON.stringify(environmentIDs.map((x) => parseInt(x))) },
|
|
||||||
responseType: 'blob',
|
|
||||||
headers: {
|
|
||||||
Accept: 'text/yaml',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
43
app/kubernetes/services/kubeconfig.service.ts
Normal file
43
app/kubernetes/services/kubeconfig.service.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/portainer/environments/types';
|
||||||
|
import { publicSettings } from '@/portainer/settings/settings.service';
|
||||||
|
|
||||||
|
const baseUrl = 'kubernetes';
|
||||||
|
|
||||||
|
export async function downloadKubeconfigFile(environmentIds: EnvironmentId[]) {
|
||||||
|
try {
|
||||||
|
const { headers, data } = await axios.get<Blob>(`${baseUrl}/config`, {
|
||||||
|
params: { ids: JSON.stringify(environmentIds) },
|
||||||
|
responseType: 'blob',
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/yaml',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const contentDispositionHeader = headers['content-disposition'];
|
||||||
|
const filename = contentDispositionHeader.replace('attachment;', '').trim();
|
||||||
|
saveAs(data, filename);
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expiryMessage() {
|
||||||
|
const settings = await publicSettings();
|
||||||
|
|
||||||
|
const prefix = 'Kubeconfig file will';
|
||||||
|
switch (settings.KubeconfigExpiry) {
|
||||||
|
case '24h':
|
||||||
|
return `${prefix} expire in 1 day.`;
|
||||||
|
case '168h':
|
||||||
|
return `${prefix} expire in 7 days.`;
|
||||||
|
case '720h':
|
||||||
|
return `${prefix} expire in 30 days.`;
|
||||||
|
case '8640h':
|
||||||
|
return `${prefix} expire in 1 year.`;
|
||||||
|
case '0':
|
||||||
|
default:
|
||||||
|
return `${prefix} not expire.`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,40 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
|
|
||||||
class KubernetesConfigService {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor(KubernetesConfig, FileSaver, SettingsService) {
|
|
||||||
this.KubernetesConfig = KubernetesConfig;
|
|
||||||
this.FileSaver = FileSaver;
|
|
||||||
this.SettingsService = SettingsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
async downloadKubeconfigFile(environmentIDs) {
|
|
||||||
const response = await this.KubernetesConfig.get(environmentIDs);
|
|
||||||
const headers = response.headers();
|
|
||||||
const contentDispositionHeader = headers['content-disposition'];
|
|
||||||
const filename = contentDispositionHeader.replace('attachment;', '').trim();
|
|
||||||
return this.FileSaver.saveAs(response.data, filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
async expiryMessage() {
|
|
||||||
const settings = await this.SettingsService.publicSettings();
|
|
||||||
const expiryDays = settings.KubeconfigExpiry;
|
|
||||||
const prefix = 'Kubeconfig file will ';
|
|
||||||
switch (expiryDays) {
|
|
||||||
case '0':
|
|
||||||
return prefix + 'not expire.';
|
|
||||||
case '24h':
|
|
||||||
return prefix + 'expire in 1 day.';
|
|
||||||
case '168h':
|
|
||||||
return prefix + 'expire in 7 days.';
|
|
||||||
case '720h':
|
|
||||||
return prefix + 'expire in 30 days.';
|
|
||||||
case '8640h':
|
|
||||||
return prefix + 'expire in 1 year.';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default KubernetesConfigService;
|
|
||||||
angular.module('portainer.kubernetes').service('KubernetesConfigService', KubernetesConfigService);
|
|
|
@ -6,9 +6,8 @@ import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
|
||||||
|
|
||||||
class KubernetesPersistentVolumeClaimService {
|
class KubernetesPersistentVolumeClaimService {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor($async, EndpointProvider, KubernetesPersistentVolumeClaims) {
|
constructor($async, KubernetesPersistentVolumeClaims) {
|
||||||
this.$async = $async;
|
this.$async = $async;
|
||||||
this.EndpointProvider = EndpointProvider;
|
|
||||||
this.KubernetesPersistentVolumeClaims = KubernetesPersistentVolumeClaims;
|
this.KubernetesPersistentVolumeClaims = KubernetesPersistentVolumeClaims;
|
||||||
|
|
||||||
this.getAsync = this.getAsync.bind(this);
|
this.getAsync = this.getAsync.bind(this);
|
||||||
|
@ -18,7 +17,7 @@ class KubernetesPersistentVolumeClaimService {
|
||||||
this.deleteAsync = this.deleteAsync.bind(this);
|
this.deleteAsync = this.deleteAsync.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAsync(namespace, name) {
|
async getAsync(namespace, storageClasses, name) {
|
||||||
try {
|
try {
|
||||||
const params = new KubernetesCommonParams();
|
const params = new KubernetesCommonParams();
|
||||||
params.id = name;
|
params.id = name;
|
||||||
|
@ -26,28 +25,28 @@ class KubernetesPersistentVolumeClaimService {
|
||||||
this.KubernetesPersistentVolumeClaims(namespace).get(params).$promise,
|
this.KubernetesPersistentVolumeClaims(namespace).get(params).$promise,
|
||||||
this.KubernetesPersistentVolumeClaims(namespace).getYaml(params).$promise,
|
this.KubernetesPersistentVolumeClaims(namespace).getYaml(params).$promise,
|
||||||
]);
|
]);
|
||||||
const storageClasses = this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.StorageClasses;
|
|
||||||
return KubernetesPersistentVolumeClaimConverter.apiToPersistentVolumeClaim(raw, storageClasses, yaml);
|
return KubernetesPersistentVolumeClaimConverter.apiToPersistentVolumeClaim(raw, storageClasses, yaml);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new PortainerError('Unable to retrieve persistent volume claim', err);
|
throw new PortainerError('Unable to retrieve persistent volume claim', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllAsync(namespace) {
|
async getAllAsync(namespace, storageClasses) {
|
||||||
try {
|
try {
|
||||||
const data = await this.KubernetesPersistentVolumeClaims(namespace).get().$promise;
|
const data = await this.KubernetesPersistentVolumeClaims(namespace).get().$promise;
|
||||||
const storageClasses = this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.StorageClasses;
|
|
||||||
return _.map(data.items, (item) => KubernetesPersistentVolumeClaimConverter.apiToPersistentVolumeClaim(item, storageClasses));
|
return _.map(data.items, (item) => KubernetesPersistentVolumeClaimConverter.apiToPersistentVolumeClaim(item, storageClasses));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new PortainerError('Unable to retrieve persistent volume claims', err);
|
throw new PortainerError('Unable to retrieve persistent volume claims', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get(namespace, name) {
|
get(namespace, storageClasses, name) {
|
||||||
if (name) {
|
if (name) {
|
||||||
return this.$async(this.getAsync, namespace, name);
|
return this.$async(this.getAsync, namespace, storageClasses, name);
|
||||||
}
|
}
|
||||||
return this.$async(this.getAllAsync, namespace);
|
return this.$async(this.getAllAsync, namespace, storageClasses);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -19,28 +19,28 @@ class KubernetesVolumeService {
|
||||||
/**
|
/**
|
||||||
* GET
|
* GET
|
||||||
*/
|
*/
|
||||||
async getAsync(namespace, name) {
|
async getAsync(namespace, storageClasses, name) {
|
||||||
const [pvc, pool] = await Promise.all([this.KubernetesPersistentVolumeClaimService.get(namespace, name), this.KubernetesResourcePoolService.get(namespace)]);
|
const [pvc, pool] = await Promise.all([this.KubernetesPersistentVolumeClaimService.get(namespace, storageClasses, name), this.KubernetesResourcePoolService.get(namespace)]);
|
||||||
return KubernetesVolumeConverter.pvcToVolume(pvc, pool);
|
return KubernetesVolumeConverter.pvcToVolume(pvc, pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllAsync(namespace) {
|
async getAllAsync(namespace, storageClasses) {
|
||||||
const data = await this.KubernetesResourcePoolService.get(namespace);
|
const data = await this.KubernetesResourcePoolService.get(namespace);
|
||||||
const pools = data instanceof Array ? data : [data];
|
const pools = data instanceof Array ? data : [data];
|
||||||
const res = await Promise.all(
|
const res = await Promise.all(
|
||||||
_.map(pools, async (pool) => {
|
_.map(pools, async (pool) => {
|
||||||
const pvcs = await this.KubernetesPersistentVolumeClaimService.get(pool.Namespace.Name);
|
const pvcs = await this.KubernetesPersistentVolumeClaimService.get(pool.Namespace.Name, storageClasses);
|
||||||
return _.map(pvcs, (pvc) => KubernetesVolumeConverter.pvcToVolume(pvc, pool));
|
return _.map(pvcs, (pvc) => KubernetesVolumeConverter.pvcToVolume(pvc, pool));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return _.flatten(res);
|
return _.flatten(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(namespace, name) {
|
get(namespace, storageClasses, name) {
|
||||||
if (name) {
|
if (name) {
|
||||||
return this.$async(this.getAsync, namespace, name);
|
return this.$async(this.getAsync, namespace, storageClasses, name);
|
||||||
}
|
}
|
||||||
return this.$async(this.getAllAsync, namespace);
|
return this.$async(this.getAllAsync, namespace, storageClasses);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -932,7 +932,8 @@ class KubernetesCreateApplicationController {
|
||||||
refreshVolumes(namespace) {
|
refreshVolumes(namespace) {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
try {
|
try {
|
||||||
const volumes = await this.KubernetesVolumeService.get(namespace);
|
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
|
||||||
|
const volumes = await this.KubernetesVolumeService.get(namespace, storageClasses);
|
||||||
_.forEach(volumes, (volume) => {
|
_.forEach(volumes, (volume) => {
|
||||||
volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, this.applications);
|
volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, this.applications);
|
||||||
});
|
});
|
||||||
|
@ -1045,9 +1046,11 @@ class KubernetesCreateApplicationController {
|
||||||
return this.$async(async () => {
|
return this.$async(async () => {
|
||||||
try {
|
try {
|
||||||
const namespace = this.$state.params.namespace;
|
const namespace = this.$state.params.namespace;
|
||||||
|
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
|
||||||
|
|
||||||
[this.application, this.persistentVolumeClaims] = await Promise.all([
|
[this.application, this.persistentVolumeClaims] = await Promise.all([
|
||||||
this.KubernetesApplicationService.get(namespace, this.$state.params.name),
|
this.KubernetesApplicationService.get(namespace, this.$state.params.name),
|
||||||
this.KubernetesPersistentVolumeClaimService.get(namespace),
|
this.KubernetesPersistentVolumeClaimService.get(namespace, storageClasses),
|
||||||
]);
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve application details');
|
this.Notifications.error('Failure', err, 'Unable to retrieve application details');
|
||||||
|
|
|
@ -348,12 +348,6 @@ class KubernetesApplicationController {
|
||||||
}
|
}
|
||||||
|
|
||||||
async onInit() {
|
async onInit() {
|
||||||
const endpointId = this.LocalStorage.getEndpointID();
|
|
||||||
const endpoints = this.LocalStorage.getEndpoints();
|
|
||||||
const endpoint = _.find(endpoints, function (item) {
|
|
||||||
return item.Id === endpointId;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
activeTab: 0,
|
activeTab: 0,
|
||||||
currentName: this.$state.$current.name,
|
currentName: this.$state.$current.name,
|
||||||
|
@ -372,7 +366,7 @@ class KubernetesApplicationController {
|
||||||
expandedNote: false,
|
expandedNote: false,
|
||||||
useIngress: false,
|
useIngress: false,
|
||||||
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
|
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
|
||||||
publicUrl: endpoint.PublicURL,
|
publicUrl: this.endpoint.PublicURL,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.state.activeTab = this.LocalStorage.getActiveTab('application');
|
this.state.activeTab = this.LocalStorage.getActiveTab('application');
|
||||||
|
|
|
@ -33,13 +33,14 @@ class KubernetesDashboardController {
|
||||||
|
|
||||||
async getAllAsync() {
|
async getAllAsync() {
|
||||||
const isAdmin = this.Authentication.isAdmin();
|
const isAdmin = this.Authentication.isAdmin();
|
||||||
|
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [pools, applications, configurations, volumes, tags] = await Promise.all([
|
const [pools, applications, configurations, volumes, tags] = await Promise.all([
|
||||||
this.KubernetesResourcePoolService.get(),
|
this.KubernetesResourcePoolService.get(),
|
||||||
this.KubernetesApplicationService.get(),
|
this.KubernetesApplicationService.get(),
|
||||||
this.KubernetesConfigurationService.get(),
|
this.KubernetesConfigurationService.get(),
|
||||||
this.KubernetesVolumeService.get(),
|
this.KubernetesVolumeService.get(undefined, storageClasses),
|
||||||
this.TagService.tags(),
|
this.TagService.tags(),
|
||||||
]);
|
]);
|
||||||
this.applications = applications;
|
this.applications = applications;
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<kubernetes-resource-pools-datatable
|
<kubernetes-resource-pools-datatable
|
||||||
|
endpoint="ctrl.endpoint"
|
||||||
dataset="ctrl.resourcePools"
|
dataset="ctrl.resourcePools"
|
||||||
table-key="kubernetes.resourcePools"
|
table-key="kubernetes.resourcePools"
|
||||||
order-by="Namespace.Name"
|
order-by="Namespace.Name"
|
||||||
|
|
|
@ -4,5 +4,6 @@ angular.module('portainer.kubernetes').component('kubernetesVolumeView', {
|
||||||
controllerAs: 'ctrl',
|
controllerAs: 'ctrl',
|
||||||
bindings: {
|
bindings: {
|
||||||
$transition$: '<',
|
$transition$: '<',
|
||||||
|
endpoint: '<',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -116,9 +116,10 @@ class KubernetesVolumeController {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVolumeAsync() {
|
async getVolumeAsync() {
|
||||||
|
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
|
||||||
try {
|
try {
|
||||||
const [volume, applications] = await Promise.all([
|
const [volume, applications] = await Promise.all([
|
||||||
this.KubernetesVolumeService.get(this.state.namespace, this.state.name),
|
this.KubernetesVolumeService.get(this.state.namespace, storageClasses, this.state.name),
|
||||||
this.KubernetesApplicationService.get(this.state.namespace),
|
this.KubernetesApplicationService.get(this.state.namespace),
|
||||||
]);
|
]);
|
||||||
volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, applications);
|
volume.Applications = KubernetesVolumeHelper.getUsingApplications(volume, applications);
|
||||||
|
|
|
@ -71,9 +71,10 @@ class KubernetesVolumesController {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVolumesAsync() {
|
async getVolumesAsync() {
|
||||||
|
const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses;
|
||||||
try {
|
try {
|
||||||
const [volumes, applications, storages] = await Promise.all([
|
const [volumes, applications, storages] = await Promise.all([
|
||||||
this.KubernetesVolumeService.get(),
|
this.KubernetesVolumeService.get(undefined, storageClasses),
|
||||||
this.KubernetesApplicationService.get(),
|
this.KubernetesApplicationService.get(),
|
||||||
this.KubernetesStorageService.get(this.endpoint.Id),
|
this.KubernetesStorageService.get(this.endpoint.Id),
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -7,6 +7,7 @@ import featureFlagModule from './feature-flags';
|
||||||
import userActivityModule from './user-activity';
|
import userActivityModule from './user-activity';
|
||||||
import servicesModule from './services';
|
import servicesModule from './services';
|
||||||
import teamsModule from './teams';
|
import teamsModule from './teams';
|
||||||
|
import homeModule from './home';
|
||||||
|
|
||||||
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||||
authManager.checkAuthOnRefresh();
|
authManager.checkAuthOnRefresh();
|
||||||
|
@ -25,6 +26,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat
|
||||||
|
|
||||||
angular
|
angular
|
||||||
.module('portainer.app', [
|
.module('portainer.app', [
|
||||||
|
homeModule,
|
||||||
'portainer.oauth',
|
'portainer.oauth',
|
||||||
'portainer.rbac',
|
'portainer.rbac',
|
||||||
componentsModule,
|
componentsModule,
|
||||||
|
@ -74,7 +76,7 @@ angular
|
||||||
parent: 'root',
|
parent: 'root',
|
||||||
abstract: true,
|
abstract: true,
|
||||||
resolve: {
|
resolve: {
|
||||||
endpoint: /* @ngInject */ function endpoint($async, $state, $transition$, EndpointService, Notifications) {
|
endpoint: /* @ngInject */ function endpoint($async, $state, $transition$, EndpointProvider, EndpointService, Notifications) {
|
||||||
return $async(async () => {
|
return $async(async () => {
|
||||||
try {
|
try {
|
||||||
const endpointId = +$transition$.params().endpointId;
|
const endpointId = +$transition$.params().endpointId;
|
||||||
|
@ -85,6 +87,8 @@ angular
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EndpointProvider.setCurrentEndpoint(endpoint);
|
||||||
|
|
||||||
return endpoint;
|
return endpoint;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notifications.error('Failed loading environment', e);
|
Notifications.error('Failed loading environment', e);
|
||||||
|
@ -322,8 +326,7 @@ angular
|
||||||
url: '/home',
|
url: '/home',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: './views/home/home.html',
|
component: 'homeView',
|
||||||
controller: 'HomeController',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { PropsWithChildren } from 'react';
|
import { MouseEventHandler, PropsWithChildren } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
type Type = 'submit' | 'button' | 'reset';
|
type Type = 'submit' | 'button' | 'reset';
|
||||||
|
@ -13,7 +13,7 @@ export interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
dataCy?: string;
|
dataCy?: string;
|
||||||
type?: Type;
|
type?: Type;
|
||||||
onClick?: () => void;
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
|
|
|
@ -3,5 +3,3 @@ import { AddButton } from './AddButton';
|
||||||
import { ButtonGroup } from './ButtonGroup';
|
import { ButtonGroup } from './ButtonGroup';
|
||||||
|
|
||||||
export { Button, AddButton, 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 {
|
.row.header {
|
||||||
height: 60px;
|
min-height: 60px;
|
||||||
background: var(--bg-row-header-color);
|
background: var(--bg-row-header-color);
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,11 +6,17 @@ import { HeaderContainer } from './HeaderContainer';
|
||||||
import { HeaderContent } from './HeaderContent';
|
import { HeaderContent } from './HeaderContent';
|
||||||
|
|
||||||
test('should not render without a wrapping HeaderContainer', async () => {
|
test('should not render without a wrapping HeaderContainer', async () => {
|
||||||
|
const consoleErrorFn = jest
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => jest.fn());
|
||||||
|
|
||||||
function renderComponent() {
|
function renderComponent() {
|
||||||
return render(<HeaderContent />);
|
return render(<HeaderContent />);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(renderComponent).toThrowErrorMatchingSnapshot();
|
expect(renderComponent).toThrowErrorMatchingSnapshot();
|
||||||
|
|
||||||
|
consoleErrorFn.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display a HeaderContent', async () => {
|
test('should display a HeaderContent', async () => {
|
||||||
|
|
|
@ -6,12 +6,18 @@ import { HeaderContainer } from './HeaderContainer';
|
||||||
import { HeaderTitle } from './HeaderTitle';
|
import { HeaderTitle } from './HeaderTitle';
|
||||||
|
|
||||||
test('should not render without a wrapping HeaderContainer', async () => {
|
test('should not render without a wrapping HeaderContainer', async () => {
|
||||||
|
const consoleErrorFn = jest
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => jest.fn());
|
||||||
|
|
||||||
const title = 'title';
|
const title = 'title';
|
||||||
function renderComponent() {
|
function renderComponent() {
|
||||||
return render(<HeaderTitle title={title} />);
|
return render(<HeaderTitle title={title} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(renderComponent).toThrowErrorMatchingSnapshot();
|
expect(renderComponent).toThrowErrorMatchingSnapshot();
|
||||||
|
|
||||||
|
consoleErrorFn.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display a HeaderTitle', async () => {
|
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 { HeaderContainer } from './HeaderContainer';
|
||||||
import { HeaderContent } from './HeaderContent';
|
import { HeaderContent } from './HeaderContent';
|
||||||
import { HeaderTitle } from './HeaderTitle';
|
import { HeaderTitle } from './HeaderTitle';
|
||||||
|
import styles from './PageHeader.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
reload?: boolean;
|
reload?: boolean;
|
||||||
|
@ -20,7 +21,12 @@ export function PageHeader({ title, breadcrumbs = [], reload }: Props) {
|
||||||
<HeaderContainer>
|
<HeaderContainer>
|
||||||
<HeaderTitle title={title}>
|
<HeaderTitle title={title}>
|
||||||
{reload && (
|
{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" />
|
<i className="fa fa-sync" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
import { useContext, createContext, PropsWithChildren } from 'react';
|
|
||||||
|
|
||||||
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
onChange(value: string): void;
|
onChange(value: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchBar({ value, onChange }: Props) {
|
export function SearchBar({
|
||||||
|
value,
|
||||||
|
placeholder = 'Search...',
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="searchBar">
|
<div className="searchBar">
|
||||||
<i className="fa fa-search searchIcon" aria-hidden="true" />
|
<i className="fa fa-search searchIcon" aria-hidden="true" />
|
||||||
|
@ -16,44 +19,21 @@ export function SearchBar({ value, onChange }: Props) {
|
||||||
className="searchInput"
|
className="searchInput"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder="Search..."
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchBarContext = createContext<
|
export function useSearchBarState(
|
||||||
[string, (value: string) => void] | null
|
key: string
|
||||||
>(null);
|
): [string, (value: string) => void] {
|
||||||
|
const filterKey = keyBuilder(key);
|
||||||
|
const [value, setValue] = useLocalStorage(filterKey, '', sessionStorage);
|
||||||
|
|
||||||
interface SearchBarProviderProps {
|
return [value, setValue];
|
||||||
defaultValue?: string;
|
|
||||||
storageKey: string;
|
function keyBuilder(key: string) {
|
||||||
|
return `datatable_text_filter_${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,17 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
import { useTableContext } from './TableContainer';
|
||||||
|
|
||||||
export function TableActions({ children }: PropsWithChildren<unknown>) {
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableActions({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: PropsWithChildren<Props>) {
|
||||||
useTableContext();
|
useTableContext();
|
||||||
|
|
||||||
return <div className="actionBar">{children}</div>;
|
return <div className={clsx('actionBar', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,8 +87,20 @@
|
||||||
margin: 0;
|
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 > a,
|
||||||
|
.datatable .pagination > li > button,
|
||||||
.pagination > li > span {
|
.pagination > li > span {
|
||||||
|
user-select: none;
|
||||||
float: 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 _ from 'lodash-es';
|
||||||
import PortainerEndpointTagHelper from 'Portainer/helpers/tagHelper';
|
import { idsToTagNames } from 'Portainer/helpers/tagHelper';
|
||||||
|
|
||||||
angular.module('portainer.app').component('groupAssociationTable', {
|
angular.module('portainer.app').component('groupAssociationTable', {
|
||||||
templateUrl: './groupAssociationTable.html',
|
templateUrl: './groupAssociationTable.html',
|
||||||
|
@ -42,7 +42,7 @@ angular.module('portainer.app').component('groupAssociationTable', {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.tagIdsToTagNames = function tagIdsToTagNames(tagIds) {
|
this.tagIdsToTagNames = function tagIdsToTagNames(tagIds) {
|
||||||
return PortainerEndpointTagHelper.idsToTagNames(this.tags, tagIds).join(', ') || '-';
|
return idsToTagNames(this.tags, tagIds).join(', ') || '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
this.groupIdToGroupName = function groupIdToGroupName(groupId) {
|
this.groupIdToGroupName = function groupIdToGroupName(groupId) {
|
||||||
|
|
|
@ -13,9 +13,11 @@ import headerModule from './PageHeader';
|
||||||
import { ReactExampleAngular } from './ReactExample';
|
import { ReactExampleAngular } from './ReactExample';
|
||||||
import { TooltipAngular } from './Tip/Tooltip';
|
import { TooltipAngular } from './Tip/Tooltip';
|
||||||
import { beFeatureIndicatorAngular } from './BEFeatureIndicator';
|
import { beFeatureIndicatorAngular } from './BEFeatureIndicator';
|
||||||
|
import { InformationPanelAngular } from './InformationPanel';
|
||||||
|
|
||||||
export default angular
|
export default angular
|
||||||
.module('portainer.app.components', [headerModule, boxSelectorModule, widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
|
.module('portainer.app.components', [headerModule, boxSelectorModule, widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
|
||||||
|
.component('informationPanel', InformationPanelAngular)
|
||||||
.component('portainerTooltip', TooltipAngular)
|
.component('portainerTooltip', TooltipAngular)
|
||||||
.component('reactExample', ReactExampleAngular)
|
.component('reactExample', ReactExampleAngular)
|
||||||
.component('beFeatureIndicator', beFeatureIndicatorAngular)
|
.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 PortainerError from '@/portainer/error';
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { type EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||||
|
import { type TagId } from '@/portainer/tags/types';
|
||||||
|
|
||||||
import {
|
import { type Environment, EnvironmentCreationTypes } from '../types';
|
||||||
Environment,
|
|
||||||
EnvironmentGroupId,
|
|
||||||
EnvironmentCreationTypes,
|
|
||||||
TagId,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
import { arrayToJson, buildUrl, json2formData } from './utils';
|
import { arrayToJson, buildUrl, json2formData } from './utils';
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
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,
|
Environment,
|
||||||
EnvironmentGroupId,
|
|
||||||
EnvironmentId,
|
EnvironmentId,
|
||||||
EnvironmentType,
|
EnvironmentType,
|
||||||
EnvironmentSettings,
|
EnvironmentSettings,
|
||||||
TagId,
|
|
||||||
TeamId,
|
|
||||||
UserId,
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import { arrayToJson, buildUrl } from './utils';
|
import { arrayToJson, buildUrl } from './utils';
|
||||||
|
@ -51,9 +51,14 @@ export async function getEndpoints(
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<Environment[]>(url, { params });
|
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) {
|
} catch (e) {
|
||||||
throw parseAxiosError(e as Error);
|
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 type EnvironmentId = number;
|
||||||
|
|
||||||
export enum EnvironmentType {
|
export enum EnvironmentType {
|
||||||
|
@ -17,13 +22,6 @@ export enum EnvironmentType {
|
||||||
EdgeAgentOnKubernetes,
|
EdgeAgentOnKubernetes,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TagId = number;
|
|
||||||
|
|
||||||
export interface Tag {
|
|
||||||
Id: TagId;
|
|
||||||
Name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum EnvironmentStatus {
|
export enum EnvironmentStatus {
|
||||||
Up = 1,
|
Up = 1,
|
||||||
Down,
|
Down,
|
||||||
|
@ -55,14 +53,14 @@ export interface KubernetesSnapshot {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KubernetesSettings {
|
export interface KubernetesSettings {
|
||||||
Snapshots: KubernetesSnapshot[];
|
Snapshots?: KubernetesSnapshot[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Environment = {
|
export type Environment = {
|
||||||
Id: EnvironmentId;
|
Id: EnvironmentId;
|
||||||
Type: EnvironmentType;
|
Type: EnvironmentType;
|
||||||
TagIds: TagId[];
|
TagIds: TagId[];
|
||||||
GroupName: string;
|
GroupId: EnvironmentGroupId;
|
||||||
EdgeID?: string;
|
EdgeID?: string;
|
||||||
EdgeCheckinInterval?: number;
|
EdgeCheckinInterval?: number;
|
||||||
LastCheckInDate?: number;
|
LastCheckInDate?: number;
|
||||||
|
@ -88,8 +86,6 @@ export enum EnvironmentCreationTypes {
|
||||||
LocalKubernetesEnvironment,
|
LocalKubernetesEnvironment,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EnvironmentGroupId = number;
|
|
||||||
|
|
||||||
export enum PlatformType {
|
export enum PlatformType {
|
||||||
Docker,
|
Docker,
|
||||||
Kubernetes,
|
Kubernetes,
|
||||||
|
@ -117,11 +113,9 @@ export interface EnvironmentSettings {
|
||||||
enableHostManagementFeatures: boolean;
|
enableHostManagementFeatures: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserId = number;
|
|
||||||
export type TeamId = number;
|
|
||||||
export type RoleId = number;
|
export type RoleId = number;
|
||||||
interface AccessPolicy {
|
interface AccessPolicy {
|
||||||
RoleId: RoleId;
|
RoleId: RoleId;
|
||||||
}
|
}
|
||||||
export type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
|
export type UserAccessPolicies = Record<UserId, AccessPolicy>;
|
||||||
export type TeamAccessPolicies = Record<TeamId, 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) {
|
export function isEdgeEnvironment(envType: EnvironmentType) {
|
||||||
return [
|
return [
|
||||||
EnvironmentType.EdgeAgentOnDocker,
|
EnvironmentType.EdgeAgentOnDocker,
|
||||||
|
|
|
@ -92,7 +92,7 @@ export function endpointTypeName(type) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function endpointTypeIcon(type) {
|
export function environmentTypeIcon(type) {
|
||||||
if (type === 3) {
|
if (type === 3) {
|
||||||
return 'fab fa-microsoft';
|
return 'fab fa-microsoft';
|
||||||
} else if (type === 4) {
|
} else if (type === 4) {
|
||||||
|
@ -131,10 +131,3 @@ export function truncate(text, length, end) {
|
||||||
return String(text).substring(0, length - end.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 {
|
import {
|
||||||
arrayToStr,
|
arrayToStr,
|
||||||
endpointStatusBadge,
|
environmentTypeIcon,
|
||||||
endpointTypeIcon,
|
|
||||||
endpointTypeName,
|
endpointTypeName,
|
||||||
getPairKey,
|
getPairKey,
|
||||||
getPairValue,
|
getPairValue,
|
||||||
|
@ -35,6 +34,5 @@ angular
|
||||||
.filter('arraytostr', () => arrayToStr)
|
.filter('arraytostr', () => arrayToStr)
|
||||||
.filter('labelsToStr', () => labelsToStr)
|
.filter('labelsToStr', () => labelsToStr)
|
||||||
.filter('endpointtypename', () => endpointTypeName)
|
.filter('endpointtypename', () => endpointTypeName)
|
||||||
.filter('endpointtypeicon', () => endpointTypeIcon)
|
.filter('endpointtypeicon', () => environmentTypeIcon)
|
||||||
.filter('ownershipicon', () => ownershipIcon)
|
.filter('ownershipicon', () => ownershipIcon);
|
||||||
.filter('endpointstatusbadge', () => endpointStatusBadge);
|
|
||||||
|
|
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';
|
import _ from 'lodash';
|
||||||
|
|
||||||
export default class PortainerEndpointTagHelper {
|
export function idsToTagNames(tags, ids) {
|
||||||
static idsToTagNames(tags, ids) {
|
|
||||||
const filteredTags = _.filter(tags, (tag) => _.includes(ids, tag.Id));
|
const filteredTags = _.filter(tags, (tag) => _.includes(ids, tag.Id));
|
||||||
const tagNames = _.map(filteredTags, 'Name');
|
const tagNames = _.map(filteredTags, 'Name');
|
||||||
return tagNames;
|
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';
|
const localStoragePrefix = 'portainer';
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ export function useLocalStorage<T>(
|
||||||
[key, storage]
|
[key, storage]
|
||||||
);
|
);
|
||||||
|
|
||||||
return [value, handleChange];
|
return useMemo(() => [value, handleChange], [value, handleChange]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get<T>(
|
export function get<T>(
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue