[ui] Actions service and flyout (#19084)

* Initial pass at a global actions instance queue

* Action card with a bunch of functionality that needs to be pared back a bit

* Happy little actions button

* runAction performs updated to use actions service

* Stop All and Clear Finished buttons

* Keyboard service now passes element, so we can pseudo-click the actions dropdown

* resizable sidebar code blocks

* Contextual actions within task and job levels

* runAction greatly consolidated

* Pluralize action text

* Peer grouping of flyout action intances

* ShortIDs instead of full alloc IDs

* Testfixes that previously depended on notifications

* Stop and stop all for peered action instances

* Job name in action instance card linkable

* Componentized actions global button

* scss consolidation

* Clear and Stop buttons become mutually exclusive in an action card

* Clean up action card title styles a bit

* todo-bashing

* stopAll and stopPeers separated and fixed up

* Socket handling functions moved to the Actions service

* Error handling on socket message

* Smarter import

* Documentation note: need alloc-exec and alloc-raw-exec for raw_exec jobs

* Tests for flyout and dropdown actions

* Docs link when in empty flyout/queue state and percy snapshot test for it
This commit is contained in:
Phil Renaud
2023-11-26 23:46:44 -05:00
committed by GitHub
parent cfbb2e8923
commit fb14c2b556
31 changed files with 1158 additions and 250 deletions

View File

@@ -38,8 +38,8 @@ Usage: nomad alloc exec [options] <allocation> <command>
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:

View File

@@ -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<import('../adapters/application').default>}
*/
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 ? `&region=${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;
}
}

View File

@@ -0,0 +1,101 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="action-card">
<Hds::PageHeader class="action-card-header" as |PH|>
<PH.Title>
<span class="action-card-title">
<span>{{this.instance.action.name}}</span>
<LinkTo class="job-name" @route="jobs.job" @model={{this.instance.action.task.taskGroup.job}}>
{{this.instance.action.task.taskGroup.job.name}}
</LinkTo>
</span>
<Hds::Badge @text="{{capitalize this.instance.state}}" @color={{this.stateColor}} @size="medium" />
</PH.Title>
<PH.Actions>
{{!--
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")}}
<Hds::Button @text="Stop" @color="critical" @size="medium" {{on "click" this.stop}} />
{{/if}}
{{#if (get (filter-by 'state' 'running' (filter-by 'peerID' this.instance.peerID this.nomadActions.actionsQueue)) 'length')}}
<Hds::Button @text="Stop All" @color="critical" @size="medium" {{on "click" this.stopAll}} />
{{else}}
<Hds::Button @text="Clear"
@color="secondary"
{{on "click" (action this.nomadActions.clearActionInstance this.instance)}}
/>
{{/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")}}
<Hds::Button @text="Stop" @color="critical" @size="medium" {{on "click" this.stop}} />
{{else}}
<Hds::Button @text="Clear"
@color="secondary"
{{on "click" (action this.nomadActions.clearActionInstance this.instance)}}
/>
{{/if}}
{{/if}}
</PH.Actions>
</Hds::PageHeader>
{{#if this.instance.peerID}}
<Hds::ButtonSet class="peers">
{{#each (filter-by 'peerID' this.instance.peerID this.nomadActions.actionsQueue) as |peer|}}
<Hds::Button
class="peer"
@icon={{if (eq peer.state "running") "loading" null}}
@iconPosition="trailing"
@text={{peer.allocShortID}}
@color={{if (eq this.instance.id peer.id) "primary" "secondary"}}
{{on "click" (action this.selectPeer peer)}}
/>
{{/each}}
</Hds::ButtonSet>
{{/if}}
<div class="messages">
{{#if this.instance.error}}
<code><pre>Error: {{this.instance.error}}</pre></code>
{{/if}}
{{#if this.instance.messages.length}}
<code><pre>{{this.instance.messages}}</pre></code>
{{else}}
{{#if (eq this.instance.state "complete")}}
<p class="no-messages">Action completed with no output</p>
{{/if}}
{{/if}}
</div>
<footer>
<Hds::Reveal @text="Action Info">
<ul>
<li><span>Task:</span> {{this.instance.action.task.name}}</li>
<li><span>Job:</span> {{this.instance.action.task.taskGroup.job.name}}</li>
<li><span>Allocation:</span> {{this.instance.allocID}}</li>
<li><span>Created:</span> {{format-ts this.instance.createdAt}}</li>
{{#if this.instance.completedAt}}
{{#if (gt (moment-diff this.instance.createdAt this.instance.completedAt precision='seconds') 1)}}
<li>Completed after {{moment-diff this.instance.createdAt this.instance.completedAt precision='seconds'}} seconds</li>
{{else}}
<li>Completed in {{moment-diff this.instance.createdAt this.instance.completedAt precision='seconds' float=true}} seconds</li>
{{/if}}
{{/if}}
</ul>
</Hds::Reveal>
</footer>
{{yield}}
</div>

View File

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

View File

@@ -0,0 +1,57 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Hds::Dropdown class="actions-dropdown"
{{keyboard-shortcut
label="Open Actions Dropdown"
pattern=(array "a" "c")
action=(action this.openActionsDropdown)
}}
as |dd|>
<dd.ToggleButton class="action-toggle-button" @color="secondary" @text="Actions{{if @context (concat " for " @context.name)}}" @size="medium" />
{{#each @actions as |action|}}
{{#if @allocation}}
{{!-- If an allocation was passed in, we run the action on that alloc --}}
<dd.Interactive
{{keyboard-shortcut
enumerated=true
action=(fn this.nomadActions.runAction (hash action=action allocID=@allocation.id))
}}
{{on "click" (fn this.nomadActions.runAction (hash action=action allocID=@allocation.id))}} @text={{action.name}}
/>
{{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 --}}
<dd.Interactive
{{keyboard-shortcut
enumerated=true
action=(fn this.nomadActions.runAction (hash action=action allocID=(get (object-at 0 action.allocations) "id")))
}}
{{on "click" (fn this.nomadActions.runAction (hash action=action allocID=(get (object-at 0 action.allocations) "id")))}} @text="{{action.name}}"
/>
{{else}}
{{!-- Either no allocation was passed in, or there are multiple allocatios on the action to choose from --}}
<dd.Generic>
<Hds::Reveal @text={{action.name}}>
<dd.Interactive
{{keyboard-shortcut
enumerated=true
action=(fn this.nomadActions.runActionOnRandomAlloc action)
}}
{{on "click" (fn this.nomadActions.runActionOnRandomAlloc action)}}
@text="Run on a random alloc"
/>
<dd.Interactive
{{keyboard-shortcut
enumerated=true
action=(fn this.nomadActions.runActionOnAllAllocs action)
}}
{{on "click" (fn this.nomadActions.runActionOnAllAllocs action)}}
@text="Run on all {{action.allocations.length}} allocs"
/>
</Hds::Reveal>
</dd.Generic>
{{/if}}
{{/each}}
</Hds::Dropdown>

View File

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

View File

@@ -0,0 +1,21 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if this.nomadActions.actionsQueue.length}}
{{#unless this.nomadActions.flyoutActive}}
<div class="actions-flyout-button"
{{keyboard-shortcut menuLevel=true pattern=(array "a" "c") action=(action this.nomadActions.openFlyout) }}
>
<Hds::Button
{{on "click" this.nomadActions.openFlyout}}
disabled={{this.nomadActions.flyoutActive}}
@text={{if this.nomadActions.runningActions.length (concat this.nomadActions.runningActions.length " " (pluralize 'Action' this.nomadActions.runningActions.length) " Running") "Actions"}}
@icon={{if this.nomadActions.runningActions.length "loading" "chevron-right"}}
@iconPosition="trailing"
@color="secondary"
/>
</div>
{{/unless}}
{{/if}}

View File

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

View File

@@ -0,0 +1,44 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if this.nomadActions.flyoutActive}}
<Hds::Flyout id="actions-flyout"
@onClose={{this.nomadActions.closeFlyout}} @size="large" as |Fly|
>
<Fly.Header>
<h3>
Actions
</h3>
{{#if this.contextualActions.length}}
<ActionsDropdown
@actions={{this.contextualActions}}
@allocation={{this.allocation}}
@context={{if this.task this.task this.job}}
/>
{{/if}}
{{#if this.nomadActions.runningActions.length}}
<Hds::Button @text="Stop All" @color="critical" @size="medium" {{on "click" this.nomadActions.stopAll}} />
{{/if}}
{{#if this.nomadActions.finishedActions.length}}
<Hds::Button data-test-clear-finished-actions @text="Clear Finished Actions" @color="secondary" @size="medium" {{on "click" this.nomadActions.clearFinishedActions}} />
{{/if}}
</Fly.Header>
<Fly.Body>
<ul class="actions-queue">
{{#each this.actionInstances as |instance|}}
<ActionCard @instance={{instance}} />
{{else}}
<Hds::ApplicationState as |A|>
<A.Header @title="No actions in queue" />
<A.Body @text="Your actions have been manually cleared. To run more, head to a Job or Task page with actions in its Jobspec, and an Actions dropdown will automatically populate." />
<A.Footer @hasDivider={{true}} as |F|>
<F.Link::Standalone @icon="docs-link" @text="Learn more about Actions" @href="https://developer.hashicorp.com/nomad/docs/job-specification/action" @iconPosition="trailing" />
</A.Footer>
</Hds::ApplicationState>
{{/each}}
</ul>
</Fly.Body>
</Hds::Flyout>
{{/if}}

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
{{#if this.keyboard.shortcutsVisible}}
{{keyboard-commands (array this.escapeCommand)}}
<div class="keyboard-shortcuts"
<section class="keyboard-shortcuts"
{{on-click-outside
(toggle "keyboard.shortcutsVisible" this)
}}
@@ -54,7 +54,7 @@
title="{{if this.keyboard.enabled "enable" "disable"}} keyboard shortcuts"
/>
</footer>
</div>
</section>
{{/if}}
{{#if (and this.keyboard.enabled this.keyboard.displayHints)}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,3 +59,4 @@
@import './components/metadata-editor';
@import './components/job-status-panel';
@import './components/access-control';
@import './components/actions';

View File

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

View File

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

View File

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

View File

@@ -13,3 +13,5 @@ $z-subnav: 200;
$z-popover: 150;
$z-base: 100;
$z-icon-decorators: 50;
$z-actions: 400;
$z-actions-backdrop: 399;

View File

@@ -49,12 +49,10 @@
{{#if this.model.isRunning}}
{{#if this.shouldShowActions}}
<Hds::Dropdown class="actions-dropdown" as |dd|>
<dd.ToggleButton @color="secondary" @text="Actions" @size="medium" />
{{#each this.model.task.actions as |action|}}
<dd.Interactive {{on "click" (perform this.runAction action (get (object-at 0 action.allocations) "id"))}} @text="{{action.name}}" />
{{/each}}
</Hds::Dropdown>
<ActionsDropdown
@actions={{this.model.task.actions}}
@allocation={{this.model.allocation}}
/>
{{/if}}
<div class="two-step-button">

View File

@@ -47,6 +47,9 @@
<KeyboardShortcutsModal />
<ActionsFlyout />
<ActionsFlyoutGlobalButton />
<PortalTarget @name="log-sidebar-portal" />
{{#if this.error}}

View File

@@ -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)}}
<Hds::Dropdown class="actions-dropdown" as |dd|>
<dd.ToggleButton @color="secondary" @text="Actions" @size="medium" />
{{#each this.job.actions as |action|}}
{{#if (gt action.allocations.length 1)}}
<dd.Generic>
<Hds::Reveal @text={{action.name}}>
<dd.Interactive {{on "click" (perform this.runAction action null)}} @text="Run on a random alloc" />
<dd.Interactive {{on "click" (perform this.runAction action "all")}} @text="Run on all {{action.allocations.length}} allocs" />
</Hds::Reveal>
</dd.Generic>
{{else}}
<dd.Interactive {{on "click" (perform this.runAction action (get (object-at 0 action.allocations) "id"))}} @text="{{action.name}}" />
{{/if}}
{{/each}}
</Hds::Dropdown>
<ActionsDropdown @actions={{this.job.actions}} />
{{/if}}
{{/if}}
<Exec::OpenButton @job={{this.job}} />

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<td data-test-task-group-name>
<td data-test-task-group-name={{this.taskGroup.name}}>
<LinkTo @route="jobs.job.task-group" @models={{array this.taskGroup.job this.taskGroup}} class="is-primary">
{{this.taskGroup.name}}
</LinkTo>

View File

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

View File

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

View File

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