diff --git a/services/harmony/app/frontends/workflow-ui.ts b/services/harmony/app/frontends/workflow-ui.ts index 01880fa10..c62c8f7b5 100644 --- a/services/harmony/app/frontends/workflow-ui.ts +++ b/services/harmony/app/frontends/workflow-ui.ts @@ -53,6 +53,8 @@ interface TableQuery { userValues: string[], providerValues: string[], labelValues: string[], + messageCategoryValues: string[], + allowMessageCategoryValues: boolean, from: Date, to: Date, dateKind: 'createdAt' | 'updatedAt', @@ -85,10 +87,12 @@ function parseQuery( /* eslint-disable @typescript-eslint/no-explicit-any */ userValues: [], providerValues: [], labelValues: [], + messageCategoryValues: [], allowStatuses: true, allowServices: true, allowUsers: true, allowProviders: true, + allowMessageCategoryValues: true, // date controls from: undefined, to: undefined, @@ -101,6 +105,7 @@ function parseQuery( /* eslint-disable @typescript-eslint/no-explicit-any */ tableQuery.allowServices = !(requestQuery.disallowservice === 'on'); tableQuery.allowUsers = !(requestQuery.disallowuser === 'on'); tableQuery.allowProviders = !(requestQuery.disallowprovider === 'on'); + tableQuery.allowMessageCategoryValues = !(requestQuery.disallowmessagecategory === 'on'); const selectedOptions: { field: string, dbValue: string, value: string }[] = JSON.parse(requestQuery.tablefilter); const validStatusSelections = selectedOptions @@ -123,6 +128,10 @@ function parseQuery( /* eslint-disable @typescript-eslint/no-explicit-any */ .filter(option => /^provider: [A-Za-z0-9_]{1,100}$/.test(option.value)); const providerValues = validProviderSelections.map(option => option.value.split('provider: ')[1].toLowerCase()); + const validMessageCategorySelections = selectedOptions + .filter(option => /^message category: .{1,100}$/.test(option.value)); + const messageCategoryValues = validMessageCategorySelections.map(option => option.dbValue || option.value.split('message category: ')[1].toLowerCase()); + if ((statusValues.length + serviceValues.length + userValues.length + providerValues.length) > maxFilters) { throw new RequestValidationError(`Maximum amount of filters (${maxFilters}) was exceeded.`); } @@ -130,12 +139,14 @@ function parseQuery( /* eslint-disable @typescript-eslint/no-explicit-any */ .concat(validServiceSelections) .concat(validUserSelections) .concat(validProviderSelections) - .concat(validLabelSelections)); + .concat(validLabelSelections) + .concat(validMessageCategorySelections)); tableQuery.statusValues = statusValues; tableQuery.serviceValues = serviceValues; tableQuery.userValues = userValues; tableQuery.providerValues = providerValues; tableQuery.labelValues = labelValues; + tableQuery.messageCategoryValues = messageCategoryValues; } // everything in the Workflow UI uses the browser timezone, so we need a timezone offset const offSetMs = parseInt(requestQuery.tzoffsetminutes || 0) * 60 * 1000; @@ -426,6 +437,7 @@ export async function getJob( updatedAtChecked: dateKind == 'updatedAt' ? 'checked' : '', createdAtChecked: dateKind != 'updatedAt' ? 'checked' : '', disallowStatusChecked: requestQuery.disallowstatus === 'on' ? 'checked' : '', + disallowMessageCategoryChecked: requestQuery.disallowmessagecategory === 'on' ? 'checked' : '', selectedFilters: originalValues, version, isAdminRoute: req.context.isAdminAccess, @@ -481,8 +493,10 @@ function workItemRenderingFunctions(job: Job, isAdmin: boolean, isLogViewer: boo badgeClasses[WorkItemStatus.SUCCESSFUL] = 'success'; badgeClasses[WorkItemStatus.RUNNING] = 'info'; badgeClasses[WorkItemStatus.QUEUED] = 'warning'; + badgeClasses[WorkItemStatus.WARNING] = 'warning'; return { workflowItemBadge(): string { return badgeClasses[this.status]; }, + workflowItemStatus(): string { return this.message_category ? `${this.status}: ${this.message_category}` : this.status; }, workflowItemStep(): string { return sanitizeImage(this.serviceID); }, workflowItemCreatedAt(): string { return this.createdAt.getTime(); }, workflowItemUpdatedAt(): string { return this.updatedAt.getTime(); }, @@ -536,6 +550,12 @@ function tableQueryToWorkItemQuery(tableFilter: TableQuery, jobID: string, id?: in: tableFilter.allowStatuses, }; } + if (tableFilter.messageCategoryValues.length) { + itemQuery.whereIn.message_category = { + values: tableFilter.messageCategoryValues, + in: tableFilter.allowMessageCategoryValues, + }; + } if (tableFilter.from || tableFilter.to) { itemQuery.dates = { field: tableFilter.dateKind }; itemQuery.dates.from = tableFilter.from; diff --git a/services/harmony/app/models/work-item-interface.ts b/services/harmony/app/models/work-item-interface.ts index 72986ec0b..c3db41fc5 100644 --- a/services/harmony/app/models/work-item-interface.ts +++ b/services/harmony/app/models/work-item-interface.ts @@ -91,6 +91,7 @@ export interface WorkItemQuery { }; whereIn?: { status?: { in: boolean, values: string[] }; + message_category?: { in: boolean, values: string[] }; }; dates?: { from?: Date; diff --git a/services/harmony/app/models/work-item.ts b/services/harmony/app/models/work-item.ts index 93eb233be..bf4136b35 100644 --- a/services/harmony/app/models/work-item.ts +++ b/services/harmony/app/models/work-item.ts @@ -431,7 +431,7 @@ export async function queryAll( if (constraint.in) { void queryBuilder.whereIn(field, constraint.values); } else { - void queryBuilder.whereNotIn(field, constraint.values); + void queryBuilder.where(builder => builder.whereNotIn(field, constraint.values).orWhereNull(field)); } } } diff --git a/services/harmony/app/views/workflow-ui/job/index.mustache.html b/services/harmony/app/views/workflow-ui/job/index.mustache.html index 852be7034..288347e6b 100644 --- a/services/harmony/app/views/workflow-ui/job/index.mustache.html +++ b/services/harmony/app/views/workflow-ui/job/index.mustache.html @@ -63,6 +63,12 @@ negate statuses +
+ + +
page size diff --git a/services/harmony/app/views/workflow-ui/job/work-item-table-row.mustache.html b/services/harmony/app/views/workflow-ui/job/work-item-table-row.mustache.html index d25e01e21..7b35c4692 100644 --- a/services/harmony/app/views/workflow-ui/job/work-item-table-row.mustache.html +++ b/services/harmony/app/views/workflow-ui/job/work-item-table-row.mustache.html @@ -2,7 +2,7 @@ {{workflowStepIndex}} {{workflowItemStep}} {{id}} - {{status}} + {{workflowItemStatus}} {{#isAdminOrLogViewer}} {{{workflowItemLogsButton}}} {{/isAdminOrLogViewer}} diff --git a/services/harmony/public/js/workflow-ui/job/index.js b/services/harmony/public/js/workflow-ui/job/index.js index f22616653..0b1d5c93f 100644 --- a/services/harmony/public/js/workflow-ui/job/index.js +++ b/services/harmony/public/js/workflow-ui/job/index.js @@ -14,6 +14,7 @@ async function init() { }); params.tableFilter = document.getElementsByName('tableFilter')[0].getAttribute('data-value'); params.disallowStatus = document.getElementsByName('disallowStatus')[0].checked ? 'on' : ''; + params.disallowMessageCategory = document.getElementsByName('disallowMessageCategory')[0].checked ? 'on' : ''; params.dateKind = document.getElementById('dateKindUpdated').checked ? 'updatedAt' : 'createdAt'; // kick off job state change links logic if this user is allowed to change the job state diff --git a/services/harmony/public/js/workflow-ui/job/work-items-table.js b/services/harmony/public/js/workflow-ui/job/work-items-table.js index a44d7f8b6..269d642fe 100644 --- a/services/harmony/public/js/workflow-ui/job/work-items-table.js +++ b/services/harmony/public/js/workflow-ui/job/work-items-table.js @@ -1,4 +1,4 @@ -import { formatDates, initCopyHandler } from '../table.js'; +import { formatDates, initCopyHandler, trimForDisplay } from '../table.js'; import toasts from '../toasts.js'; import PubSub from '../../pub-sub.js'; @@ -36,7 +36,7 @@ import PubSub from '../../pub-sub.js'; */ async function load(params, checkJobStatus) { let tableUrl = `./${params.jobID}/work-items?page=${params.currentPage}&limit=${params.limit}&checkJobStatus=${checkJobStatus}`; - tableUrl += `&tableFilter=${encodeURIComponent(params.tableFilter)}&disallowStatus=${params.disallowStatus}`; + tableUrl += `&tableFilter=${encodeURIComponent(params.tableFilter)}&disallowStatus=${params.disallowStatus}&disallowMessageCategory=${params.disallowMessageCategory}`; tableUrl += `&fromDateTime=${encodeURIComponent(params.fromDateTime)}&toDateTime=${encodeURIComponent(params.toDateTime)}`; tableUrl += `&tzOffsetMinutes=${params.tzOffsetMinutes}&dateKind=${params.dateKind}`; const res = await fetch(tableUrl); @@ -97,13 +97,17 @@ function initFilter(tableFilter) { { value: 'status: running', dbValue: 'running', field: 'status' }, { value: 'status: failed', dbValue: 'failed', field: 'status' }, { value: 'status: queued', dbValue: 'queued', field: 'status' }, + { value: 'status: warning', dbValue: 'warning', field: 'status' }, ]; const allowedValues = allowedList.map((t) => t.value); + allowedList.push({ value: 'message category: nodata', dbValue: 'nodata', field: 'message_category' }); // eslint-disable-next-line no-new const tagInput = new Tagify(filterInput, { whitelist: allowedList, + delimiters: null, validate(tag) { - if (allowedValues.includes(tag.value)) { + if (allowedValues.includes(tag.value) + || /^message category: .{1,100}$/.test(tag.value)) { return true; } return false; @@ -115,6 +119,21 @@ function initFilter(tableFilter) { enabled: 0, closeOnSelect: true, }, + templates: { + tag(tagData) { + return ` + +
+ ${trimForDisplay(tagData.value.split(': ')[1], 20)} +
+
`; + }, + }, }); const initialTags = JSON.parse(tableFilter); tagInput.addTags(initialTags); diff --git a/services/harmony/test/workflow-ui/work-items-table-row.ts b/services/harmony/test/workflow-ui/work-items-table-row.ts index f461d296c..87e4b5412 100644 --- a/services/harmony/test/workflow-ui/work-items-table-row.ts +++ b/services/harmony/test/workflow-ui/work-items-table-row.ts @@ -37,7 +37,7 @@ const step2 = buildWorkflowStep( // build the items // give them an id so we know what id to request in the tests const item1 = buildWorkItem( - { jobID: targetJob.jobID, workflowStepIndex: 1, serviceID: step1ServiceId, status: WorkItemStatus.RUNNING, id: 1 }, + { jobID: targetJob.jobID, workflowStepIndex: 1, serviceID: step1ServiceId, status: WorkItemStatus.RUNNING, id: 1, message_category: 'smoothly' }, ); const item2 = buildWorkItem( { jobID: targetJob.jobID, workflowStepIndex: 1, serviceID: step1ServiceId, status: WorkItemStatus.SUCCESSFUL, id: 2 }, @@ -190,6 +190,10 @@ describe('Workflow UI work items table row route', function () { const listing = this.res.text; expect((listing.match(/retry-button/g) || []).length).to.equal(1); }); + it('returns the message_category along with the main status', async function () { + const listing = this.res.text; + expect(listing).to.contain(`${WorkItemStatus.RUNNING.valueOf()}: smoothly`); + }); }); describe('who requests a queued work item for their job', function () { @@ -288,7 +292,7 @@ describe('Workflow UI work items table row route', function () { expect(listing).to.not.contain(`${WorkItemStatus.SUCCESSFUL.valueOf()}`); expect(listing).to.not.contain(`${WorkItemStatus.CANCELED.valueOf()}`); expect(listing).to.not.contain(`${WorkItemStatus.READY.valueOf()}`); - expect(listing).to.contain(`${WorkItemStatus.RUNNING.valueOf()}`); + expect(listing).to.contain(`${WorkItemStatus.RUNNING.valueOf()}: smoothly`); }); }); }); diff --git a/services/harmony/test/workflow-ui/work-items-table.ts b/services/harmony/test/workflow-ui/work-items-table.ts index bbfe11e3d..ee325a550 100644 --- a/services/harmony/test/workflow-ui/work-items-table.ts +++ b/services/harmony/test/workflow-ui/work-items-table.ts @@ -124,13 +124,15 @@ describe('Workflow UI work items table route', function () { await step2.save(this.trx); await otherJob.save(this.trx); - const otherItem1 = buildWorkItem({ jobID: otherJob.jobID, status: WorkItemStatus.CANCELED }); + const otherItem1 = buildWorkItem({ jobID: otherJob.jobID, status: WorkItemStatus.CANCELED, message_category: 'jeepers' }); await otherItem1.save(this.trx); const otherItem2 = buildWorkItem({ jobID: otherJob.jobID, status: WorkItemStatus.FAILED }); await otherItem2.save(this.trx); await otherItem3.save(this.trx); const otherItem4 = buildWorkItem({ jobID: otherJob.jobID, status: WorkItemStatus.READY }); await otherItem4.save(this.trx); + const otherItem5 = buildWorkItem({ jobID: otherJob.jobID, status: WorkItemStatus.WARNING, message_category: 'no-data' }); + await otherItem5.save(this.trx); const otherStep1 = buildWorkflowStep({ jobID: otherJob.jobID, stepIndex: 1 }); await otherStep1.save(this.trx); const otherStep2 = buildWorkflowStep({ jobID: otherJob.jobID, stepIndex: 2 }); @@ -572,7 +574,7 @@ describe('Workflow UI work items table route', function () { hookWorkflowUIWorkItems({ username: 'adam', jobID: otherJob.jobID }); it('returns metrics logs links for each each work item', function () { const listing = this.res.text; - expect((listing.match(/logs-metrics/g) || []).length).to.equal(4); + expect((listing.match(/logs-metrics/g) || []).length).to.equal(5); }); }); @@ -585,6 +587,34 @@ describe('Workflow UI work items table route', function () { }); }); + describe('when the admin filters otherJob\'s items by status IN [WARNING]', function () { + hookWorkflowUIWorkItems({ username: 'adam', jobID: otherJob.jobID, + query: { tableFilter: '[{"value":"status: warning","dbValue":"warning","field":"status"}]' } }); + it('contains the WARNING work item', async function () { + expect((this.res.text.match(/work-item-table-row/g) || []).length).to.equal(1); + expect(this.res.text).to.contain(`${WorkItemStatus.WARNING.valueOf()}: no-data`); + }); + }); + + describe('when the admin filters otherJob\'s items by message_category IN [no-data]', function () { + hookWorkflowUIWorkItems({ username: 'adam', jobID: otherJob.jobID, + query: { tableFilter: '[{"value":"message category: no-data","dbValue":"no-data","field":"message_category"}]' } }); + it('contains the no-data work item', async function () { + expect((this.res.text.match(/work-item-table-row/g) || []).length).to.equal(1); + expect(this.res.text).to.contain(`${WorkItemStatus.WARNING.valueOf()}: no-data`); + }); + }); + + describe('when the admin filters otherJob\'s items by message_category NOT IN [no-data]', function () { + hookWorkflowUIWorkItems({ username: 'adam', jobID: otherJob.jobID, + query: { disallowMessageCategory: 'on', tableFilter: '[{"value":"message category: no-data","dbValue":"no-data","field":"message_category"}]' } }); + it('contains all but the no-data work item', async function () { + expect((this.res.text.match(/work-item-table-row/g) || []).length).to.equal(4); + expect(this.res.text).to.not.contain(`${WorkItemStatus.WARNING.valueOf()}: no-data`); + expect(this.res.text).to.contain(`${WorkItemStatus.CANCELED.valueOf()}: jeepers`); + }); + }); + describe('when the user is not part of the admin group', function () { hookAdminWorkflowUIWorkItems({ username: 'eve', jobID: targetJob.jobID }); it('returns an error', function () {