refactor(edge/stacks): migrate envs table to react [EE-5613] (#9093)

pull/9120/head
Chaim Lev-Ari 2023-06-25 12:38:43 +07:00 committed by GitHub
parent dfc1a7b1d7
commit 11571fd6ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 652 additions and 281 deletions

View File

@ -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 = {

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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: '<',
},
});

View File

@ -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)), [

View File

@ -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>

View File

@ -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');
}
});
}
}

View File

@ -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>}
</>
);
}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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;
}
}

View File

@ -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');
},
}
);
}
}

View File

@ -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(', ');
}

View File

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

View File

@ -0,0 +1,7 @@
import { Environment } from '@/react/portainer/environments/types';
import { EdgeStackStatus } from '../../types';
export type EdgeStackEnvironment = Environment & {
StackStatus: EdgeStackStatus;
};

View File

@ -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');
}
}

View File

@ -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, '');
}
}

View File

@ -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, '');
}
}

View File

@ -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');
}
}

View File

@ -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,
};

View File

@ -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);
}
}

View File

@ -17,6 +17,8 @@ interface EdgeStackStatusDetails {
ImagesPulled: boolean;
}
export type StatusType = keyof EdgeStackStatusDetails;
export interface EdgeStackStatus {
Details: EdgeStackStatusDetails;
Error: string;

View File

@ -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;
}

View File

@ -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 {