Extracted ActionEditor from the Execute plugin.

So it can be reused in other components that need to handle actions.
This commit is contained in:
Fabio Manganiello 2023-12-12 23:33:16 +01:00
parent 4e85087c0f
commit 9beb10c373
Signed by: blacklight
GPG key ID: D90FBA7F76362774
9 changed files with 519 additions and 476 deletions

View file

@ -0,0 +1,510 @@
<template>
<div class="action-editor-container" @click="onClick">
<Loading v-if="loading" />
<!-- Action executor container -->
<div class="action-editor">
<!-- cURL snippet modal -->
<Modal ref="curlModal" title="curl request" v-if="curlSnippet?.length">
<div class="output curl-snippet" @click="copyToClipboard(curlSnippet)" >
<pre><code v-html="highlightedCurlSnippet" /></pre>
</div>
</Modal>
<!-- Execute panel views -->
<Tabs>
<Tab :selected="structuredInput" icon-class="fas fa-list" @input="onInputTypeChange(true)">
Structured
</Tab>
<Tab :selected="!structuredInput" icon-class="fas fa-code" @input="onInputTypeChange(false)">
Raw
</Tab>
</Tabs>
<form ref="actionForm" autocomplete="off" @submit.prevent="executeAction">
<!-- Structured request container -->
<div class="request structured" v-if="structuredInput">
<!-- Request header -->
<header>
<!-- Action autocomplete container -->
<div class="autocomplete-container">
<Autocomplete
ref="autocomplete"
:items="autocompleteItems"
@input="updateAction"
placeholder="Action"
show-results-when-blank
autofocus
:disabled="running"
:value="action.name" />
</div>
<div class="buttons">
<button type="submit" class="run-btn btn-primary"
:disabled="running || !action?.name?.length" title="Run">
<i class="fas fa-play" />
</button>
</div>
</header>
<!-- Action documentation container -->
<ActionDoc
:action="action"
:curl-snippet="curlSnippet"
:loading="docLoading"
:doc="selectedDoc"
@curl-modal="$refs.curlModal.show()" />
<!-- Action arguments container -->
<section class="args"
v-if="action.name in actions && (Object.keys(action.args).length || action.supportsExtraArgs)">
<h2>
<i class="fas fa-code" /> &nbsp;
Arguments
</h2>
<ActionArgs :action="action"
:loading="loading"
:running="running"
:selected-arg="selectedArg"
:selected-argdoc="selectedArgdoc"
@add="addArg"
@select="selectArgdoc"
@remove="removeArg"
@arg-edit="action.args[$event.name].value = $event.value"
@extra-arg-name-edit="action.extraArgs[$event.index].name = $event.value"
@extra-arg-value-edit="action.extraArgs[$event.index].value = $event.value" />
</section>
<!-- Structured response container -->
<Response :response="response" :error="error" />
</div>
<!-- Raw request container -->
<div class="request raw-request" v-if="!structuredInput">
<div class="first-row">
<label>
<textarea v-model="rawRequest" ref="rawAction" :placeholder="rawRequestPlaceholder" />
</label>
<button type="submit" :disabled="running" class="run-btn btn-primary" title="Run">
<i class="fas fa-play" />
</button>
</div>
<!-- Raw response container -->
<Response :response="response" :error="error" />
</div>
</form>
</div>
</div>
</template>
<script>
import 'highlight.js/lib/common'
import 'highlight.js/styles/stackoverflow-dark.min.css'
import hljs from "highlight.js"
import ActionArgs from "./ActionArgs"
import ActionDoc from "./ActionDoc"
import Autocomplete from "@/components/elements/Autocomplete"
import Loading from "@/components/Loading"
import Modal from "@/components/Modal";
import Response from "./Response"
import Tab from "@/components/elements/Tab"
import Tabs from "@/components/elements/Tabs"
import Utils from "@/Utils"
export default {
mixins: [Utils],
emits: ['input'],
components: {
ActionArgs,
ActionDoc,
Autocomplete,
Loading,
Modal,
Response,
Tab,
Tabs,
},
data() {
return {
loading: false,
running: false,
docLoading: false,
structuredInput: true,
selectedDoc: undefined,
selectedArg: undefined,
selectedArgdoc: undefined,
response: undefined,
error: undefined,
rawRequest: undefined,
rawRequestPlaceholder: 'Raw JSON request. Example:\n\n' +
'{"type": "request", "action": "file.list", "args": {"path": "/"}}',
actions: {},
plugins: {},
procedures: {},
actionDocsCache: {},
action: {
name: undefined,
args: {},
extraArgs: [],
supportsExtraArgs: false,
},
}
},
computed: {
currentActionDocURL() {
return this.action?.doc_url
},
autocompleteItems() {
if (this.getPluginName(this.action.name) in this.plugins) {
return Object.keys(this.actions).sort()
}
return Object.keys(this.plugins).sort().map((pluginName) => `${pluginName}.`)
},
actionInput() {
return this.$refs.autocomplete.$el.parentElement.querySelector('input[type=text]')
},
requestArgs() {
if (!this.action.name)
return {}
return {
...Object.entries(this.action.args).reduce((args, arg) => {
if (arg[1].value != null) {
let value = arg[1].value
try {
value = JSON.parse(value)
} catch (e) {
console.debug('Not a valid JSON value')
console.debug(value)
}
args[arg[0]] = value
}
return args
}, {}),
...(this.action.extraArgs || []).reduce((args, arg) => {
let value = arg.value
try {
value = JSON.parse(value)
} catch (e) {
console.debug('Not a valid JSON value')
console.debug(value)
}
args[arg.name] = value
return args
}, {})
}
},
curlURL() {
return `${window.location.protocol}//${window.location.host}/execute`
},
curlSnippet() {
if (!this.action.name)
return ''
const request = {
type: 'request',
action: this.action.name,
args: this.requestArgs,
}
const reqStr = JSON.stringify(request, null, 2)
return (
'curl -XPOST -H "Content-Type: application/json" \\\n ' +
`-H "Cookie: session_token=${this.getCookies()['session_token']}"`+
" \\\n -d '\n {\n " +
this.indent(
reqStr.split('\n').slice(1, reqStr.length - 2).join('\n'), 2
).trim() +
"' \\\n " +
`'${this.curlURL}'`
)
},
highlightedCurlSnippet() {
return hljs.highlight(
'bash',
'# Note: Replace the cookie with a JWT token for production cases\n' +
this.curlSnippet
).value
},
},
methods: {
async refresh() {
this.loading = true
try {
[this.procedures, this.plugins] = await Promise.all([
this.request('inspect.get_procedures'),
this.request('inspect.get_all_plugins'),
])
} finally {
this.loading = false
}
// Register procedures as actions
this.plugins.procedure = {
name: 'procedure',
actions: Object.entries(this.procedures || {}).reduce((actions, [name, procedure]) => {
actions[name] = {
name: name,
args: (procedure.args || []).reduce((args, arg) => {
args[arg] = {
name: arg,
required: false,
}
return args
}, {}),
supportsExtraArgs: true,
}
return actions
}, {}),
}
// Parse actions from the plugins map
for (const plugin of Object.values(this.plugins)) {
for (const action of Object.values(plugin.actions)) {
action.name = plugin.name + '.' + action.name
action.supportsExtraArgs = !!action.has_kwargs
delete action.has_kwargs
this.actions[action.name] = action
}
}
// If an action has been passed on the URL, set it
const args = this.getUrlArgs()
const actionName = args?.action
if (actionName?.length && actionName in this.actions && actionName !== this.action.name) {
this.updateAction(actionName)
}
},
async updateAction(actionName) {
if (actionName === this.action.name)
return
this.action.name = actionName
if (!(this.action.name in this.actions)) {
this.selectedDoc = undefined
this.resetArgdoc()
return
}
this.resetArgdoc()
this.docLoading = true
try {
this.action = {
...this.actions[this.action.name],
args: Object.entries(this.actions[this.action.name].args).reduce((args, entry) => {
args[entry[0]] = {
...entry[1],
value: entry[1].default,
}
return args
}, {}),
extraArgs: [],
}
} finally {
this.docLoading = false
}
this.selectedDoc =
this.actionDocsCache[this.action.name]?.html ||
await this.parseDoc(this.action.doc)
if (!this.actionDocsCache[this.action.name])
this.actionDocsCache[this.action.name] = {}
this.actionDocsCache[this.action.name].html = this.selectedDoc
this.setUrlArgs({action: this.action.name})
const firstArg = this.$el.querySelector('.action-arg-value')
if (firstArg) {
firstArg.focus()
} else {
this.$nextTick(() => {
this.actionInput.focus()
})
}
this.response = undefined
this.error = undefined
},
async parseDoc(docString) {
if (!docString?.length)
return docString
return await this.request('utils.rst_to_html', {text: docString})
},
addArg() {
this.action.extraArgs.push({
name: undefined,
value: undefined,
})
},
removeArg(i) {
this.action.extraArgs.pop(i)
},
async selectArgdoc(name) {
this.selectedArg = name
this.selectedArgdoc =
this.actionDocsCache[this.action.name]?.[name]?.html ||
await this.parseDoc(this.action.args[name].doc)
if (!this.actionDocsCache[this.action.name])
this.actionDocsCache[this.action.name] = {}
this.actionDocsCache[this.action.name][name] = {html: this.selectedArgdoc}
},
resetArgdoc() {
this.selectedArg = undefined
this.selectedArgdoc = undefined
},
onInputTypeChange(structuredInput) {
this.structuredInput = structuredInput
this.response = undefined
this.error = undefined
this.$nextTick(() => {
if (structuredInput) {
this.actionInput.focus()
} else {
this.$refs.rawAction.focus()
}
})
},
onResponse(response) {
this.response = (
typeof response === 'string' ? response : JSON.stringify(response, null, 2)
).trim()
this.error = undefined
},
onError(error) {
this.response = undefined
this.error = error
},
onDone() {
this.running = false
},
getPluginName(actionName) {
if (!actionName?.length)
return ''
return actionName.split('.').slice(0, -1).join('.')
},
executeAction() {
if (!this.action.name && !this.rawRequest || this.running)
return
this.running = true
if (this.structuredInput) {
this.request(this.action.name, this.requestArgs).then(this.onResponse).catch(this.onError).finally(this.onDone)
} else {
try {
const request = JSON.parse(this.rawRequest)
this.execute(request).then(this.onResponse).catch(this.onError).finally(this.onDone)
} catch (e) {
this.notify({
error: true,
title: 'Invalid JSON request',
text: e.toString(),
})
}
}
},
onClick(event) {
// Intercept any clicks from RST rendered links and open them in a new tab
if (event.target.tagName.toLowerCase() === 'a') {
event.stopPropagation()
event.preventDefault()
window.open(event.target.getAttribute('href', '_blank'))
}
},
},
mounted() {
this.refresh()
},
}
</script>
<style lang="scss" scoped>
@import "common";
.action-editor-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
.action-editor {
width: 100%;
max-width: 1000px;
display: flex;
flex-direction: column;
box-shadow: $section-shadow;
}
.request {
display: flex;
flex-direction: column;
margin: 0 .5em;
}
.run-btn {
background: $background-color;
border-radius: .25em;
padding: .5em 1.5em;
box-shadow: $primary-btn-shadow;
cursor: pointer;
&:hover {
background: $hover-bg;
box-shadow: none;
}
&:disabled {
opacity: 0.7;
color: $default-fg-2;
cursor: initial;
box-shadow: none;
&:hover {
background: $background-color;
box-shadow: none;
}
}
}
}
</style>

View file

@ -1,467 +1,25 @@
<template> <template>
<div class="row plugin execute-container" @click="onClick"> <div class="row plugin execute-container">
<Loading v-if="loading" />
<!-- Action executor container --> <!-- Action executor container -->
<main> <main>
<h1>Execute Action</h1> <h1>Execute Action</h1>
<ActionEditor />
<!-- cURL snippet modal -->
<Modal ref="curlModal" title="curl request" v-if="curlSnippet?.length">
<div class="output curl-snippet" @click="copyToClipboard(curlSnippet)" >
<pre><code v-html="highlightedCurlSnippet" /></pre>
</div>
</Modal>
<!-- Execute panel views -->
<Tabs>
<Tab :selected="structuredInput" icon-class="fas fa-list" @input="onInputTypeChange(true)">
Structured
</Tab>
<Tab :selected="!structuredInput" icon-class="fas fa-code" @input="onInputTypeChange(false)">
Raw
</Tab>
</Tabs>
<form ref="actionForm" autocomplete="off" @submit.prevent="executeAction">
<!-- Structured request container -->
<div class="request structured" v-if="structuredInput">
<!-- Request header -->
<header>
<!-- Action autocomplete container -->
<div class="autocomplete-container">
<Autocomplete
ref="autocomplete"
:items="autocompleteItems"
@input="updateAction"
placeholder="Action"
show-results-when-blank
autofocus
:disabled="running"
:value="action.name" />
</div>
<div class="buttons">
<button type="submit" class="run-btn btn-primary"
:disabled="running || !action?.name?.length" title="Run">
<i class="fas fa-play" />
</button>
</div>
</header>
<!-- Action documentation container -->
<ActionDoc
:action="action"
:curl-snippet="curlSnippet"
:loading="docLoading"
:doc="selectedDoc"
@curl-modal="$refs.curlModal.show()" />
<!-- Action arguments container -->
<section class="args"
v-if="action.name in actions && (Object.keys(action.args).length || action.supportsExtraArgs)">
<h2>
<i class="fas fa-code" /> &nbsp;
Arguments
</h2>
<ActionArgs :action="action"
:loading="loading"
:running="running"
:selected-arg="selectedArg"
:selected-argdoc="selectedArgdoc"
@add="addArg"
@select="selectArgdoc"
@remove="removeArg"
@arg-edit="action.args[$event.name].value = $event.value"
@extra-arg-name-edit="action.extraArgs[$event.index].name = $event.value"
@extra-arg-value-edit="action.extraArgs[$event.index].value = $event.value" />
</section>
<!-- Structured response container -->
<Response :response="response" :error="error" />
</div>
<!-- Raw request container -->
<div class="request raw-request" v-if="!structuredInput">
<div class="first-row">
<label>
<textarea v-model="rawRequest" ref="rawAction" :placeholder="rawRequestPlaceholder" />
</label>
<button type="submit" :disabled="running" class="run-btn btn-primary" title="Run">
<i class="fas fa-play" />
</button>
</div>
<!-- Raw response container -->
<Response :response="response" :error="error" />
</div>
</form>
</main> </main>
</div> </div>
</template> </template>
<script> <script>
import 'highlight.js/lib/common' import ActionEditor from '@/components/Action/ActionEditor'
import 'highlight.js/styles/stackoverflow-dark.min.css'
import hljs from "highlight.js"
import ActionArgs from "./ActionArgs"
import ActionDoc from "./ActionDoc"
import Autocomplete from "@/components/elements/Autocomplete"
import Loading from "@/components/Loading"
import Modal from "@/components/Modal";
import Response from "./Response"
import Tab from "@/components/elements/Tab"
import Tabs from "@/components/elements/Tabs"
import Utils from "@/Utils"
export default { export default {
name: "Execute",
mixins: [Utils],
components: { components: {
ActionArgs, ActionEditor,
ActionDoc,
Autocomplete,
Loading,
Modal,
Response,
Tab,
Tabs,
},
data() {
return {
loading: false,
running: false,
docLoading: false,
structuredInput: true,
selectedDoc: undefined,
selectedArg: undefined,
selectedArgdoc: undefined,
response: undefined,
error: undefined,
rawRequest: undefined,
rawRequestPlaceholder: 'Raw JSON request. Example:\n\n' +
'{"type": "request", "action": "file.list", "args": {"path": "/"}}',
actions: {},
plugins: {},
procedures: {},
actionDocsCache: {},
action: {
name: undefined,
args: {},
extraArgs: [],
supportsExtraArgs: false,
},
}
},
computed: {
currentActionDocURL() {
return this.action?.doc_url
},
autocompleteItems() {
if (this.getPluginName(this.action.name) in this.plugins) {
return Object.keys(this.actions).sort()
}
return Object.keys(this.plugins).sort().map((pluginName) => `${pluginName}.`)
},
actionInput() {
return this.$refs.autocomplete.$el.parentElement.querySelector('input[type=text]')
},
requestArgs() {
if (!this.action.name)
return {}
return {
...Object.entries(this.action.args).reduce((args, arg) => {
if (arg[1].value != null) {
let value = arg[1].value
try {
value = JSON.parse(value)
} catch (e) {
console.debug('Not a valid JSON value')
console.debug(value)
}
args[arg[0]] = value
}
return args
}, {}),
...(this.action.extraArgs || []).reduce((args, arg) => {
let value = arg.value
try {
value = JSON.parse(value)
} catch (e) {
console.debug('Not a valid JSON value')
console.debug(value)
}
args[arg.name] = value
return args
}, {})
}
},
curlURL() {
return `${window.location.protocol}//${window.location.host}/execute`
},
curlSnippet() {
if (!this.action.name)
return ''
const request = {
type: 'request',
action: this.action.name,
args: this.requestArgs,
}
const reqStr = JSON.stringify(request, null, 2)
return (
'curl -XPOST -H "Content-Type: application/json" \\\n ' +
`-H "Cookie: session_token=${this.getCookies()['session_token']}"`+
" \\\n -d '\n {\n " +
this.indent(
reqStr.split('\n').slice(1, reqStr.length - 2).join('\n'), 2
).trim() +
"' \\\n " +
`'${this.curlURL}'`
)
},
highlightedCurlSnippet() {
return hljs.highlight(
'bash',
'# Note: Replace the cookie with a JWT token for production cases\n' +
this.curlSnippet
).value
},
},
methods: {
async refresh() {
this.loading = true
try {
[this.procedures, this.plugins] = await Promise.all([
this.request('inspect.get_procedures'),
this.request('inspect.get_all_plugins'),
])
} finally {
this.loading = false
}
// Register procedures as actions
this.plugins.procedure = {
name: 'procedure',
actions: Object.entries(this.procedures || {}).reduce((actions, [name, procedure]) => {
actions[name] = {
name: name,
args: (procedure.args || []).reduce((args, arg) => {
args[arg] = {
name: arg,
required: false,
}
return args
}, {}),
supportsExtraArgs: true,
}
return actions
}, {}),
}
// Parse actions from the plugins map
for (const plugin of Object.values(this.plugins)) {
for (const action of Object.values(plugin.actions)) {
action.name = plugin.name + '.' + action.name
action.supportsExtraArgs = !!action.has_kwargs
delete action.has_kwargs
this.actions[action.name] = action
}
}
// If an action has been passed on the URL, set it
const args = this.getUrlArgs()
const actionName = args?.action
if (actionName?.length && actionName in this.actions && actionName !== this.action.name) {
this.updateAction(actionName)
}
},
async updateAction(actionName) {
if (actionName === this.action.name)
return
this.action.name = actionName
if (!(this.action.name in this.actions)) {
this.selectedDoc = undefined
this.resetArgdoc()
return
}
this.resetArgdoc()
this.docLoading = true
try {
this.action = {
...this.actions[this.action.name],
args: Object.entries(this.actions[this.action.name].args).reduce((args, entry) => {
args[entry[0]] = {
...entry[1],
value: entry[1].default,
}
return args
}, {}),
extraArgs: [],
}
} finally {
this.docLoading = false
}
this.selectedDoc =
this.actionDocsCache[this.action.name]?.html ||
await this.parseDoc(this.action.doc)
if (!this.actionDocsCache[this.action.name])
this.actionDocsCache[this.action.name] = {}
this.actionDocsCache[this.action.name].html = this.selectedDoc
this.setUrlArgs({action: this.action.name})
const firstArg = this.$el.querySelector('.action-arg-value')
if (firstArg) {
firstArg.focus()
} else {
this.$nextTick(() => {
this.actionInput.focus()
})
}
this.response = undefined
this.error = undefined
},
async parseDoc(docString) {
if (!docString?.length)
return docString
return await this.request('utils.rst_to_html', {text: docString})
},
addArg() {
this.action.extraArgs.push({
name: undefined,
value: undefined,
})
},
removeArg(i) {
this.action.extraArgs.pop(i)
},
async selectArgdoc(name) {
this.selectedArg = name
this.selectedArgdoc =
this.actionDocsCache[this.action.name]?.[name]?.html ||
await this.parseDoc(this.action.args[name].doc)
if (!this.actionDocsCache[this.action.name])
this.actionDocsCache[this.action.name] = {}
this.actionDocsCache[this.action.name][name] = {html: this.selectedArgdoc}
},
resetArgdoc() {
this.selectedArg = undefined
this.selectedArgdoc = undefined
},
onInputTypeChange(structuredInput) {
this.structuredInput = structuredInput
this.response = undefined
this.error = undefined
this.$nextTick(() => {
if (structuredInput) {
this.actionInput.focus()
} else {
this.$refs.rawAction.focus()
}
})
},
onResponse(response) {
this.response = (
typeof response === 'string' ? response : JSON.stringify(response, null, 2)
).trim()
this.error = undefined
},
onError(error) {
this.response = undefined
this.error = error
},
onDone() {
this.running = false
},
getPluginName(actionName) {
if (!actionName?.length)
return ''
return actionName.split('.').slice(0, -1).join('.')
},
executeAction() {
if (!this.action.name && !this.rawRequest || this.running)
return
this.running = true
if (this.structuredInput) {
this.request(this.action.name, this.requestArgs).then(this.onResponse).catch(this.onError).finally(this.onDone)
} else {
try {
const request = JSON.parse(this.rawRequest)
this.execute(request).then(this.onResponse).catch(this.onError).finally(this.onDone)
} catch (e) {
this.notify({
error: true,
title: 'Invalid JSON request',
text: e.toString(),
})
}
}
},
onClick(event) {
// Intercept any clicks from RST rendered links and open them in a new tab
if (event.target.tagName.toLowerCase() === 'a') {
event.stopPropagation()
event.preventDefault()
window.open(event.target.getAttribute('href', '_blank'))
}
},
},
mounted() {
this.refresh()
}, },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "common"; @import "@/components/Action/common";
.execute-container { .execute-container {
width: 100%; width: 100%;
@ -484,35 +42,10 @@ export default {
margin: 1em; margin: 1em;
border-radius: 1em 1em 0 0; border-radius: 1em 1em 0 0;
} }
}
.request { :deep(.action-editor-container) {
display: flex; .action-editor {
flex-direction: column; height: 100%;
margin: 0 .5em;
}
.run-btn {
background: $background-color;
border-radius: .25em;
padding: .5em 1.5em;
box-shadow: $primary-btn-shadow;
cursor: pointer;
&:hover {
background: $hover-bg;
box-shadow: none;
}
&:disabled {
opacity: 0.7;
color: $default-fg-2;
cursor: initial;
box-shadow: none;
&:hover {
background: $background-color;
box-shadow: none;
} }
} }
} }

View file

@ -209,7 +209,7 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
@import "src/style/items"; @import "src/style/items";
@import "../Execute/common"; @import "@/components/Action/common";
$header-height: 3.25em; $header-height: 3.25em;