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';