Files
watchstate/frontend/pages/console.vue
2025-04-11 21:21:49 +03:00

331 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<h1 class="title is-4">
<span class="icon"><i class="fas fa-terminal"></i></span> Console
</h1>
<div class="subtitle">
You can execute <strong>non-interactive</strong> commands here.
<template v-if="allEnabled">
The console defaults to <code>console</code> command, if you want to run a different command, prefix
the command with <code>$</code>. For example <code>$ ls</code>.
</template>
<template v-else>
This interface is jailed to <code>console</code> command.
</template>
</div>
</div>
<div class="column is-12">
<div class="card">
<header class="card-header">
<p class="card-header-title">
<span class="icon"><i class="fas fa-terminal"></i>&nbsp;</span> Terminal
</p>
<p class="card-header-icon">
<span class="icon" @click="clearOutput"><i class="fa fa-broom"></i></span>
</p>
</header>
<section class="card-content p-0 m-0">
<div ref="outputConsole" style="min-height: 60vh; max-height:70vh;"/>
</section>
<section class="card-content p-1 m-1">
<div class="field">
<div class="field-body">
<div class="field is-grouped-tablet">
<p class="control is-expanded has-icons-left">
<input type="text" class="input is-fullwidth" v-model="command"
:placeholder="`system:view ${allEnabled ? 'or $ ls' : ''}`"
list="recent_commands"
autocomplete="off" ref="commandInput" @keydown.enter="RunCommand" :disabled="isLoading">
<span class="icon is-left"><i class="fas fa-terminal" :class="{'fa-spin':isLoading}"></i></span>
</p>
<p class="control" v-if="!isLoading">
<button class="button is-primary" type="button" :disabled="hasPrefix" @click="RunCommand">
<span class="icon"><i class="fa fa-paper-plane"></i></span>
</button>
</p>
<p class="control" v-if="isLoading">
<button class="button is-danger" type="button" @click="finished" v-tooltip="'Close connection.'">
<span class="icon"><i class="fa fa-power-off"></i></span>
</button>
</p>
</div>
</div>
</div>
</section>
</div>
</div>
<div class="column is-12" v-if="hasPrefix || hasPlaceholder">
<Message message_class="has-background-warning-90 has-text-dark" title="Warning"
icon="fas fa-exclamation-triangle" v-if="hasPrefix">
<p>Use the command directly, For example i.e. <code>db:list -o yaml</code></p>
</Message>
<Message message_class="has-background-warning-90 has-text-dark" title="Warning"
icon="fas fa-exclamation-triangle" v-if="hasPlaceholder">
<span class="icon has-text-warning"><i class="fas fa-exclamation-circle"></i></span>
<span>The command contains <code>[...]</code> which are considered a placeholder, So, please replace
<code>[...]</code> with the intended value if applicable.</span>
</Message>
</div>
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
<ul>
<li>You dont need to type <code>console</code> or run <code>docker exec -ti watchstate console</code> when
using this interface. Just enter the command and options directly. For example: <code>db:list --output
yaml</code>.
</li>
<li>Clicking <strong>Close Connection</strong> only stops the output from being shownit does <em>not</em>
stop the command itself. The command will continue running until it finishes.
</li>
<li>Most commands wont display anything unless theres an error or important message. Use <code>-v</code>
to see more details. If youre debugging, try <code>-vv --context</code> for even more information.
</li>
<li>Theres an environment variable <code>WS_CONSOLE_ENABLE_ALL</code> that you can set to <code>true</code>
to allow all commands to run from the console. Its turned off by default.
</li>
<li>To clear the recent command suggestions, use the <code>clear_ac</code> command.</li>
<li>
The number inside the parentheses is the exit code of the last command. If its <code>0</code>, the
command ran successfully. Any other value usually means something went wrong.
</li>
</ul>
</Message>
</div>
<datalist id="recent_commands">
<option v-for="item in recentCommands" :key="item" :value="item"/>
</datalist>
</div>
</div>
</template>
<style scoped>
.xterm {
padding: 0.50rem !important;
}
.xterm-viewport {
background-color: #1f2229 !important;
}
</style>
<script setup>
import "@xterm/xterm/css/xterm.css"
import {Terminal} from "@xterm/xterm"
import {FitAddon} from "@xterm/addon-fit"
import {useStorage} from '@vueuse/core'
import {disableOpacity, enableOpacity, notification} from '~/utils/index'
import Message from '~/components/Message'
import request from "~/utils/request.js";
useHead({title: `Console`})
const route = useRoute()
const fromCommand = route.query.cmd || false ? atob(route.query.cmd) : ''
let sse
const terminal = ref()
const terminalFit = ref()
const response = ref([])
const command = ref(fromCommand)
const isLoading = ref(false)
const outputConsole = ref()
const commandInput = ref()
const executedCommands = useStorage('executedCommands', [])
const exitCode = ref(0)
const hasPrefix = computed(() => command.value.startsWith('console') || command.value.startsWith('docker'))
const hasPlaceholder = computed(() => command.value && command.value.match(/\[.*]/))
const show_page_tips = useStorage('show_page_tips', true)
const allEnabled = ref(false)
const RunCommand = async () => {
const api_path = useStorage('api_path', '/v1/api')
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
/** @type {string} */
let userCommand = command.value
// -- check if the user command starts with console or docker exec -ti watchstate
if (userCommand.startsWith('console') || userCommand.startsWith('docker')) {
notification('info', 'Warning', 'Removing leading prefix command from the input.', 2000)
userCommand = userCommand.replace(/^(console|docker exec -ti watchstate)/i, '')
}
// use regex to check if command contains [...]
if (userCommand.match(/\[.*]/)) {
if (!confirm(`The command contains placeholders '[...]'. Are you sure you want to run as it is?`)) {
return
}
}
response.value = []
if (userCommand === 'clear') {
command.value = ''
terminal.value.clear()
return
}
if (userCommand === 'clear_ac') {
executedCommands.value = []
command.value = ''
return
}
const commandBody = JSON.parse(JSON.stringify({command: userCommand}))
if (userCommand.startsWith('$')) {
if (!allEnabled.value) {
notification('error', 'Error', 'The option to execute all commands is disabled.')
commandInput.value.focus()
return
}
userCommand = userCommand.slice(1)
} else {
userCommand = `console ${userCommand}`
}
isLoading.value = true
let token;
try {
const response = await request('/system/command', {
method: 'POST',
body: JSON.stringify(commandBody)
})
const json = await response.json()
if (201 !== response.status) {
await finished()
notification('error', 'Error', `${json.error.code}: ${json.error.message}`, 5000)
return;
}
token = json.token
} catch (e) {
await finished()
notification('error', 'Error', e.message, 5000)
return;
}
sse = new EventSource(`${api_url.value}${api_path.value}/system/command/${token}?apikey=${api_token.value}`)
if ('' !== command.value) {
terminal.value.writeln(`(${exitCode.value}) ~ ${userCommand}`)
}
sse.addEventListener('data', async e => terminal.value.write(JSON.parse(e.data).data))
sse.addEventListener('close', async () => finished())
sse.addEventListener('exit_code', async e => exitCode.value = e.data)
sse.onclose = async () => finished()
sse.onerror = async () => finished()
}
const finished = async () => {
if (sse) {
sse.close()
}
isLoading.value = false
const route = useRoute()
if (route.query?.cmd || route.query?.run) {
route.query.cmd = ''
route.query.run = ''
await useRouter().push({path: '/console'})
}
if (executedCommands.value.includes(command.value)) {
executedCommands.value.splice(executedCommands.value.indexOf(command.value), 1)
}
executedCommands.value.push(command.value)
if (executedCommands.value.length > 30) {
executedCommands.value.shift()
}
terminal.value.writeln(`\n(${exitCode.value}) ~ `)
command.value = ''
await nextTick()
commandInput.value.focus()
}
const recentCommands = computed(() => executedCommands.value.reverse().slice(-10))
const reSizeTerminal = () => {
if (!terminal.value) {
return
}
terminalFit.value.fit()
}
const clearOutput = async () => {
if (terminal.value) {
terminal.value ? terminal.value.clear() : ''
}
commandInput.value.focus()
}
onUnmounted(() => {
window.removeEventListener("resize", reSizeTerminal)
if (sse) {
sse.close()
}
enableOpacity()
})
onMounted(async () => {
disableOpacity()
window.addEventListener("resize", reSizeTerminal);
commandInput.value.focus()
if (!terminal.value) {
terminalFit.value = new FitAddon()
terminal.value = new Terminal({
fontSize: 16,
fontFamily: "'JetBrains Mono', monospace",
cursorBlink: false,
cols: 108,
rows: 10,
disableStdin: true,
convertEol: true,
altClickMovesCursor: false,
})
terminal.value.open(outputConsole.value)
terminal.value.loadAddon(terminalFit.value)
terminalFit.value.fit()
}
try {
const response = await request('/system/env/WS_CONSOLE_ENABLE_ALL')
const json = await response.json()
allEnabled.value = 200 === response.status ? Boolean(json.value) : false
} catch (e) {
allEnabled.value = false
}
const run = route.query?.run ? Boolean(route.query.run) : false
if (true === run && command.value) {
await RunCommand()
}
})
</script>