openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-scriptrunner/src/tools/ScriptRunner/ScriptRunner.vue
<!--
# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# Modified by OpenC3, Inc.
# All changes Copyright 2024, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.
-->
<template>
<div>
<top-bar :menus="menus" :title="title" />
<v-snackbar v-model="showAlert" top :color="alertType" :timeout="3000">
<v-icon> mdi-{{ alertType }} </v-icon>
{{ alertText }}
<template v-slot:action="{ attrs }">
<v-btn text v-bind="attrs" @click="showAlert = false"> Close </v-btn>
</template>
</v-snackbar>
<v-snackbar v-model="showEditingToast" top :timeout="-1" color="orange">
<v-icon> mdi-pencil-off </v-icon>
{{ lockedBy }} is editing this script. Editor is in read-only mode
<template v-slot:action="{ attrs }">
<v-btn
text
v-bind="attrs"
color="danger"
@click="confirmLocalUnlock"
data-test="unlock-button"
>
Unlock
</v-btn>
<v-btn
text
v-bind="attrs"
@click="
() => {
showEditingToast = false
}
"
>
dismiss
</v-btn>
</template>
</v-snackbar>
<div class="grid">
<div
class="item"
v-for="def in screens"
:key="def.id"
:id="screenId(def.id)"
ref="gridItem"
>
<div class="item-content">
<openc3-screen
:target="def.target"
:screen="def.screen"
:definition="def.definition"
:keywords="screenKeywords"
:initialFloated="true"
:initialTop="def.top"
:initialLeft="def.left"
:initialZ="3"
:minZ="3"
:fixFloated="true"
:count="def.count"
@close-screen="closeScreen(def.id)"
@delete-screen="closeScreen(def.id)"
/>
</div>
</div>
</div>
<v-card style="padding: 10px">
<suite-runner
v-if="suiteRunner"
class="suite-runner"
:suite-map="suiteMap"
:disable-buttons="disableSuiteButtons"
:filename="fullFilename"
@button="suiteRunnerButton"
@loaded="doResize"
/>
<div id="sr-controls">
<v-row no-gutters justify="space-between">
<v-icon v-if="showDisconnect" class="mr-2" color="red">
mdi-connection
</v-icon>
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
v-on="on"
v-bind="attrs"
icon
@click="reloadFile"
:disabled="filename === NEW_FILENAME"
>
<v-icon>mdi-cached</v-icon>
</v-btn>
</template>
<span> Reload File </span>
</v-tooltip>
<v-select
v-model="filenameSelect"
@change="fileNameChanged"
:items="fileList"
:disabled="fileList.length <= 1"
label="Filename"
id="filename"
data-test="filename"
style="width: 300px"
dense
outlined
hide-details
/>
<v-text-field
v-model="scriptId"
label="Script ID"
data-test="id"
class="shrink ml-2 script-state"
style="width: 100px"
dense
outlined
readonly
hide-details
/>
<v-text-field
v-model="stateTimer"
label="Script State"
data-test="state"
class="shrink ml-2 script-state"
style="width: 120px"
dense
outlined
readonly
hide-details
/>
<v-progress-circular
v-if="state === 'Connecting...'"
:size="40"
class="ml-2 mr-2"
indeterminate
color="primary"
/>
<div v-else style="width: 40px; height: 40px" class="ml-2 mr-2"></div>
<!-- Hide the Start button when Suite Runner controls are showing -->
<v-spacer />
<div v-if="startOrGoButton === 'Start'">
<v-btn
@click="startHandler"
class="mx-1"
color="primary"
data-test="start-button"
:disabled="startOrGoDisabled || !executeUser"
:hidden="suiteRunner"
>
<span> Start </span>
</v-btn>
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
v-on="on"
v-bind="attrs"
@click="scriptEnvironment.show = !scriptEnvironment.show"
class="mx-1"
data-test="env-button"
:color="environmentIconColor"
:disabled="envDisabled"
>
<v-icon> {{ environmentIcon }} </v-icon>
</v-btn>
</template>
<span>Script Environment</span>
</v-tooltip>
</div>
<div v-else>
<v-btn
@click="go"
color="primary"
class="mr-2"
:disabled="startOrGoDisabled"
data-test="go-button"
>
Go
</v-btn>
<v-btn
color="primary"
@click="pauseOrRetry"
class="mr-2"
:disabled="pauseOrRetryDisabled"
data-test="pause-retry-button"
>
{{ pauseOrRetryButton }}
</v-btn>
<v-btn
color="primary"
@click="stop"
data-test="stop-button"
:disabled="stopDisabled"
>
Stop
</v-btn>
</div>
</v-row>
</div>
</v-card>
<!-- Create Multipane container to support resizing.
NOTE: We listen to paneResize event and call editor.resize() to prevent weird sizing issues,
The event must be paneResize and not pane-resize -->
<multipane layout="horizontal" @paneResize="doResize">
<div class="editorbox">
<v-snackbar
v-model="showSave"
absolute
top
right
:timeout="-1"
class="saving"
>
Saving...
</v-snackbar>
<pre
ref="editor"
class="editor"
@contextmenu.prevent="showExecuteSelectionMenu"
></pre>
<v-menu
v-model="executeSelectionMenu"
:position-x="menuX"
:position-y="menuY"
absolute
offset-y
>
<v-list>
<v-list-item
v-for="item in executeSelectionMenuItems"
link
:key="item.label"
:disabled="scriptId"
>
<v-list-item-title @click="item.command">
{{ item.label }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<multipane-resizer><hr /></multipane-resizer>
<div id="messages" class="mt-2" ref="messagesDiv">
<div id="debug" class="pa-0" v-if="showDebug">
<v-row no-gutters>
<v-btn
color="primary"
@click="step"
style="width: 100px"
class="mr-4"
:disabled="!scriptId"
data-test="step-button"
>
Step
<v-icon right> mdi-step-forward </v-icon>
</v-btn>
<v-text-field
class="mb-2"
outlined
dense
hide-details
label="Debug"
v-model="debug"
@keydown="debugKeydown"
data-test="debug-text"
/>
</v-row>
</div>
<script-log-messages
id="log-messages"
v-model="messages"
@sort="messageSortOrder"
/>
</div>
</multipane>
<!--- MENUS --->
<file-open-save-dialog
v-if="fileOpen"
v-model="fileOpen"
type="open"
api-url="/script-api/scripts"
@file="setFile($event)"
@error="setError($event)"
@clear-temp="clearTemp($event)"
/>
<file-open-save-dialog
v-if="showSaveAs"
v-model="showSaveAs"
type="save"
api-url="/script-api/scripts"
require-target-parent-dir
:input-filename="filenameOrBlank"
@filename="saveAsFilename($event)"
@error="setError($event)"
@clear-temp="clearTemp($event)"
/>
<environment-dialog v-if="showEnvironment" v-model="showEnvironment" />
<ask-dialog
v-if="ask.show"
v-model="ask.show"
:question="ask.question"
:default="ask.default"
:password="ask.password"
:answer-required="ask.answerRequired"
@response="ask.callback"
/>
<file-dialog
v-if="file.show"
v-model="file.show"
:title="file.title"
:message="file.message"
:multiple="file.multiple"
:filter="file.filter"
@response="fileDialogCallback"
/>
<information-dialog
v-if="information.show"
v-model="information.show"
:title="information.title"
:text="information.text"
/>
<event-list-dialog
v-if="inputMetadata.show"
v-model="inputMetadata.show"
:events="inputMetadata.events"
:time-zone="timeZone"
new-metadata
@close="inputMetadata.callback"
/>
<overrides-dialog v-if="showOverrides" v-model="showOverrides" />
<prompt-dialog
v-if="prompt.show"
v-model="prompt.show"
:title="prompt.title"
:subtitle="prompt.subtitle"
:message="prompt.message"
:details="prompt.details"
:buttons="prompt.buttons"
:layout="prompt.layout"
:multiple="prompt.multiple"
@response="prompt.callback"
/>
<results-dialog
v-if="results.show"
v-model="results.show"
:text="results.text"
/>
<script-environment-dialog
v-if="scriptEnvironment.show"
v-model="scriptEnvironment.show"
:input-environment="scriptEnvironment.env"
@environment="environmentHandler"
/>
<simple-text-dialog
v-model="showSuiteError"
title="Suite Analysis Error"
:text="suiteError"
:width="1000"
/>
<critical-cmd-dialog
:uuid="criticalCmdUuid"
:cmdString="criticalCmdString"
:cmdUser="criticalCmdUser"
:persistent="true"
v-model="displayCriticalCmd"
@status="promptDialogCallback"
/>
<v-bottom-sheet v-model="showScripts">
<v-sheet class="pb-11 pt-5 px-5">
<running-scripts
v-if="showScripts"
:connect-in-new-tab="!!fileModified"
@disconnect="scriptDisconnect"
@close="
() => {
showScripts = false
}
"
/>
</v-sheet>
</v-bottom-sheet>
</div>
</template>
<script>
import axios from 'axios'
import Cable from '@openc3/tool-common/src/services/cable.js'
import Api from '@openc3/tool-common/src/services/api'
import * as ace from 'ace-builds'
import 'ace-builds/src-min-noconflict/mode-ruby'
import 'ace-builds/src-min-noconflict/mode-python'
import 'ace-builds/src-min-noconflict/theme-twilight'
import 'ace-builds/src-min-noconflict/ext-language_tools'
import 'ace-builds/src-min-noconflict/ext-searchbox'
import { format } from 'date-fns'
import { Multipane, MultipaneResizer } from 'vue-multipane'
import FileOpenSaveDialog from '@openc3/tool-common/src/components/FileOpenSaveDialog'
import EnvironmentDialog from '@openc3/tool-common/src/components/EnvironmentDialog'
import SimpleTextDialog from '@openc3/tool-common/src/components/SimpleTextDialog'
import CriticalCmdDialog from '@openc3/tool-common/src/components/CriticalCmdDialog'
import TopBar from '@openc3/tool-common/src/components/TopBar'
import { OpenC3Api } from '@openc3/tool-common/src/services/openc3-api'
import { fileIcon } from '@openc3/tool-common/src/tools/base/util/fileIcon'
import AskDialog from '@/tools/ScriptRunner/Dialogs/AskDialog'
import FileDialog from '@/tools/ScriptRunner/Dialogs/FileDialog'
import InformationDialog from '@/tools/ScriptRunner/Dialogs/InformationDialog'
import EventListDialog from '@openc3/tool-common/src/tools/calendar/Dialogs/EventListDialog'
import OverridesDialog from '@/tools/ScriptRunner/Dialogs/OverridesDialog'
import PromptDialog from '@/tools/ScriptRunner/Dialogs/PromptDialog'
import ResultsDialog from '@/tools/ScriptRunner/Dialogs/ResultsDialog'
import ScriptEnvironmentDialog from '@/tools/ScriptRunner/Dialogs/ScriptEnvironmentDialog'
import SuiteRunner from '@/tools/ScriptRunner/SuiteRunner'
import ScriptLogMessages from '@/tools/ScriptRunner/ScriptLogMessages'
import Openc3Screen from '@openc3/tool-common/src/components/Openc3Screen'
import {
CmdCompleter,
TlmCompleter,
MnemonicChecker,
} from '@/tools/ScriptRunner/autocomplete'
import { SleepAnnotator } from '@/tools/ScriptRunner/annotations'
import RunningScripts from './RunningScripts.vue'
// Matches target_file.rb TEMP_FOLDER
const TEMP_FOLDER = '__TEMP__'
const NEW_FILENAME = '<Untitled>'
const START = 'Start'
const GO = 'Go'
const PAUSE = 'Pause'
const RETRY = 'Retry'
export default {
components: {
FileOpenSaveDialog,
Openc3Screen,
EnvironmentDialog,
Multipane,
MultipaneResizer,
TopBar,
AskDialog,
FileDialog,
InformationDialog,
EventListDialog,
OverridesDialog,
PromptDialog,
ResultsDialog,
ScriptEnvironmentDialog,
SimpleTextDialog,
SuiteRunner,
RunningScripts,
ScriptLogMessages,
CriticalCmdDialog,
},
data() {
return {
title: 'Script Runner',
suiteRunner: false, // Whether to display the SuiteRunner GUI
disableSuiteButtons: false,
suiteMap: {
// Useful for testing the various options in the SuiteRunner GUI
// Suite: {
// teardown: true,
// groups: {
// Group: {
// setup: true,
// cases: ['case1', 'case2', 'really_long_test_case_name3'],
// },
// ReallyLongGroupName: {
// cases: ['case1', 'case2', 'case3'],
// },
// },
// },
},
filenameSelect: null,
currentFilename: null, // This is the currently shown filename while running
showSave: false,
showAlert: false,
alertType: null,
alertText: '',
state: null,
scriptId: null,
startOrGoButton: START,
startOrGoDisabled: false,
envDisabled: false,
pauseOrRetryButton: PAUSE,
pauseOrRetryDisabled: false,
stopDisabled: false,
showEnvironment: false,
showDebug: false,
debug: '',
debugHistory: [],
debugHistoryIndex: 0,
showDisconnect: false,
files: {},
breakpoints: {},
enableStackTraces: false,
filename: NEW_FILENAME,
readOnlyUser: false,
executeUser: true,
saveAllowed: true,
tempFilename: null,
fileModified: '',
fileOpen: false,
lockedBy: null,
showEditingToast: false,
showSaveAs: false,
areYouSure: false,
subscription: null,
cable: null,
fatal: false,
updateInterval: null,
receivedEvents: [],
messages: [],
messagesNewestOnTop: true,
maxArrayLength: 200,
Range: ace.require('ace/range').Range,
ask: {
show: false,
question: '',
default: null,
password: false,
answerRequired: true,
callback: () => {},
},
file: {
show: false,
message: '',
directory: null,
filter: '*',
multiple: false,
callback: () => {},
},
prompt: {
show: false,
title: '',
subtitle: '',
message: '',
details: '',
buttons: null,
layout: 'horizontal',
callback: () => {},
},
information: {
show: false,
title: '',
text: [],
},
inputMetadata: {
show: false,
events: [],
callback: () => {},
},
results: {
show: false,
text: '',
},
scriptEnvironment: {
show: false,
env: [],
},
showSuiteError: false,
suiteError: '',
executeSelectionMenu: false,
menuX: 0,
menuY: 0,
mnemonicChecker: new MnemonicChecker(),
showScripts: false,
showOverrides: false,
activePromptId: '',
api: null,
timeZone: 'local',
screens: [],
screenKeywords: null,
idCounter: 0,
updateCounter: 0,
recent: [],
waitingInterval: null,
waitingTime: 0,
waitingStart: 0,
criticalCmdUuid: null,
criticalCmdString: null,
criticalCmdUser: null,
displayCriticalCmd: false,
}
},
computed: {
stateTimer: function () {
if (this.state === 'waiting' || this.state === 'paused') {
return `${this.state} ${this.waitingTime}s`
}
return this.state
},
// This is the list of files shown in the select dropdown
fileList: function () {
// this.files is the list of all files seen while running
const filenames = Object.keys(this.files)
filenames.push(this.fullFilename) // Make sure the currently shown filename is last
return [...new Set(filenames)] // ensure unique
},
environmentIcon: function () {
return this.scriptEnvironment.env.length > 0
? 'mdi-bookmark'
: 'mdi-bookmark-outline'
},
environmentIconColor: function () {
return this.scriptEnvironment.env.length > 0 ? 'primary' : ''
},
isLocked: function () {
return !!this.lockedBy
},
// Returns the currently shown filename
fullFilename: function () {
if (this.currentFilename) return this.currentFilename
// New filenames should not indicate modified
if (this.filename === NEW_FILENAME) return NEW_FILENAME
return `${this.filename} ${this.fileModified}`.trim()
},
// It's annoying for people (and tests) to clear the <Untitled>
// when saving a new file so replace with blank
// This makes sure that string doesn't show up in the dialog
filenameOrBlank: function () {
return this.filename === NEW_FILENAME ? '' : this.filename
},
menus: function () {
return [
{
label: 'File',
items: [
{
label: 'New File',
icon: 'mdi-file-plus',
disabled: this.scriptId || this.readOnlyUser,
command: () => {
this.newFile()
},
},
{
label: 'New Suite',
icon: 'mdi-file-document-plus',
disabled: this.scriptId || this.readOnlyUser,
subMenu: [
{
label: 'Ruby',
icon: 'mdi-language-ruby',
command: () => {
this.newRubyTestSuite()
},
},
{
label: 'Python',
icon: 'mdi-language-python',
command: () => {
this.newPythonTestSuite()
},
},
],
},
{
label: 'Open File',
icon: 'mdi-folder-open',
disabled: this.scriptId,
command: () => {
this.openFile()
},
},
{
label: 'Open Recent',
icon: 'mdi-folder-open',
disabled: this.scriptId,
subMenu: this.recent,
},
{
divider: true,
},
{
label: 'Save File',
icon: 'mdi-content-save',
disabled: this.scriptId || this.readOnlyUser,
command: () => {
this.saveFile()
},
},
{
label: 'Save As...',
icon: 'mdi-content-save',
disabled: this.scriptId || this.readOnlyUser,
command: () => {
this.saveAs()
},
},
{
divider: true,
},
{
label: 'Download',
icon: 'mdi-cloud-download',
disabled: this.scriptId,
command: () => {
this.download()
},
},
{
divider: true,
},
{
label: 'Delete File',
icon: 'mdi-delete',
disabled: this.scriptId || this.readOnlyUser,
command: () => {
this.delete()
},
},
],
},
{
label: 'Edit',
items: [
{
label: 'Find',
icon: 'mdi-magnify',
command: () => {
this.editor.execCommand('find')
},
},
{
label: 'Replace',
icon: 'mdi-find-replace',
disabled: this.scriptId,
command: () => {
this.editor.execCommand('replace')
},
},
{
label: 'Set Line Delay',
icon: 'mdi-invoice-text-clock',
disabled: this.scriptId,
command: () => {
this.$dialog.open({
title: 'Info',
text:
'You can set the line delay in seconds using the api method set_line_delay().<br/><br/>' +
'The default line delay is 0.1 seconds between lines. ' +
'Adding set_line_delay(0) to the top of your script will execute the script at maximum speed. ' +
'However, this can make it difficult to see and pause the script. ' +
'Executing set_line_delay(1) will cause a 1 second delay between lines.',
okText: 'OK',
okClass: 'primary',
validateText: null,
cancelText: null,
html: true,
})
},
},
],
},
{
label: 'Script',
items: [
{
label: 'Execution Status',
icon: 'mdi-run',
command: () => {
this.showScripts = true
},
},
{
divider: true,
},
{
label: 'Global Environment',
icon: 'mdi-library',
disabled: this.scriptId,
command: () => {
this.showEnvironment = !this.showEnvironment
},
},
{
label: 'Metadata',
icon: 'mdi-calendar',
disabled: this.scriptId,
command: () => {
this.inputMetadata.callback = () => {}
this.showMetadata()
},
},
{
label: 'Overrides',
icon: 'mdi-swap-horizontal',
command: () => {
this.showOverrides = true
},
},
{
divider: true,
},
{
label: 'Syntax Check',
icon: 'mdi-file-check',
disabled: this.scriptId,
command: () => {
this.syntaxCheck()
},
},
{
label: 'Mnemonic Check',
icon: 'mdi-spellcheck',
disabled: this.scriptId,
command: () => {
this.checkMnemonics()
},
},
{
label: 'Instrumented Script',
icon: 'mdi-code-braces-box',
disabled: this.scriptId,
command: () => {
this.showInstrumented()
},
},
{
label: 'Call Stack',
icon: 'mdi-format-list-numbered',
disabled: !this.scriptId,
command: () => {
this.showCallStack()
},
},
{
divider: true,
},
{
label: 'Toggle Debug',
icon: 'mdi-bug',
command: () => {
this.toggleDebug()
},
},
{
label: 'Toggle Disconnect',
icon: 'mdi-connection',
disabled: this.scriptId,
command: () => {
this.toggleDisconnect()
},
},
{
label: 'Enable Stack Traces',
checkbox: true,
checked: false,
disabled: this.scriptId,
command: (item) => {
this.enableStackTraces = item.checked
},
},
{
divider: true,
},
{
label: 'Delete All Breakpoints',
icon: 'mdi-delete-circle-outline',
disabled: this.scriptId,
command: () => {
this.deleteAllBreakpoints()
},
},
],
},
]
},
executeSelectionMenuItems: function () {
return [
{
label: 'Execute selection',
command: this.executeSelection,
},
{
label: 'Run from here',
command: this.runFromCursor,
},
{
label: 'Clear local breakpoints',
command: this.clearBreakpoints,
},
]
},
},
watch: {
isLocked: function (val) {
this.showEditingToast = val
if (!this.suiteRunner) {
this.startOrGoDisabled = val
}
if (this.readOnlyUser == false && val == false) {
this.editor.setReadOnly(val)
} else {
this.editor.setReadOnly(true)
}
},
fullFilename: function (filename) {
this.filenameSelect = filename
if (filename === NEW_FILENAME) {
localStorage.removeItem('script_runner__filename')
} else {
localStorage['script_runner__filename'] = filename
}
},
},
created: async function () {
// Ensure Offline Access Is Setup For the Current User
this.api = new OpenC3Api()
this.api.ensure_offline_access()
this.api
.get_setting('time_zone')
.then((response) => {
if (response) {
this.timeZone = response
}
})
.catch((error) => {
// Do nothing
})
// Make NEW_FILENAME available to the template
this.NEW_FILENAME = NEW_FILENAME
window.onbeforeunload = this.unlockFile
let user = OpenC3Auth.user()
let roles = OpenC3Auth.userroles()
this.readOnlyUser = true
this.executeUser = false
for (let role of roles) {
if (role == 'viewer') {
continue
}
if (role == 'admin' || role == 'operator') {
this.readOnlyUser = false
this.executeUser = true
} else if (role == 'runner') {
this.executeUser = true
} else {
await Api.get(`/openc3-api/roles/${role}`).then((response) => {
if (
response.data !== null &&
response.data.permissions !== undefined
) {
if (
response.data.permissions.some(
(i) => i.permission == 'script_edit',
)
) {
this.readOnlyUser = false
}
if (
response.data.permissions.some(
(i) => i.permission == 'script_run',
)
) {
this.executeUser = true
}
}
})
}
}
// Output the userinfo for use in the SuiteRunner component
localStorage['script_runner__userinfo'] = JSON.stringify({
name: user['preferred_username'],
readOnly: this.readOnlyUser,
execute: this.executeUser,
})
if (this.readOnlyUser == true) {
this.alertType = 'info'
let text = `User ${user['preferred_username']} is read only`
if (this.executeUser) {
text += ' but can execute scripts'
}
this.alertText = text
this.showAlert = true
}
Api.get('/openc3-api/autocomplete/keywords/screen').then((response) => {
this.screenKeywords = response.data
})
},
mounted: async function () {
this.editor = ace.edit(this.$refs.editor)
this.editor.setTheme('ace/theme/twilight')
// Public apis in api_shared but not in OpenC3Api
const api_shared = [
'check',
'check_raw',
'check_formatted',
'check_with_units',
'check_exception',
'check_tolerance',
'check_expression',
'wait',
'wait_tolerance',
'wait_expression',
'wait_check',
'wait_check_tolerance',
'wait_check_expression',
'wait_packet',
'wait_check_packet',
'disable_instrumentation',
'set_line_delay',
'get_line_delay',
'set_max_output',
'get_max_output',
]
const openC3RubyMode = this.buildOpenC3RubyMode(api_shared)
const openC3PythonMode = this.buildOpenC3PythonMode(api_shared)
this.openC3RubyMode = new openC3RubyMode()
this.openC3PythonMode = new openC3PythonMode()
this.editor.session.setMode(this.openC3RubyMode)
this.editor.session.setTabSize(2)
this.editor.session.setUseWrapMode(true)
this.editor.$blockScrolling = Infinity
this.editor.setOption('enableBasicAutocompletion', true)
this.editor.setOption('enableLiveAutocompletion', true)
this.editor.completers = [new CmdCompleter(), new TlmCompleter()]
this.editor.setHighlightActiveLine(false)
this.editor.focus()
this.editor.on('guttermousedown', this.toggleBreakpoint)
// We listen to tokenizerUpdate rather than change because this
// is the background process that updates as changes are processed
// while change fires immediately before the UndoManager is updated.
this.editor.session.on('tokenizerUpdate', this.onChange)
if (this.readOnlyUser) {
this.editor.setReadOnly(true)
this.editor.renderer.$cursorLayer.element.style.display = 'none'
}
const sleepAnnotator = new SleepAnnotator(this.editor)
this.editor.session.on('change', ($event, session) => {
sleepAnnotator.annotate($event, session)
this.updateBreakpoints($event, session)
})
this.editor.container.addEventListener('resize', this.doResize)
this.editor.container.addEventListener('keydown', this.keydown)
this.doResize()
this.cable = new Cable('/script-api/cable')
if (localStorage['script_runner__recent']) {
this.recent = JSON.parse(localStorage['script_runner__recent'])
// Rebuild the command since that doesn't get stringified
this.recent = this.recent.map((item) => ({
...item,
command: async (event) => {
this.filename = event.label
await this.reloadFile()
},
}))
}
if (this.$route.query?.file) {
this.filename = this.$route.query.file
await this.reloadFile()
} else if (this.$route.params?.id) {
await this.tryLoadRunningScript(this.$route.params.id)
} else {
if (localStorage['script_runner__filename']) {
this.filename = localStorage['script_runner__filename']
await this.reloadFile(false)
}
}
// TODO: Potentially still bad interactions with autoSave
// see https://github.com/OpenC3/cosmos/issues/915
// this.autoSaveInterval = setInterval(async () => {
// // Only save if not-running, modified, and visible (e.g. not open in another tab)
// if (
// !this.scriptId &&
// this.fileModified.length > 0 &&
// document.visibilityState === 'visible'
// ) {
// await this.saveFile('auto')
// }
// }, 60000) // Save every minute
this.updateInterval = setInterval(async () => {
this.processReceived()
}, 100) // Every 100ms
},
beforeDestroy() {
this.editor.destroy()
this.editor.container.remove()
},
destroyed() {
this.unlockFile()
if (this.updateInterval != null) {
clearInterval(this.updateInterval)
}
if (this.subscription) {
this.subscription.unsubscribe()
this.subscription = null
}
this.cable.disconnect()
},
beforeRouteUpdate: function (to, from, next) {
if (to.params.id) {
this.tryLoadRunningScript(to.params.id).then(next)
} else {
next()
}
},
methods: {
doResize() {
this.editor.resize()
// nextTick allows the resize to work correctly
// when we remove the SuiteRunner chrome
this.$nextTick(() => {
this.calcHeight()
})
},
calcHeight() {
var editor = document.getElementsByClassName('editorbox')[0]
var h = Math.max(
document.documentElement.offsetHeight,
window.innerHeight || 0,
)
var editorHeight = 0
if (editor) {
editorHeight = editor.offsetHeight
}
var suitesHeight = 0
var suites = document.getElementsByClassName('suite-runner')[0]
if (suites) {
suitesHeight = suites.offsetHeight
}
var logMessages = document.getElementById('script-log-messages')
if (logMessages) {
// 295 is magic and was determined by experimentation
logMessages.style.height = `${h - editorHeight - suitesHeight - 292}px`
}
},
scriptDisconnect() {
if (this.subscription) {
this.subscription.unsubscribe()
this.subscription = null
}
this.receivedEvents.length = 0 // Clear any unprocessed events
},
showMetadata() {
Api.get('/openc3-api/metadata').then((response) => {
// TODO: This is how Calendar creates new metadata items via makeMetadataEvent
this.inputMetadata.events = response.data.map((event) => {
return {
name: 'Metadata',
start: new Date(event.start * 1000),
end: new Date(event.start * 1000),
color: event.color,
type: event.type,
timed: true,
metadata: event,
}
})
this.inputMetadata.show = true
})
},
buildOpenC3RubyMode(api_shared) {
var oop = ace.require('ace/lib/oop')
var RubyHighlightRules = ace.require(
'ace/mode/ruby_highlight_rules',
).RubyHighlightRules
let apis = Object.getOwnPropertyNames(OpenC3Api.prototype)
.filter((a) => a !== 'constructor')
.filter((a) => a !== 'exec')
.concat(api_shared)
let regex = new RegExp(`(\\b${apis.join('\\b|\\b')}\\b)`)
var OpenC3HighlightRules = function () {
RubyHighlightRules.call(this)
// add openc3 rules to the ruby rules
for (var rule in this.$rules) {
this.$rules[rule].unshift({
regex: regex,
token: 'support.function',
})
}
}
oop.inherits(OpenC3HighlightRules, RubyHighlightRules)
var MatchingBraceOutdent = ace.require(
'ace/mode/matching_brace_outdent',
).MatchingBraceOutdent
var CstyleBehaviour = ace.require(
'ace/mode/behaviour/cstyle',
).CstyleBehaviour
var FoldMode = ace.require('ace/mode/folding/ruby').FoldMode
var Mode = function () {
this.HighlightRules = OpenC3HighlightRules
this.$outdent = new MatchingBraceOutdent()
this.$behaviour = new CstyleBehaviour()
this.foldingRules = new FoldMode()
this.indentKeywords = this.foldingRules.indentKeywords
}
var RubyMode = ace.require('ace/mode/ruby').Mode
oop.inherits(Mode, RubyMode)
;(function () {
this.$id = 'ace/mode/openc3'
}).call(Mode.prototype)
return Mode
},
buildOpenC3PythonMode(api_shared) {
var oop = ace.require('ace/lib/oop')
var PythonHighlightRules = ace.require(
'ace/mode/python_highlight_rules',
).PythonHighlightRules
let apis = Object.getOwnPropertyNames(OpenC3Api.prototype)
.filter((a) => a !== 'constructor')
.filter((a) => a !== 'exec')
.concat(api_shared)
let regex = new RegExp(`(\\b${apis.join('\\b|\\b')}\\b)`)
var OpenC3HighlightRules = function () {
PythonHighlightRules.call(this)
// add openc3 rules to the python rules
for (var rule in this.$rules) {
this.$rules[rule].unshift({
regex: regex,
token: 'support.function',
})
}
}
oop.inherits(OpenC3HighlightRules, PythonHighlightRules)
var MatchingBraceOutdent = ace.require(
'ace/mode/matching_brace_outdent',
).MatchingBraceOutdent
var CstyleBehaviour = ace.require(
'ace/mode/behaviour/cstyle',
).CstyleBehaviour
var FoldMode = ace.require('ace/mode/folding/pythonic').FoldMode
var Mode = function () {
this.HighlightRules = OpenC3HighlightRules
this.$outdent = new MatchingBraceOutdent()
this.$behaviour = new CstyleBehaviour()
this.foldingRules = new FoldMode()
this.indentKeywords = this.foldingRules.indentKeywords
}
var PythonMode = ace.require('ace/mode/python').Mode
oop.inherits(Mode, PythonMode)
;(function () {
this.$id = 'ace/mode/openc3'
}).call(Mode.prototype)
return Mode
},
messageSortOrder(order) {
// See ScriptLogMessages for these strings
if (order === 'Newest on Top' && this.messagesNewestOnTop === false) {
this.messagesNewestOnTop = true
this.messages.reverse()
} else if (
order === 'Newest on Bottom' &&
this.messagesNewestOnTop === true
) {
this.messagesNewestOnTop = false
this.messages.reverse()
}
},
// This only gets called when the user changes the filename dropdown
// Or when a user hits Go
fileNameChanged(filename) {
// Split off the '*' which indicates modified
filename = filename.split('*')[0]
this.editor.setValue(this.files[filename].content)
this.restoreBreakpoints(filename)
this.editor.clearSelection()
this.removeAllMarkers()
this.editor.session.addMarker(
new this.Range(
this.files[filename].lineNo - 1,
0,
this.files[filename].lineNo - 1,
1,
),
`${this.state}Marker`,
'fullLine',
)
this.editor.gotoLine(this.files[filename].lineNo)
},
tryLoadRunningScript: function (id) {
return Api.get('/script-api/running-script').then((response) => {
const loadRunningScript = response.data.find(
(s) => `${s.id}` === `${id}`,
)
if (loadRunningScript) {
this.filename = loadRunningScript.name
this.tryLoadSuites()
this.initScriptStart()
this.scriptStart(loadRunningScript.id)
} else {
this.$notify.caution({
title: `Running Script ${id} not found`,
body: 'Check the Completed Scripts below ...',
})
this.showScripts = true
}
})
},
tryLoadSuites: function () {
Api.get(`/script-api/scripts/${this.filename}`).then((response) => {
if (response.data.suites) {
this.startOrGoDisabled = true
this.suiteRunner = true
this.suiteMap = JSON.parse(response.data.suites)
}
if (response.data.error) {
this.suiteError = response.data.error
this.showSuiteError = true
}
// Disable suite buttons if we didn't successfully parse the suite
this.disableSuiteButtons = response.data.success == false
this.doResize()
})
},
showExecuteSelectionMenu: function ($event) {
this.menuX = $event.pageX
this.menuY = $event.pageY
this.executeSelectionMenu = true
},
runFromCursor: function () {
const start = this.editor.getCursorPosition().row
const text = this.editor.session.doc
.getLines(start, this.editor.session.doc.getLength())
.join('\n')
const breakpoints = this.getBreakpointRows()
.filter((row) => row >= start)
.map((row) => row - start)
this.executeText(text, breakpoints)
},
executeSelection: function () {
const text = this.editor.getSelectedText()
const range = this.editor.getSelectionRange()
const breakpoints = this.getBreakpointRows()
.filter((row) => row <= range.end.row && row >= range.start.row)
.map((row) => row - range.start.row)
this.executeText(text, breakpoints)
},
async executeText(text, breakpoints = []) {
let extension = this.fullFilename.split('.').pop()
if (extension.includes(' *')) {
extension = extension.split(' *')[0]
}
if (extension !== 'rb' && extension !== 'py') {
extension = 'rb' // Still default to Ruby if we can't determine
}
// Create a new temp script and open in new tab
const selectionTempFilename =
TEMP_FOLDER +
'/' +
format(Date.now(), 'yyyy_MM_dd_HH_mm_ss_SSS') +
`_temp.${extension}`
await Api.post(`/script-api/scripts/${selectionTempFilename}`, {
data: {
text,
breakpoints,
},
})
.then((_response) => {
let env = this.scriptEnvironment.env
if (this.enableStackTraces) {
env = env.concat({
key: 'OPENC3_FULL_BACKTRACE',
value: '1',
})
}
return Api.post(`/script-api/scripts/${selectionTempFilename}/run`, {
data: {
environment: env,
},
})
})
.then((response) => {
window.open(`/tools/scriptrunner/${response.data}`)
})
},
clearBreakpoints: function () {
this.editor.session.clearBreakpoints()
},
toggleBreakpoint: function ($event) {
// Don't allow setting breakpoints while running
if (!this.scriptId) {
const row = $event.getDocumentPosition().row
if ($event.editor.session.getBreakpoints(row, 0)[row]) {
$event.editor.session.clearBreakpoint(row)
} else {
$event.editor.session.setBreakpoint(row)
}
}
},
updateBreakpoints: function ($event, session) {
if ($event.lines.length <= 1) {
return
}
const rowsToUpdate = this.getBreakpointRows(session).filter(
(row) =>
($event.start.column === 0 && row === $event.start.row) ||
row > $event.start.row,
)
let rowsToDelete = []
let offset = 0
switch ($event.action) {
case 'insert':
offset = $event.lines.length - 1
rowsToUpdate.reverse() // shift the lower ones down out of the way first
break
case 'remove':
offset = -$event.lines.length + 1
rowsToDelete = [...Array($event.lines.length).keys()].map(
(row) => row + $event.start.row,
)
break
}
rowsToUpdate.forEach((row) => {
session.clearBreakpoint(row)
if (!rowsToDelete.includes(row)) {
session.setBreakpoint(row + offset)
}
})
},
getBreakpointRows: function (session = this.editor.session) {
return session
.getBreakpoints()
.map((breakpoint, row) => breakpoint && row) // [empty, 'ace_breakpoint', 'ace_breakpoint', empty] -> [empty, 1, 2, empty]
.filter(Number.isInteger) // [empty, 1, 2, empty] -> [1, 2]
},
restoreBreakpoints: function (filename) {
this.clearBreakpoints()
this.breakpoints[filename]?.forEach((breakpoint) => {
this.editor.session.setBreakpoint(breakpoint)
})
},
deleteAllBreakpoints: function () {
this.$dialog
.confirm('Permanently delete all breakpoints for ALL scripts?', {
okText: 'Delete',
cancelText: 'Cancel',
})
.then((dialog) => {
return Api.delete('/script-api/breakpoints/delete/all')
})
.then((response) => {
this.clearBreakpoints()
})
},
suiteRunnerButton(event) {
if (this.startOrGoButton === START) {
this.start(event, 'suiteRunner')
} else {
this.go(event, 'suiteRunner')
}
},
async keydown(event) {
// Don't ever save if running or readonly
if (this.scriptId || this.editor.getReadOnly() === true) {
return
}
// NOTE: Chrome does not allow overriding Ctrl-N, Ctrl-Shift-N, Ctrl-T, Ctrl-Shift-T, Ctrl-W
// NOTE: metaKey == Command on Mac
if (
(event.metaKey || event.ctrlKey) &&
event.keyCode === 'S'.charCodeAt(0)
) {
if (event.shiftKey) {
event.preventDefault()
this.saveAs()
} else {
event.preventDefault()
await this.saveFile()
}
}
},
onChange(event) {
// Don't track changes when we're running or read-only (locked)
if (this.scriptId || this.editor.getReadOnly() === true) {
return
}
if (this.editor.session.getUndoManager().canUndo()) {
this.fileModified = '*'
} else {
this.fileModified = ''
}
},
checkMnemonics: function () {
this.mnemonicChecker
.checkText(this.editor.getValue())
.then(({ skipped, problems }) => {
let alertText = ''
if (problems.length) {
const problemText = problems
.map((problem) => `${problem.lineNumber}: ${problem.error}`)
.join('<br/>')
alertText += `<strong>The following lines have problems:</strong><br/>${problemText}<br/><br/>`
}
if (skipped.length) {
alertText +=
'<strong>Mnemonics with string interpolation were not checked.</strong>'
}
if (alertText === '') {
alertText = '<strong>Everything looks good!</strong>'
}
this.$dialog.alert(alertText.trim(), { html: true })
})
},
initScriptStart() {
this.disableSuiteButtons = true
this.startOrGoDisabled = true
this.envDisabled = true
this.pauseOrRetryDisabled = true
this.stopDisabled = true
this.state = 'Connecting...'
this.startOrGoButton = GO
this.editor.setReadOnly(true)
},
scriptStart(id) {
this.scriptId = id
this.cable
.createSubscription(
'RunningScriptChannel',
window.openc3Scope,
{
received: (data) => this.received(data),
},
{
id: this.scriptId,
},
)
.then((subscription) => {
this.subscription = subscription
})
},
async scriptComplete() {
// Ensure stopped, if the script has an error we don't get the server stopped message
this.state = 'stopped'
this.fatal = false
this.scriptId = null // No current scriptId
this.currentFilename = null // No current file running
this.files = {} // Clear the file cache
// Make sure we process no more events
if (this.subscription) {
await this.subscription.unsubscribe()
this.subscription = null
}
this.receivedEvents.length = 0 // Clear any unprocessed events
await this.reloadFile() // Make sure the right file is shown
// We may have changed the contents (if there were sub-scripts)
// so don't let the undo manager think this is a change
this.editor.session.getUndoManager().reset()
if (this.readOnlyUser == false) {
this.editor.setReadOnly(false)
}
// Lastly enable the buttons so another script can start
this.disableSuiteButtons = false
this.startOrGoButton = START
this.pauseOrRetryButton = PAUSE
// Disable start if suiteRunner
this.startOrGoDisabled = this.suiteRunner
this.envDisabled = false
this.pauseOrRetryDisabled = true
this.stopDisabled = true
},
environmentHandler: function (event) {
this.scriptEnvironment.env = event
},
startHandler: function () {
this.start()
},
async start(event, suiteRunner = null) {
// Initialize variables and disable buttons before actually posting.
// This prevents delays in the backend from delaying frontend changes
// like disabling start which could allow users to click start twice.
this.initScriptStart()
await this.saveFile('start')
this.saveAllowed = false
let filename = this.filename
if (this.filename === NEW_FILENAME) {
// NEW_FILENAME so use tempFilename created by saveFile()
filename = this.tempFilename
}
let url = `/script-api/scripts/${filename}/run`
if (this.showDisconnect) {
url += '/disconnect'
}
let env = this.scriptEnvironment.env
if (this.enableStackTraces) {
env = env.concat({
key: 'OPENC3_FULL_BACKTRACE',
value: '1',
})
}
let data = {
environment: env,
}
if (suiteRunner) {
data['suiteRunner'] = event
}
Api.post(url, { data })
.then((response) => {
this.scriptStart(response.data)
})
.catch((error) => {
this.scriptComplete()
})
},
go() {
// Ensure we're on the correct filename when we hit go
// They may have changed it using the drop down
this.filenameSelect = this.currentFilename
this.fileNameChanged(this.currentFilename)
Api.post(`/script-api/running-script/${this.scriptId}/go`)
},
pauseOrRetry() {
if (this.pauseOrRetryButton === PAUSE) {
Api.post(`/script-api/running-script/${this.scriptId}/pause`)
} else {
this.pauseOrRetryButton = PAUSE
Api.post(`/script-api/running-script/${this.scriptId}/retry`)
}
},
stop() {
// We previously encountered a fatal error so remove the marker
// and cleanup by calling scriptComplete() because the script
// is already stopped in the backend
if (this.fatal) {
this.removeAllMarkers()
this.scriptComplete()
} else {
Api.post(`/script-api/running-script/${this.scriptId}/stop`)
}
},
step() {
Api.post(`/script-api/running-script/${this.scriptId}/step`)
},
// This is called by processLine no matter the current state
handleWaiting() {
// First check if we're not waiting and if so clear the interval
if (this.state !== 'waiting' && this.state !== 'paused') {
this.clearWaiting()
} else if (this.waitingInterval !== null) {
// If we're waiting and the interval is active then nothing to do
return
}
this.waitingStart = Date.now()
// Create an interval to count every second
this.waitingInterval = setInterval(() => {
this.waitingTime = Math.round((Date.now() - this.waitingStart) / 1000)
}, 1000)
},
clearWaiting() {
this.waitingTime = 0
clearInterval(this.waitingInterval)
this.waitingInterval = null
},
processLine(data) {
if (data.filename && data.filename !== this.currentFilename) {
if (!this.files[data.filename]) {
// We don't have the contents of the running file (probably because connected to running script)
// Set the contents initially to an empty string so we don't start slamming the API
this.files[data.filename] = { content: '', lineNo: 0 }
// Request the script we need
Api.get(`/script-api/scripts/${data.filename}`)
.then((response) => {
// Success - Save the script text and mark the currentFilename as null
// so it will get loaded in on the next line executed
this.files[data.filename] = {
content: response.data.contents,
lineNo: 0,
}
this.breakpoints[data.filename] = response.data.breakpoints
this.restoreBreakpoints(data.filename)
this.currentFilename = null
})
.catch((err) => {
// Error - Restore the file contents to null so we'll try the API again on the next line
this.files[data.filename] = null
})
} else {
this.currentFilename = data.filename
this.editor.setValue(this.files[data.filename].content)
this.restoreBreakpoints(data.filename)
this.editor.clearSelection()
}
}
this.state = data.state
const markers = this.editor.session.getMarkers()
switch (this.state) {
case 'running':
this.handleWaiting()
this.startOrGoDisabled = false
this.pauseOrRetryDisabled = false
this.stopDisabled = false
this.pauseOrRetryButton = PAUSE
this.removeAllMarkers()
this.editor.session.addMarker(
new this.Range(data.line_no - 1, 0, data.line_no - 1, 1),
'runningMarker',
'fullLine',
)
this.editor.gotoLine(data.line_no)
this.files[data.filename].lineNo = data.line_no
break
case 'fatal':
this.fatal = true
// Deliberate fall through (no break)
case 'error':
this.pauseOrRetryButton = RETRY
// Deliberate fall through (no break)
case 'breakpoint':
case 'waiting':
case 'paused':
this.handleWaiting()
if (this.state == 'fatal') {
this.startOrGoDisabled = true
this.pauseOrRetryDisabled = true
} else {
this.startOrGoDisabled = false
this.pauseOrRetryDisabled = false
}
this.stopDisabled = false
let existing = Object.keys(markers).filter(
(key) => markers[key].clazz === `${this.state}Marker`,
)
if (existing.length === 0) {
this.removeAllMarkers()
let line = data.line_no > 0 ? data.line_no : 1
this.editor.session.addMarker(
new this.Range(line - 1, 0, line - 1, 1),
`${this.state}Marker`,
'fullLine',
)
this.editor.gotoLine(line)
// Fatal errors don't always have a filename set
if (data.filename) {
this.files[data.filename].lineNo = line
}
}
break
default:
break
}
},
processReceived() {
let count = 0
for (let data of this.receivedEvents) {
count += 1
// eslint-disable-next-line
// console.log(data) // Uncomment for debugging
let index = 0
switch (data.type) {
case 'file':
this.files[data.filename] = { content: data.text, lineNo: 0 }
this.breakpoints[data.filename] = data.breakpoints
if (this.currentFilename === data.filename) {
this.restoreBreakpoints(data.filename)
}
break
case 'line':
// A further optimization would be to only process the last line of a batch
// However with some testing this did not seem to make much difference
// and was preventing the highlighting of the final line of a script because
// the last line of the final batch was line_number 0 with state stopped
// and that would never highlight the actual final line
this.processLine(data)
break
case 'output':
// data.line can consist of multiple lines split by newlines
// thus we split and only output if the content is not empty
for (const line of data.line.split('\n')) {
if (line) {
if (this.messagesNewestOnTop) {
this.messages.unshift({ message: line })
} else {
this.messages.push({ message: line })
}
}
}
while (this.messages.length > this.maxArrayLength) {
this.messages.pop()
}
break
case 'script':
this.handleScript(data)
break
case 'report':
this.results.text = data.report
this.results.show = true
break
case 'complete':
// Don't complete on fatal because we just sit there on the fatal line
if (!this.fatal) {
this.removeAllMarkers()
this.scriptComplete()
}
break
case 'step':
this.showDebug = true
break
case 'screen':
let found = false
let definition = {}
for (screen of this.screens) {
if (
screen.target == data.target_name &&
screen.screen == data.screen_name
) {
definition = screen
found = true
break
}
index += 1
}
this.$set(definition, 'target', data.target_name)
this.$set(definition, 'screen', data.screen_name)
this.$set(definition, 'definition', data.definition)
if (data.x) {
this.$set(definition, 'left', data.x)
} else {
this.$set(definition, 'left', 0)
}
if (data.y) {
this.$set(definition, 'top', data.y)
} else {
this.$set(definition, 'top', 0)
}
this.$set(definition, 'count', this.updateCounter++)
if (!found) {
this.$set(definition, 'id', this.idCounter++)
this.$set(this.screens, this.screens.length, definition)
} else {
this.$set(this.screens, index, definition)
}
break
case 'clearscreen':
for (screen of this.screens) {
if (
screen.target == data.target_name &&
screen.screen == data.screen_name
) {
this.screens.splice(index, 1)
break
}
index += 1
}
break
case 'clearallscreens':
this.screens = []
break
case 'downloadfile':
// Make a link and then 'click' on it to start the download
const link = document.createElement('a')
link.href = window.location.origin + data.url
link.setAttribute('download', data.filename)
link.click()
break
default:
// console.log('Unexpected ActionCable message')
// console.log(data)
break
}
}
// Remove all the events we processed
this.receivedEvents.splice(0, count)
},
received(data) {
this.cable.recordPing()
this.receivedEvents.push(data)
},
promptDialogCallback(value) {
this.prompt.show = false
Api.post(`/script-api/running-script/${this.scriptId}/prompt`, {
data: {
method: this.prompt.method,
answer: value,
prompt_id: this.activePromptId,
multiple: this.prompt.multiple,
},
})
},
handleScript(data) {
if (data.prompt_complete) {
this.activePromptId = ''
this.prompt.show = false
this.ask.show = false
return
}
this.activePromptId = data.prompt_id
this.prompt.method = data.method // Set it here since all prompts use this
this.prompt.layout = 'horizontal' // Reset the layout since most are horizontal
this.prompt.title = 'Prompt'
this.prompt.subtitle = ''
this.prompt.details = ''
this.prompt.buttons = []
this.prompt.multiple = null
switch (data.method) {
case 'ask':
case 'ask_string':
// Reset values since this dialog can be re-used
this.ask.default = null
this.ask.answerRequired = true
this.ask.password = false
this.ask.question = data.args[0]
// If the second parameter is not true or false it indicates a default value
if (data.args[1] && data.args[1] !== true && data.args[1] !== false) {
this.ask.default = data.args[1].toString()
} else if (data.args[1] === true) {
// If the second parameter is true it means no value is required to be entered
this.ask.answerRequired = false
}
// The third parameter indicates a password textfield
if (data.args[2] === true) {
this.ask.password = true
}
this.ask.callback = (value) => {
this.ask.show = false // Close the dialog
if (this.ask.password) {
Api.post(`/script-api/running-script/${this.scriptId}/prompt`, {
data: {
method: data.method,
password: value, // Using password as a key automatically filters it from rails logs
prompt_id: this.activePromptId,
},
})
} else {
Api.post(`/script-api/running-script/${this.scriptId}/prompt`, {
data: {
method: data.method,
answer: value,
prompt_id: this.activePromptId,
},
})
}
}
this.ask.show = true // Display the dialog
break
case 'prompt_for_hazardous':
this.prompt.title = 'Hazardous Command'
this.prompt.message = `Warning: Command ${data.args[0]} ${data.args[1]} is Hazardous. `
if (data.args[2]) {
this.prompt.message += data.args[2] + ' '
}
this.prompt.message += 'Send?'
this.prompt.buttons = [{ text: 'Send', value: 'Send' }]
this.prompt.callback = this.promptDialogCallback
this.prompt.show = true
break
case 'prompt_for_critical_cmd':
this.criticalCmdUuid = data.args[0]
this.criticalCmdString = data.args[5]
this.criticalCmdUser = data.args[1]
this.displayCriticalCmd = true
break
case 'prompt':
if (data.kwargs && data.kwargs.informative) {
this.prompt.subtitle = data.kwargs.informative
}
if (data.kwargs && data.kwargs.details) {
this.prompt.details = data.kwargs.details
}
this.prompt.message = data.args[0]
this.prompt.buttons = [{ text: 'Ok', value: 'Ok' }]
this.prompt.callback = this.promptDialogCallback
this.prompt.show = true
break
case 'combo_box':
if (data.kwargs && data.kwargs.informative) {
this.prompt.subtitle = data.kwargs.informative
}
if (data.kwargs && data.kwargs.details) {
this.prompt.details = data.kwargs.details
}
if (data.kwargs && data.kwargs.multiple) {
this.prompt.multiple = true
}
this.prompt.message = data.args[0]
data.args.slice(1).forEach((v) => {
this.prompt.buttons.push({ text: v, value: v })
})
this.prompt.layout = 'combo'
this.prompt.callback = this.promptDialogCallback
this.prompt.show = true
break
case 'message_box':
case 'vertical_message_box':
if (data.kwargs && data.kwargs.informative) {
this.prompt.subtitle = data.kwargs.informative
}
if (data.kwargs && data.kwargs.details) {
this.prompt.details = data.kwargs.details
}
this.prompt.message = data.args[0]
data.args.slice(1).forEach((v) => {
this.prompt.buttons.push({ text: v, value: v })
})
if (data.method.includes('vertical')) {
this.prompt.layout = 'vertical'
}
this.prompt.callback = this.promptDialogCallback
this.prompt.show = true
break
case 'backtrace':
this.information.title = 'Call Stack'
this.information.text = data.args
this.information.show = true
break
case 'metadata_input':
this.inputMetadata.callback = (value) => {
this.inputMetadata.show = false
Api.post(`/script-api/running-script/${this.scriptId}/prompt`, {
data: {
method: data.method,
answer: value,
prompt_id: this.activePromptId,
},
})
}
this.showMetadata()
break
// This is called continuously by the backend
case 'open_file_dialog':
case 'open_files_dialog':
this.file.title = data.args[0]
this.file.message = data.args[1]
if (data.kwargs && data.kwargs.filter) {
this.file.filter = data.kwargs.filter
}
if (data.method == 'open_files_dialog') {
this.file.multiple = true
}
this.file.show = true
break
default:
/* console.log(
'Unknown script method:' + data.method + ' with args:' + data.args
) */
break
}
},
async fileDialogCallback(files) {
// Set fileNames to 'Cancel' in case they cancelled
// otherwise we will populate it with the file names they selected
let fileNames = 'Cancel'
// Record all the API request promises so we can ensure they complete
let promises = []
if (files != 'Cancel') {
fileNames = []
files.forEach((file) => {
fileNames.push(file.name)
promises.push(
Api.get(
`/openc3-api/storage/upload/${encodeURIComponent(
`${window.openc3Scope}/tmp/${file.name}`,
)}?bucket=OPENC3_CONFIG_BUCKET`,
).then((response) => {
// This pushes the file into storage by using the fields in the presignedRequest
// See storage_controller.rb get_upload_presigned_request()
promises.push(
axios({
...response.data,
data: file,
}),
)
}),
)
})
}
// We have to wait for all the upload API requests to finish before notifying the prompt
Promise.all(promises).then((responses) => {
Api.post(`/script-api/running-script/${this.scriptId}/prompt`, {
data: {
method: this.file.multiple
? 'open_files_dialog'
: 'open_file_dialog',
answer: fileNames,
prompt_id: this.activePromptId,
},
}).then((response) => {
this.file.show = false // Close the dialog
})
})
},
setError(event) {
this.alertType = 'error'
this.alertText = `Error: ${event}`
this.showAlert = true
},
// ScriptRunner File menu actions
newFile() {
this.unlockFile()
this.filename = NEW_FILENAME
this.currentFilename = null
this.tempFilename = null
this.files = {} // Clear the cached file list
this.editor.session.setValue('')
this.saveAllowed = true
this.fileModified = ''
this.suiteRunner = false
this.startOrGoDisabled = false
this.envDisabled = false
this.$router
.replace({
name: 'ScriptRunner',
})
// catch the error in case we route to where we already are
.catch((err) => {})
document.title = 'Script Runner'
this.doResize()
},
async newRubyTestSuite() {
this.newFile()
this.editor.session.setValue(`require 'openc3/script/suite.rb'
# Group class name should indicate what the scripts are testing
class Power < OpenC3::Group
# Methods beginning with script_ are added to Script dropdown
def script_power_on
# Using OpenC3::Group.puts adds the output to the Test Report
# This can be useful for requirements verification, QA notes, etc
OpenC3::Group.puts "Verifying requirement SR-1"
configure()
end
# Other methods are not added to Script dropdown
def configure
end
def setup
# Run when Group Setup button is pressed
# Run before all scripts when Group Start is pressed
end
def teardown
# Run when Group Teardown button is pressed
# Run after all scripts when Group Start is pressed
end
end
class TestSuite < OpenC3::Suite
def initialize
add_group('Power')
end
def setup
# Run when Suite Setup button is pressed
# Run before all groups when Suite Start is pressed
end
def teardown
# Run when Suite Teardown button is pressed
# Run after all groups when Suite Start is pressed
end
end
`)
await this.saveFile('auto')
},
async newPythonTestSuite() {
this.newFile()
this.editor.session.setValue(`from openc3.script.suite import Suite, Group
# Group class name should indicate what the scripts are testing
class Power(Group):
# Methods beginning with script_ are added to Script dropdown
def script_power_on(self):
# Using Group.print adds the output to the Test Report
# This can be useful for requirements verification, QA notes, etc
Group.print("Verifying requirement SR-1")
self.configure()
# Other methods are not added to Script dropdown
def configure(self):
pass
def setup(self):
# Run when Group Setup button is pressed
# Run before all scripts when Group Start is pressed
pass
def teardown(self):
# Run when Group Teardown button is pressed
# Run after all scripts when Group Start is pressed
pass
class TestSuite(Suite):
def __init__(self):
self.add_group(Power)
def setup(self):
# Run when Suite Setup button is pressed
# Run before all groups when Suite Start is pressed
pass
def teardown(self):
# Run when Suite Teardown button is pressed
# Run after all groups when Suite Start is pressed
pass
`)
await this.saveFile('auto')
},
addToRecent(filename) {
// See if this filename is already in the recent ... if so remove it
let index = this.recent.findIndex((i) => i.label === filename)
if (index !== -1) {
this.recent.splice(index, 1)
}
// Push this filename to the front of the recently used
this.recent.unshift({
label: filename,
icon: fileIcon(filename),
command: async (event) => {
this.filename = event.label
await this.reloadFile()
},
})
if (this.recent.length > 8) {
this.recent.pop()
}
// This only stringifies the label and icon ... not the command
localStorage['script_runner__recent'] = JSON.stringify(this.recent)
},
removeFromRecent(filename) {
this.recent = this.recent.filter((entry) => entry.label !== filename)
localStorage['script_runner__recent'] = JSON.stringify(this.recent)
if (localStorage['script_runner__filename'] === filename) {
localStorage.removeItem('script_runner__filename')
}
},
openFile() {
this.fileOpen = true
},
async reloadFile(showError = true) {
// Disable start while we're loading the file so we don't hit Start
// before it's fully loaded and then save over it with a blank file
this.saveAllowed = false
this.startOrGoDisabled = true
await Api.get(`/script-api/scripts/${this.filename}`, {
headers: {
Accept: 'application/json',
'Ignore-Errors': '404',
},
})
.then((response) => {
const file = {
name: this.filename,
contents: response.data.contents,
}
if (response.data.suites) {
file['suites'] = JSON.parse(response.data.suites)
}
if (response.data.error) {
file['error'] = response.data.error
}
if (response.data.success) {
file['success'] = response.data.success
}
const locked = response.data.locked
const breakpoints = response.data.breakpoints
this.setFile({ file, locked, breakpoints }, true)
this.saveAllowed = true
})
.catch((error) => {
if (showError === true) {
this.$notify.caution({
title: 'File Open Error',
body: `Failed to open ${this.filename} due to ${error}`,
})
}
this.removeFromRecent(this.filename)
this.newFile() // Reset the GUI
})
},
// Called by the FileOpenDialog to set the file contents
setFile({ file, locked, breakpoints }, local = false) {
this.files = {} // Clear the cached file list
// Split off the ' *' which indicates a file is modified on the server
let newFilename = file.name.split('*')[0]
if (local === false) {
// We only need to unlock if the file is different
if (this.filename !== newFilename) {
this.unlockFile() // first unlock what was just being edited
this.lockedBy = locked
}
}
this.filename = newFilename
// Update the URL with the filename
this.$router
.replace({
name: 'ScriptRunner',
query: {
file: this.filename,
},
})
// catch the error in case we route to where we already are
.catch((err) => {})
// Update the browser tab with the name of the file first
// so squished tabs are still useful, followed by the rest
// of the path for context. Target name will be first which
// is probably the most useful part of the path.
let parts = this.filename.split('/')
document.title = `${parts.pop()} (${parts.join('/')})`
if (this.filename.split('.').pop() === 'py') {
this.editor.session.setMode(this.openC3PythonMode)
} else {
this.editor.session.setMode(this.openC3RubyMode)
}
this.currentFilename = null
this.editor.session.setValue(file.contents)
this.breakpoints[filename] = breakpoints
this.restoreBreakpoints(filename)
this.fileModified = ''
this.envDisabled = false
this.addToRecent(this.filename)
if (file.suites) {
this.suiteRunner = true
this.suiteMap = file.suites
this.startOrGoDisabled = true
} else {
this.suiteRunner = false
this.startOrGoDisabled = false
}
if (file.error) {
this.suiteError = file.error
this.showSuiteError = true
}
// Disable suite buttons if we didn't successfully parse the suite
this.disableSuiteButtons = file.success == false
this.doResize()
},
clearTemp() {
this.recent = this.recent.filter(
(entry) => !entry.label.includes('__TEMP__'),
)
localStorage['script_runner__recent'] = JSON.stringify(this.recent)
},
detectLanguage() {
let rubyRegex1 = new RegExp('^\\s*(require|load|puts) ')
let pythonRegex1 = new RegExp('^\\s*(import|from) ')
let rubyRegex2 = new RegExp('^\\s*end\\s*$')
let pythonRegex2 = new RegExp(
'^\\s*(if|def|while|else|elif|class).*:\\s*$',
)
let pythonRegex3 = new RegExp('\\(f"') // f strings
let text = this.editor.getValue()
let lines = text.split('\n')
for (let line of lines) {
if (line.match(rubyRegex1)) {
return 'ruby'
}
if (line.match(pythonRegex1)) {
return 'python'
}
if (line.match(rubyRegex2)) {
return 'ruby'
}
if (line.match(pythonRegex2)) {
return 'python'
}
if (line.match(pythonRegex3)) {
return 'python'
}
}
return 'unknown' // otherwise unknown
},
// saveFile takes a type to indicate if it was called by the Menu
// or automatically by 'Start' (to ensure a consistent backend file) or autoSave
async saveFile(type = 'menu') {
if (this.readOnlyUser) {
return
}
if (this.saveAllowed) {
const breakpoints = this.getBreakpointRows()
if (this.filename === NEW_FILENAME) {
if (type === 'menu') {
// Menu driven saves on a new file should prompt SaveAs
this.saveAs()
return
} else {
// start or auto with NEW_FILENAME
if (this.tempFilename === null) {
let language = this.detectLanguage()
if (
language === 'ruby' ||
(type !== 'auto' && language == 'unknown')
) {
this.tempFilename =
TEMP_FOLDER +
'/' +
format(Date.now(), 'yyyy_MM_dd_HH_mm_ss_SSS') +
'_temp.rb'
} else if (language === 'python') {
this.tempFilename =
TEMP_FOLDER +
'/' +
format(Date.now(), 'yyyy_MM_dd_HH_mm_ss_SSS') +
'_temp.py'
} else {
// No autosave for unknown language
return
}
this.filename = this.tempFilename
this.addToRecent(this.filename)
}
}
}
this.showSave = true
await Api.post(`/script-api/scripts/${this.filename}`, {
data: {
text: this.editor.getValue(), // Pass in the raw file text
breakpoints,
},
})
.then((response) => {
if (response.status == 200) {
if (response.data.suites) {
this.startOrGoDisabled = true
this.suiteRunner = true
this.suiteMap = JSON.parse(response.data.suites)
} else {
this.startOrGoDisabled = false
this.suiteRunner = false
this.suiteMap = {}
}
if (response.data.error) {
this.suiteError = response.data.error
this.showSuiteError = true
}
this.fileModified = ''
setTimeout(() => {
this.showSave = false
}, 2000)
} else {
this.showSave = false
this.alertType = 'error'
this.alertText = `Error saving file. Code: ${response.status} Text: ${response.statusText}`
this.showAlert = true
}
this.lockFile() // Ensure this file is locked for editing
this.doResize()
})
.catch(({ response }) => {
this.showSave = false
// 422 error means we couldn't parse the script file into Suites
// response.data.suites holds the parse result
if (response.status == 422) {
this.alertType = 'error'
this.alertText = response.data.suites
} else {
this.alertType = 'error'
this.alertText = `Error saving file. Code: ${response.status} Text: ${response.statusText}`
}
this.showAlert = true
})
} else {
this.setError('Attempt to save file when not allowed')
}
},
saveAs() {
this.showSaveAs = true
},
async saveAsFilename(filename) {
this.filename = filename.split('*')[0]
this.currentFilename = null
if (this.tempFilename) {
Api.post(`/script-api/scripts/${this.tempFilename}/delete`)
this.tempFilename = null
}
await this.saveFile('menu')
},
delete() {
let filename = this.filename
if (this.tempFilename) {
filename = this.tempFilename
}
this.$dialog
.confirm(`Permanently delete file: ${filename}`, {
okText: 'Delete',
cancelText: 'Cancel',
})
.then((dialog) => {
return Api.post(`/script-api/scripts/${filename}/delete`, {
data: {},
})
})
.then((response) => {
this.removeFromRecent(filename)
this.newFile()
})
.catch((error) => {
if (error !== true) {
const alertObject = {
text: `Failed Multi-Delete. ${error}`,
type: 'error',
}
this.$emit('alert', alertObject)
}
})
},
download() {
const blob = new Blob([this.editor.getValue()], {
type: 'text/plain',
})
// Make a link and then 'click' on it to start the download
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.setAttribute('download', this.filename)
link.click()
},
// ScriptRunner Script menu actions
syntaxCheck() {
Api.post(`/script-api/scripts/${this.filename}/syntax`, {
data: this.editor.getValue(),
headers: {
Accept: 'application/json',
'Content-Type': 'plain/text',
},
}).then((response) => {
this.information.title = response.data.title
this.information.text = JSON.parse(response.data.description)
this.information.show = true
})
},
showInstrumented() {
Api.post(`/script-api/scripts/${this.filename}/instrumented`, {
data: this.editor.getValue(),
headers: {
Accept: 'application/json',
'Content-Type': 'plain/text',
},
}).then((response) => {
this.information.title = response.data.title
this.information.text = JSON.parse(response.data.description)
this.information.show = true
})
},
showCallStack() {
Api.post(`/script-api/running-script/${this.scriptId}/backtrace`)
},
toggleDebug() {
this.showDebug = !this.showDebug
},
toggleDisconnect() {
this.showDisconnect = !this.showDisconnect
},
debugKeydown(event) {
if (event.key === 'Escape') {
this.debug = ''
this.debugHistoryIndex = this.debugHistory.length
} else if (event.key === 'Enter') {
this.debugHistory.push(this.debug)
this.debugHistoryIndex = this.debugHistory.length
// Post the code to /debug, output is processed by receive()
Api.post(`/script-api/running-script/${this.scriptId}/debug`, {
data: {
args: this.debug,
},
})
this.debug = ''
} else if (event.key === 'ArrowUp') {
this.debugHistoryIndex -= 1
if (this.debugHistoryIndex < 0) {
this.debugHistoryIndex = this.debugHistory.length - 1
}
this.debug = this.debugHistory[this.debugHistoryIndex]
// Prevent the cursor/caret from moving to the front
event.preventDefault()
} else if (event.key === 'ArrowDown') {
this.debugHistoryIndex += 1
if (this.debugHistoryIndex >= this.debugHistory.length) {
this.debugHistoryIndex = 0
}
this.debug = this.debugHistory[this.debugHistoryIndex]
}
},
removeAllMarkers: function () {
const allMarkers = this.editor.session.getMarkers()
Object.keys(allMarkers)
.filter((key) => allMarkers[key].type === 'fullLine')
.forEach((marker) => this.editor.session.removeMarker(marker))
},
confirmLocalUnlock: function () {
this.$dialog
.confirm(
'Are you sure you want to unlock this script for editing? If another user is editing this script, your changes might conflict with each other.',
{
okText: 'Force Unlock',
cancelText: 'Cancel',
},
)
.then(() => {
this.lockedBy = null
return this.lockFile() // Re-lock it as this user so it's locked for anyone else who opens it
})
},
lockFile: function () {
if (!this.readOnlyUser) {
return Api.post(`/script-api/scripts/${this.filename}/lock`)
}
},
unlockFile: function () {
if (
this.filename !== NEW_FILENAME &&
!this.readOnly &&
!this.readOnlyUser
) {
Api.post(`/script-api/scripts/${this.filename}/unlock`)
}
},
screenId(id) {
return 'scriptRunnerScreen' + id
},
closeScreen(id) {
let index = 0
for (screen of this.screens) {
if (screen.id == id) {
this.screens.splice(index, 1)
break
}
index += 1
}
},
},
}
</script>
<style scoped>
#sr-controls {
padding: 0px;
}
.editorbox {
height: 40vh;
}
.editor {
height: 100%;
width: 100%;
position: relative;
font-size: 16px;
}
hr {
pointer-events: none;
position: relative;
top: 7px;
background-color: grey;
height: 3px;
width: 5%;
margin: auto;
}
.script-state {
background-color: var(--color-background-base-default);
}
.script-state :deep(input) {
text-transform: capitalize;
}
</style>
<style>
.runningMarker {
position: absolute;
background: rgba(0, 255, 0, 0.5);
z-index: 20;
}
.waitingMarker {
position: absolute;
background: rgba(0, 155, 0, 1);
z-index: 20;
}
.breakpointMarker {
position: absolute;
border-style: solid;
border-color: red;
background: rgba(0, 255, 0, 0.5);
z-index: 20;
}
.pausedMarker {
position: absolute;
background: rgba(0, 140, 255, 0.5);
z-index: 20;
}
.errorMarker {
position: absolute;
background: rgba(255, 0, 119, 0.5);
z-index: 20;
}
.fatalMarker {
position: absolute;
background: rgba(255, 0, 0, 0.5);
z-index: 20;
}
.saving {
z-index: 20;
opacity: 0.35;
}
.ace_gutter-cell.ace_breakpoint {
border-radius: 20px 0px 0px 20px;
box-shadow: 0px 0px 1px 1px red inset;
}
.grid {
position: relative;
}
.item {
position: absolute;
display: block;
margin: 5px;
z-index: 1;
}
.item-content {
position: relative;
cursor: pointer;
border-radius: 6px;
}
</style>