mirror of https://github.com/portainer/portainer
refactor(nomad): sync frontend with EE [EE-3353] (#7758)
parent
78dcba614d
commit
881e99df53
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { BROWSER_OS_PLATFORM } from '@/react/constants';
|
||||
|
||||
export const NEW_LINE_BREAKER = BROWSER_OS_PLATFORM === 'win' ? '\r\n' : '\n';
|
|
@ -1,2 +1,3 @@
|
|||
export { formatLogs } from './formatLogs';
|
||||
export { concatLogsToString } from './concatLogsToString';
|
||||
export { NEW_LINE_BREAKER } from './constants';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
|||
import controller from './logsController';
|
||||
|
||||
export const logsView = {
|
||||
templateUrl: './logs.html',
|
||||
controller,
|
||||
};
|
|
@ -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();
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { nomadLogViewer } from './nomad-log-viewer';
|
|
@ -0,0 +1,12 @@
|
|||
import controller from './nomadLogViewerController';
|
||||
|
||||
export const nomadLogViewer = {
|
||||
templateUrl: './nomadLogViewer.html',
|
||||
controller,
|
||||
bindings: {
|
||||
stderrLog: '<',
|
||||
stdoutLog: '<',
|
||||
resourceName: '<',
|
||||
logCollectionChange: '<',
|
||||
},
|
||||
};
|
|
@ -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>
|
|
@ -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');
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { DashboardView } from './DashboardView';
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 },
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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], []);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { EventsDatatable } from './EventsDatatable';
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { EventsView } from './EventsView';
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 },
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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],
|
||||
[]
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { TasksDatatable } from './TasksDatatable';
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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], []);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { JobsDatatable } from './JobsDatatable';
|
|
@ -0,0 +1,5 @@
|
|||
export interface JobsTableSettings {
|
||||
autoRefreshRate: number;
|
||||
pageSize: number;
|
||||
sortBy: { id: string; desc: boolean };
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { JobsView } from './JobsView';
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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[];
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) ||
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { NomadSidebar } from './NomadSidebar';
|
Loading…
Reference in New Issue