mirror of
https://github.com/kemko/nomad.git
synced 2026-01-01 16:05:42 +03:00
[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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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 ? `®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;
|
||||
}
|
||||
}
|
||||
|
||||
101
ui/app/components/action-card.hbs
Normal file
101
ui/app/components/action-card.hbs
Normal 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>
|
||||
51
ui/app/components/action-card.js
Normal file
51
ui/app/components/action-card.js
Normal 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;
|
||||
}
|
||||
}
|
||||
57
ui/app/components/actions-dropdown.hbs
Normal file
57
ui/app/components/actions-dropdown.hbs
Normal 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>
|
||||
25
ui/app/components/actions-dropdown.js
Normal file
25
ui/app/components/actions-dropdown.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
21
ui/app/components/actions-flyout-global-button.hbs
Normal file
21
ui/app/components/actions-flyout-global-button.hbs
Normal 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}}
|
||||
11
ui/app/components/actions-flyout-global-button.js
Normal file
11
ui/app/components/actions-flyout-global-button.js
Normal 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;
|
||||
}
|
||||
44
ui/app/components/actions-flyout.hbs
Normal file
44
ui/app/components/actions-flyout.hbs
Normal 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}}
|
||||
68
ui/app/components/actions-flyout.js
Normal file
68
ui/app/components/actions-flyout.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
45
ui/app/models/action-instance.js
Normal file
45
ui/app/models/action-instance.js
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -59,3 +59,4 @@
|
||||
@import './components/metadata-editor';
|
||||
@import './components/job-status-panel';
|
||||
@import './components/access-control';
|
||||
@import './components/actions';
|
||||
|
||||
176
ui/app/styles/components/actions.scss
Normal file
176
ui/app/styles/components/actions.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,3 +13,5 @@ $z-subnav: 200;
|
||||
$z-popover: 150;
|
||||
$z-base: 100;
|
||||
$z-icon-decorators: 50;
|
||||
$z-actions: 400;
|
||||
$z-actions-backdrop: 399;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
|
||||
<KeyboardShortcutsModal />
|
||||
|
||||
<ActionsFlyout />
|
||||
<ActionsFlyoutGlobalButton />
|
||||
|
||||
<PortalTarget @name="log-sidebar-portal" />
|
||||
|
||||
{{#if this.error}}
|
||||
|
||||
@@ -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}} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user