mirror of
https://github.com/kemko/nomad.git
synced 2026-01-06 18:35:44 +03:00
* 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
311 lines
8.3 KiB
JavaScript
311 lines
8.3 KiB
JavaScript
/**
|
|
* Copyright (c) HashiCorp, Inc.
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
|
|
// @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.
|
|
// For now, we simply check alloc exec privileges.
|
|
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
|
|
}
|