diff --git a/command/alloc_exec.go b/command/alloc_exec.go index 8188e0717..af53b3bfa 100644 --- a/command/alloc_exec.go +++ b/command/alloc_exec.go @@ -38,8 +38,8 @@ Usage: nomad alloc exec [options] When ACLs are enabled, this command requires a token with the 'alloc-exec', 'read-job', and 'list-jobs' capabilities for the allocation's namespace. If the task driver does not have file system isolation (as with 'raw_exec'), - this command requires the 'alloc-node-exec', 'read-job', and 'list-jobs' - capabilities for the allocation's namespace. + this command requires the 'alloc-node-exec', 'alloc-exec', 'read-job', + and 'list-jobs' capabilities for the allocation's namespace. General Options: diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index a4578511e..413a40e55 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -3,20 +3,17 @@ * SPDX-License-Identifier: BUSL-1.1 */ +// @ts-check import WatchableNamespaceIDs from './watchable-namespace-ids'; import addToPath from 'nomad-ui/utils/add-to-path'; import { base64EncodeString } from 'nomad-ui/utils/encode'; import classic from 'ember-classic-decorator'; import { inject as service } from '@ember/service'; import { getOwner } from '@ember/application'; -import { base64DecodeString } from '../utils/encode'; -import config from 'nomad-ui/config/environment'; @classic export default class JobAdapter extends WatchableNamespaceIDs { @service system; - @service notifications; - @service token; relationshipFallbackLinks = { summary: '/summary', @@ -173,11 +170,20 @@ export default class JobAdapter extends WatchableNamespaceIDs { }); } - runAction(job, action, allocID) { - let messageBuffer = ''; - + /** + * + * @param {import('../models/job').default} job + * @param {import('../models/action').default} action + * @param {string} allocID + * @returns {string} + */ + getActionSocketUrl(job, action, allocID) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const region = this.system.activeRegion; + + /** + * @type {Partial} + */ const applicationAdapter = getOwner(this).lookup('adapter:application'); const prefix = `${ applicationAdapter.host || window.location.host @@ -194,119 +200,6 @@ export default class JobAdapter extends WatchableNamespaceIDs { }&tty=true&ws_handshake=true` + (region ? `®ion=${region}` : ''); - let socket; - - const mirageEnabled = - config.environment !== 'production' && - config['ember-cli-mirage'] && - config['ember-cli-mirage'].enabled !== false; - - if (mirageEnabled) { - socket = new Object({ - messageDisplayed: false, - addEventListener: function (event, callback) { - if (event === 'message') { - this.onmessage = callback; - } - if (event === 'open') { - this.onopen = callback; - } - if (event === 'close') { - this.onclose = callback; - } - }, - - send(e) { - if (!this.messageDisplayed) { - this.messageDisplayed = true; - this.onmessage({ - data: `{"stdout":{"data":"${btoa('Message Received')}"}}`, - }); - } else { - this.onmessage({ data: e.replace('stdin', 'stdout') }); - } - }, - }); - } else { - socket = new WebSocket(wsUrl); - } - - let notification; - socket.addEventListener('open', () => { - notification = this.notifications - .add({ - title: `Action ${action.name} Started`, - color: 'neutral', - code: true, - sticky: true, - customAction: { - label: 'Stop Action', - action: () => { - socket.close(); - }, - }, - }) - .getFlashObject(); - - socket.send( - JSON.stringify({ version: 1, auth_token: this.token?.secret || '' }) - ); - socket.send( - JSON.stringify({ - tty_size: { width: 250, height: 100 }, // Magic numbers, but they pass the eye test. - }) - ); - }); - - socket.addEventListener('message', (event) => { - if (!this.notifications.queue.includes(notification)) { - // User has manually closed the notification; - // explicitly close the socket and return; - socket.close(); - return; - } - - let jsonData = JSON.parse(event.data); - if (jsonData.stdout && jsonData.stdout.data) { - // strip ansi escape characters that are common in action responses; - // for example, we shouldn't show the newline or color code characters. - messageBuffer += base64DecodeString(jsonData.stdout.data); - messageBuffer += '\n'; - messageBuffer = messageBuffer.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); - notification.set('message', messageBuffer); - notification.set('title', `Action ${action.name} Running`); - } else if (jsonData.stderr && jsonData.stderr.data) { - messageBuffer = base64DecodeString(jsonData.stderr.data); - messageBuffer += '\n'; - this.notifications.add({ - title: `Error received from ${action.name}`, - message: messageBuffer, - color: 'critical', - code: true, - sticky: true, - }); - } - }); - - socket.addEventListener('close', () => { - notification.set('title', `Action ${action.name} Finished`); - notification.set('customAction', null); - }); - - socket.addEventListener('error', function (event) { - this.notifications.add({ - title: `Error received from ${action.name}`, - message: event, - color: 'critical', - sticky: true, - }); - }); - - if (mirageEnabled) { - socket.onopen(); - socket.onclose(); - } - - return socket; + return wsUrl; } } diff --git a/ui/app/components/action-card.hbs b/ui/app/components/action-card.hbs new file mode 100644 index 000000000..ba4d797fb --- /dev/null +++ b/ui/app/components/action-card.hbs @@ -0,0 +1,101 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ + + + {{this.instance.action.name}} + + {{this.instance.action.task.taskGroup.job.name}} + + + + + + {{!-- + Action instance with peers (run on multiple allocs): + - If instance is running, user can stop it. + - If peers are running, user can stop all of them, + - And if none are running, user can clear all of them. + --}} + {{#if this.instance.peerID}} + {{#if (eq this.instance.state "running")}} + + {{/if}} + {{#if (get (filter-by 'state' 'running' (filter-by 'peerID' this.instance.peerID this.nomadActions.actionsQueue)) 'length')}} + + {{else}} + + {{/if}} + {{else}} + {{!-- + Action instance run on a single alloc: + - If instance is running, user can stop it. + - if not, user can clear it from queue. + --}} + {{#if (eq this.instance.state "running")}} + + {{else}} + + {{/if}} + {{/if}} + + + + {{#if this.instance.peerID}} + + {{#each (filter-by 'peerID' this.instance.peerID this.nomadActions.actionsQueue) as |peer|}} + + {{/each}} + + {{/if}} + +
+ {{#if this.instance.error}} +
Error: {{this.instance.error}}
+ {{/if}} + {{#if this.instance.messages.length}} +
{{this.instance.messages}}
+ {{else}} + {{#if (eq this.instance.state "complete")}} +

Action completed with no output

+ {{/if}} + {{/if}} +
+ +
+ +
    +
  • Task: {{this.instance.action.task.name}}
  • +
  • Job: {{this.instance.action.task.taskGroup.job.name}}
  • +
  • Allocation: {{this.instance.allocID}}
  • +
  • Created: {{format-ts this.instance.createdAt}}
  • + {{#if this.instance.completedAt}} + {{#if (gt (moment-diff this.instance.createdAt this.instance.completedAt precision='seconds') 1)}} +
  • Completed after {{moment-diff this.instance.createdAt this.instance.completedAt precision='seconds'}} seconds
  • + {{else}} +
  • Completed in {{moment-diff this.instance.createdAt this.instance.completedAt precision='seconds' float=true}} seconds
  • + {{/if}} + {{/if}} +
+
+
+ + {{yield}} +
diff --git a/ui/app/components/action-card.js b/ui/app/components/action-card.js new file mode 100644 index 000000000..5f129695f --- /dev/null +++ b/ui/app/components/action-card.js @@ -0,0 +1,51 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class ActionCardComponent extends Component { + @service nomadActions; + get stateColor() { + /** + * @type {import('../models/action-instance').default} + */ + const instance = this.instance; + switch (instance.state) { + case 'starting': + return 'neutral'; + case 'running': + return 'highlight'; + case 'complete': + return 'success'; + case 'error': + return 'critical'; + default: + return 'neutral'; + } + } + + @action stop() { + this.instance.socket.close(); + } + + @action stopAll() { + this.nomadActions.stopPeers(this.instance.peerID); + } + + @tracked selectedPeer = null; + + @action selectPeer(peer) { + this.selectedPeer = peer; + } + + get instance() { + // Either the passed instance, or the peer-selected instance + return this.selectedPeer || this.args.instance; + } +} diff --git a/ui/app/components/actions-dropdown.hbs b/ui/app/components/actions-dropdown.hbs new file mode 100644 index 000000000..635f84c5b --- /dev/null +++ b/ui/app/components/actions-dropdown.hbs @@ -0,0 +1,57 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + + + {{#each @actions as |action|}} + {{#if @allocation}} + {{!-- If an allocation was passed in, we run the action on that alloc --}} + + {{else if (eq action.allocations.length 1)}} + {{!-- If there is only one allocation on the action, we can just run it on the 0th alloc --}} + + {{else}} + {{!-- Either no allocation was passed in, or there are multiple allocatios on the action to choose from --}} + + + + + + + {{/if}} + {{/each}} + diff --git a/ui/app/components/actions-dropdown.js b/ui/app/components/actions-dropdown.js new file mode 100644 index 000000000..8b74e4f00 --- /dev/null +++ b/ui/app/components/actions-dropdown.js @@ -0,0 +1,25 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check + +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class ActionsDropdownComponent extends Component { + @service nomadActions; + @service notifications; + + /** + * @param {HTMLElement} el + */ + @action openActionsDropdown(el) { + const dropdownTrigger = el?.getElementsByTagName('button')[0]; + if (dropdownTrigger) { + dropdownTrigger.click(); + } + } +} diff --git a/ui/app/components/actions-flyout-global-button.hbs b/ui/app/components/actions-flyout-global-button.hbs new file mode 100644 index 000000000..26507e650 --- /dev/null +++ b/ui/app/components/actions-flyout-global-button.hbs @@ -0,0 +1,21 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#if this.nomadActions.actionsQueue.length}} + {{#unless this.nomadActions.flyoutActive}} +
+ +
+ {{/unless}} +{{/if}} diff --git a/ui/app/components/actions-flyout-global-button.js b/ui/app/components/actions-flyout-global-button.js new file mode 100644 index 000000000..e2f62457f --- /dev/null +++ b/ui/app/components/actions-flyout-global-button.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class ActionsFlyoutGlobalButtonComponent extends Component { + @service nomadActions; +} diff --git a/ui/app/components/actions-flyout.hbs b/ui/app/components/actions-flyout.hbs new file mode 100644 index 000000000..7944afdb5 --- /dev/null +++ b/ui/app/components/actions-flyout.hbs @@ -0,0 +1,44 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#if this.nomadActions.flyoutActive}} + + +

+ Actions +

+ {{#if this.contextualActions.length}} + + {{/if}} + {{#if this.nomadActions.runningActions.length}} + + {{/if}} + {{#if this.nomadActions.finishedActions.length}} + + {{/if}} +
+ +
    + {{#each this.actionInstances as |instance|}} + + {{else}} + + + + + + + + {{/each}} +
+
+
+{{/if}} diff --git a/ui/app/components/actions-flyout.js b/ui/app/components/actions-flyout.js new file mode 100644 index 000000000..0622323b5 --- /dev/null +++ b/ui/app/components/actions-flyout.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; + +export default class ActionsFlyoutComponent extends Component { + @service nomadActions; + @service router; + + get job() { + if (this.task) { + return this.task.taskGroup.job; + } else { + return ( + this.router.currentRouteName.startsWith('jobs.job') && + this.router.currentRoute.attributes + ); + } + } + + get task() { + return ( + this.router.currentRouteName.startsWith('allocations.allocation.task') && + this.router.currentRoute.attributes.task + ); + } + + get allocation() { + return ( + this.args.allocation || + (this.task && this.router.currentRoute.attributes.allocation) + ); + } + + get contextualParent() { + return this.task || this.job; + } + + get contextualActions() { + return this.contextualParent?.actions || []; + } + + @alias('nomadActions.flyoutActive') isOpen; + + /** + * Group peers together by their peerID + */ + get actionInstances() { + let instances = this.nomadActions.actionsQueue; + + // Only keep the first of any found peerID value from the list + let peerIDs = new Set(); + let filteredInstances = []; + for (let instance of instances) { + if (!instance.peerID || !peerIDs.has(instance.peerID)) { + filteredInstances.push(instance); + peerIDs.add(instance.peerID); + } + } + + return filteredInstances; + } +} diff --git a/ui/app/components/job-page/parts/title.js b/ui/app/components/job-page/parts/title.js index 3c06d0881..f49acabe7 100644 --- a/ui/app/components/job-page/parts/title.js +++ b/ui/app/components/job-page/parts/title.js @@ -103,37 +103,4 @@ export default class Title extends Component { } }) startJob; - - // run action task - - /** - * @param {string} action - The action to run - * @param {string} allocID - The allocation ID to run the action on - * @param {Event} ev - The event that triggered the action - */ - @task(function* (action, allocID) { - if (!allocID) { - allocID = - action.allocations[ - Math.floor(Math.random() * action.allocations.length) - ].id; - } - try { - const job = this.job; - if (allocID === 'all') { - yield action.allocations.map((alloc) => { - return job.runAction(action, alloc.id); - }); - } else { - yield job.runAction(action, allocID); - } - } catch (err) { - this.notifications.add({ - title: `Error starting ${action.name}`, - message: err, - color: 'critical', - }); - } - }) - runAction; } diff --git a/ui/app/components/keyboard-shortcuts-modal.hbs b/ui/app/components/keyboard-shortcuts-modal.hbs index 5a4c68bdd..ddacda683 100644 --- a/ui/app/components/keyboard-shortcuts-modal.hbs +++ b/ui/app/components/keyboard-shortcuts-modal.hbs @@ -5,7 +5,7 @@ {{#if this.keyboard.shortcutsVisible}} {{keyboard-commands (array this.escapeCommand)}} -
-
+ {{/if}} {{#if (and this.keyboard.enabled this.keyboard.displayHints)}} diff --git a/ui/app/components/task-sub-row.js b/ui/app/components/task-sub-row.js index 71e9ccdd8..e23c3d674 100644 --- a/ui/app/components/task-sub-row.js +++ b/ui/app/components/task-sub-row.js @@ -17,6 +17,7 @@ export default class TaskSubRowComponent extends Component { @service store; @service router; @service notifications; + @service nomadActions; @service('stats-trackers-registry') statsTrackersRegistry; constructor() { @@ -101,14 +102,19 @@ export default class TaskSubRowComponent extends Component { return this.task.task?.taskGroup.job.namespace; } + /** + * @param {string} action - The action to run + * @param {string} allocID - The allocation ID to run the action on + * @param {Event} ev - The event that triggered the action + */ @task(function* (action, allocID) { try { - const job = this.task.task.taskGroup.job; - yield job.runAction(action, allocID); + yield this.nomadActions.runAction({ action, allocID }); } catch (err) { this.notifications.add({ title: `Error starting ${action.name}`, message: err, + sticky: true, color: 'critical', }); } diff --git a/ui/app/controllers/allocations/allocation/task/index.js b/ui/app/controllers/allocations/allocation/task/index.js index 145c1bb4c..6b1cfd984 100644 --- a/ui/app/controllers/allocations/allocation/task/index.js +++ b/ui/app/controllers/allocations/allocation/task/index.js @@ -13,6 +13,7 @@ import { inject as service } from '@ember/service'; @classic export default class IndexController extends Controller { @service nomadActions; + @service notifications; @overridable(() => { // { title, description } return null; @@ -42,18 +43,4 @@ export default class IndexController extends Controller { this.nomadActions.hasActionPermissions ); } - - @task(function* (action, allocID) { - try { - const job = this.model.task.taskGroup.job; - yield job.runAction(action, allocID); - } catch (err) { - this.notifications.add({ - title: `Error starting ${action.name}`, - message: err, - color: 'critical', - }); - } - }) - runAction; } diff --git a/ui/app/models/action-instance.js b/ui/app/models/action-instance.js new file mode 100644 index 000000000..b0ab737fa --- /dev/null +++ b/ui/app/models/action-instance.js @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import Model from '@ember-data/model'; +import { attr, belongsTo } from '@ember-data/model'; + +export default class ActionInstanceModel extends Model { + @belongsTo('action') action; + + /** + * @type {'starting'|'running'|'complete'|'error'} + */ + @attr('string') state; + + @attr('string', { + defaultValue() { + return ''; + }, + }) + messages; + @attr('date') createdAt; + + @attr('date') completedAt; + + @attr('string') allocID; + + @attr('string') error; + + get allocShortID() { + return this.allocID?.substring(0, 8); + } + + /** + * Used to group action instances "run on all allocs" + */ + @attr('string') peerID; + + /** + * @type {WebSocket} + */ + @attr() socket; +} diff --git a/ui/app/models/action.js b/ui/app/models/action.js index 658005d6f..946f9118e 100644 --- a/ui/app/models/action.js +++ b/ui/app/models/action.js @@ -3,6 +3,8 @@ * SPDX-License-Identifier: BUSL-1.1 */ +// @ts-check + import { attr } from '@ember-data/model'; import { fragmentOwner } from 'ember-data-model-fragments/attributes'; import Fragment from 'ember-data-model-fragments/fragment'; @@ -10,9 +12,21 @@ import Fragment from 'ember-data-model-fragments/fragment'; export default class ActionModel extends Fragment { @attr('string') name; @attr('string') command; + + /** + * @type {string[]} + */ @attr() args; + + /** + * @type {import('../models/task').default} + */ @fragmentOwner() task; + /** + * The allocations that the action could be run on. + * @type {import('../models/allocation').default[]} + */ get allocations() { return this.task.taskGroup.allocations.filter((a) => { return a.clientStatus === 'running'; diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 3cb9fbae7..b873c0a51 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -167,8 +167,17 @@ export default class Job extends Model { }, []); } - runAction(action, allocID) { - return this.store.adapterFor('job').runAction(this, action, allocID); + /** + * + * @param {import('../models/action').default} action + * @param {string} allocID + * @param {import('../models/action-instance').default} actionInstance + * @returns + */ + getActionSocketUrl(action, allocID, actionInstance) { + return this.store + .adapterFor('job') + .getActionSocketUrl(this, action, allocID, actionInstance); } @computed('taskGroups.@each.drivers') diff --git a/ui/app/services/keyboard.js b/ui/app/services/keyboard.js index 1367cfa7f..648726b96 100644 --- a/ui/app/services/keyboard.js +++ b/ui/app/services/keyboard.js @@ -30,6 +30,7 @@ import localStorageProperty from 'nomad-ui/utils/properties/local-storage'; * @property {boolean} [recording] * @property {boolean} [custom] * @property {boolean} [exclusive] + * @property {HTMLElement} [element] */ const DEBOUNCE_MS = 750; @@ -417,7 +418,7 @@ export default class KeyboardService extends Service { command.label === 'Hide Keyboard Shortcuts' ) { event.preventDefault(); - command.action(); + command.action(command.element); } }); this.clearBuffer(); diff --git a/ui/app/services/nomad-actions.js b/ui/app/services/nomad-actions.js index 5a0c55d41..fdb763901 100644 --- a/ui/app/services/nomad-actions.js +++ b/ui/app/services/nomad-actions.js @@ -3,15 +3,18 @@ * SPDX-License-Identifier: BUSL-1.1 */ -// Guess who just found out that "actions" is a reserved name in Ember? -// Signed, the person who just renamed this NomadActions. - // @ts-check import Service from '@ember/service'; import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { base64DecodeString } from '../utils/encode'; +import config from 'nomad-ui/config/environment'; export default class NomadActionsService extends Service { @service can; + @service store; + @service token; // Note: future Actions Governance work (https://github.com/hashicorp/nomad/issues/18800) // will require this to be a computed property that depends on the current user's permissions. @@ -19,4 +22,289 @@ export default class NomadActionsService extends Service { get hasActionPermissions() { return this.can.can('exec allocation'); } + + @tracked flyoutActive = false; + + @action openFlyout() { + this.flyoutActive = true; + } + @action closeFlyout() { + this.flyoutActive = false; + } + + /** + * @type {import('../models/action-instance').default[]} + */ + @tracked + actionsQueue = []; + + updateQueue() { + this.actionsQueue = [...this.actionsQueue]; + } + + get runningActions() { + return this.actionsQueue.filter((a) => a.state === 'running'); + } + + get finishedActions() { + return this.actionsQueue.filter( + (a) => a.state === 'complete' || a.state === 'error' + ); + } + + /** + * @typedef {Object} RunActionParams + * @property {import("../models/action").default} action + * @property {string} allocID + * @property {string} [peerID] + */ + + /** + * @param {RunActionParams} params + */ + @action runAction({ action, allocID, peerID = null }) { + const job = action.task.taskGroup.job; + + const actionQueueID = `${action.name}-${allocID}-${Date.now()}`; + /** + * @type {import ('../models/action-instance').default} + */ + const actionInstance = this.store.createRecord('action-instance', { + state: 'pending', + id: actionQueueID, + allocID, + peerID, + }); + + // Note: setting post-createRecord because of a noticed bug + // when passing action as a property to createRecord. + actionInstance.set('action', action); + + let wsURL = job.getActionSocketUrl(action, allocID, actionInstance); + + this.establishInstanceSocket(actionInstance, wsURL); + + this.actionsQueue.unshift(actionInstance); // add to the front of the queue + this.updateQueue(); + this.openFlyout(); + } + + /** + * @param {import('../models/action').default} action + */ + @action runActionOnRandomAlloc(action) { + let allocID = + action.allocations[Math.floor(Math.random() * action.allocations.length)] + .id; + this.runAction({ action, allocID }); + } + + /** + * @param {import('../models/action').default} action + */ + @action runActionOnAllAllocs(action) { + // Generate a new peer ID for these action instances to use + const peerID = `${action.name}-${Date.now()}`; + action.allocations.forEach((alloc) => { + this.runAction({ action, allocID: alloc.id, peerID }); + }); + } + + /** + * @param {import ('../models/action-instance').default} actionInstance + */ + @action clearActionInstance(actionInstance) { + // if instance is still running, stop it + if (actionInstance.state === 'running') { + actionInstance.socket.close(); + } + this.actionsQueue = this.actionsQueue.filter( + (a) => a.id !== actionInstance.id + ); + + // If action had peers, clear them out as well + if (actionInstance.peerID) { + this.actionsQueue = this.actionsQueue.filter( + (a) => a.peerID !== actionInstance.peerID + ); + } + this.updateQueue(); + } + + @action clearFinishedActions() { + this.actionsQueue = this.actionsQueue.filter((a) => a.state !== 'complete'); + } + + @action stopAll() { + this.actionsQueue.forEach((a) => { + if (a.state === 'running') { + a.socket.close(); + } + }); + this.updateQueue(); + } + + @action stopPeers(peerID) { + if (!peerID) { + return; + } + this.actionsQueue + .filter((a) => a.peerID === peerID) + .forEach((a) => { + if (a.state === 'running') { + a.socket.close(); + } + }); + this.updateQueue(); + } + + //#region Socket + + get mirageEnabled() { + return ( + config.environment !== 'production' && + config['ember-cli-mirage'] && + config['ember-cli-mirage'].enabled !== false + ); + } + + /** + * Mocks a WebSocket for testing. + * @returns {Object} + */ + createMockWebSocket() { + let socket = new Object({ + messageDisplayed: false, + addEventListener: function (event, callback) { + if (event === 'message') { + this.onmessage = callback; + } + if (event === 'open') { + this.onopen = callback; + } + if (event === 'close') { + this.onclose = callback; + } + if (event === 'error') { + this.onerror = callback; + } + }, + + send(e) { + if (!this.messageDisplayed) { + this.messageDisplayed = true; + this.onmessage({ + data: `{"stdout":{"data":"${btoa('Message Received')}"}}`, + }); + } else { + this.onmessage({ data: e.replace('stdin', 'stdout') }); + } + }, + }); + return socket; + } + + /** + * Establishes a WebSocket connection for a given action instance. + * + * @param {import('../models/action-instance').default} actionInstance - The action instance model. + * @param {string} wsURL - The WebSocket URL. + */ + establishInstanceSocket(actionInstance, wsURL) { + let socket = this.createWebSocket(wsURL); + actionInstance.set('socket', socket); + + socket.addEventListener('open', () => + this.handleSocketOpen(actionInstance, socket) + ); + socket.addEventListener('message', (event) => + this.handleSocketMessage(actionInstance, event) + ); + socket.addEventListener('close', () => + this.handleSocketClose(actionInstance) + ); + socket.addEventListener('error', () => + this.handleSocketError(actionInstance) + ); + + // Open, + if (this.mirageEnabled) { + socket.onopen(); + socket.onclose(); + } + } + + /** + * Creates a WebSocket or a mock WebSocket for testing. + * + * @param {string} wsURL - The WebSocket URL. + * @returns {WebSocket|Object} - The WebSocket or a mock WebSocket object. + */ + createWebSocket(wsURL) { + return this.mirageEnabled + ? this.createMockWebSocket() + : new WebSocket(wsURL); + } + + /** + * @param {import('../models/action-instance').default} actionInstance - The action instance model. + * @param {WebSocket} socket - The WebSocket instance. + */ + handleSocketOpen(actionInstance, socket) { + actionInstance.state = 'starting'; + actionInstance.createdAt = new Date(); + + socket.send( + JSON.stringify({ version: 1, auth_token: this.token?.secret || '' }) + ); + socket.send(JSON.stringify({ tty_size: { width: 250, height: 100 } })); + } + + /** + * @param {import('../models/action-instance').default} actionInstance - The action instance model. + * @param {MessageEvent} event - The message event. + */ + handleSocketMessage(actionInstance, event) { + actionInstance.state = 'running'; + + try { + let jsonData = JSON.parse(event.data); + if (jsonData.stdout && jsonData.stdout.data) { + const message = base64DecodeString(jsonData.stdout.data).replace( + /\x1b\[[0-9;]*[a-zA-Z]/g, + '' + ); + actionInstance.messages += '\n' + message; + } else if (jsonData.stderr && jsonData.stderr.data) { + actionInstance.state = 'error'; + actionInstance.error += '\n' + base64DecodeString(jsonData.stderr.data); + } + } catch (e) { + actionInstance.state = 'error'; + actionInstance.error += '\n' + e; + } + } + + /** + * Handles the WebSocket 'close' event. + * + * @param {import('../models/action-instance').default} actionInstance - The action instance model. + */ + handleSocketClose(actionInstance) { + actionInstance.state = 'complete'; + actionInstance.completedAt = new Date(); + } + + /** + * Handles the WebSocket 'error' event. + * + * @param {import('../models/action-instance').default} actionInstance - The action instance model. + */ + handleSocketError(actionInstance) { + actionInstance.state = 'error'; + actionInstance.completedAt = new Date(); + actionInstance.error = 'Error connecting to action socket'; + } + + // #endregion Socket } diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 04edbb317..3321362e3 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -59,3 +59,4 @@ @import './components/metadata-editor'; @import './components/job-status-panel'; @import './components/access-control'; +@import './components/actions'; diff --git a/ui/app/styles/components/actions.scss b/ui/app/styles/components/actions.scss new file mode 100644 index 000000000..0bf31a07c --- /dev/null +++ b/ui/app/styles/components/actions.scss @@ -0,0 +1,176 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +.action-card { + .peers { + width: 100%; + overflow: auto; + padding: 4px; + .peer { + white-space: nowrap; + &.active { + background: blue; + } + } + } +} + +.actions-dropdown { + z-index: 3; + .hds-dropdown__list { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + .hds-reveal { + width: auto; + .hds-reveal__toggle-button { + color: black; + flex-direction: row-reverse; + width: 100%; + text-align: left; + font-weight: 600; + padding-top: calc(0.25rem + 8px); + padding-bottom: calc(0.25rem + 8px); + padding-left: 0; + padding-right: 0; + border-width: 0; + } + } +} + +// The actions sidebar is more "global" than others, +// and as such sits higher onthe page vertically as +// well as on the z-index. +#actions-flyout { + z-index: $z-actions; + left: unset; + animation-name: FlyoutSlideIn; + animation-duration: 0.2s; + animation-fill-mode: both; + + & + .hds-flyout__overlay { + z-index: $z-actions-backdrop; + animation-name: FlyoutOverlayFadeIn; + animation-duration: 0.2s; + animation-fill-mode: both; + } + + & > .hds-flyout__header { + position: relative; + z-index: $z-base; + .hds-flyout__title { + display: flex; + align-items: center; + gap: 1rem; + justify-content: space-between; + h3 { + flex-grow: 1; + } + } + } + + .hds-application-state { + width: auto; + } + + .action-card-header { + position: relative; + z-index: $z-base - 1; + .hds-page-header__main { + flex-direction: unset; + .hds-page-header__content { + gap: 0; + } + .hds-page-header__actions { + align-items: stretch; + } + } + } + .actions-queue { + display: grid; + gap: 1rem; + .action-card { + display: grid; + gap: 1rem; + grid-template-rows: auto 1fr auto; + border-bottom: 1px solid $grey-blue; + padding: 1rem 0 2rem; + + &:last-of-type { + border-bottom: none; + } + + header { + .action-card-title { + display: block; + .job-name { + opacity: 0.5; + font-size: 1rem; + color: black; + } + } + } + + .messages { + overflow: hidden; + + code > pre { + height: 200px; + background-color: #0a0a0a; + color: whitesmoke; + border-radius: 6px; + resize: vertical; + } + } + + footer { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + } + } + } +} + +// The centre of the subnav +$actionButtonTopOffset: calc($subNavOffset + ($secondaryNavbarHeight/4)); + +.actions-flyout-button { + position: fixed; + top: $actionButtonTopOffset; + right: 1.5rem; + z-index: $z-actions; + animation-name: FlyoutButtonSlideIn; + animation-duration: 0.2s; + animation-fill-mode: both; +} + +@keyframes FlyoutSlideIn { + from { + // right: -480px; //medium + right: -720px; //large + } + to { + right: 0px; + } +} + +@keyframes FlyoutButtonSlideIn { + from { + right: -200px; + } + to { + right: 1.5rem; + } +} + +@keyframes FlyoutOverlayFadeIn { + from { + opacity: 0; + } + to { + opacity: 0.5; + } +} diff --git a/ui/app/styles/core/navbar.scss b/ui/app/styles/core/navbar.scss index e1587cab9..f2714a71c 100644 --- a/ui/app/styles/core/navbar.scss +++ b/ui/app/styles/core/navbar.scss @@ -3,6 +3,8 @@ * SPDX-License-Identifier: BUSL-1.1 */ +$secondaryNavbarHeight: 4.5rem; + .navbar { display: flex; align-items: center; @@ -82,7 +84,7 @@ &.is-secondary { background-color: $nomad-green-dark; padding: 1.25rem 20px 1.25rem 0; - height: 4.5rem; + height: $secondaryNavbarHeight; font-weight: $weight-semibold; color: $primary-invert; diff --git a/ui/app/styles/core/title.scss b/ui/app/styles/core/title.scss index 1be1504d9..8948677bd 100644 --- a/ui/app/styles/core/title.scss +++ b/ui/app/styles/core/title.scss @@ -35,28 +35,6 @@ align-items: stretch; } } - .actions-dropdown { - z-index: 3; - .hds-dropdown__list { - padding-top: 0.5rem; - padding-bottom: 0.5rem; - } - .hds-reveal { - width: auto; - .hds-reveal__toggle-button { - color: black; - flex-direction: row-reverse; - width: 100%; - text-align: left; - font-weight: 600; - padding-top: calc(0.25rem + 8px); - padding-bottom: calc(0.25rem + 8px); - padding-left: 0; - padding-right: 0; - border-width: 0; - } - } - } .exec-open-button, .two-step-button { & > button { diff --git a/ui/app/styles/utils/z-indices.scss b/ui/app/styles/utils/z-indices.scss index 561487102..383312ad6 100644 --- a/ui/app/styles/utils/z-indices.scss +++ b/ui/app/styles/utils/z-indices.scss @@ -13,3 +13,5 @@ $z-subnav: 200; $z-popover: 150; $z-base: 100; $z-icon-decorators: 50; +$z-actions: 400; +$z-actions-backdrop: 399; diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 8b89d29e8..5b4221061 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -49,12 +49,10 @@ {{#if this.model.isRunning}} {{#if this.shouldShowActions}} - - - {{#each this.model.task.actions as |action|}} - - {{/each}} - + {{/if}}
diff --git a/ui/app/templates/application.hbs b/ui/app/templates/application.hbs index f6da45eb7..bae05ce79 100644 --- a/ui/app/templates/application.hbs +++ b/ui/app/templates/application.hbs @@ -47,6 +47,9 @@ + + + {{#if this.error}} diff --git a/ui/app/templates/components/job-page/parts/title.hbs b/ui/app/templates/components/job-page/parts/title.hbs index 1b2764c32..b45be90c0 100644 --- a/ui/app/templates/components/job-page/parts/title.hbs +++ b/ui/app/templates/components/job-page/parts/title.hbs @@ -19,21 +19,7 @@ {{#if (not (eq this.job.status "dead"))}} {{#if (can "exec allocation" namespace=this.job.namespace)}} {{#if (and this.job.actions.length this.job.allocations.length)}} - - - {{#each this.job.actions as |action|}} - {{#if (gt action.allocations.length 1)}} - - - - - - - {{else}} - - {{/if}} - {{/each}} - + {{/if}} {{/if}} diff --git a/ui/app/templates/components/task-group-row.hbs b/ui/app/templates/components/task-group-row.hbs index 9891f3253..e03b5a1b8 100644 --- a/ui/app/templates/components/task-group-row.hbs +++ b/ui/app/templates/components/task-group-row.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 ~}} - + {{this.taskGroup.name}} diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index a81e5bfc7..6c94ff75c 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -130,6 +130,14 @@ function smallCluster(server) { clientStatus: 'running', }); + // Set its task state to running + server.schema.allocations + .all() + .filter((x) => x.taskGroup === actionsGroup.name) + .models[0].taskStates.models[0].update({ + state: 'running', + }); + server.create('policy', { id: 'client-reader', name: 'client-reader', diff --git a/ui/tests/acceptance/actions-test.js b/ui/tests/acceptance/actions-test.js index 30b5e76f6..2bef8ab08 100644 --- a/ui/tests/acceptance/actions-test.js +++ b/ui/tests/acceptance/actions-test.js @@ -11,6 +11,7 @@ import Tokens from 'nomad-ui/tests/pages/settings/tokens'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; import percySnapshot from '@percy/ember'; import Actions from 'nomad-ui/tests/pages/jobs/job/actions'; +import { triggerEvent, visit, click } from '@ember/test-helpers'; module('Acceptance | actions', function (hooks) { setupApplicationTest(hooks); @@ -104,7 +105,7 @@ module('Acceptance | actions', function (hooks) { // Running actions test test('Running actions and notifications', async function (assert) { - assert.expect(13); + assert.expect(20); allScenarios.smallCluster(server); let managementToken = server.create('token', { type: 'management', @@ -122,6 +123,7 @@ module('Acceptance | actions', function (hooks) { // Open the dropdown await Actions.titleActions.click(); + assert.equal(Actions.titleActions.expandedValue, 'true'); assert.equal( Actions.titleActions.actions.length, 5, @@ -165,36 +167,68 @@ module('Acceptance | actions', function (hooks) { // run on a random alloc await Actions.titleActions.multiAllocActions[0].subActions[0].click(); + assert.ok(Actions.flyout.isPresent); assert.equal( - Actions.toast.length, + Actions.flyout.instances.length, 1, - 'A toast notification pops up upon running an action' + 'A sidebar instance pops up upon running an action' ); assert.ok( - Actions.toast[0].code.includes('Message Received'), - 'The notification contains the message from the action' + Actions.flyout.instances[0].code.includes('Message Received'), + 'The instance contains the message from the action' ); assert.ok( - Actions.toast[0].titleBar.includes('Finished'), - 'The notification contains the message from the action' + Actions.flyout.instances[0].statusBadge.includes('Complete'), + 'The instance contains the status of the action' ); - // run on all allocs + await Actions.flyout.close(); + // Type the escape key: the Helios dropdown doesn't automatically close on click-away events + // as defined by clickable in the page object here, so we should explicitly make sure it's closed. + await triggerEvent('.job-page-header .actions-dropdown', 'keyup', { + key: 'Escape', + }); + + assert.notOk(Actions.flyout.isPresent); + assert.equal(Actions.titleActions.expandedValue, 'false'); + + await Actions.titleActions.click(); + await Actions.titleActions.multiAllocActions[0].button[0].click(); await Actions.titleActions.multiAllocActions[0].subActions[1].click(); + assert.ok(Actions.flyout.isPresent); + + // 2 assets, the second of which has multiple peer allocs within it assert.equal( - Actions.toast.length, - 6, - 'Running on all allocs in the group (5) results in 6 total toasts' + Actions.flyout.instances.length, + 2, + 'Running on all allocs in the group (1) results in 2 total instances' ); - // Click the orphan alloc action + assert.ok( + Actions.flyout.instances[0].hasPeers, + 'The first instance has peers' + ); + assert.notOk( + Actions.flyout.instances[1].hasPeers, + 'The second instance does not have peers' + ); + + await Actions.flyout.close(); + // Type the escape key: the Helios dropdown doesn't automatically close on click-away events + // as defined by clickable in the page object here, so we should explicitly make sure it's closed. + await triggerEvent('.job-page-header .actions-dropdown', 'keyup', { + key: 'Escape', + }); + + await Actions.titleActions.click(); await Actions.titleActions.singleAllocActions[0].button[0].click(); + assert.equal( - Actions.toast.length, - 7, - 'Running on an orphan alloc results in 1 further action/toast' + Actions.flyout.instances.length, + 3, + 'Running on an orphan alloc results in 1 further action instance' ); await percySnapshot(assert); @@ -234,14 +268,83 @@ module('Acceptance | actions', function (hooks) { ); await Actions.taskRowActions[0].actions[0].click(); + assert.ok(Actions.flyout.isPresent); assert.equal( - Actions.toast.length, + Actions.flyout.instances.length, 1, - 'A toast notification pops up upon running an action' + 'A sidebar instance pops up upon running an action' ); assert.ok( - Actions.toast[0].code.includes('Message Received'), - 'The notification contains the message from the action' + Actions.flyout.instances[0].code.includes('Message Received'), + 'The instance contains the message from the action' + ); + }); + + test('Actions flyout gets dynamic actions list', async function (assert) { + assert.expect(8); + allScenarios.smallCluster(server); + let managementToken = server.create('token', { + type: 'management', + name: 'Management Token', + }); + await Tokens.visit(); + const { secretId } = managementToken; + await Tokens.secret(secretId).submit(); + await Actions.visitIndex({ id: 'actionable-job' }); + // Run an action to open the flyout; observe the dropdown there + await Actions.titleActions.click(); + await Actions.titleActions.singleAllocActions[0].button[0].click(); + + // Is flyout open? + assert.ok(Actions.flyout.isPresent, 'Flyout is open'); + + // Is there a dropdown in the flyout? + assert.ok(Actions.flyout.actions.isPresent, 'Flyout has actions dropdown'); + + // Close the flyout go to the Jobs page + await Actions.flyout.close(); + await visit('/jobs'); + + assert.notOk(Actions.flyout.isPresent, 'Flyout is closed'); + + // Global button should be present + assert.ok(Actions.globalButton.isPresent, 'Global button is present'); + + // click it + await Actions.globalButton.click(); + + // actions flyout should be open + assert.ok(Actions.flyout.isPresent, 'Flyout is open'); + + // it shouldn't have a dropdown in it + assert.notOk( + Actions.flyout.actions.isPresent, + 'Flyout has no actions dropdown' + ); + await Actions.flyout.close(); + + // head back into the job, and into a task + await Actions.visitIndex({ id: 'actionable-job' }); + await click('[data-test-task-group="actionable-group"] a'); + await click('.task-name'); + // Click global button + await Actions.globalButton.click(); + // Dropdown present + assert.ok( + Actions.flyout.actions.isPresent, + 'Flyout has actions dropdown on task page' + ); + await percySnapshot(assert); + + // Clear finished actions and take a snapshot + await click('button[data-test-clear-finished-actions]'); + await percySnapshot('Cleared actions/flyout open state'); + + // Close flyout; global button is no longer present + await Actions.flyout.close(); + assert.notOk( + Actions.globalButton.isPresent, + 'Global button is not present after flyout close' ); }); }); diff --git a/ui/tests/pages/jobs/job/actions.js b/ui/tests/pages/jobs/job/actions.js index b5fd7b96f..c2b37de47 100644 --- a/ui/tests/pages/jobs/job/actions.js +++ b/ui/tests/pages/jobs/job/actions.js @@ -28,7 +28,13 @@ export default create({ }), titleActions: { - click: clickable('.job-page-header .actions-dropdown button'), + click: clickable( + '.job-page-header .actions-dropdown .action-toggle-button' + ), + expandedValue: attribute( + 'aria-expanded', + '.job-page-header .actions-dropdown .action-toggle-button' + ), actions: collection( '.job-page-header .actions-dropdown .hds-dropdown__list li', { @@ -67,9 +73,66 @@ export default create({ ), }, - toast: collection('.hds-toast', { - text: text(), - code: text('code'), - titleBar: text('.hds-alert__title'), - }), + globalButton: { + isPresent: isPresent('.actions-flyout-button button'), + click: clickable('.actions-flyout-button button'), + }, + + flyout: { + isPresent: isPresent('#actions-flyout'), + instances: collection('.actions-queue .action-card', { + text: text(), + code: text('.messages code'), + hasPeers: isPresent('.peers'), + // titleBar: text('header'), + statusBadge: text('header .hds-badge'), + }), + close: clickable('.hds-flyout__dismiss'), + actions: { + // find within actions-flyout + isPresent: isPresent('#actions-flyout .actions-dropdown'), + click: clickable('.actions-dropdown .action-toggle-button'), + // expandedValue: attribute( + // 'aria-expanded', + // '.actions-dropdown .action-toggle-button' + // ), + actions: collection('.actions-dropdown .hds-dropdown__list li', { + text: text(), + click: clickable('button'), + }), + multiAllocActions: collection( + '.actions-dropdown .hds-dropdown__list li.hds-dropdown-list-item--variant-generic', + { + text: text(), + button: collection('button', { + click: clickable(), + expanded: attribute('aria-expanded'), + }), + subActions: collection( + '.hds-disclosure-primitive__content .hds-reveal__content li', + { + text: text(), + click: clickable('button'), + } + ), + showsDisclosureContent: isPresent( + '.hds-disclosure-primitive__content' + ), + } + ), + singleAllocActions: collection( + '.actions-dropdown .hds-dropdown__list li.hds-dropdown-list-item--variant-interactive', + { + text: text(), + button: collection('button', { + click: clickable(), + expanded: attribute('aria-expanded'), + }), + showsDisclosureContent: isPresent( + '.hds-disclosure-primitive__content' + ), + } + ), + }, + }, });