refactor(nomad): sync frontend with EE [EE-3353] (#7758)

pull/7761/head
Chaim Lev-Ari 2022-11-13 12:29:25 +02:00 committed by GitHub
parent 78dcba614d
commit 881e99df53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 1799 additions and 17 deletions

View File

@ -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)

View File

@ -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',

View File

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

View File

@ -0,0 +1,3 @@
import { BROWSER_OS_PLATFORM } from '@/react/constants';
export const NEW_LINE_BREAKER = BROWSER_OS_PLATFORM === 'win' ? '\r\n' : '\n';

View File

@ -1,2 +1,3 @@
export { formatLogs } from './formatLogs';
export { concatLogsToString } from './concatLogsToString';
export { NEW_LINE_BREAKER } from './constants';

View File

@ -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);
}

9
app/nomad/logs/index.ts Normal file
View File

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

3
app/nomad/logs/logs.html Normal file
View File

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

6
app/nomad/logs/logs.ts Normal file
View File

@ -0,0 +1,6 @@
import controller from './logsController';
export const logsView = {
templateUrl: './logs.html',
controller,
};

View File

@ -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();
}

View File

@ -0,0 +1 @@
export { nomadLogViewer } from './nomad-log-viewer';

View File

@ -0,0 +1,12 @@
import controller from './nomadLogViewerController';
export const nomadLogViewer = {
templateUrl: './nomadLogViewer.html',
controller,
bindings: {
stderrLog: '<',
stdoutLog: '<',
resourceName: '<',
logCollectionChange: '<',
},
};

View File

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

View File

@ -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');
};
}

11
app/react/constants.ts Normal file
View File

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

View File

View File

@ -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>
</>
)}
</>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export { DashboardView } from './DashboardView';

View File

@ -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);
}
}

View File

@ -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 },
}));
}
}

View File

@ -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,
};

View File

@ -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], []);
}

View File

@ -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,
};

View File

@ -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,
};

View File

@ -0,0 +1 @@
export { EventsDatatable } from './EventsDatatable';

View File

@ -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>
</>
);
}

View File

@ -0,0 +1 @@
export { EventsView } from './EventsView';

View File

@ -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);
}
}

View File

@ -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 },
}));
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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>
);
}

View File

@ -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,
};

View File

@ -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],
[]
);
}

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

@ -0,0 +1 @@
export { TasksDatatable } from './TasksDatatable';

View File

@ -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();
},
});
}
}

View File

@ -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}`
);
}
})
);
}

View File

@ -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>
);
}

View File

@ -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,
};

View File

@ -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], []);
}

View File

@ -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>
);
}

View File

@ -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,
};

View File

@ -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,
};

View File

@ -0,0 +1 @@
export { JobsDatatable } from './JobsDatatable';

View File

@ -0,0 +1,5 @@
export interface JobsTableSettings {
autoRefreshRate: number;
pageSize: number;
sortBy: { id: string; desc: boolean };
}

View File

@ -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>
</>
);
}

View File

@ -0,0 +1 @@
export { JobsView } from './JobsView';

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

25
app/react/nomad/types.ts Normal file
View File

@ -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[];
};

View File

@ -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(

View File

@ -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,
}

View File

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

View File

@ -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) ||

View File

@ -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];

View File

@ -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>
);
}

View File

@ -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"
/>
</>
);
}

View File

@ -0,0 +1 @@
export { NomadSidebar } from './NomadSidebar';