mirror of https://github.com/portainer/portainer
refactor(edge/stacks): migrate envs table to react [EE-5613] (#9093)
parent
dfc1a7b1d7
commit
11571fd6ea
|
@ -66,15 +66,12 @@ angular
|
|||
|
||||
const stacksEdit = {
|
||||
name: 'edge.stacks.edit',
|
||||
url: '/:stackId',
|
||||
url: '/:stackId?tab&status',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'editEdgeStackView',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
tab: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const edgeJobs = {
|
||||
|
|
|
@ -1,91 +0,0 @@
|
|||
<div class="datatable">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle vertical-center"> <pr-icon icon="$ctrl.titleIcon"></pr-icon> {{ $ctrl.titleText }} </div>
|
||||
<div class="searchBar vertical-center">
|
||||
<pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
auto-focus
|
||||
placeholder="Search..."
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table-hover nowrap-cells table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<table-column-header
|
||||
col-title="'Name'"
|
||||
can-sort="true"
|
||||
is-sorted="$ctrl.state.orderBy === 'Name'"
|
||||
is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
|
||||
ng-click="$ctrl.changeOrderBy('Name')"
|
||||
></table-column-header>
|
||||
</th>
|
||||
<th>
|
||||
<table-column-header
|
||||
col-title="'Status'"
|
||||
can-sort="true"
|
||||
is-sorted="$ctrl.state.orderBy === 'Status'"
|
||||
is-sorted-desc="$ctrl.state.orderBy === 'Status' && $ctrl.state.reverseOrder"
|
||||
ng-click="$ctrl.changeOrderBy('Status')"
|
||||
></table-column-header>
|
||||
</th>
|
||||
<th>
|
||||
<table-column-header
|
||||
col-title="'Error'"
|
||||
can-sort="true"
|
||||
is-sorted="$ctrl.state.orderBy === 'Error'"
|
||||
is-sorted-desc="$ctrl.state.orderBy === 'Error' && $ctrl.state.reverseOrder"
|
||||
ng-click="$ctrl.changeOrderBy('Error')"
|
||||
></table-column-header>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
dir-paginate="item in $ctrl.state.filteredDataSet | itemsPerPage: $ctrl.state.paginatedItemLimit"
|
||||
total-items="$ctrl.state.totalFilteredDataSet"
|
||||
ng-class="{ active: item.Checked }"
|
||||
>
|
||||
<td>{{ item.Name }}</td>
|
||||
<td>{{ $ctrl.endpointStatusLabel(item.Id) }}</td>
|
||||
<td>{{ $ctrl.endpointStatusError(item.Id) }}</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.state.loading">
|
||||
<td colspan="5" class="text-muted text-center">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.state.loading && $ctrl.state.filteredDataSet.length === 0">
|
||||
<td colspan="5" class="text-muted text-center">No environment available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer" ng-if="!$ctrl.state.loading">
|
||||
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
|
||||
<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="component-paginationSelect">
|
||||
<option value="0">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.onPageChange(newPageNumber, oldPageNumber)"></dir-pagination-controls>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
|
@ -1,120 +0,0 @@
|
|||
import angular from 'angular';
|
||||
|
||||
export class EdgeStackEndpointsDatatableController {
|
||||
/* @ngInject */
|
||||
constructor($async, $scope, $controller, DatatableService, PaginationService, Notifications) {
|
||||
this.extendGenericController($controller, $scope);
|
||||
this.DatatableService = DatatableService;
|
||||
this.PaginationService = PaginationService;
|
||||
this.Notifications = Notifications;
|
||||
this.$async = $async;
|
||||
|
||||
this.state = Object.assign(this.state, {
|
||||
orderBy: this.orderBy,
|
||||
loading: true,
|
||||
filteredDataSet: [],
|
||||
totalFilteredDataset: 0,
|
||||
pageNumber: 1,
|
||||
});
|
||||
|
||||
this.onPageChange = this.onPageChange.bind(this);
|
||||
this.paginationChanged = this.paginationChanged.bind(this);
|
||||
this.paginationChangedAsync = this.paginationChangedAsync.bind(this);
|
||||
}
|
||||
|
||||
extendGenericController($controller, $scope) {
|
||||
// extending the controller overrides the current controller functions
|
||||
const $onInit = this.$onInit.bind(this);
|
||||
const changePaginationLimit = this.changePaginationLimit.bind(this);
|
||||
const onTextFilterChange = this.onTextFilterChange.bind(this);
|
||||
angular.extend(this, $controller('GenericDatatableController', { $scope }));
|
||||
this.$onInit = $onInit;
|
||||
this.changePaginationLimit = changePaginationLimit;
|
||||
this.onTextFilterChange = onTextFilterChange;
|
||||
}
|
||||
|
||||
getEndpointStatus(endpointId) {
|
||||
return this.endpointsStatus[endpointId];
|
||||
}
|
||||
|
||||
endpointStatusLabel(endpointId) {
|
||||
const status = this.getEndpointStatus(endpointId);
|
||||
const details = (status && status.Details) || {};
|
||||
|
||||
return (details.Error && 'Error') || (details.Ok && 'Ok') || (details.ImagesPulled && 'Images pre-pulled') || (details.Acknowledged && 'Acknowledged') || 'Pending';
|
||||
}
|
||||
|
||||
endpointStatusError(endpointId) {
|
||||
const status = this.getEndpointStatus(endpointId);
|
||||
|
||||
return status && status.Error ? status.Error : '-';
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.setDefaults();
|
||||
this.prepareTableFromDataset();
|
||||
|
||||
var storedOrder = this.DatatableService.getDataTableOrder(this.tableKey);
|
||||
if (storedOrder !== null) {
|
||||
this.state.reverseOrder = storedOrder.reverse;
|
||||
this.state.orderBy = storedOrder.orderBy;
|
||||
}
|
||||
|
||||
var textFilter = this.DatatableService.getDataTableTextFilters(this.tableKey);
|
||||
if (textFilter !== null) {
|
||||
this.state.textFilter = textFilter;
|
||||
this.onTextFilterChange();
|
||||
}
|
||||
|
||||
var storedFilters = this.DatatableService.getDataTableFilters(this.tableKey);
|
||||
if (storedFilters !== null) {
|
||||
this.filters = storedFilters;
|
||||
}
|
||||
if (this.filters && this.filters.state) {
|
||||
this.filters.state.open = false;
|
||||
}
|
||||
|
||||
this.paginationChanged();
|
||||
}
|
||||
|
||||
onPageChange(newPageNumber) {
|
||||
this.state.pageNumber = newPageNumber;
|
||||
this.paginationChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Overridden
|
||||
*/
|
||||
changePaginationLimit() {
|
||||
this.PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
|
||||
this.paginationChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Overridden
|
||||
*/
|
||||
onTextFilterChange() {
|
||||
var filterValue = this.state.textFilter;
|
||||
this.DatatableService.setDataTableTextFilters(this.tableKey, filterValue);
|
||||
this.paginationChanged();
|
||||
}
|
||||
|
||||
paginationChanged() {
|
||||
this.$async(this.paginationChangedAsync);
|
||||
}
|
||||
|
||||
async paginationChangedAsync() {
|
||||
this.state.loading = true;
|
||||
this.state.filteredDataSet = [];
|
||||
const start = (this.state.pageNumber - 1) * this.state.paginatedItemLimit + 1;
|
||||
try {
|
||||
const { endpoints, totalCount } = await this.retrievePage(start, this.state.paginatedItemLimit, this.state.textFilter);
|
||||
this.state.filteredDataSet = endpoints;
|
||||
this.state.totalFilteredDataSet = totalCount;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve environments');
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { EdgeStackEndpointsDatatableController } from './edgeStackEndpointsDatatableController';
|
||||
|
||||
angular.module('portainer.edge').component('edgeStackEndpointsDatatable', {
|
||||
templateUrl: './edgeStackEndpointsDatatable.html',
|
||||
controller: EdgeStackEndpointsDatatableController,
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
titleIcon: '@',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
retrievePage: '<',
|
||||
edgeStackId: '<',
|
||||
endpointsStatus: '<',
|
||||
},
|
||||
});
|
|
@ -12,9 +12,14 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
|||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
||||
|
||||
export const componentsModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
.component(
|
||||
'edgeStackEnvironmentsDatatable',
|
||||
r2a(withUIRouter(withReactQuery(EnvironmentsDatatable)), [])
|
||||
)
|
||||
.component(
|
||||
'edgeGroupsSelector',
|
||||
r2a(withUIRouter(withReactQuery(EdgeGroupsSelector)), [
|
||||
|
|
|
@ -29,17 +29,7 @@
|
|||
</uib-tab-heading>
|
||||
|
||||
<div style="margin-top: 25px">
|
||||
<edge-stack-endpoints-datatable
|
||||
title-text="Environments Status"
|
||||
dataset="$ctrl.endpoints"
|
||||
title-icon="hard-drive"
|
||||
table-key="edgeStackEndpoints"
|
||||
order-by="Name"
|
||||
retrieve-page="$ctrl.getPaginatedEndpoints"
|
||||
edge-stack-id="$ctrl.stack.Id"
|
||||
endpoints-status="$ctrl.stack.Status"
|
||||
>
|
||||
</edge-stack-endpoints-datatable>
|
||||
<edge-stack-environments-datatable></edge-stack-environments-datatable>
|
||||
</div>
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||
|
@ -28,7 +27,6 @@ export class EditEdgeStackViewController {
|
|||
|
||||
this.deployStack = this.deployStack.bind(this);
|
||||
this.deployStackAsync = this.deployStackAsync.bind(this);
|
||||
this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this);
|
||||
this.onEditorChange = this.onEditorChange.bind(this);
|
||||
this.isEditorDirty = this.isEditorDirty.bind(this);
|
||||
}
|
||||
|
@ -36,7 +34,7 @@ export class EditEdgeStackViewController {
|
|||
async $onInit() {
|
||||
return this.$async(async () => {
|
||||
const { stackId, tab } = this.$state.params;
|
||||
this.state.activeTab = tab;
|
||||
this.state.activeTab = tab ? parseInt(tab, 10) : 0;
|
||||
try {
|
||||
const [edgeGroups, model, file] = await Promise.all([this.EdgeGroupService.groups(), this.EdgeStackService.stack(stackId), this.EdgeStackService.stackFile(stackId)]);
|
||||
|
||||
|
@ -110,20 +108,4 @@ export class EditEdgeStackViewController {
|
|||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
getPaginatedEndpoints(lastId, limit, search) {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const query = {
|
||||
search,
|
||||
edgeStackId: this.stack.Id,
|
||||
};
|
||||
const { value, totalCount } = await getEnvironments({ start: lastId, limit, query });
|
||||
|
||||
return { endpoints: value, totalCount };
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve environment information');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,20 +18,22 @@ export function TableTitle({
|
|||
className,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className={clsx('toolBar flex-col', className)}>
|
||||
<div className="flex w-full items-center gap-1 p-0">
|
||||
<div className="toolBarTitle">
|
||||
{icon && (
|
||||
<div className="widget-icon">
|
||||
<Icon icon={icon} className="space-right" />
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<div className={clsx('toolBar flex-col', className)}>
|
||||
<div className="flex w-full items-center gap-1 p-0">
|
||||
<div className="toolBarTitle">
|
||||
{icon && (
|
||||
<div className="widget-icon">
|
||||
<Icon icon={icon} className="space-right" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{label}
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{description}
|
||||
</div>
|
||||
{!!description && <div className="toolBar !pt-0">{description}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { useLogsStatus } from './useLogsStatus';
|
||||
|
||||
interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
}
|
||||
|
||||
export function ActionStatus({ environmentId }: Props) {
|
||||
const {
|
||||
params: { stackId: edgeStackId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const logsStatusQuery = useLogsStatus(edgeStackId, environmentId);
|
||||
|
||||
return <>{getStatusText(logsStatusQuery.data)}</>;
|
||||
}
|
||||
|
||||
function getStatusText(status?: 'pending' | 'collected' | 'idle') {
|
||||
switch (status) {
|
||||
case 'collected':
|
||||
return 'Logs available for download';
|
||||
case 'pending':
|
||||
return 'Logs marked for collection, please wait until the logs are available';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { Search } from 'lucide-react';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { LogsActions } from './LogsActions';
|
||||
|
||||
interface Props {
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
export function EnvironmentActions({ environment }: Props) {
|
||||
const {
|
||||
params: { stackId: edgeStackId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
return (
|
||||
<div className="space-x-2">
|
||||
{environment.Snapshots.length > 0 && (
|
||||
<Link
|
||||
to="edge.browse.containers"
|
||||
params={{ environmentId: environment.Id, edgeStackId }}
|
||||
className="!text-inherit hover:!no-underline"
|
||||
>
|
||||
<Button color="none" title="Browse Snapshot">
|
||||
<Icon icon={Search} className="searchIcon" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{environment.Edge.AsyncMode && (
|
||||
<LogsActions environmentId={environment.Id} edgeStackId={edgeStackId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types';
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
|
||||
import { useEdgeStack } from '../../queries/useEdgeStack';
|
||||
|
||||
import { EdgeStackEnvironment } from './types';
|
||||
import { columns } from './columns';
|
||||
|
||||
export function EnvironmentsDatatable() {
|
||||
const {
|
||||
params: { stackId },
|
||||
} = useCurrentStateAndParams();
|
||||
const edgeStackQuery = useEdgeStack(stackId);
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useParamState<StatusType>(
|
||||
'status',
|
||||
parseStatusFilter
|
||||
);
|
||||
const tableState = useTableStateWithoutStorage('name');
|
||||
const endpointsQuery = useEnvironmentList({
|
||||
pageLimit: tableState.pageSize,
|
||||
page,
|
||||
search: tableState.search,
|
||||
sort: tableState.sortBy.id as 'Group' | 'Name',
|
||||
order: tableState.sortBy.desc ? 'desc' : 'asc',
|
||||
edgeStackId: stackId,
|
||||
edgeStackStatus: statusFilter,
|
||||
});
|
||||
|
||||
const environments: Array<EdgeStackEnvironment> = useMemo(
|
||||
() =>
|
||||
endpointsQuery.environments.map((env) => ({
|
||||
...env,
|
||||
StackStatus:
|
||||
edgeStackQuery.data?.Status[env.Id] ||
|
||||
({
|
||||
Details: {
|
||||
Pending: true,
|
||||
Acknowledged: false,
|
||||
ImagesPulled: false,
|
||||
Error: false,
|
||||
Ok: false,
|
||||
RemoteUpdateSuccess: false,
|
||||
Remove: false,
|
||||
},
|
||||
EndpointID: env.Id,
|
||||
Error: '',
|
||||
} satisfies EdgeStackStatus),
|
||||
})),
|
||||
[edgeStackQuery.data?.Status, endpointsQuery.environments]
|
||||
);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
columns={columns}
|
||||
isLoading={endpointsQuery.isLoading}
|
||||
dataset={environments}
|
||||
settingsManager={tableState}
|
||||
title="Environments Status"
|
||||
titleIcon={HardDrive}
|
||||
onPageChange={setPage}
|
||||
emptyContentLabel="No environment available."
|
||||
disableSelect
|
||||
description={
|
||||
isBE && (
|
||||
<div className="w-1/4">
|
||||
<PortainerSelect<StatusType | undefined>
|
||||
isClearable
|
||||
bindToBody
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e || undefined)}
|
||||
options={[
|
||||
{ value: 'Pending', label: 'Pending' },
|
||||
{ value: 'Acknowledged', label: 'Acknowledged' },
|
||||
{ value: 'ImagesPulled', label: 'Images pre-pulled' },
|
||||
{ value: 'Ok', label: 'Deployed' },
|
||||
{ value: 'Error', label: 'Failed' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function parseStatusFilter(status: string | undefined): StatusType | undefined {
|
||||
switch (status) {
|
||||
case 'Pending':
|
||||
return 'Pending';
|
||||
case 'Acknowledged':
|
||||
return 'Acknowledged';
|
||||
case 'ImagesPulled':
|
||||
return 'ImagesPulled';
|
||||
case 'Ok':
|
||||
return 'Ok';
|
||||
case 'Error':
|
||||
return 'Error';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { EdgeStack } from '../../types';
|
||||
|
||||
import { useCollectLogsMutation } from './useCollectLogsMutation';
|
||||
import { useDeleteLogsMutation } from './useDeleteLogsMutation';
|
||||
import { useDownloadLogsMutation } from './useDownloadLogsMutation';
|
||||
import { useLogsStatus } from './useLogsStatus';
|
||||
|
||||
interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
edgeStackId: EdgeStack['Id'];
|
||||
}
|
||||
|
||||
export function LogsActions({ environmentId, edgeStackId }: Props) {
|
||||
const logsStatusQuery = useLogsStatus(edgeStackId, environmentId);
|
||||
const collectLogsMutation = useCollectLogsMutation();
|
||||
const downloadLogsMutation = useDownloadLogsMutation();
|
||||
const deleteLogsMutation = useDeleteLogsMutation();
|
||||
|
||||
if (!logsStatusQuery.isSuccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = logsStatusQuery.data;
|
||||
|
||||
const collecting = collectLogsMutation.isLoading || status === 'pending';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button color="none" title="Retrieve logs" onClick={handleCollectLogs}>
|
||||
<Icon
|
||||
icon={clsx({
|
||||
'file-text': !collecting,
|
||||
loader: collecting,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
color="none"
|
||||
title="Download logs"
|
||||
disabled={status !== 'collected'}
|
||||
onClick={handleDownloadLogs}
|
||||
>
|
||||
<Icon
|
||||
icon={clsx({
|
||||
'download-cloud': !downloadLogsMutation.isLoading,
|
||||
loader: downloadLogsMutation.isLoading,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
color="none"
|
||||
title="Delete logs"
|
||||
disabled={status !== 'collected'}
|
||||
onClick={handleDeleteLogs}
|
||||
>
|
||||
<Icon
|
||||
icon={clsx({
|
||||
delete: !deleteLogsMutation.isLoading,
|
||||
loader: deleteLogsMutation.isLoading,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
function handleCollectLogs() {
|
||||
if (status === 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
collectLogsMutation.mutate(
|
||||
{
|
||||
edgeStackId,
|
||||
environmentId,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Logs Collection started');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleDownloadLogs() {
|
||||
downloadLogsMutation.mutate({
|
||||
edgeStackId,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteLogs() {
|
||||
deleteLogsMutation.mutate(
|
||||
{
|
||||
edgeStackId,
|
||||
environmentId,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Logs Deleted');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
import { CellContext, createColumnHelper } from '@tanstack/react-table';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { EdgeStackStatus } from '../../types';
|
||||
|
||||
import { EnvironmentActions } from './EnvironmentActions';
|
||||
import { ActionStatus } from './ActionStatus';
|
||||
import { EdgeStackEnvironment } from './types';
|
||||
|
||||
const columnHelper = createColumnHelper<EdgeStackEnvironment>();
|
||||
|
||||
export const columns = [
|
||||
columnHelper.accessor('Name', {
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
}),
|
||||
columnHelper.accessor((env) => endpointStatusLabel(env.StackStatus), {
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
}),
|
||||
columnHelper.accessor((env) => env.StackStatus.Error, {
|
||||
id: 'error',
|
||||
header: 'Error',
|
||||
cell: ErrorCell,
|
||||
}),
|
||||
...(isBE
|
||||
? [
|
||||
columnHelper.display({
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell({ row: { original: env } }) {
|
||||
return <EnvironmentActions environment={env} />;
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'actionStatus',
|
||||
header: 'Action Status',
|
||||
cell({ row: { original: env } }) {
|
||||
return <ActionStatus environmentId={env.Id} />;
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const value = getValue();
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="flex cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="pt-0.5 pr-1">
|
||||
<Icon icon={isExpanded ? ChevronDown : ChevronRight} />
|
||||
</div>
|
||||
<div
|
||||
className={clsx('overflow-hidden whitespace-normal', {
|
||||
'h-[1.5em]': isExpanded,
|
||||
})}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function endpointStatusLabel(status: EdgeStackStatus) {
|
||||
const details = (status && status.Details) || {};
|
||||
|
||||
const labels = [];
|
||||
|
||||
if (details.Acknowledged) {
|
||||
labels.push('Acknowledged');
|
||||
}
|
||||
|
||||
if (details.ImagesPulled) {
|
||||
labels.push('Images pre-pulled');
|
||||
}
|
||||
|
||||
if (details.Ok) {
|
||||
labels.push('Deployed');
|
||||
}
|
||||
|
||||
if (details.Error) {
|
||||
labels.push('Failed');
|
||||
}
|
||||
|
||||
if (!labels.length) {
|
||||
labels.push('Pending');
|
||||
}
|
||||
|
||||
return labels.join(', ');
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { EnvironmentsDatatable } from './EnvironmentsDatatable';
|
|
@ -0,0 +1,7 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EdgeStackStatus } from '../../types';
|
||||
|
||||
export type EdgeStackEnvironment = Environment & {
|
||||
StackStatus: EdgeStackStatus;
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { EdgeStack } from '../../types';
|
||||
|
||||
import { logsStatusQueryKey } from './useLogsStatus';
|
||||
|
||||
export function useCollectLogsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(collectLogs, {
|
||||
onSuccess(data, variables) {
|
||||
return queryClient.invalidateQueries(
|
||||
logsStatusQueryKey(variables.edgeStackId, variables.environmentId)
|
||||
);
|
||||
},
|
||||
...withError('Unable to retrieve logs'),
|
||||
});
|
||||
}
|
||||
|
||||
interface CollectLogs {
|
||||
edgeStackId: EdgeStack['Id'];
|
||||
environmentId: EnvironmentId;
|
||||
}
|
||||
|
||||
async function collectLogs({ edgeStackId, environmentId }: CollectLogs) {
|
||||
try {
|
||||
await axios.put(`/edge_stacks/${edgeStackId}/logs/${environmentId}`);
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error, 'Unable to start logs collection');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { EdgeStack } from '../../types';
|
||||
|
||||
import { logsStatusQueryKey } from './useLogsStatus';
|
||||
|
||||
export function useDeleteLogsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(deleteLogs, {
|
||||
onSuccess(data, variables) {
|
||||
return queryClient.invalidateQueries(
|
||||
logsStatusQueryKey(variables.edgeStackId, variables.environmentId)
|
||||
);
|
||||
},
|
||||
...withError('Unable to delete logs'),
|
||||
});
|
||||
}
|
||||
|
||||
interface DeleteLogs {
|
||||
edgeStackId: EdgeStack['Id'];
|
||||
environmentId: EnvironmentId;
|
||||
}
|
||||
|
||||
async function deleteLogs({ edgeStackId, environmentId }: DeleteLogs) {
|
||||
try {
|
||||
await axios.delete(`/edge_stacks/${edgeStackId}/logs/${environmentId}`, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Accept: 'text/yaml',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, '');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { saveAs } from 'file-saver';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { mutationOptions, withError } from '@/react-tools/react-query';
|
||||
|
||||
import { EdgeStack } from '../../types';
|
||||
|
||||
export function useDownloadLogsMutation() {
|
||||
return useMutation(
|
||||
downloadLogs,
|
||||
mutationOptions(withError('Unable to download logs'))
|
||||
);
|
||||
}
|
||||
|
||||
interface DownloadLogs {
|
||||
edgeStackId: EdgeStack['Id'];
|
||||
environmentId: EnvironmentId;
|
||||
}
|
||||
|
||||
async function downloadLogs({ edgeStackId, environmentId }: DownloadLogs) {
|
||||
try {
|
||||
const { headers, data } = await axios.get<Blob>(
|
||||
`/edge_stacks/${edgeStackId}/logs/${environmentId}/file`,
|
||||
{
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Accept: 'text/yaml',
|
||||
},
|
||||
}
|
||||
);
|
||||
const contentDispositionHeader = headers['content-disposition'];
|
||||
const filename = contentDispositionHeader
|
||||
.replace('attachment; filename=', '')
|
||||
.trim();
|
||||
saveAs(data, filename);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, '');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||
|
||||
import { queryKeys } from '../../queries/query-keys';
|
||||
|
||||
export function logsStatusQueryKey(
|
||||
edgeStackId: EdgeStack['Id'],
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
return [...queryKeys.item(edgeStackId), 'logs', environmentId] as const;
|
||||
}
|
||||
|
||||
export function useLogsStatus(
|
||||
edgeStackId: EdgeStack['Id'],
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
return useQuery(
|
||||
logsStatusQueryKey(edgeStackId, environmentId),
|
||||
() => getLogsStatus(edgeStackId, environmentId),
|
||||
{
|
||||
refetchInterval(status) {
|
||||
if (status === 'pending') {
|
||||
return 30 * 1000;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface LogsStatusResponse {
|
||||
status: 'collected' | 'idle' | 'pending';
|
||||
}
|
||||
|
||||
async function getLogsStatus(
|
||||
edgeStackId: EdgeStack['Id'],
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<LogsStatusResponse>(
|
||||
`/edge_stacks/${edgeStackId}/logs/${environmentId}`
|
||||
);
|
||||
return data.status;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error, 'Unable to retrieve logs status');
|
||||
}
|
||||
}
|
|
@ -1,10 +1,6 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EdgeStack } from '../types';
|
||||
|
||||
export const queryKeys = {
|
||||
base: () => ['edge-stacks'] as const,
|
||||
item: (id: EdgeStack['Id']) => [...queryKeys.base(), id] as const,
|
||||
logsStatus: (edgeStackId: EdgeStack['Id'], environmentId: EnvironmentId) =>
|
||||
[...queryKeys.item(edgeStackId), 'logs', environmentId] as const,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { EdgeStack } from '../types';
|
||||
|
||||
import { buildUrl } from './buildUrl';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useEdgeStack(id?: EdgeStack['Id']) {
|
||||
return useQuery(id ? queryKeys.item(id) : [], () => getEdgeStack(id), {
|
||||
...withError('Failed loading Edge stack'),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEdgeStack(id?: EdgeStack['Id']) {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.get<EdgeStack>(buildUrl(id));
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
|
@ -17,6 +17,8 @@ interface EdgeStackStatusDetails {
|
|||
ImagesPulled: boolean;
|
||||
}
|
||||
|
||||
export type StatusType = keyof EdgeStackStatusDetails;
|
||||
|
||||
export interface EdgeStackStatus {
|
||||
Details: EdgeStackStatusDetails;
|
||||
Error: string;
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
|
||||
export function useParamState<T>(
|
||||
param: string,
|
||||
parseParam: (param: string | undefined) => T | undefined
|
||||
) {
|
||||
const {
|
||||
params: { [param]: paramValue },
|
||||
} = useCurrentStateAndParams();
|
||||
const router = useRouter();
|
||||
const state = parseParam(paramValue);
|
||||
|
||||
return [
|
||||
state,
|
||||
(value: T | undefined) => {
|
||||
router.stateService.go('.', { [param]: value });
|
||||
},
|
||||
] as const;
|
||||
}
|
|
@ -3,7 +3,10 @@ import { type EnvironmentGroupId } from '@/react/portainer/environments/environm
|
|||
import { type TagId } from '@/portainer/tags/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { EdgeStack, EdgeStackStatus } from '@/react/edge/edge-stacks/types';
|
||||
import {
|
||||
EdgeStack,
|
||||
StatusType as EdgeStackStatusType,
|
||||
} from '@/react/edge/edge-stacks/types';
|
||||
|
||||
import type {
|
||||
Environment,
|
||||
|
@ -21,7 +24,7 @@ export type EdgeStackEnvironmentsQueryParams =
|
|||
}
|
||||
| {
|
||||
edgeStackId: EdgeStack['Id'];
|
||||
edgeStackStatus?: keyof EdgeStackStatus['Details'];
|
||||
edgeStackStatus?: EdgeStackStatusType;
|
||||
};
|
||||
|
||||
export interface BaseEnvironmentsQueryParams {
|
||||
|
|
Loading…
Reference in New Issue