diff --git a/app/constants.js b/app/constants.js index 5212ba524..2d26ba851 100644 --- a/app/constants.js +++ b/app/constants.js @@ -1,3 +1,5 @@ +import { BROWSER_OS_PLATFORM } from './react/constants'; + export const API_ENDPOINT_AUTH = 'api/auth'; export const API_ENDPOINT_BACKUP = 'api/backup'; export const API_ENDPOINT_CUSTOM_TEMPLATES = 'api/custom_templates'; @@ -21,7 +23,6 @@ export const API_ENDPOINT_TEAMS = 'api/teams'; export const API_ENDPOINT_TEAM_MEMBERSHIPS = 'api/team_memberships'; export const API_ENDPOINT_TEMPLATES = 'api/templates'; export const API_ENDPOINT_WEBHOOKS = 'api/webhooks'; -export const DEFAULT_TEMPLATES_URL = 'https://raw.githubusercontent.com/portainer/templates/master/templates.json'; export const PAGINATION_MAX_ITEMS = 10; export const APPLICATION_CACHE_VALIDITY = 3600; export const CONSOLE_COMMANDS_LABEL_PREFIX = 'io.portainer.commands.'; @@ -31,8 +32,6 @@ export const KUBERNETES_SYSTEM_NAMESPACES = ['kube-system', 'kube-public', 'kube export const PORTAINER_FADEOUT = 1500; export const STACK_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$'; export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$'; -export const BROWSER_OS_PLATFORM = navigator.userAgent.indexOf('Windows') > -1 ? 'win' : navigator.userAgent.indexOf('Mac') > -1 ? 'mac' : 'lin'; -export const NEW_LINE_BREAKER = BROWSER_OS_PLATFORM === 'win' ? '\r\n' : '\n'; // don't declare new constants, either: // - if only used in one file or module, declare in that file or module (as a regular js constant) @@ -62,7 +61,6 @@ angular .constant('API_ENDPOINT_TEAM_MEMBERSHIPS', API_ENDPOINT_TEAM_MEMBERSHIPS) .constant('API_ENDPOINT_TEMPLATES', API_ENDPOINT_TEMPLATES) .constant('API_ENDPOINT_WEBHOOKS', API_ENDPOINT_WEBHOOKS) - .constant('DEFAULT_TEMPLATES_URL', DEFAULT_TEMPLATES_URL) .constant('PAGINATION_MAX_ITEMS', PAGINATION_MAX_ITEMS) .constant('APPLICATION_CACHE_VALIDITY', APPLICATION_CACHE_VALIDITY) .constant('CONSOLE_COMMANDS_LABEL_PREFIX', CONSOLE_COMMANDS_LABEL_PREFIX) diff --git a/app/docker/components/log-viewer/logViewerController.js b/app/docker/components/log-viewer/logViewerController.js index 07b47267e..632187e47 100644 --- a/app/docker/components/log-viewer/logViewerController.js +++ b/app/docker/components/log-viewer/logViewerController.js @@ -1,7 +1,6 @@ import moment from 'moment'; -import { NEW_LINE_BREAKER } from '@/constants'; -import { concatLogsToString } from '@/docker/helpers/logHelper'; +import { concatLogsToString, NEW_LINE_BREAKER } from '@/docker/helpers/logHelper'; angular.module('portainer.docker').controller('LogViewerController', [ '$scope', diff --git a/app/docker/helpers/logHelper/concatLogsToString.ts b/app/docker/helpers/logHelper/concatLogsToString.ts index b67d4312e..164089536 100644 --- a/app/docker/helpers/logHelper/concatLogsToString.ts +++ b/app/docker/helpers/logHelper/concatLogsToString.ts @@ -1,5 +1,4 @@ -import { NEW_LINE_BREAKER } from '@/constants'; - +import { NEW_LINE_BREAKER } from './constants'; import { FormattedLine } from './types'; type FormatFunc = (line: FormattedLine) => string; diff --git a/app/docker/helpers/logHelper/constants.ts b/app/docker/helpers/logHelper/constants.ts new file mode 100644 index 000000000..c0e22d21a --- /dev/null +++ b/app/docker/helpers/logHelper/constants.ts @@ -0,0 +1,3 @@ +import { BROWSER_OS_PLATFORM } from '@/react/constants'; + +export const NEW_LINE_BREAKER = BROWSER_OS_PLATFORM === 'win' ? '\r\n' : '\n'; diff --git a/app/docker/helpers/logHelper/index.ts b/app/docker/helpers/logHelper/index.ts index 91fe97ed7..9b9bfe195 100644 --- a/app/docker/helpers/logHelper/index.ts +++ b/app/docker/helpers/logHelper/index.ts @@ -1,2 +1,3 @@ export { formatLogs } from './formatLogs'; export { concatLogsToString } from './concatLogsToString'; +export { NEW_LINE_BREAKER } from './constants'; diff --git a/app/nomad/index.ts b/app/nomad/index.ts index 7f5d76abd..09037dc3f 100644 --- a/app/nomad/index.ts +++ b/app/nomad/index.ts @@ -1,8 +1,121 @@ import angular from 'angular'; +import { StateRegistry, StateService } from '@uirouter/angularjs'; + +import { isNomadEnvironment } from '@/react/portainer/environments/utils'; +import { DashboardView } from '@/react/nomad/DashboardView'; +import { r2a } from '@/react-tools/react2angular'; +import { EventsView } from '@/react/nomad/jobs/EventsView'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { JobsView } from '@/react/nomad/jobs/JobsView'; +import { getLeader } from '@/react/nomad/nomad.service'; +import { Environment } from '@/react/portainer/environments/types'; +import { StateManager } from '@/portainer/services/types'; +import { notifyError } from '@/portainer/services/notifications'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { reactModule } from './react'; +import { logsModule } from './logs'; -export const nomadModule = angular.module('portainer.nomad', [ - 'portainer.app', - reactModule, -]).name; +export const nomadModule = angular + .module('portainer.nomad', [reactModule, logsModule]) + .config(config) + + .component( + 'nomadDashboardView', + r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), []) + ) + .component( + 'nomadEventsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(EventsView))), []) + ) + .component( + 'nomadJobsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(JobsView))), []) + ).name; + +/* @ngInject */ +function config($stateRegistryProvider: StateRegistry) { + // limits module to BE only + if (!isBE) { + return; + } + + const nomad = { + name: 'nomad', + url: '/nomad', + parent: 'endpoint', + abstract: true, + + onEnter: /* @ngInject */ function onEnter( + $async: (fn: () => Promise<void>) => Promise<void>, + $state: StateService, + endpoint: Environment, + StateManager: StateManager + ) { + return $async(async () => { + if (!isNomadEnvironment(endpoint.Type)) { + $state.go('portainer.home'); + return; + } + + try { + await getLeader(endpoint.Id); + await StateManager.updateEndpointState(endpoint); + } catch (e) { + notifyError( + 'Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.' + ); + $state.go('portainer.home', {}, { reload: true }); + } + }); + }, + }; + + const dashboard = { + name: 'nomad.dashboard', + url: '/dashboard', + views: { + 'content@': { + component: 'nomadDashboardView', + }, + }, + }; + + const jobs = { + name: 'nomad.jobs', + url: '/jobs', + views: { + 'content@': { + component: 'nomadJobsView', + }, + }, + }; + + const events = { + name: 'nomad.events', + url: '/jobs/:jobID/tasks/:taskName/allocations/:allocationID/events?namespace', + views: { + 'content@': { + component: 'nomadEventsView', + }, + }, + }; + + const logs = { + name: 'nomad.logs', + url: '/jobs/:jobID/tasks/:taskName/allocations/:allocationID/logs?namespace', + views: { + 'content@': { + component: 'nomadLogsView', + }, + }, + }; + + $stateRegistryProvider.register(nomad); + $stateRegistryProvider.register(dashboard); + $stateRegistryProvider.register(jobs); + $stateRegistryProvider.register(events); + $stateRegistryProvider.register(logs); +} diff --git a/app/nomad/logs/index.ts b/app/nomad/logs/index.ts new file mode 100644 index 000000000..9dc9afd95 --- /dev/null +++ b/app/nomad/logs/index.ts @@ -0,0 +1,9 @@ +import angular from 'angular'; + +import { logsView } from './logs'; +import { nomadLogViewer } from './nomad-log-viewer'; + +export const logsModule = angular + .module('portainer.app.nomad.logs', []) + .component('nomadLogViewer', nomadLogViewer) + .component('nomadLogsView', logsView).name; diff --git a/app/nomad/logs/logs.html b/app/nomad/logs/logs.html new file mode 100644 index 000000000..a33545891 --- /dev/null +++ b/app/nomad/logs/logs.html @@ -0,0 +1,3 @@ +<page-header title="'Task logs'" breadcrumbs="[{label:'Nomad Jobs', link:'nomad.jobs'}, jobID, taskName, 'Logs']"> </page-header> + +<nomad-log-viewer stderr-log="stderrLog" stdout-log="stdoutLog" resource-name="taskName" log-collection-change="changeLogCollection"></nomad-log-viewer> diff --git a/app/nomad/logs/logs.ts b/app/nomad/logs/logs.ts new file mode 100644 index 000000000..4824024aa --- /dev/null +++ b/app/nomad/logs/logs.ts @@ -0,0 +1,6 @@ +import controller from './logsController'; + +export const logsView = { + templateUrl: './logs.html', + controller, +}; diff --git a/app/nomad/logs/logsController.js b/app/nomad/logs/logsController.js new file mode 100644 index 000000000..4975843be --- /dev/null +++ b/app/nomad/logs/logsController.js @@ -0,0 +1,85 @@ +import axios from '@/portainer/services/axios'; + +/* @ngInject */ +export default function LogsController($scope, $async, $state, Notifications) { + let controller = new AbortController(); + + $scope.stderrLog = []; + $scope.stdoutLog = []; + + $scope.changeLogCollection = function (logCollectionStatus) { + if (!logCollectionStatus) { + controller.abort(); + controller = new AbortController(); + } else { + loadLogs('stderr', $scope.jobID, $scope.taskName, $scope.namespace, $scope.endpointId, controller); + loadLogs('stdout', $scope.jobID, $scope.taskName, $scope.namespace, $scope.endpointId, controller); + } + }; + + function stripEscapeCodes(logs) { + return logs.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); + } + + function formatLogs(logs, splitter = '\\n') { + if (!logs) { + return []; + } + + const formattedLogs = []; + const logInLines = logs.trim().split(splitter); + + for (const logInLine of logInLines) { + const line = stripEscapeCodes(logInLine).replace('\n', '').replace(/[""]+/g, ''); + formattedLogs.push({ line, spans: [{ foregroundColor: null, backgroundColor: null, text: line }] }); + } + + return formattedLogs; + } + async function loadLogs(logType, jobID, taskName, namespace, endpointId, controller, refresh = true, offset = 50000) { + axios + .get(`/nomad/endpoints/${endpointId}/allocation/${$scope.allocationID}/logs`, { + params: { + jobID, + taskName, + namespace, + refresh, + logType, + offset, + }, + signal: controller.signal, + onDownloadProgress: (progressEvent) => { + $scope[`${logType}Log`] = formatLogs(progressEvent.currentTarget.response); + $scope.$apply(); + }, + }) + .then((response) => { + $scope[`${logType}Log`] = formatLogs(response.data, '\n'); + $scope.$apply(); + }) + .catch((err) => { + if (err.message !== 'canceled') Notifications.error('Failure', err, 'Unable to retrieve task logs'); + }); + } + + async function initView() { + return $async(async () => { + $scope.jobID = $state.params.jobID; + $scope.taskName = $state.params.taskName; + $scope.allocationID = $state.params.allocationID; + $scope.namespace = $state.params.namespace; + $scope.endpointId = $state.params.endpointId; + + loadLogs('stderr', $scope.jobID, $scope.taskName, $scope.namespace, $scope.endpointId, controller); + loadLogs('stdout', $scope.jobID, $scope.taskName, $scope.namespace, $scope.endpointId, controller); + }); + } + + $scope.$on('$destroy', function () { + if (controller) { + controller.abort(); + } + }); + + initView(); +} diff --git a/app/nomad/logs/nomad-log-viewer/index.ts b/app/nomad/logs/nomad-log-viewer/index.ts new file mode 100644 index 000000000..df2a5ab97 --- /dev/null +++ b/app/nomad/logs/nomad-log-viewer/index.ts @@ -0,0 +1 @@ +export { nomadLogViewer } from './nomad-log-viewer'; diff --git a/app/nomad/logs/nomad-log-viewer/nomad-log-viewer.js b/app/nomad/logs/nomad-log-viewer/nomad-log-viewer.js new file mode 100644 index 000000000..df4621b0e --- /dev/null +++ b/app/nomad/logs/nomad-log-viewer/nomad-log-viewer.js @@ -0,0 +1,12 @@ +import controller from './nomadLogViewerController'; + +export const nomadLogViewer = { + templateUrl: './nomadLogViewer.html', + controller, + bindings: { + stderrLog: '<', + stdoutLog: '<', + resourceName: '<', + logCollectionChange: '<', + }, +}; diff --git a/app/nomad/logs/nomad-log-viewer/nomadLogViewer.html b/app/nomad/logs/nomad-log-viewer/nomadLogViewer.html new file mode 100644 index 000000000..8937213f4 --- /dev/null +++ b/app/nomad/logs/nomad-log-viewer/nomadLogViewer.html @@ -0,0 +1,95 @@ +<div class="row"> + <div class="col-sm-12"> + <rd-widget> + <rd-widget-header icon="fa-file-alt" title-text="Nomad Log viewer settings"></rd-widget-header> + <rd-widget-body> + <form class="form-horizontal"> + <div class="form-group"> + <label for="repository_mechanism" class="col-sm-1 control-label text-left"> Log type </label> + <div class="col-sm-11"> + <div class="input-group col-sm-10 input-group-sm"> + <div class="btn-group btn-group-sm"> + <label class="btn btn-primary" ng-click="$ctrl.onChangeLogType($ctrl.model.logType)" ng-model="$ctrl.model.logType" uib-btn-radio="'stderr'">stderr</label> + <label class="btn btn-primary" ng-click="$ctrl.onChangeLogType($ctrl.model.logType)" ng-model="$ctrl.model.logType" uib-btn-radio="'stdout'">stdout</label> + </div> + </div> + </div> + </div> + <div class="form-group"> + <div class="col-sm-1"> + <label for="tls" class="control-label text-left"> + Auto-refresh + <portainer-tooltip message="'Disabling this option allows you to pause the log collection process and the auto-scrolling.'"></portainer-tooltip> + </label> + </div> + <div class="col-sm-11"> + <label class="switch"> + <input + type="checkbox" + ng-model="$ctrl.state.logCollection" + ng-change="$ctrl.state.autoScroll = $ctrl.state.logCollection; $ctrl.logCollectionChange($ctrl.state.logCollection)" + /><i></i> + </label> + </div> + </div> + <div class="form-group"> + <label for="logs_search" class="col-sm-1 control-label text-left"> Search </label> + <div class="col-sm-11"> + <input class="form-control" type="text" name="logs_search" ng-model="$ctrl.state.search" ng-change="$ctrl.state.selectedLines.length = 0;" placeholder="Filter..." /> + </div> + </div> + <div class="form-group"> + <div class="col-sm-1"> + <label for="tls" class="control-label text-left"> Wrap lines </label> + </div> + <div class="col-sm-11"> + <label class="switch"> <input type="checkbox" ng-model="$ctrl.state.wrapLines" /><i></i> </label> + </div> + </div> + <div class="form-group" ng-if="$ctrl.state.copySupported"> + <label class="col-sm-1 control-label text-left"> Actions </label> + <div class="col-sm-11"> + <button class="btn btn-primary btn-sm" type="button" ng-click="$ctrl.downloadLogs()" style="margin-left: 0"><i class="fa fa-download"></i> Download logs</button> + <button + class="btn btn-primary btn-sm" + ng-click="$ctrl.copy()" + ng-disabled="($ctrl.state[$ctrl.model.logType].filteredLogs.length === 1 && !$ctrl.state[$ctrl.model.logType].filteredLogs[0].line) || !$ctrl.state[$ctrl.model.logType].filteredLogs.length" + ><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy</button + > + <button + class="btn btn-primary btn-sm" + ng-click="$ctrl.copySelection()" + ng-disabled="($ctrl.state[$ctrl.model.logType].filteredLogs.length === 1 && !$ctrl.state[$ctrl.model.logType].filteredLogs[0].line) || !$ctrl.state[$ctrl.model.logType].filteredLogs.length || !$ctrl.state[$ctrl.model.logType].selectedLines.length" + ><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy selected lines</button + > + <button class="btn btn-primary btn-sm" ng-click="$ctrl.clearSelection()" ng-disabled="$ctrl.state[$ctrl.model.logType].selectedLines.length === 0" + ><i class="fa fa-times space-right" aria-hidden="true"></i>Unselect</button + > + <span> + <i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none"></i> + </span> + </div> + </div> + </form> + </rd-widget-body> + </rd-widget> + </div> +</div> + +<div class="row" style="height: 54%"> + <div class="col-sm-12" style="height: 100%" ng-if="$ctrl.model.logType === $ctrl.NomadLogType.STDERR"> + <pre ng-class="{ wrap_lines: $ctrl.state.wrapLines }" class="log_viewer" scroll-glue="$ctrl.state.autoScroll" force-glue> + <div ng-if="$ctrl.stderrLog.length === 0 && $ctrl.state.stderr.filteredLogs.length === 0 && !$ctrl.state.logCollection" class="line"><p class="inner_line">No logs available</p></div> + <div ng-repeat="log in $ctrl.state.stderr.filteredLogs = ($ctrl.stderrLog | filter:{ 'line': $ctrl.state.search }) track by $index" class="line" ng-if="log.line"><p class="inner_line" ng-click="$ctrl.selectLine(log.line)" ng-class="{ 'line_selected': $ctrl.state.stderr.selectedLines.indexOf(log.line) > -1 }"><span ng-repeat="span in log.spans" ng-style="{ 'color': span.foregroundColor, 'background-color': span.backgroundColor }">{{ span.text }}</span></p></div> + <div ng-if="$ctrl.stderrLog.length !== 0 && !$ctrl.state.stderr.filteredLogs.length && $ctrl.state.search" class="line"><p class="inner_line">No log line matching the '{{ $ctrl.state.search }}' filter</p></div> + </pre> + </div> + + <div class="col-sm-12" style="height: 100%" ng-if="$ctrl.model.logType === $ctrl.NomadLogType.STDOUT"> + <pre ng-class="{ wrap_lines: $ctrl.state.wrapLines }" class="log_viewer" scroll-glue="$ctrl.state.autoScroll" force-glue> + <div ng-if="$ctrl.stdoutLog.length === 0 && $ctrl.state.stdout.filteredLogs.length === 0 && !$ctrl.state.logCollection" class="line"><p class="inner_line">No logs available</p></div> + <div ng-repeat="log in $ctrl.state.stdout.filteredLogs = ($ctrl.stdoutLog | filter:{ 'line': $ctrl.state.search }) track by $index" class="line" ng-if="log.line"><p class="inner_line" ng-click="$ctrl.selectLine(log.line)" ng-class="{ 'line_selected': $ctrl.state.stdout.selectedLines.indexOf(log.line) > -1 }"><span ng-repeat="span in log.spans" ng-style="{ 'color': span.foregroundColor, 'background-color': span.backgroundColor }">{{ span.text }}</span></p></div> + <div ng-if="$ctrl.stdoutLog.length !== 0 && !$ctrl.state.stdout.filteredLogs.length && $ctrl.state.search" class="line"><p class="inner_line">No log line matching the '{{ $ctrl.state.search }}' filter</p></div> + </pre> + </div> +</div> diff --git a/app/nomad/logs/nomad-log-viewer/nomadLogViewerController.js b/app/nomad/logs/nomad-log-viewer/nomadLogViewerController.js new file mode 100644 index 000000000..4191a13bf --- /dev/null +++ b/app/nomad/logs/nomad-log-viewer/nomadLogViewerController.js @@ -0,0 +1,64 @@ +import { concatLogsToString, NEW_LINE_BREAKER } from '@/docker/helpers/logHelper'; + +/* @ngInject */ +export default function NomadLogViewerController(clipboard, Blob, FileSaver) { + this.NomadLogType = Object.freeze({ + STDERR: 'stderr', + STDOUT: 'stdout', + }); + + this.state = { + copySupported: clipboard.supported, + logCollection: true, + autoScroll: true, + wrapLines: true, + search: '', + stderr: { + filteredLogs: [], + selectedLines: [], + }, + stdout: { + filteredLogs: [], + selectedLines: [], + }, + }; + + this.model = { + logType: this.NomadLogType.STDERR, + }; + + this.onChangeLogType = function (logType) { + this.model.logType = this.NomadLogType[logType.toUpperCase()]; + }; + + this.copy = function () { + clipboard.copyText(this.state[this.model.logType].filteredLogs.map((log) => log.line).join(NEW_LINE_BREAKER)); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(2000); + }; + + this.copySelection = function () { + clipboard.copyText(this.state[this.model.logType].selectedLines.join(NEW_LINE_BREAKER)); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(2000); + }; + + this.clearSelection = function () { + this.state[this.model.logType].selectedLines = []; + }; + + this.selectLine = function (line) { + var idx = this.state[this.model.logType].selectedLines.indexOf(line); + if (idx === -1) { + this.state[this.model.logType].selectedLines.push(line); + } else { + this.state[this.model.logType].selectedLines.splice(idx, 1); + } + }; + + this.downloadLogs = function () { + const logsAsString = concatLogsToString(this.state[this.model.logType].filteredLogs); + const data = new Blob([logsAsString]); + FileSaver.saveAs(data, this.resourceName + '_logs.txt'); + }; +} diff --git a/app/react/constants.ts b/app/react/constants.ts new file mode 100644 index 000000000..a2a191a11 --- /dev/null +++ b/app/react/constants.ts @@ -0,0 +1,11 @@ +export const BROWSER_OS_PLATFORM = getOs(); + +function getOs() { + const { userAgent } = navigator; + + if (userAgent.includes('Windows')) { + return 'win'; + } + + return userAgent.includes('Mac') ? 'mac' : 'lin'; +} diff --git a/app/react/nomad/.keep b/app/react/nomad/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/nomad/DashboardView/.keep b/app/react/nomad/DashboardView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/nomad/DashboardView/DashboardView.tsx b/app/react/nomad/DashboardView/DashboardView.tsx new file mode 100644 index 000000000..5255ef8b3 --- /dev/null +++ b/app/react/nomad/DashboardView/DashboardView.tsx @@ -0,0 +1,83 @@ +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { DashboardItem } from '@@/DashboardItem'; +import { Widget, WidgetTitle, WidgetBody } from '@@/Widget'; +import { PageHeader } from '@@/PageHeader'; +import { DashboardGrid } from '@@/DashboardItem/DashboardGrid'; + +import { useDashboard } from './useDashboard'; +import { RunningStatus } from './RunningStatus'; + +export function DashboardView() { + const environmentId = useEnvironmentId(); + const dashboardQuery = useDashboard(environmentId); + + const running = dashboardQuery.data?.RunningTaskCount || 0; + const stopped = (dashboardQuery.data?.TaskCount || 0) - running; + + return ( + <> + <PageHeader + title="Dashboard" + breadcrumbs={[{ label: 'Environment summary' }]} + /> + + {dashboardQuery.isLoading ? ( + <div className="text-center" style={{ marginTop: '30%' }}> + Connecting to the Edge environment... + <i className="fa fa-cog fa-spin space-left" /> + </div> + ) : ( + <> + <div className="row"> + <div className="col-sm-12"> + {/* cluster info */} + <Widget> + <WidgetTitle + icon="fa-tachometer-alt" + title="Cluster information" + /> + <WidgetBody className="no-padding"> + <table className="table"> + <tbody> + <tr> + <td>Nodes in the cluster</td> + <td>{dashboardQuery.data?.NodeCount ?? '-'}</td> + </tr> + </tbody> + </table> + </WidgetBody> + </Widget> + </div> + </div> + + <div className="mx-4"> + <DashboardGrid> + {/* jobs */} + <DashboardItem + value={dashboardQuery.data?.JobCount} + icon="fa fa-th-list" + type="Nomad Job" + /> + {/* groups */} + <DashboardItem + value={dashboardQuery.data?.GroupCount} + icon="fa fa-list-alt" + type="Group" + /> + {/* tasks */} + <DashboardItem + value={dashboardQuery.data?.TaskCount} + icon="fa fa-cubes" + type="Task" + > + {/* running status of tasks */} + <RunningStatus running={running} stopped={stopped} /> + </DashboardItem> + </DashboardGrid> + </div> + </> + )} + </> + ); +} diff --git a/app/react/nomad/DashboardView/RunningStatus.tsx b/app/react/nomad/DashboardView/RunningStatus.tsx new file mode 100644 index 000000000..ebb1a6195 --- /dev/null +++ b/app/react/nomad/DashboardView/RunningStatus.tsx @@ -0,0 +1,25 @@ +interface Props { + running: number; + stopped: number; +} + +export function RunningStatus({ running, stopped }: Props) { + return ( + <div> + <div> + <i + className="fa fa-power-off green-icon space-right" + aria-hidden="true" + /> + {`${running || '-'} running`} + </div> + <div> + <i + className="fa fa-power-off red-icon space-right" + aria-hidden="true" + /> + {`${stopped || '-'} stopped`} + </div> + </div> + ); +} diff --git a/app/react/nomad/DashboardView/index.ts b/app/react/nomad/DashboardView/index.ts new file mode 100644 index 000000000..ea829dbf3 --- /dev/null +++ b/app/react/nomad/DashboardView/index.ts @@ -0,0 +1 @@ +export { DashboardView } from './DashboardView'; diff --git a/app/react/nomad/DashboardView/useDashboard.ts b/app/react/nomad/DashboardView/useDashboard.ts new file mode 100644 index 000000000..d491b63c6 --- /dev/null +++ b/app/react/nomad/DashboardView/useDashboard.ts @@ -0,0 +1,41 @@ +import { useQuery } from 'react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +export type DashboardResponse = { + JobCount: number; + GroupCount: number; + TaskCount: number; + RunningTaskCount: number; + NodeCount: number; +}; + +export function useDashboard(environmentId: EnvironmentId) { + return useQuery( + ['environments', environmentId, 'nomad', 'dashboard'], + () => getDashboard(environmentId), + { + meta: { + error: { + title: 'Failure', + message: 'Unable to get dashboard information', + }, + }, + } + ); +} + +export async function getDashboard(environmentId: EnvironmentId) { + try { + const { data: dashboard } = await axios.get<DashboardResponse>( + `/nomad/endpoints/${environmentId}/dashboard`, + { + params: {}, + } + ); + return dashboard; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/nomad/jobs/.keep b/app/react/nomad/jobs/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/nomad/jobs/EventsView/.keep b/app/react/nomad/jobs/EventsView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx b/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx new file mode 100644 index 000000000..a2f263317 --- /dev/null +++ b/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx @@ -0,0 +1,163 @@ +import { Fragment, useEffect } from 'react'; +import { + useFilters, + useGlobalFilter, + usePagination, + useSortBy, + useTable, +} from 'react-table'; + +import { NomadEvent } from '@/react/nomad/types'; +import { useDebounce } from '@/react/hooks/useDebounce'; + +import { PaginationControls } from '@@/PaginationControls'; +import { + Table, + TableContainer, + TableHeaderRow, + TableRow, + TableTitle, +} from '@@/datatables'; +import { multiple } from '@@/datatables/filter-types'; +import { useTableSettings } from '@@/datatables/useTableSettings'; +import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar'; +import { TableFooter } from '@@/datatables/TableFooter'; +import { TableContent } from '@@/datatables/TableContent'; + +import { useColumns } from './columns'; + +export interface EventsDatatableProps { + data: NomadEvent[]; + isLoading: boolean; +} + +export interface EventsTableSettings { + autoRefreshRate: number; + pageSize: number; + sortBy: { id: string; desc: boolean }; +} + +export function EventsDatatable({ data, isLoading }: EventsDatatableProps) { + const { settings, setTableSettings } = + useTableSettings<EventsTableSettings>(); + const [searchBarValue, setSearchBarValue] = useSearchBarState('events'); + const columns = useColumns(); + const debouncedSearchValue = useDebounce(searchBarValue); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + page, + prepareRow, + gotoPage, + setPageSize, + setGlobalFilter, + state: { pageIndex, pageSize }, + } = useTable<NomadEvent>( + { + defaultCanFilter: false, + columns, + data, + filterTypes: { multiple }, + initialState: { + pageSize: settings.pageSize || 10, + sortBy: [settings.sortBy], + globalFilter: searchBarValue, + }, + }, + useFilters, + useGlobalFilter, + useSortBy, + usePagination + ); + + useEffect(() => { + setGlobalFilter(debouncedSearchValue); + }, [debouncedSearchValue, setGlobalFilter]); + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + + return ( + <TableContainer> + <TableTitle icon="fa-history" label="Events" /> + + <SearchBar value={searchBarValue} onChange={handleSearchBarChange} /> + + <Table + className={tableProps.className} + role={tableProps.role} + style={tableProps.style} + > + <thead> + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + + return ( + <TableHeaderRow<NomadEvent> + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={handleSortChange} + /> + ); + })} + </thead> + <tbody + className={tbodyProps.className} + role={tbodyProps.role} + style={tbodyProps.style} + > + <TableContent + rows={page} + prepareRow={prepareRow} + isLoading={isLoading} + emptyContent="No events found" + renderRow={(row, { key, className, role, style }) => ( + <Fragment key={key}> + <TableRow<NomadEvent> + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + </Fragment> + )} + /> + </tbody> + </Table> + + <TableFooter> + <PaginationControls + showAll + pageLimit={pageSize} + page={pageIndex + 1} + onPageChange={(p) => gotoPage(p - 1)} + totalCount={data.length} + onPageLimitChange={handlePageSizeChange} + /> + </TableFooter> + </TableContainer> + ); + + function handlePageSizeChange(pageSize: number) { + setPageSize(pageSize); + setTableSettings((settings) => ({ ...settings, pageSize })); + } + + function handleSearchBarChange(value: string) { + setSearchBarValue(value); + } + + function handleSortChange(id: string, desc: boolean) { + setTableSettings((settings) => ({ + ...settings, + sortBy: { id, desc }, + })); + } +} diff --git a/app/react/nomad/jobs/EventsView/EventsDatatable/columns/date.tsx b/app/react/nomad/jobs/EventsView/EventsDatatable/columns/date.tsx new file mode 100644 index 000000000..4d411dc40 --- /dev/null +++ b/app/react/nomad/jobs/EventsView/EventsDatatable/columns/date.tsx @@ -0,0 +1,12 @@ +import { Column } from 'react-table'; + +import { NomadEvent } from '@/react/nomad/types'; +import { isoDate } from '@/portainer/filters/filters'; + +export const date: Column<NomadEvent> = { + Header: 'Date', + accessor: (row) => (row.Date ? isoDate(row.Date) : '-'), + id: 'date', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/EventsView/EventsDatatable/columns/index.tsx b/app/react/nomad/jobs/EventsView/EventsDatatable/columns/index.tsx new file mode 100644 index 000000000..c6b25f787 --- /dev/null +++ b/app/react/nomad/jobs/EventsView/EventsDatatable/columns/index.tsx @@ -0,0 +1,9 @@ +import { useMemo } from 'react'; + +import { date } from './date'; +import { type } from './type'; +import { message } from './message'; + +export function useColumns() { + return useMemo(() => [date, type, message], []); +} diff --git a/app/react/nomad/jobs/EventsView/EventsDatatable/columns/message.tsx b/app/react/nomad/jobs/EventsView/EventsDatatable/columns/message.tsx new file mode 100644 index 000000000..d85b49664 --- /dev/null +++ b/app/react/nomad/jobs/EventsView/EventsDatatable/columns/message.tsx @@ -0,0 +1,11 @@ +import { Column } from 'react-table'; + +import { NomadEvent } from '@/react/nomad/types'; + +export const message: Column<NomadEvent> = { + Header: 'Message', + accessor: 'Message', + id: 'message', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/EventsView/EventsDatatable/columns/type.tsx b/app/react/nomad/jobs/EventsView/EventsDatatable/columns/type.tsx new file mode 100644 index 000000000..c50d86ba3 --- /dev/null +++ b/app/react/nomad/jobs/EventsView/EventsDatatable/columns/type.tsx @@ -0,0 +1,11 @@ +import { Column } from 'react-table'; + +import { NomadEvent } from '@/react/nomad/types'; + +export const type: Column<NomadEvent> = { + Header: 'Type', + accessor: 'Type', + id: 'type', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/EventsView/EventsDatatable/index.ts b/app/react/nomad/jobs/EventsView/EventsDatatable/index.ts new file mode 100644 index 000000000..79b6c371a --- /dev/null +++ b/app/react/nomad/jobs/EventsView/EventsDatatable/index.ts @@ -0,0 +1 @@ +export { EventsDatatable } from './EventsDatatable'; diff --git a/app/react/nomad/jobs/EventsView/EventsView.tsx b/app/react/nomad/jobs/EventsView/EventsView.tsx new file mode 100644 index 000000000..ec5eb59b2 --- /dev/null +++ b/app/react/nomad/jobs/EventsView/EventsView.tsx @@ -0,0 +1,62 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { NomadEventsList } from '@/react/nomad/types'; + +import { TableSettingsProvider } from '@@/datatables/useTableSettings'; +import { PageHeader } from '@@/PageHeader'; + +import { EventsDatatable } from './EventsDatatable'; +import { useEvents } from './useEvents'; + +export function EventsView() { + const environmentId = useEnvironmentId(); + const { query, invalidateQuery } = useEvents(); + const { + params: { jobID, taskName }, + } = useCurrentStateAndParams(); + + const breadcrumbs = [ + { + label: 'Nomad Jobs', + link: 'nomad.jobs', + linkParams: { id: environmentId }, + }, + { label: jobID }, + { label: taskName }, + { label: 'Events' }, + ]; + + const defaultSettings = { + pageSize: 10, + sortBy: {}, + }; + + return ( + <> + {/* header */} + <PageHeader + title="Event list" + breadcrumbs={breadcrumbs} + reload + loading={query.isLoading || query.isFetching} + onReload={invalidateQuery} + /> + + <div className="row"> + <div className="col-sm-12"> + <TableSettingsProvider + defaults={defaultSettings} + storageKey="nomad-events" + > + {/* events table */} + <EventsDatatable + data={(query.data || []) as NomadEventsList} + isLoading={query.isLoading} + /> + </TableSettingsProvider> + </div> + </div> + </> + ); +} diff --git a/app/react/nomad/jobs/EventsView/index.ts b/app/react/nomad/jobs/EventsView/index.ts new file mode 100644 index 000000000..89943afeb --- /dev/null +++ b/app/react/nomad/jobs/EventsView/index.ts @@ -0,0 +1 @@ +export { EventsView } from './EventsView'; diff --git a/app/react/nomad/jobs/EventsView/useEvents.ts b/app/react/nomad/jobs/EventsView/useEvents.ts new file mode 100644 index 000000000..1d1cec667 --- /dev/null +++ b/app/react/nomad/jobs/EventsView/useEvents.ts @@ -0,0 +1,75 @@ +import { useQuery, useQueryClient } from 'react-query'; +import { useCurrentStateAndParams } from '@uirouter/react'; + +import * as notifications from '@/portainer/services/notifications'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { NomadEventsList } from '../../types'; + +export function useEvents() { + const queryClient = useQueryClient(); + + const { + params: { + endpointId: environmentID, + allocationID, + jobID, + taskName, + namespace, + }, + } = useCurrentStateAndParams(); + + if (!environmentID) { + throw new Error('endpointId url param is required'); + } + + const key = [ + 'environments', + environmentID, + 'nomad', + 'events', + allocationID, + jobID, + taskName, + namespace, + ]; + + function invalidateQuery() { + return queryClient.invalidateQueries(key); + } + + const query = useQuery( + key, + () => + getTaskEvents(environmentID, allocationID, jobID, taskName, namespace), + { + refetchOnWindowFocus: false, + onError: (err) => { + notifications.error('Failed loading events', err as Error, ''); + }, + } + ); + + return { query, invalidateQuery }; +} + +export async function getTaskEvents( + environmentId: EnvironmentId, + allocationId: string, + jobId: string, + taskName: string, + namespace: string +) { + try { + const ret = await axios.get<NomadEventsList>( + `/nomad/endpoints/${environmentId}/allocation/${allocationId}/events`, + { + params: { jobId, taskName, namespace }, + } + ); + return ret.data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/nomad/jobs/JobsView/.keep b/app/react/nomad/jobs/JobsView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx new file mode 100644 index 000000000..8e15cbb37 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx @@ -0,0 +1,204 @@ +import { Fragment, useEffect } from 'react'; +import { + useExpanded, + useFilters, + useGlobalFilter, + usePagination, + useSortBy, + useTable, +} from 'react-table'; +import { useRowSelectColumn } from '@lineup-lite/hooks'; + +import { Job } from '@/react/nomad/types'; +import { useDebounce } from '@/react/hooks/useDebounce'; + +import { PaginationControls } from '@@/PaginationControls'; +import { + Table, + TableActions, + TableContainer, + TableHeaderRow, + TableRow, + TableTitle, + TableSettingsMenu, + TableTitleActions, +} from '@@/datatables'; +import { multiple } from '@@/datatables/filter-types'; +import { useTableSettings } from '@@/datatables/useTableSettings'; +import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar'; +import { useRowSelect } from '@@/datatables/useRowSelect'; +import { TableFooter } from '@@/datatables/TableFooter'; +import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount'; +import { TableContent } from '@@/datatables/TableContent'; +import { useRepeater } from '@@/datatables/useRepeater'; + +import { JobsTableSettings } from './types'; +import { TasksDatatable } from './TasksDatatable'; +import { useColumns } from './columns'; +import { JobsDatatableSettings } from './JobsDatatableSettings'; + +export interface JobsDatatableProps { + jobs: Job[]; + refreshData: () => Promise<void>; + isLoading?: boolean; +} + +export function JobsDatatable({ + jobs, + refreshData, + isLoading, +}: JobsDatatableProps) { + const { settings, setTableSettings } = useTableSettings<JobsTableSettings>(); + const [searchBarValue, setSearchBarValue] = useSearchBarState('jobs'); + const columns = useColumns(); + const debouncedSearchValue = useDebounce(searchBarValue); + useRepeater(settings.autoRefreshRate, refreshData); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + page, + prepareRow, + selectedFlatRows, + gotoPage, + setPageSize, + setGlobalFilter, + state: { pageIndex, pageSize }, + } = useTable<Job>( + { + defaultCanFilter: false, + columns, + data: jobs, + filterTypes: { multiple }, + initialState: { + pageSize: settings.pageSize || 10, + sortBy: [settings.sortBy], + globalFilter: searchBarValue, + }, + isRowSelectable() { + return false; + }, + autoResetExpanded: false, + autoResetSelectedRows: false, + selectColumnWidth: 5, + getRowId(job, relativeIndex) { + return `${job.ID}-${relativeIndex}`; + }, + }, + useFilters, + useGlobalFilter, + useSortBy, + useExpanded, + usePagination, + useRowSelect, + useRowSelectColumn + ); + + useEffect(() => { + setGlobalFilter(debouncedSearchValue); + }, [debouncedSearchValue, setGlobalFilter]); + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + + return ( + <TableContainer> + <TableTitle icon="fa-cubes" label="Nomad Jobs"> + <TableTitleActions> + <TableSettingsMenu> + <JobsDatatableSettings /> + </TableSettingsMenu> + </TableTitleActions> + </TableTitle> + + <TableActions /> + + <SearchBar value={searchBarValue} onChange={handleSearchBarChange} /> + + <Table + className={tableProps.className} + role={tableProps.role} + style={tableProps.style} + > + <thead> + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + + return ( + <TableHeaderRow<Job> + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={handleSortChange} + /> + ); + })} + </thead> + <tbody + className={tbodyProps.className} + role={tbodyProps.role} + style={tbodyProps.style} + > + <TableContent + rows={page} + prepareRow={prepareRow} + isLoading={isLoading} + emptyContent="No jobs found" + renderRow={(row, { key, className, role, style }) => ( + <Fragment key={key}> + <TableRow<Job> + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + + {row.isExpanded && ( + <tr> + <td /> + <td colSpan={row.cells.length - 1}> + <TasksDatatable data={row.original.Tasks} /> + </td> + </tr> + )} + </Fragment> + )} + /> + </tbody> + </Table> + + <TableFooter> + <SelectedRowsCount value={selectedFlatRows.length} /> + <PaginationControls + showAll + pageLimit={pageSize} + page={pageIndex + 1} + onPageChange={(p) => gotoPage(p - 1)} + totalCount={jobs.length} + onPageLimitChange={handlePageSizeChange} + /> + </TableFooter> + </TableContainer> + ); + + function handlePageSizeChange(pageSize: number) { + setPageSize(pageSize); + setTableSettings((settings) => ({ ...settings, pageSize })); + } + + function handleSearchBarChange(value: string) { + setSearchBarValue(value); + } + + function handleSortChange(id: string, desc: boolean) { + setTableSettings((settings) => ({ + ...settings, + sortBy: { id, desc }, + })); + } +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatableSettings.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatableSettings.tsx new file mode 100644 index 000000000..8f0c3bcb1 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatableSettings.tsx @@ -0,0 +1,19 @@ +import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; +import { useTableSettings } from '@@/datatables/useTableSettings'; + +import { JobsTableSettings } from './types'; + +export function JobsDatatableSettings() { + const { settings, setTableSettings } = useTableSettings<JobsTableSettings>(); + + return ( + <TableSettingsMenuAutoRefresh + value={settings.autoRefreshRate} + onChange={handleRefreshRateChange} + /> + ); + + function handleRefreshRateChange(autoRefreshRate: number) { + setTableSettings({ autoRefreshRate }); + } +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/TasksDatatable.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/TasksDatatable.tsx new file mode 100644 index 000000000..ff3af7638 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/TasksDatatable.tsx @@ -0,0 +1,97 @@ +import { useFilters, usePagination, useSortBy, useTable } from 'react-table'; +import { useState } from 'react'; + +import { Task } from '@/react/nomad/types'; + +import { Table, TableContainer, TableHeaderRow, TableRow } from '@@/datatables'; +import { InnerDatatable } from '@@/datatables/InnerDatatable'; + +import { useColumns } from './columns'; + +export interface TasksTableProps { + data: Task[]; +} + +export function TasksDatatable({ data }: TasksTableProps) { + const columns = useColumns(); + const [sortBy, setSortBy] = useState({ id: 'taskName', desc: false }); + + const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } = + useTable<Task>( + { + columns, + data, + initialState: { + sortBy: [sortBy], + }, + }, + useFilters, + useSortBy, + usePagination + ); + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + + return ( + <InnerDatatable> + <TableContainer> + <Table + className={tableProps.className} + role={tableProps.role} + style={tableProps.style} + > + <thead> + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + + return ( + <TableHeaderRow<Task> + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={handleSortChange} + /> + ); + })} + </thead> + <tbody + className={tbodyProps.className} + role={tbodyProps.role} + style={tbodyProps.style} + > + {data.length > 0 ? ( + page.map((row) => { + prepareRow(row); + const { key, className, role, style } = row.getRowProps(); + + return ( + <TableRow<Task> + key={key} + cells={row.cells} + className={className} + role={role} + style={style} + /> + ); + }) + ) : ( + <tr> + <td colSpan={5} className="text-center text-muted"> + no tasks + </td> + </tr> + )} + </tbody> + </Table> + </TableContainer> + </InnerDatatable> + ); + + function handleSortChange(id: string, desc: boolean) { + setSortBy({ id, desc }); + } +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/actions.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/actions.tsx new file mode 100644 index 000000000..ebe41d2e3 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/actions.tsx @@ -0,0 +1,45 @@ +import { CellProps, Column } from 'react-table'; + +import { Task } from '@/react/nomad/types'; + +import { Link } from '@@/Link'; + +export const actions: Column<Task> = { + Header: 'Task Actions', + id: 'actions', + disableFilters: true, + canHide: true, + disableResizing: true, + width: '5px', + sortType: 'string', + Filter: () => null, + Cell: ActionsCell, +}; + +export function ActionsCell({ row }: CellProps<Task>) { + const params = { + allocationID: row.original.AllocationID, + taskName: row.original.TaskName, + namespace: row.original.Namespace, + jobID: row.original.JobID, + }; + + return ( + <div className="text-center"> + {/* events */} + <Link + to="nomad.events" + params={params} + title="Events" + className="space-right" + > + <i className="fa fa-history space-right" aria-hidden="true" /> + </Link> + + {/* logs */} + <Link to="nomad.logs" params={params} title="Logs"> + <i className="fa fa-file-alt space-right" aria-hidden="true" /> + </Link> + </div> + ); +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/allocationID.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/allocationID.tsx new file mode 100644 index 000000000..bbb939bd8 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/allocationID.tsx @@ -0,0 +1,11 @@ +import { Column } from 'react-table'; + +import { Task } from '@/react/nomad/types'; + +export const allocationID: Column<Task> = { + Header: 'Allocation ID', + accessor: (row) => row.AllocationID || '-', + id: 'allocationID', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/index.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/index.tsx new file mode 100644 index 000000000..8f571bdf0 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/index.tsx @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; + +import { taskStatus } from './taskStatus'; +import { taskName } from './taskName'; +import { taskGroup } from './taskGroup'; +import { allocationID } from './allocationID'; +import { started } from './started'; +import { actions } from './actions'; + +export function useColumns() { + return useMemo( + () => [taskStatus, taskName, taskGroup, allocationID, actions, started], + [] + ); +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/started.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/started.tsx new file mode 100644 index 000000000..a39c9dda3 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/started.tsx @@ -0,0 +1,19 @@ +import moment from 'moment'; +import { Column } from 'react-table'; + +import { Task } from '@/react/nomad/types'; +import { isoDate } from '@/portainer/filters/filters'; + +function accessor(row: Task) { + const momentDate = moment(row.StartedAt); + const isValid = momentDate.unix() > 0; + return isValid ? isoDate(momentDate) : '-'; +} + +export const started: Column<Task> = { + accessor, + Header: 'Started', + id: 'startedName', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/taskGroup.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/taskGroup.tsx new file mode 100644 index 000000000..80ded7472 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/taskGroup.tsx @@ -0,0 +1,11 @@ +import { Column } from 'react-table'; + +import { Task } from '@/react/nomad/types'; + +export const taskGroup: Column<Task> = { + Header: 'Task Group', + accessor: (row) => row.TaskGroup || '-', + id: 'taskGroup', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/taskName.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/taskName.tsx new file mode 100644 index 000000000..7feead4b7 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/taskName.tsx @@ -0,0 +1,11 @@ +import { Column } from 'react-table'; + +import { Task } from '@/react/nomad/types'; + +export const taskName: Column<Task> = { + Header: 'Task Name', + accessor: (row) => row.TaskName || '-', + id: 'taskName', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/taskStatus.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/taskStatus.tsx new file mode 100644 index 000000000..7b83cafb8 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/columns/taskStatus.tsx @@ -0,0 +1,35 @@ +import _ from 'lodash'; +import clsx from 'clsx'; +import { CellProps, Column } from 'react-table'; + +import { Task } from '@/react/nomad/types'; + +import { DefaultFilter } from '@@/datatables/Filter'; + +export const taskStatus: Column<Task> = { + Header: 'Task Status', + accessor: 'State', + id: 'status', + Filter: DefaultFilter, + canHide: true, + sortType: 'string', + Cell: StateCell, +}; + +function StateCell({ value }: CellProps<Task, string>) { + const className = getClassName(); + + return <span className={clsx('label', className)}>{value}</span>; + + function getClassName() { + if (['dead'].includes(_.toLower(value))) { + return 'label-danger'; + } + + if (['pending'].includes(_.toLower(value))) { + return 'label-warning'; + } + + return 'label-success'; + } +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/index.ts b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/index.ts new file mode 100644 index 000000000..f95acf3ac --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/TasksDatatable/index.ts @@ -0,0 +1 @@ +export { TasksDatatable } from './TasksDatatable'; diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/actions/JobActions.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/actions/JobActions.tsx new file mode 100644 index 000000000..dcb64249b --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/actions/JobActions.tsx @@ -0,0 +1,49 @@ +import { useMutation } from 'react-query'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { Job } from '@/react/nomad/types'; +import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; + +import { LoadingButton } from '@@/buttons/LoadingButton'; + +import { deleteJobs } from './delete'; + +interface Props { + selectedItems: Job[]; + refreshData: () => Promise<void> | void; +} + +export function JobActions({ selectedItems, refreshData }: Props) { + const environmentId = useEnvironmentId(); + + const mutation = useMutation(() => deleteJobs(environmentId, selectedItems)); + + return ( + <LoadingButton + loadingText="Removing..." + isLoading={mutation.isLoading} + disabled={selectedItems.length < 1 || mutation.isLoading} + color="danger" + onClick={handleDeleteClicked} + > + <i className="fa fa-trash-alt space-right" aria-hidden="true" /> + Remove + </LoadingButton> + ); + + async function handleDeleteClicked() { + const confirmed = await confirmDeletionAsync( + 'Are you sure to delete all selected jobs?' + ); + + if (!confirmed) { + return; + } + + mutation.mutate(undefined, { + onSuccess() { + return refreshData(); + }, + }); + } +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/actions/delete.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/actions/delete.tsx new file mode 100644 index 000000000..2aadc46ed --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/actions/delete.tsx @@ -0,0 +1,22 @@ +import * as notifications from '@/portainer/services/notifications'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { Job } from '@/react/nomad/types'; + +import { deleteJob } from '../../../jobs.service'; + +export async function deleteJobs(environmentID: EnvironmentId, jobs: Job[]) { + return Promise.all( + jobs.map(async (job) => { + try { + await deleteJob(environmentID, job.ID, job.Namespace); + notifications.success('Job successfully removed', job.ID); + } catch (err) { + notifications.error( + 'Failure', + err as Error, + `Failed to delete job ${job.ID}` + ); + } + }) + ); +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/columns/actions.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/actions.tsx new file mode 100644 index 000000000..cc15190a6 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/actions.tsx @@ -0,0 +1,24 @@ +import { CellProps, Column } from 'react-table'; + +import { Job } from '@/react/nomad/types'; + +export const actions: Column<Job> = { + Header: 'Job Actions', + id: 'actions', + disableFilters: true, + canHide: true, + disableResizing: true, + width: '110px', + sortType: 'string', + Filter: () => null, + Cell: ActionsCell, +}; + +export function ActionsCell({ row }: CellProps<Job>) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + <div className="text-center" {...row.getToggleRowExpandedProps()}> + <i className="fa fa-history space-right" aria-hidden="true" /> + </div> + ); +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/columns/created.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/created.tsx new file mode 100644 index 000000000..68bdb199f --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/created.tsx @@ -0,0 +1,13 @@ +import { Column } from 'react-table'; + +import { Job } from '@/react/nomad/types'; +import { isoDate } from '@/portainer/filters/filters'; + +export const created: Column<Job> = { + Header: 'Created', + accessor: (row) => + row.SubmitTime ? isoDate(parseInt(row.SubmitTime, 10)) : '-', + id: 'createdName', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/columns/index.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/index.tsx new file mode 100644 index 000000000..b3f79a8e2 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/index.tsx @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; + +import { name } from './name'; +import { status } from './status'; +import { created } from './created'; +import { actions } from './actions'; +import { namespace } from './namespace'; + +export function useColumns() { + return useMemo(() => [name, status, namespace, actions, created], []); +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/columns/name.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/name.tsx new file mode 100644 index 000000000..fe22350dc --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/name.tsx @@ -0,0 +1,24 @@ +import { CellProps, Column } from 'react-table'; + +import { Job } from '@/react/nomad/types'; + +import { ExpandingCell } from '@@/datatables/ExpandingCell'; + +export const name: Column<Job> = { + Header: 'Name', + accessor: (row) => row.ID, + id: 'name', + Cell: NameCell, + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', +}; + +export function NameCell({ value: name, row }: CellProps<Job>) { + return ( + <ExpandingCell row={row} showExpandArrow> + {name} + </ExpandingCell> + ); +} diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/columns/namespace.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/namespace.tsx new file mode 100644 index 000000000..98f5ca4c4 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/namespace.tsx @@ -0,0 +1,11 @@ +import { Column } from 'react-table'; + +import { Job } from '@/react/nomad/types'; + +export const namespace: Column<Job> = { + Header: 'Namespace', + accessor: (row) => row.Namespace || '-', + id: 'namespace', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/columns/status.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/status.tsx new file mode 100644 index 000000000..76a839a61 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/columns/status.tsx @@ -0,0 +1,11 @@ +import { Column } from 'react-table'; + +import { Job } from '@/react/nomad/types'; + +export const status: Column<Job> = { + Header: 'Job Status', + accessor: (row) => row.Status || '-', + id: 'statusName', + disableFilters: true, + canHide: true, +}; diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/index.ts b/app/react/nomad/jobs/JobsView/JobsDatatable/index.ts new file mode 100644 index 000000000..fde9e775b --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/index.ts @@ -0,0 +1 @@ +export { JobsDatatable } from './JobsDatatable'; diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/types.ts b/app/react/nomad/jobs/JobsView/JobsDatatable/types.ts new file mode 100644 index 000000000..72a268e09 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/types.ts @@ -0,0 +1,5 @@ +export interface JobsTableSettings { + autoRefreshRate: number; + pageSize: number; + sortBy: { id: string; desc: boolean }; +} diff --git a/app/react/nomad/jobs/JobsView/JobsView.tsx b/app/react/nomad/jobs/JobsView/JobsView.tsx new file mode 100644 index 000000000..857054dc3 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/JobsView.tsx @@ -0,0 +1,46 @@ +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { PageHeader } from '@@/PageHeader'; +import { TableSettingsProvider } from '@@/datatables/useTableSettings'; + +import { useJobs } from './useJobs'; +import { JobsDatatable } from './JobsDatatable'; + +export function JobsView() { + const environmentId = useEnvironmentId(); + const jobsQuery = useJobs(environmentId); + + const defaultSettings = { + autoRefreshRate: 10, + pageSize: 10, + sortBy: { id: 'name', desc: false }, + }; + + async function reloadData() { + await jobsQuery.refetch(); + } + + return ( + <> + <PageHeader + title="Nomad Job list" + breadcrumbs={[{ label: 'Nomad Jobs' }]} + reload + loading={jobsQuery.isLoading} + onReload={reloadData} + /> + + <div className="row"> + <div className="col-sm-12"> + <TableSettingsProvider defaults={defaultSettings} storageKey="jobs"> + <JobsDatatable + jobs={jobsQuery.data || []} + refreshData={reloadData} + isLoading={jobsQuery.isLoading} + /> + </TableSettingsProvider> + </div> + </div> + </> + ); +} diff --git a/app/react/nomad/jobs/JobsView/index.ts b/app/react/nomad/jobs/JobsView/index.ts new file mode 100644 index 000000000..29a6fb69e --- /dev/null +++ b/app/react/nomad/jobs/JobsView/index.ts @@ -0,0 +1 @@ +export { JobsView } from './JobsView'; diff --git a/app/react/nomad/jobs/JobsView/useJobs.ts b/app/react/nomad/jobs/JobsView/useJobs.ts new file mode 100644 index 000000000..5a8618516 --- /dev/null +++ b/app/react/nomad/jobs/JobsView/useJobs.ts @@ -0,0 +1,34 @@ +import { useQuery } from 'react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { Job } from '@/react/nomad/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +export function useJobs(environmentId: EnvironmentId) { + return useQuery<Job[]>( + ['environments', environmentId, 'nomad', 'jobs'], + () => listJobs(environmentId), + { + meta: { + error: { + title: 'Failure', + message: 'Unable to list jobs', + }, + }, + } + ); +} + +export async function listJobs(environmentId: EnvironmentId) { + try { + const { data: jobs } = await axios.get<Job[]>( + `/nomad/endpoints/${environmentId}/jobs`, + { + params: {}, + } + ); + return jobs; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/nomad/jobs/jobs.service.ts b/app/react/nomad/jobs/jobs.service.ts new file mode 100644 index 000000000..8e0977d59 --- /dev/null +++ b/app/react/nomad/jobs/jobs.service.ts @@ -0,0 +1,16 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export async function deleteJob( + environmentId: EnvironmentId, + jobId: string, + namespace: string +) { + try { + await axios.delete(`/nomad/endpoints/${environmentId}/jobs/${jobId}`, { + params: { namespace }, + }); + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/nomad/nomad.service.ts b/app/react/nomad/nomad.service.ts new file mode 100644 index 000000000..4ce318940 --- /dev/null +++ b/app/react/nomad/nomad.service.ts @@ -0,0 +1,20 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +interface LeaderResponse { + Leader: string; +} + +export async function getLeader(environmentId: EnvironmentId) { + try { + const { data } = await axios.get<LeaderResponse>( + `/nomad/endpoints/${environmentId}/leader`, + { + params: {}, + } + ); + return data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/nomad/types.ts b/app/react/nomad/types.ts new file mode 100644 index 000000000..dcf2d1d32 --- /dev/null +++ b/app/react/nomad/types.ts @@ -0,0 +1,25 @@ +export type NomadEvent = { + Type: string; + Message: string; + Date: number; +}; + +export type NomadEventsList = NomadEvent[]; + +export type Task = { + JobID: string; + Namespace: string; + TaskName: string; + State: string; + TaskGroup: string; + AllocationID: string; + StartedAt: string; +}; + +export type Job = { + ID: string; + Status: string; + Namespace: string; + SubmitTime: string; + Tasks: Task[]; +}; diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx index a216d582a..99169d0c2 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx @@ -22,6 +22,7 @@ import { useTags } from '@/portainer/tags/queries'; import { useAgentVersionsList } from '@/react/portainer/environments/queries/useAgentVersionsList'; import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service'; import { useUser } from '@/react/hooks/useUser'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { TableFooter } from '@@/datatables/TableFooter'; import { TableActions, TableContainer, TableTitle } from '@@/datatables'; @@ -350,6 +351,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { EnvironmentType.AgentOnKubernetes, EnvironmentType.EdgeAgentOnKubernetes, ], + [PlatformType.Nomad]: [EnvironmentType.EdgeAgentOnNomad], }; const typesByConnection = { @@ -475,6 +477,7 @@ function getConnectionTypeOptions(platformTypes: Filter<PlatformType>[]) { ConnectionType.EdgeAgent, ConnectionType.EdgeDevice, ], + [PlatformType.Nomad]: [ConnectionType.EdgeAgent, ConnectionType.EdgeDevice], }; const connectionTypesDefaultOptions = [ @@ -501,6 +504,13 @@ function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) { { value: PlatformType.Kubernetes, label: 'Kubernetes' }, ]; + if (isBE) { + platformDefaultOptions.push({ + value: PlatformType.Nomad, + label: 'Nomad', + }); + } + if (connectionTypes.length === 0) { return platformDefaultOptions; } @@ -508,8 +518,16 @@ function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) { const connectionTypePlatformType = { [ConnectionType.API]: [PlatformType.Docker, PlatformType.Azure], [ConnectionType.Agent]: [PlatformType.Docker, PlatformType.Kubernetes], - [ConnectionType.EdgeAgent]: [PlatformType.Kubernetes, PlatformType.Docker], - [ConnectionType.EdgeDevice]: [PlatformType.Docker, PlatformType.Kubernetes], + [ConnectionType.EdgeAgent]: [ + PlatformType.Kubernetes, + PlatformType.Nomad, + PlatformType.Docker, + ], + [ConnectionType.EdgeDevice]: [ + PlatformType.Nomad, + PlatformType.Docker, + PlatformType.Kubernetes, + ], }; return _.compact( diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index a30d254ca..197d429c2 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -18,6 +18,8 @@ export enum EnvironmentType { AgentOnKubernetes, // EdgeAgentOnKubernetes represents an environment(endpoint) connected to an Edge agent deployed on a Kubernetes environment(endpoint) EdgeAgentOnKubernetes, + // EdgeAgentOnNomad represents an environment(endpoint) connected to an Edge agent deployed on a Nomad environment(endpoint) + EdgeAgentOnNomad, } export const EdgeTypes = [ @@ -147,4 +149,5 @@ export enum PlatformType { Docker, Kubernetes, Azure, + Nomad, } diff --git a/app/react/portainer/environments/utils/get-platform-icon.ts b/app/react/portainer/environments/utils/get-platform-icon.ts index c26231a05..2775861af 100644 --- a/app/react/portainer/environments/utils/get-platform-icon.ts +++ b/app/react/portainer/environments/utils/get-platform-icon.ts @@ -7,6 +7,7 @@ import { import Docker from './docker.svg?c'; import Azure from './azure.svg?c'; import Kubernetes from './kubernetes.svg?c'; +import Nomad from './nomad.svg?c'; const icons: { [key in PlatformType]: SvgrComponent; @@ -14,6 +15,7 @@ const icons: { [PlatformType.Docker]: Docker, [PlatformType.Kubernetes]: Kubernetes, [PlatformType.Azure]: Azure, + [PlatformType.Nomad]: Nomad, }; export function getPlatformIcon(type: EnvironmentType) { diff --git a/app/react/portainer/environments/utils/index.ts b/app/react/portainer/environments/utils/index.ts index 97d967bd6..d1513aa50 100644 --- a/app/react/portainer/environments/utils/index.ts +++ b/app/react/portainer/environments/utils/index.ts @@ -12,8 +12,10 @@ export function getPlatformType(envType: EnvironmentType) { return PlatformType.Docker; case EnvironmentType.Azure: return PlatformType.Azure; + case EnvironmentType.EdgeAgentOnNomad: + return PlatformType.Nomad; default: - throw new Error(`${envType} is not a supported environment type`); + throw new Error(`Environment Type ${envType} is not supported`); } } @@ -25,6 +27,14 @@ export function isKubernetesEnvironment(envType: EnvironmentType) { return getPlatformType(envType) === PlatformType.Kubernetes; } +export function getPlatformTypeName(envType: EnvironmentType): string { + return PlatformType[getPlatformType(envType)]; +} + +export function isNomadEnvironment(envType: EnvironmentType) { + return getPlatformType(envType) === PlatformType.Nomad; +} + export function isAgentEnvironment(envType: EnvironmentType) { return ( isEdgeEnvironment(envType) || diff --git a/app/react/sidebar/EnvironmentSidebar.tsx b/app/react/sidebar/EnvironmentSidebar.tsx index 2f760de14..25baa608a 100644 --- a/app/react/sidebar/EnvironmentSidebar.tsx +++ b/app/react/sidebar/EnvironmentSidebar.tsx @@ -13,6 +13,7 @@ import { getPlatformType } from '@/react/portainer/environments/utils'; import { useEnvironment } from '@/react/portainer/environments/queries/useEnvironment'; import { useLocalStorage } from '@/react/hooks/useLocalStorage'; import { EndpointProviderInterface } from '@/portainer/services/endpointProvider'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { getPlatformIcon } from '../portainer/environments/utils/get-platform-icon'; @@ -22,6 +23,7 @@ import { DockerSidebar } from './DockerSidebar'; import { KubernetesSidebar } from './KubernetesSidebar'; import { SidebarSection, SidebarSectionTitle } from './SidebarSection'; import { useSidebarState } from './useSidebarState'; +import { NomadSidebar } from './NomadSidebar'; export function EnvironmentSidebar() { const { query: currentEnvironmentQuery, clearEnvironment } = @@ -67,7 +69,9 @@ function Content({ environment, onClear }: ContentProps) { showTitleWhenOpen > <div className="mt-2"> - <Sidebar environmentId={environment.Id} environment={environment} /> + {Sidebar && ( + <Sidebar environmentId={environment.Id} environment={environment} /> + )} </div> </SidebarSection> ); @@ -77,11 +81,12 @@ function Content({ environment, onClear }: ContentProps) { [key in PlatformType]: React.ComponentType<{ environmentId: EnvironmentId; environment: Environment; - }>; + }> | null; } = { [PlatformType.Azure]: AzureSidebar, [PlatformType.Docker]: DockerSidebar, [PlatformType.Kubernetes]: KubernetesSidebar, + [PlatformType.Nomad]: isBE ? NomadSidebar : null, }; return sidebar[platform]; diff --git a/app/react/sidebar/NomadSidebar/NomadSidebar.test.tsx b/app/react/sidebar/NomadSidebar/NomadSidebar.test.tsx new file mode 100644 index 000000000..8f71f77a4 --- /dev/null +++ b/app/react/sidebar/NomadSidebar/NomadSidebar.test.tsx @@ -0,0 +1,38 @@ +import { UserContext } from '@/react/hooks/useUser'; +import { UserViewModel } from '@/portainer/models/user'; +import { render, within } from '@/react-tools/test-utils'; + +import { TestSidebarProvider } from '../useSidebarState'; + +import { NomadSidebar } from './NomadSidebar'; + +test('dashboard items should render correctly', () => { + const { getByLabelText } = renderComponent(); + const dashboardItem = getByLabelText(/Dashboard/i); + expect(dashboardItem).toBeVisible(); + expect(dashboardItem).toHaveTextContent('Dashboard'); + + const dashboardItemElements = within(dashboardItem); + expect( + dashboardItemElements.getByRole('img', { hidden: true }) + ).toBeVisible(); + + const jobsItem = getByLabelText('Nomad Jobs'); + expect(jobsItem).toBeVisible(); + expect(jobsItem).toHaveTextContent('Jobs'); + + const jobsItemElements = within(jobsItem); + expect(jobsItemElements.getByRole('img', { hidden: true })).toBeVisible(); +}); + +function renderComponent() { + const user = new UserViewModel({ Username: 'user' }); + + return render( + <UserContext.Provider value={{ user }}> + <TestSidebarProvider> + <NomadSidebar environmentId={1} /> + </TestSidebarProvider> + </UserContext.Provider> + ); +} diff --git a/app/react/sidebar/NomadSidebar/NomadSidebar.tsx b/app/react/sidebar/NomadSidebar/NomadSidebar.tsx new file mode 100644 index 000000000..6e19bf92b --- /dev/null +++ b/app/react/sidebar/NomadSidebar/NomadSidebar.tsx @@ -0,0 +1,30 @@ +import { Clock } from 'react-feather'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { DashboardLink } from '../items/DashboardLink'; +import { SidebarItem } from '../SidebarItem'; + +interface Props { + environmentId: EnvironmentId; +} + +export function NomadSidebar({ environmentId }: Props) { + return ( + <> + <DashboardLink + environmentId={environmentId} + platformPath="nomad" + data-cy="nomadSidebar-dashboard" + /> + + <SidebarItem + to="nomad.jobs" + params={{ endpointId: environmentId }} + icon={Clock} + label="Nomad Jobs" + data-cy="nomadSidebar-jobs" + /> + </> + ); +} diff --git a/app/react/sidebar/NomadSidebar/index.ts b/app/react/sidebar/NomadSidebar/index.ts new file mode 100644 index 000000000..27ec2b481 --- /dev/null +++ b/app/react/sidebar/NomadSidebar/index.ts @@ -0,0 +1 @@ +export { NomadSidebar } from './NomadSidebar';