mirror of
https://github.com/kemko/nomad.git
synced 2026-01-01 16:05:42 +03:00
611 lines
16 KiB
JavaScript
611 lines
16 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
// @ts-check
|
|
|
|
import Controller from '@ember/controller';
|
|
import { inject as service } from '@ember/service';
|
|
import { action, computed, set } from '@ember/object';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
|
|
import { restartableTask, timeout } from 'ember-concurrency';
|
|
import Ember from 'ember';
|
|
|
|
const JOB_LIST_THROTTLE = 5000;
|
|
const JOB_DETAILS_THROTTLE = 1000;
|
|
|
|
export default class JobsIndexController extends Controller {
|
|
@service router;
|
|
@service system;
|
|
@service store;
|
|
@service userSettings;
|
|
@service watchList;
|
|
|
|
@tracked pageSize;
|
|
|
|
constructor() {
|
|
super(...arguments);
|
|
this.pageSize = this.userSettings.pageSize;
|
|
this.rawSearchText = this.searchText || '';
|
|
}
|
|
|
|
queryParams = [
|
|
'cursorAt',
|
|
'pageSize',
|
|
{ qpNamespace: 'namespace' },
|
|
'filter',
|
|
];
|
|
|
|
isForbidden = false;
|
|
|
|
@tracked jobQueryIndex = 0;
|
|
@tracked jobAllocsQueryIndex = 0;
|
|
|
|
@tracked qpNamespace = '*';
|
|
|
|
get tableColumns() {
|
|
return [
|
|
'name',
|
|
this.system.shouldShowNamespaces ? 'namespace' : null,
|
|
'status',
|
|
'type',
|
|
this.system.shouldShowNodepools ? 'node pool' : null, // TODO: implement on system service
|
|
'running allocations',
|
|
]
|
|
.filter((c) => !!c)
|
|
.map((c) => {
|
|
return {
|
|
label: c.charAt(0).toUpperCase() + c.slice(1),
|
|
width: c === 'running allocations' ? '200px' : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
@tracked jobs = [];
|
|
@tracked jobIDs = [];
|
|
@tracked pendingJobs = null;
|
|
@tracked pendingJobIDs = null;
|
|
|
|
@action
|
|
gotoJob(job) {
|
|
this.router.transitionTo('jobs.job.index', job.idWithNamespace);
|
|
}
|
|
|
|
@action
|
|
goToRun() {
|
|
this.router.transitionTo('jobs.run');
|
|
}
|
|
|
|
// #region pagination
|
|
@tracked cursorAt;
|
|
@tracked nextToken; // route sets this when new data is fetched
|
|
|
|
/**
|
|
*
|
|
* @param {"prev"|"next"} page
|
|
*/
|
|
@action async handlePageChange(page) {
|
|
// reset indexes
|
|
this.jobQueryIndex = 0;
|
|
this.jobAllocsQueryIndex = 0;
|
|
|
|
if (page === 'prev') {
|
|
if (!this.cursorAt) {
|
|
return;
|
|
}
|
|
// Note (and TODO:) this isn't particularly efficient!
|
|
// We're making an extra full request to get the nextToken we need,
|
|
// but actually the results of that request are the reverse order, plus one job,
|
|
// of what we actually want to show on the page!
|
|
// I should investigate whether I can use the results of this query to
|
|
// overwrite this controller's jobIDs, leverage its index, and
|
|
// restart a blocking watchJobIDs here.
|
|
let prevPageToken = await this.loadPreviousPageToken();
|
|
// If there's no nextToken, we're at the "start" of our list and can drop the cursorAt
|
|
if (!prevPageToken.meta.nextToken) {
|
|
this.cursorAt = undefined;
|
|
} else {
|
|
// cursorAt should be the highest modifyIndex from the previous query.
|
|
// This will immediately fire the route model hook with the new cursorAt
|
|
this.cursorAt = prevPageToken
|
|
.sortBy('modifyIndex')
|
|
.get('lastObject').modifyIndex;
|
|
}
|
|
} else if (page === 'next') {
|
|
if (!this.nextToken) {
|
|
return;
|
|
}
|
|
this.cursorAt = this.nextToken;
|
|
} else if (page === 'first') {
|
|
this.cursorAt = undefined;
|
|
} else if (page === 'last') {
|
|
let prevPageToken = await this.loadPreviousPageToken({ last: true });
|
|
this.cursorAt = prevPageToken
|
|
.sortBy('modifyIndex')
|
|
.get('lastObject').modifyIndex;
|
|
}
|
|
}
|
|
|
|
@action handlePageSizeChange(size) {
|
|
this.pageSize = size;
|
|
}
|
|
|
|
get pendingJobIDDiff() {
|
|
return (
|
|
this.pendingJobIDs &&
|
|
JSON.stringify(
|
|
this.pendingJobIDs.map((j) => `${j.namespace}.${j.id}`)
|
|
) !== JSON.stringify(this.jobIDs.map((j) => `${j.namespace}.${j.id}`))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Manually, on click, update jobs from pendingJobs
|
|
* when live updates are disabled (via nomadLiveUpdateJobsIndex)
|
|
*/
|
|
@restartableTask *updateJobList() {
|
|
this.jobs = this.pendingJobs;
|
|
this.pendingJobs = null;
|
|
this.jobIDs = this.pendingJobIDs;
|
|
this.pendingJobIDs = null;
|
|
yield this.watchJobs.perform(
|
|
this.jobIDs,
|
|
Ember.testing ? 0 : JOB_DETAILS_THROTTLE
|
|
);
|
|
}
|
|
|
|
@localStorageProperty('nomadLiveUpdateJobsIndex', true) liveUpdatesEnabled;
|
|
|
|
// #endregion pagination
|
|
|
|
//#region querying
|
|
|
|
jobQuery(params) {
|
|
this.watchList.jobsIndexIDsController.abort();
|
|
this.watchList.jobsIndexIDsController = new AbortController();
|
|
|
|
return this.store
|
|
.query('job', params, {
|
|
adapterOptions: {
|
|
abortController: this.watchList.jobsIndexIDsController,
|
|
},
|
|
})
|
|
.catch((e) => {
|
|
if (e.name !== 'AbortError') {
|
|
console.log('error fetching job ids', e);
|
|
}
|
|
return;
|
|
});
|
|
}
|
|
|
|
jobAllocsQuery(params) {
|
|
this.watchList.jobsIndexDetailsController.abort();
|
|
this.watchList.jobsIndexDetailsController = new AbortController();
|
|
params.namespace = '*';
|
|
return this.store
|
|
.query('job', params, {
|
|
adapterOptions: {
|
|
method: 'POST',
|
|
abortController: this.watchList.jobsIndexDetailsController,
|
|
},
|
|
})
|
|
.catch((e) => {
|
|
if (e.name !== 'AbortError') {
|
|
console.log('error fetching job allocs', e);
|
|
}
|
|
return;
|
|
});
|
|
}
|
|
|
|
// Ask for the previous #page_size jobs, starting at the first job that's currently shown
|
|
// on our page, and the last one in our list should be the one we use for our
|
|
// subsequent nextToken.
|
|
async loadPreviousPageToken({ last = false } = {}) {
|
|
let next_token = +this.cursorAt + 1;
|
|
if (last) {
|
|
next_token = undefined;
|
|
}
|
|
let prevPageToken = await this.store.query(
|
|
'job',
|
|
{
|
|
next_token,
|
|
per_page: this.pageSize,
|
|
reverse: true,
|
|
},
|
|
{
|
|
adapterOptions: {
|
|
method: 'GET',
|
|
},
|
|
}
|
|
);
|
|
return prevPageToken;
|
|
}
|
|
|
|
@restartableTask *watchJobIDs(
|
|
params,
|
|
throttle = Ember.testing ? 0 : JOB_LIST_THROTTLE
|
|
) {
|
|
while (true) {
|
|
let currentParams = params;
|
|
currentParams.index = this.jobQueryIndex;
|
|
const newJobs = yield this.jobQuery(currentParams, {});
|
|
if (newJobs) {
|
|
if (newJobs.meta.index) {
|
|
this.jobQueryIndex = newJobs.meta.index;
|
|
}
|
|
if (newJobs.meta.nextToken) {
|
|
this.nextToken = newJobs.meta.nextToken;
|
|
} else {
|
|
this.nextToken = null;
|
|
}
|
|
|
|
const jobIDs = newJobs.map((job) => ({
|
|
id: job.plainId,
|
|
namespace: job.belongsTo('namespace').id(),
|
|
}));
|
|
|
|
const okayToJostle = this.liveUpdatesEnabled;
|
|
if (okayToJostle) {
|
|
this.jobIDs = jobIDs;
|
|
this.watchList.jobsIndexDetailsController.abort();
|
|
this.jobAllocsQueryIndex = 0;
|
|
this.watchList.jobsIndexDetailsController = new AbortController();
|
|
this.watchJobs.perform(jobIDs, throttle);
|
|
} else {
|
|
this.pendingJobIDs = jobIDs;
|
|
this.pendingJobs = newJobs;
|
|
}
|
|
yield timeout(throttle);
|
|
} else {
|
|
// This returns undefined on page change / cursorAt change, resulting from the aborting of the old query.
|
|
yield timeout(throttle);
|
|
this.watchJobs.perform(this.jobIDs, throttle);
|
|
continue;
|
|
}
|
|
if (Ember.testing) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Called in 3 ways:
|
|
// 1. via the setupController of the jobs index route's model
|
|
// (which can happen both on initial load, and should the queryParams change)
|
|
// 2. via the watchJobIDs task seeing new jobIDs
|
|
// 3. via the user manually clicking to updateJobList()
|
|
@restartableTask *watchJobs(
|
|
jobIDs,
|
|
throttle = Ember.testing ? 0 : JOB_DETAILS_THROTTLE
|
|
) {
|
|
while (true) {
|
|
if (jobIDs && jobIDs.length > 0) {
|
|
let jobDetails = yield this.jobAllocsQuery({
|
|
jobs: jobIDs,
|
|
index: this.jobAllocsQueryIndex,
|
|
});
|
|
if (jobDetails) {
|
|
if (jobDetails.meta.index) {
|
|
this.jobAllocsQueryIndex = jobDetails.meta.index;
|
|
}
|
|
}
|
|
this.jobs = jobDetails;
|
|
} else {
|
|
// No jobs have returned, so clear the list
|
|
this.jobs = [];
|
|
}
|
|
yield timeout(throttle);
|
|
if (Ember.testing) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
//#endregion querying
|
|
|
|
//#region filtering and searching
|
|
|
|
@tracked statusFacet = {
|
|
label: 'Status',
|
|
options: [
|
|
{
|
|
key: 'pending',
|
|
string: 'Status == pending',
|
|
checked: false,
|
|
},
|
|
{
|
|
key: 'running',
|
|
string: 'Status == running',
|
|
checked: false,
|
|
},
|
|
{
|
|
key: 'dead',
|
|
string: 'Status == dead',
|
|
checked: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
@tracked typeFacet = {
|
|
label: 'Type',
|
|
options: [
|
|
{
|
|
key: 'batch',
|
|
string: 'Type == batch',
|
|
checked: false,
|
|
},
|
|
{
|
|
key: 'service',
|
|
string: 'Type == service',
|
|
checked: false,
|
|
},
|
|
{
|
|
key: 'system',
|
|
string: 'Type == system',
|
|
checked: false,
|
|
},
|
|
{
|
|
key: 'sysbatch',
|
|
string: 'Type == sysbatch',
|
|
checked: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
@tracked nodePoolFacet = {
|
|
label: 'NodePool',
|
|
options: (this.model.nodePools || []).map((nodePool) => ({
|
|
key: nodePool.name,
|
|
string: `NodePool == ${nodePool.name}`,
|
|
checked: false,
|
|
})),
|
|
};
|
|
|
|
@computed('system.shouldShowNamespaces', 'model.namespaces.[]', 'qpNamespace')
|
|
get namespaceFacet() {
|
|
if (!this.system.shouldShowNamespaces) {
|
|
return null;
|
|
}
|
|
|
|
const availableNamespaces = (this.model.namespaces || []).map(
|
|
(namespace) => ({
|
|
key: namespace.name,
|
|
label: namespace.name,
|
|
})
|
|
);
|
|
|
|
availableNamespaces.unshift({
|
|
key: '*',
|
|
label: 'All',
|
|
});
|
|
|
|
let selectedNamespaces = this.qpNamespace || '*';
|
|
availableNamespaces.forEach((opt) => {
|
|
if (selectedNamespaces.includes(opt.key)) {
|
|
opt.checked = true;
|
|
}
|
|
});
|
|
|
|
return {
|
|
label: 'Namespace',
|
|
options: availableNamespaces,
|
|
};
|
|
}
|
|
|
|
@tracked namespaceFilter = '';
|
|
|
|
get shownNamespaces() {
|
|
return this.namespaceFacet.options.filter((option) =>
|
|
option.label.toLowerCase().includes(this.namespaceFilter)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pares down the list of namespaces
|
|
* @param {InputEvent & { target: HTMLInputElement }} event - The input event
|
|
*/
|
|
@action filterNamespaces(event) {
|
|
this.namespaceFilter = event.target.value.toLowerCase();
|
|
}
|
|
|
|
get filterFacets() {
|
|
let facets = [this.statusFacet, this.typeFacet];
|
|
if (this.system.shouldShowNodepools) {
|
|
facets.push(this.nodePoolFacet);
|
|
}
|
|
return facets;
|
|
}
|
|
|
|
/**
|
|
* On page load, takes the ?filter queryParam, and extracts it into those
|
|
* properties used by the dropdown filter toggles, and the search text.
|
|
*/
|
|
parseFilter() {
|
|
let filterString = this.filter;
|
|
if (!filterString) {
|
|
return;
|
|
}
|
|
|
|
const filterParts = filterString.split(' and ');
|
|
|
|
let unmatchedFilters = [];
|
|
|
|
// For each of those splits, if it starts and ends with (), and if all entries within it have thes ame Propname and operator of ==, populate them into the appropriate dropdown
|
|
// If it doesnt start with and end with (), or if it does but not all entries are the same propname, or not all entries have == operators, populate them into the searchbox
|
|
|
|
filterParts.forEach((part) => {
|
|
let matched = false;
|
|
if (part.startsWith('(') && part.endsWith(')')) {
|
|
part = part.slice(1, -1); // trim the parens
|
|
// Check to see if the property name (first word) is one of the ones for which we have a dropdown
|
|
let propName = part.split(' ')[0];
|
|
if (this.filterFacets.find((facet) => facet.label === propName)) {
|
|
// Split along "or" and check that all parts have the same propName
|
|
let facetParts = part.split(' or ');
|
|
let allMatch = facetParts.every((facetPart) =>
|
|
facetPart.startsWith(propName)
|
|
);
|
|
let allEqualityOperators = facetParts.every((facetPart) =>
|
|
facetPart.includes('==')
|
|
);
|
|
if (allMatch && allEqualityOperators) {
|
|
// Set all the options in the dropdown to checked
|
|
this.filterFacets.forEach((group) => {
|
|
if (group.label === propName) {
|
|
group.options.forEach((option) => {
|
|
set(option, 'checked', facetParts.includes(option.string));
|
|
});
|
|
}
|
|
});
|
|
matched = true;
|
|
}
|
|
}
|
|
}
|
|
if (!matched) {
|
|
unmatchedFilters.push(part);
|
|
}
|
|
});
|
|
|
|
// Combine all unmatched filter parts into the searchText
|
|
this.searchText = unmatchedFilters.join(' and ');
|
|
this.rawSearchText = this.searchText;
|
|
}
|
|
|
|
@computed(
|
|
'filterFacets',
|
|
'nodePoolFacet.options.@each.checked',
|
|
'searchText',
|
|
'statusFacet.options.@each.checked',
|
|
'typeFacet.options.@each.checked'
|
|
)
|
|
get computedFilter() {
|
|
let parts = this.searchText ? [this.searchText] : [];
|
|
this.filterFacets.forEach((group) => {
|
|
let groupParts = [];
|
|
group.options.forEach((option) => {
|
|
if (option.checked) {
|
|
groupParts.push(option.string);
|
|
}
|
|
});
|
|
if (groupParts.length) {
|
|
parts.push(`(${groupParts.join(' or ')})`);
|
|
}
|
|
});
|
|
return parts.join(' and ');
|
|
}
|
|
|
|
@action
|
|
toggleOption(option) {
|
|
set(option, 'checked', !option.checked);
|
|
this.updateFilter();
|
|
}
|
|
|
|
// Radio button set
|
|
@action
|
|
toggleNamespaceOption(option, dropdown) {
|
|
this.qpNamespace = option.key;
|
|
dropdown.close();
|
|
}
|
|
|
|
@action
|
|
updateFilter() {
|
|
this.cursorAt = null;
|
|
this.filter = this.computedFilter;
|
|
}
|
|
|
|
@tracked filter = '';
|
|
@tracked searchText = '';
|
|
@tracked rawSearchText = '';
|
|
|
|
@action resetFilters() {
|
|
this.searchText = '';
|
|
this.rawSearchText = '';
|
|
this.filterFacets.forEach((group) => {
|
|
group.options.forEach((option) => {
|
|
set(option, 'checked', false);
|
|
});
|
|
});
|
|
this.qpNamespace = '*';
|
|
this.updateFilter();
|
|
}
|
|
|
|
/**
|
|
* Updates the filter based on the input, distinguishing between simple job names and filter expressions.
|
|
* A simple check for operators with surrounding spaces is used to identify filter expressions.
|
|
*
|
|
* @param {string} newFilter
|
|
*/
|
|
@action
|
|
updateSearchText(newFilter) {
|
|
if (!newFilter.trim()) {
|
|
this.searchText = '';
|
|
return;
|
|
}
|
|
|
|
newFilter = newFilter.trim();
|
|
|
|
const operators = [
|
|
'==',
|
|
'!=',
|
|
'contains',
|
|
'not contains',
|
|
'is empty',
|
|
'is not empty',
|
|
'matches',
|
|
'not matches',
|
|
'in',
|
|
'not in',
|
|
];
|
|
|
|
// Check for any operator surrounded by spaces
|
|
let isFilterExpression = operators.some((op) =>
|
|
newFilter.includes(` ${op}`)
|
|
);
|
|
|
|
if (isFilterExpression) {
|
|
this.searchText = newFilter;
|
|
} else {
|
|
// If it's a string without a filter operator, assume the user is trying to look up a job name
|
|
this.searchText = `Name contains "${newFilter}"`;
|
|
}
|
|
}
|
|
|
|
get humanizedFilterError() {
|
|
let baseString = `No jobs match your current filter selection: ${this.filter}.`;
|
|
if (this.model.error?.humanized) {
|
|
return `${baseString} ${this.model.error.humanized}`;
|
|
}
|
|
return baseString;
|
|
}
|
|
|
|
@action correctFilterKey({ incorrectKey, correctKey }) {
|
|
this.searchText = this.searchText.replace(incorrectKey, correctKey);
|
|
this.rawSearchText = this.searchText;
|
|
this.updateFilter();
|
|
}
|
|
|
|
@action suggestFilter({ example }) {
|
|
this.searchText = example;
|
|
this.rawSearchText = this.searchText;
|
|
this.updateFilter();
|
|
}
|
|
|
|
// A list of combinatorial filters to show off filter expressions
|
|
// Make use of our various operators, and our various known keys
|
|
@computed('filter')
|
|
get exampleFilter() {
|
|
let examples = [
|
|
'(Status == dead) and (Type != batch)',
|
|
'(Version != 0) and (Namespace == default)',
|
|
'(StatusDescription not contains "progress deadline")',
|
|
'(Region != global) and (NodePool is not empty)',
|
|
'(Namespace != myNamespace) and (Status != running)',
|
|
'NodePool is not empty',
|
|
'(dc1 in Datacenters) or (dc2 in Datacenters)',
|
|
];
|
|
return examples[Math.floor(Math.random() * examples.length)];
|
|
}
|
|
|
|
//#endregion filtering and searching
|
|
}
|