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