diff --git a/app/edge/__module.js b/app/edge/__module.js index 5406fbf2a..a6e659d65 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -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 = { diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html deleted file mode 100644 index 831910a48..000000000 --- a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html +++ /dev/null @@ -1,91 +0,0 @@ -
- - -
-
{{ $ctrl.titleText }}
- -
-
- - - - - - - - - - - - - - - - - - - - - -
- - - - - -
{{ item.Name }}{{ $ctrl.endpointStatusLabel(item.Id) }}{{ $ctrl.endpointStatusError(item.Id) }}
Loading...
No environment available.
-
- -
-
-
diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js deleted file mode 100644 index f1419bf2f..000000000 --- a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js +++ /dev/null @@ -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; - } - } -} diff --git a/app/edge/components/edge-stack-endpoints-datatable/index.js b/app/edge/components/edge-stack-endpoints-datatable/index.js deleted file mode 100644 index 831119f7e..000000000 --- a/app/edge/components/edge-stack-endpoints-datatable/index.js +++ /dev/null @@ -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: '<', - }, -}); diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index 457d5d178..a40b04ea6 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -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)), [ diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html index 341bc2640..8bfaf2d6d 100644 --- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html +++ b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html @@ -29,17 +29,7 @@
- - +
diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js index 9b6418082..c1048759d 100644 --- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js +++ b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js @@ -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'); - } - }); - } } diff --git a/app/react/components/datatables/TableTitle.tsx b/app/react/components/datatables/TableTitle.tsx index c60ec13a5..57ed00fcc 100644 --- a/app/react/components/datatables/TableTitle.tsx +++ b/app/react/components/datatables/TableTitle.tsx @@ -18,20 +18,22 @@ export function TableTitle({ className, }: PropsWithChildren) { return ( -
-
-
- {icon && ( -
- -
- )} + <> +
+
+
+ {icon && ( +
+ +
+ )} - {label} + {label} +
+ {children}
- {children}
- {description} -
+ {!!description &&
{description}
} + ); } diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/ActionStatus.tsx b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/ActionStatus.tsx new file mode 100644 index 000000000..6232a1765 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/ActionStatus.tsx @@ -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; + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentActions.tsx b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentActions.tsx new file mode 100644 index 000000000..d34452ad1 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentActions.tsx @@ -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 ( +
+ {environment.Snapshots.length > 0 && ( + + + + )} + {environment.Edge.AsyncMode && ( + + )} +
+ ); +} diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentsDatatable.tsx b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentsDatatable.tsx new file mode 100644 index 000000000..475efa1a6 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/EnvironmentsDatatable.tsx @@ -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( + '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 = 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 ( + + + 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' }, + ]} + /> +
+ ) + } + /> + ); +} + +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; + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/LogsActions.tsx b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/LogsActions.tsx new file mode 100644 index 000000000..0886155ce --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/LogsActions.tsx @@ -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 ( + <> + + + + + ); + + 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'); + }, + } + ); + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/columns.tsx b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/columns.tsx new file mode 100644 index 000000000..ffb168adc --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/columns.tsx @@ -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(); + +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 ; + }, + }), + columnHelper.display({ + id: 'actionStatus', + header: 'Action Status', + cell({ row: { original: env } }) { + return ; + }, + }), + ] + : []), +]; + +function ErrorCell({ getValue }: CellContext) { + const [isExpanded, setIsExpanded] = useState(false); + + const value = getValue(); + if (!value) { + return '-'; + } + + return ( + + ); +} + +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(', '); +} diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/index.ts b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/index.ts new file mode 100644 index 000000000..3f139b7ce --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/index.ts @@ -0,0 +1 @@ +export { EnvironmentsDatatable } from './EnvironmentsDatatable'; diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/types.ts b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/types.ts new file mode 100644 index 000000000..65597781a --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/types.ts @@ -0,0 +1,7 @@ +import { Environment } from '@/react/portainer/environments/types'; + +import { EdgeStackStatus } from '../../types'; + +export type EdgeStackEnvironment = Environment & { + StackStatus: EdgeStackStatus; +}; diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useCollectLogsMutation.ts b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useCollectLogsMutation.ts new file mode 100644 index 000000000..0ec28c461 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useCollectLogsMutation.ts @@ -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'); + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useDeleteLogsMutation.ts b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useDeleteLogsMutation.ts new file mode 100644 index 000000000..af5191d86 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useDeleteLogsMutation.ts @@ -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, ''); + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useDownloadLogsMutation.ts b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useDownloadLogsMutation.ts new file mode 100644 index 000000000..56304eef9 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useDownloadLogsMutation.ts @@ -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( + `/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, ''); + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useLogsStatus.ts b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useLogsStatus.ts new file mode 100644 index 000000000..68642e9df --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EnvironmentsDatatable/useLogsStatus.ts @@ -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( + `/edge_stacks/${edgeStackId}/logs/${environmentId}` + ); + return data.status; + } catch (error) { + throw parseAxiosError(error as Error, 'Unable to retrieve logs status'); + } +} diff --git a/app/react/edge/edge-stacks/queries/query-keys.ts b/app/react/edge/edge-stacks/queries/query-keys.ts index fc4ae88a0..8af962a37 100644 --- a/app/react/edge/edge-stacks/queries/query-keys.ts +++ b/app/react/edge/edge-stacks/queries/query-keys.ts @@ -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, }; diff --git a/app/react/edge/edge-stacks/queries/useEdgeStack.ts b/app/react/edge/edge-stacks/queries/useEdgeStack.ts new file mode 100644 index 000000000..6aabfb26e --- /dev/null +++ b/app/react/edge/edge-stacks/queries/useEdgeStack.ts @@ -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(buildUrl(id)); + return data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/edge/edge-stacks/types.ts b/app/react/edge/edge-stacks/types.ts index bfdaf27fc..5d1ac61ae 100644 --- a/app/react/edge/edge-stacks/types.ts +++ b/app/react/edge/edge-stacks/types.ts @@ -17,6 +17,8 @@ interface EdgeStackStatusDetails { ImagesPulled: boolean; } +export type StatusType = keyof EdgeStackStatusDetails; + export interface EdgeStackStatus { Details: EdgeStackStatusDetails; Error: string; diff --git a/app/react/hooks/useParamState.ts b/app/react/hooks/useParamState.ts new file mode 100644 index 000000000..96813d0cf --- /dev/null +++ b/app/react/hooks/useParamState.ts @@ -0,0 +1,19 @@ +import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; + +export function useParamState( + 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; +} diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts index bc20b22c2..9e41aece3 100644 --- a/app/react/portainer/environments/environment.service/index.ts +++ b/app/react/portainer/environments/environment.service/index.ts @@ -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 {