This commit is contained in:
Louis Lam
2023-10-26 13:23:45 +08:00
parent 5f70fa6baf
commit 7d1da2ad99
34 changed files with 1650 additions and 510 deletions

View File

@@ -107,6 +107,8 @@ export default {
this.$root.loggedIn = true;
this.$root.username = this.$root.getJWTPayload()?.username;
this.$root.afterLogin();
// Trigger Chrome Save Password
history.pushState({}, "");
}

View File

@@ -26,7 +26,7 @@
<!-- TODO -->
<div v-if="false" class="header-filter">
<!--<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />-->
<!--<StackListFilter :filterState="filterState" @update-filter="updateFilter" />-->
</div>
<!-- TODO: Selection Controls -->
@@ -40,12 +40,12 @@
<button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ $t("Pause") }}</button>
<button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ $t("Resume") }}</button>
<span v-if="selectedMonitorCount > 0">
{{ $t("selectedMonitorCount", [ selectedMonitorCount ]) }}
<span v-if="selectedStackCount > 0">
{{ $t("selectedStackCount", [ selectedStackCount ]) }}
</span>
</div>
</div>
<div ref="monitorList" class="monitor-list" :class="{ scrollbar: scrollbar }" :style="monitorListStyle">
<div ref="stackList" class="stack-list" :class="{ scrollbar: scrollbar }" :style="stackListStyle">
<div v-if="Object.keys($root.stackList).length === 0" class="text-center mt-3">
<router-link to="/compose">{{ $t("addFirstStackMsg") }}</router-link>
</div>
@@ -53,7 +53,7 @@
<StackListItem
v-for="(item, index) in sortedStackList"
:key="index"
:monitor="item"
:stack="item"
:showPathName="filtersActive"
:isSelectMode="selectMode"
:isSelected="isSelected"
@@ -64,7 +64,7 @@
</div>
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
{{ $t("pauseMonitorMsg") }}
{{ $t("pauseStackMsg") }}
</Confirm>
</template>
@@ -89,7 +89,7 @@ export default {
selectMode: false,
selectAll: false,
disableSelectAllWatcher: false,
selectedMonitors: {},
selectedStacks: {},
windowTop: 0,
filterState: {
status: null,
@@ -103,7 +103,7 @@ export default {
* Improve the sticky appearance of the list by increasing its
* height as user scrolls down.
* Not used on mobile.
* @returns {object} Style for monitor list
* @returns {object} Style for stack list
*/
boxStyle() {
if (window.innerWidth > 550) {
@@ -119,80 +119,43 @@ export default {
},
/**
* Returns a sorted list of monitors based on the applied filters and search text.
* @returns {Array} The sorted list of monitors.
* Returns a sorted list of stacks based on the applied filters and search text.
* @returns {Array} The sorted list of stacks.
*/
sortedStackList() {
let result = Object.values(this.$root.stackList);
result = result.filter(monitor => {
result = result.filter(stack => {
// filter by search text
// finds monitor name, tag name or tag value
// finds stack name, tag name or tag value
let searchTextMatch = true;
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
searchTextMatch =
monitor.name.toLowerCase().includes(loweredSearchText)
|| monitor.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
stack.name.toLowerCase().includes(loweredSearchText)
|| stack.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
}
// filter by status
let statusMatch = true;
if (this.filterState.status != null && this.filterState.status.length > 0) {
if (monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[monitor.id]) {
monitor.status = this.$root.lastHeartbeatList[monitor.id].status;
}
statusMatch = this.filterState.status.includes(monitor.status);
}
// filter by active
let activeMatch = true;
if (this.filterState.active != null && this.filterState.active.length > 0) {
activeMatch = this.filterState.active.includes(monitor.active);
activeMatch = this.filterState.active.includes(stack.active);
}
// filter by tags
let tagsMatch = true;
if (this.filterState.tags != null && this.filterState.tags.length > 0) {
tagsMatch = monitor.tags.map(tag => tag.tag_id) // convert to array of tag IDs
.filter(monitorTagId => this.filterState.tags.includes(monitorTagId)) // perform Array Intersaction between filter and monitor's tags
tagsMatch = stack.tags.map(tag => tag.tag_id) // convert to array of tag IDs
.filter(stackTagId => this.filterState.tags.includes(stackTagId)) // perform Array Intersaction between filter and stack's tags
.length > 0;
}
// Hide children if not filtering
let showChild = true;
if (this.filterState.status == null && this.filterState.active == null && this.filterState.tags == null && this.searchText === "") {
if (monitor.parent !== null) {
showChild = false;
}
}
return searchTextMatch && statusMatch && activeMatch && tagsMatch && showChild;
return searchTextMatch && activeMatch && tagsMatch;
});
// Filter result by active state, weight and alphabetical
result.sort((m1, m2) => {
if (m1.active !== m2.active) {
if (m1.active === false) {
return 1;
}
if (m2.active === false) {
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.name.localeCompare(m2.name);
});
@@ -203,8 +166,9 @@ export default {
return document.body.classList.contains("dark");
},
monitorListStyle() {
let listHeaderHeight = 107;
stackListStyle() {
//let listHeaderHeight = 107;
let listHeaderHeight = 60;
if (this.selectMode) {
listHeaderHeight += 42;
@@ -215,8 +179,8 @@ export default {
};
},
selectedMonitorCount() {
return Object.keys(this.selectedMonitors).length;
selectedStackCount() {
return Object.keys(this.selectedStacks).length;
},
/**
@@ -229,8 +193,8 @@ export default {
},
watch: {
searchText() {
for (let monitor of this.sortedMonitorList) {
if (!this.selectedMonitors[monitor.id]) {
for (let stack of this.sortedStackList) {
if (!this.selectedStacks[stack.id]) {
if (this.selectAll) {
this.disableSelectAllWatcher = true;
this.selectAll = false;
@@ -241,11 +205,11 @@ export default {
},
selectAll() {
if (!this.disableSelectAllWatcher) {
this.selectedMonitors = {};
this.selectedStacks = {};
if (this.selectAll) {
this.sortedMonitorList.forEach((item) => {
this.selectedMonitors[item.id] = true;
this.sortedStackList.forEach((item) => {
this.selectedStacks[item.id] = true;
});
}
} else {
@@ -255,7 +219,7 @@ export default {
selectMode() {
if (!this.selectMode) {
this.selectAll = false;
this.selectedMonitors = {};
this.selectedStacks = {};
}
},
},
@@ -286,7 +250,7 @@ export default {
this.searchText = "";
},
/**
* Update the MonitorList Filter
* Update the StackList Filter
* @param {object} newFilter Object with new filter
* @returns {void}
*/
@@ -294,28 +258,28 @@ export default {
this.filterState = newFilter;
},
/**
* Deselect a monitor
* @param {number} id ID of monitor
* Deselect a stack
* @param {number} id ID of stack
* @returns {void}
*/
deselect(id) {
delete this.selectedMonitors[id];
delete this.selectedStacks[id];
},
/**
* Select a monitor
* @param {number} id ID of monitor
* Select a stack
* @param {number} id ID of stack
* @returns {void}
*/
select(id) {
this.selectedMonitors[id] = true;
this.selectedStacks[id] = true;
},
/**
* Determine if monitor is selected
* @param {number} id ID of monitor
* @returns {bool} Is the monitor selected?
* Determine if stack is selected
* @param {number} id ID of stack
* @returns {bool} Is the stack selected?
*/
isSelected(id) {
return id in this.selectedMonitors;
return id in this.selectedStacks;
},
/**
* Disable select mode and reset selection
@@ -323,7 +287,7 @@ export default {
*/
cancelSelectMode() {
this.selectMode = false;
this.selectedMonitors = {};
this.selectedStacks = {};
},
/**
* Show dialog to confirm pause
@@ -333,24 +297,24 @@ export default {
this.$refs.confirmPause.show();
},
/**
* Pause each selected monitor
* Pause each selected stack
* @returns {void}
*/
pauseSelected() {
Object.keys(this.selectedMonitors)
.filter(id => this.$root.monitorList[id].active)
.forEach(id => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
Object.keys(this.selectedStacks)
.filter(id => this.$root.stackList[id].active)
.forEach(id => this.$root.getSocket().emit("pauseStack", id, () => {}));
this.cancelSelectMode();
},
/**
* Resume each selected monitor
* Resume each selected stack
* @returns {void}
*/
resumeSelected() {
Object.keys(this.selectedMonitors)
.filter(id => !this.$root.monitorList[id].active)
.forEach(id => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
Object.keys(this.selectedStacks)
.filter(id => !this.$root.stackList[id].active)
.forEach(id => this.$root.getSocket().emit("resumeStack", id, () => {}));
this.cancelSelectMode();
},
@@ -428,7 +392,7 @@ export default {
max-width: 15em;
}
.monitor-item {
.stack-item {
width: 100%;
}

View File

@@ -7,68 +7,44 @@
class="form-check-input select-input"
type="checkbox"
:aria-label="$t('Check/Uncheck')"
:checked="isSelected(monitor.id)"
:checked="isSelected(stack.id)"
@click.stop="toggleSelection"
/>
</div>
<router-link :to="monitorURL(monitor.id)" class="item" :class="{ 'disabled': ! monitor.active }">
<router-link :to="`/compose/${stack.name}`" class="item">
<div class="row">
<div class="col-9 col-md-8 small-padding" :class="{ 'monitor-item': $root.userHeartbeatBar == 'bottom' || $root.userHeartbeatBar == 'none' }">
<div class="col-9 col-md-8 small-padding">
<div class="info">
<Uptime :monitor="monitor" type="24" :pill="true" />
<span v-if="hasChildren" class="collapse-padding" @click.prevent="changeCollapsed">
<font-awesome-icon icon="chevron-down" class="animated" :class="{ collapsed: isCollapsed}" />
</span>
{{ monitorName }}
<Uptime :stack="stack" type="24" :pill="true" />
{{ stackName }}
</div>
<div v-if="monitor.tags.length > 0" class="tags">
<Tag v-for="tag in monitor.tags" :key="tag" :item="tag" :size="'sm'" />
<div v-if="stack.tags.length > 0" class="tags">
<!--<Tag v-for="tag in stack.tags" :key="tag" :item="tag" :size="'sm'" />-->
</div>
</div>
<div v-show="$root.userHeartbeatBar == 'normal'" :key="$root.userHeartbeatBar" class="col-3 col-md-4">
<HeartbeatBar ref="heartbeatBar" size="small" :monitor-id="monitor.id" />
</div>
</div>
<div v-if="$root.userHeartbeatBar == 'bottom'" class="row">
<div class="col-12 bottom-style">
<HeartbeatBar ref="heartbeatBar" size="small" :monitor-id="monitor.id" />
</div>
</div>
</router-link>
</div>
<transition name="slide-fade-up">
<div v-if="!isCollapsed" class="childs">
<MonitorListItem
v-for="(item, index) in sortedChildMonitorList"
:key="index" :monitor="item"
:showPathName="showPathName"
:isSelectMode="isSelectMode"
:isSelected="isSelected"
:select="select"
:deselect="deselect"
:depth="depth + 1"
/>
</div>
</transition>
</div>
</template>
<script>
import Uptime from "./Uptime.vue";
export default {
components: {
Uptime
},
props: {
/** Monitor this represents */
monitor: {
/** Stack this represents */
stack: {
type: Object,
default: null,
},
/** Should the monitor name show it's parent */
/** Should the stack name show it's parent */
showPathName: {
type: Boolean,
default: false,
@@ -78,22 +54,22 @@ export default {
type: Boolean,
default: false,
},
/** How many ancestors are above this monitor */
/** How many ancestors are above this stack */
depth: {
type: Number,
default: 0,
},
/** Callback to determine if monitor is selected */
/** Callback to determine if stack is selected */
isSelected: {
type: Function,
default: () => {}
},
/** Callback fired when monitor is selected */
/** Callback fired when stack is selected */
select: {
type: Function,
default: () => {}
},
/** Callback fired when monitor is deselected */
/** Callback fired when stack is deselected */
deselect: {
type: Function,
default: () => {}
@@ -105,51 +81,16 @@ export default {
};
},
computed: {
sortedChildMonitorList() {
let result = Object.values(this.$root.monitorList);
result = result.filter(childMonitor => childMonitor.parent === this.monitor.id);
result.sort((m1, m2) => {
if (m1.active !== m2.active) {
if (m1.active === 0) {
return 1;
}
if (m2.active === 0) {
return -1;
}
}
if (m1.weight !== m2.weight) {
if (m1.weight > m2.weight) {
return -1;
}
if (m1.weight < m2.weight) {
return 1;
}
}
return m1.name.localeCompare(m2.name);
});
return result;
},
hasChildren() {
return this.sortedChildMonitorList.length > 0;
},
depthMargin() {
return {
marginLeft: `${31 * this.depth}px`,
};
},
monitorName() {
stackName() {
if (this.showPathName) {
return this.monitor.pathName;
return this.stack.pathName;
} else {
return this.monitor.name;
return this.stack.name;
}
}
},
@@ -161,28 +102,10 @@ export default {
},
beforeMount() {
// Always unfold if monitor is accessed directly
if (this.monitor.childrenIDs.includes(parseInt(this.$route.params.id))) {
this.isCollapsed = false;
return;
}
// Set collapsed value based on local storage
let storage = window.localStorage.getItem("monitorCollapsed");
if (storage === null) {
return;
}
let storageObject = JSON.parse(storage);
if (storageObject[`monitor_${this.monitor.id}`] == null) {
return;
}
this.isCollapsed = storageObject[`monitor_${this.monitor.id}`];
},
methods: {
/**
* Changes the collapsed value of the current monitor and saves
* Changes the collapsed value of the current stack and saves
* it to local storage
* @returns {void}
*/
@@ -190,25 +113,25 @@ export default {
this.isCollapsed = !this.isCollapsed;
// Save collapsed value into local storage
let storage = window.localStorage.getItem("monitorCollapsed");
let storage = window.localStorage.getItem("stackCollapsed");
let storageObject = {};
if (storage !== null) {
storageObject = JSON.parse(storage);
}
storageObject[`monitor_${this.monitor.id}`] = this.isCollapsed;
storageObject[`stack_${this.stack.id}`] = this.isCollapsed;
window.localStorage.setItem("monitorCollapsed", JSON.stringify(storageObject));
window.localStorage.setItem("stackCollapsed", JSON.stringify(storageObject));
},
/**
* Toggle selection of monitor
* Toggle selection of stack
* @returns {void}
*/
toggleSelection() {
if (this.isSelected(this.monitor.id)) {
this.deselect(this.monitor.id);
if (this.isSelected(this.stack.id)) {
this.deselect(this.stack.id);
} else {
this.select(this.monitor.id);
this.select(this.stack.id);
}
},
},
@@ -228,7 +151,7 @@ export default {
padding-right: 2px !important;
}
// .monitor-item {
// .stack-item {
// width: 100%;
// }

View File

@@ -0,0 +1,93 @@
<template>
<div class="shadow-box">
<div v-pre ref="terminal" class="main-terminal"></div>
</div>
</template>
<script>
import { Terminal } from "xterm";
import { WebLinksAddon } from "xterm-addon-web-links";
import { TERMINAL_COLS, TERMINAL_ROWS } from "../../../backend/util-common";
export default {
/**
* @type {Terminal}
*/
terminal: null,
components: {
},
props: {
allowInput: {
type: Boolean,
default: true,
},
rows: {
type: Number,
default: TERMINAL_ROWS,
}
},
emits: [ "has-data" ],
data() {
return {
name: null,
first: true,
};
},
created() {
},
mounted() {
this.terminal = new Terminal({
fontSize: 16,
fontFamily: "monospace",
cursorBlink: this.allowInput,
cols: TERMINAL_COLS,
rows: this.rows,
});
this.terminal.loadAddon(new WebLinksAddon());
// Bind to a div
this.terminal.open(this.$refs.terminal);
this.terminal.focus();
// Notify parent component when data is received
this.terminal.onCursorMove(() => {
console.debug("onData triggered");
if (this.first) {
this.$emit("has-data");
this.first = false;
}
});
},
unmounted() {
this.$root.unbindTerminal(this.name);
this.terminal.dispose();
},
methods: {
bind(name) {
if (this.name) {
this.$root.unbindTerminal(this.name);
}
this.name = name;
this.$root.bindTerminal(this.name, this.terminal);
},
}
};
</script>
<style scoped lang="scss">
.main-terminal {
height: 100%;
}
</style>
<style lang="scss">
.terminal {
padding: 10px 15px;
height: 100%;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<span :class="className" :title="title">{{ uptime }}</span>
</template>
<script>
export default {
props: {
/** Monitor this represents */
monitor: {
type: Object,
default: null,
},
/** Type of monitor */
type: {
type: String,
default: null,
},
/** Is this a pill? */
pill: {
type: Boolean,
default: false,
},
},
computed: {
uptime() {
return this.$t("notAvailableShort");
},
color() {
return "secondary";
},
className() {
if (this.pill) {
return `badge rounded-pill bg-${this.color}`;
}
return "";
},
title() {
if (this.type === "720") {
return `30${this.$t("-day")}`;
}
return `24${this.$t("-hour")}`;
}
},
};
</script>
<style>
.badge {
min-width: 62px;
}
</style>

View File

@@ -50,7 +50,7 @@ import {
faInfoCircle,
faClone,
faCertificate,
faTerminal, faWarehouse, faHome,
faTerminal, faWarehouse, faHome, faRocket,
} from "@fortawesome/free-solid-svg-icons";
library.add(
@@ -101,6 +101,7 @@ library.add(
faTerminal,
faWarehouse,
faHome,
faRocket,
);
export { FontAwesomeIcon };

View File

@@ -8,5 +8,15 @@
"console": "Console",
"registry": "Registry",
"compose": "Compose",
"addFirstStackMsg": "Compose your first stack!"
"addFirstStackMsg": "Compose your first stack!",
"stackName" : "Stack Name",
"deployStack": "Deploy",
"deleteStack": "Delete",
"stopStack": "Stop",
"restartStack": "Restart",
"startStack": "Start",
"editStack": "Edit",
"discardStack": "Discard",
"saveStackDraft": "Save",
"notAvailableShort" : "N/A"
}

View File

@@ -3,20 +3,13 @@ import { Socket } from "socket.io-client";
import { defineComponent } from "vue";
import jwtDecode from "jwt-decode";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
const terminal = new Terminal({
fontSize: 16,
fontFamily: "monospace",
cursorBlink: true,
});
terminal.loadAddon(new FitAddon());
terminal.loadAddon(new WebLinksAddon());
let terminalInputBuffer = "";
let cursorPosition = 0;
let socket : Socket;
let terminalMap : Map<string, Terminal> = new Map();
function removeInput() {
const backspaceCount = terminalInputBuffer.length;
const backspaces = "\b \b".repeat(backspaceCount);
@@ -44,7 +37,6 @@ export default defineComponent({
loggedIn: false,
allowLoginDialog: false,
username: null,
stackList: {},
};
},
@@ -66,6 +58,7 @@ export default defineComponent({
this.initSocketIO();
},
mounted() {
return;
terminal.onKey(e => {
const code = e.key.charCodeAt(0);
console.debug("Encode: " + JSON.stringify(e.key));
@@ -100,6 +93,7 @@ export default defineComponent({
// TODO
} else if (e.key === "\u0003") { // Ctrl + C
console.debug("Ctrl + C");
socket.emit("terminalInputRaw", e.key);
removeInput();
} else {
cursorPosition++;
@@ -131,7 +125,7 @@ export default defineComponent({
}
socket = io(url, {
transports: [ "websocket", "polling" ]
});
socket.on("connect", () => {
@@ -195,9 +189,20 @@ export default defineComponent({
this.$router.push("/setup");
});
socket.on("commandOutput", (data) => {
socket.on("terminalWrite", (terminalName, data) => {
const terminal = terminalMap.get(terminalName);
if (!terminal) {
console.error("Terminal not found: " + terminalName);
return;
}
terminal.write(data);
});
socket.on("stackList", (res) => {
if (res.ok) {
this.stackList = res.stackList;
}
});
},
/**
@@ -212,10 +217,6 @@ export default defineComponent({
return socket;
},
getTerminal() : Terminal {
return terminal;
},
/**
* Get payload of JWT cookie
* @returns {(object | undefined)} JWT payload
@@ -269,13 +270,23 @@ export default defineComponent({
},
afterLogin() {
terminal.clear();
},
bindTerminal(terminalName : string, terminal : Terminal) {
// Load terminal, get terminal screen
socket.emit("getTerminalBuffer", (res) => {
console.log("getTerminalBuffer");
terminal.write(res.buffer);
socket.emit("terminalJoin", terminalName, (res) => {
if (res.ok) {
terminal.write(res.buffer);
terminalMap.set(terminalName, terminal);
} else {
this.toastRes(res);
}
});
},
unbindTerminal(terminalName : string) {
terminalMap.delete(terminalName);
}
}
});

View File

@@ -1,16 +1,224 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 v-if="isAdd" class="mb-3">Compose</h1>
<h1 v-else class="mb-3">Stack: {{ stack.name }}</h1>
<div class="mb-3">
<div class="btn-group" role="group">
<button v-if="isEditMode" class="btn btn-primary" :disabled="processing" @click="deployStack">
<font-awesome-icon icon="rocket" class="me-1" />
{{ $t("deployStack") }}
</button>
<button v-if="isEditMode" class="btn btn-normal" :disabled="processing" @click="saveStack">
<font-awesome-icon icon="save" class="me-1" />
{{ $t("saveStackDraft") }}
</button>
<button v-if="!isEditMode" class="btn btn-normal" :disabled="processing" @click="isEditMode = true">{{ $t("editStack") }}</button>
<button v-if="isEditMode && !isAdd" class="btn btn-normal" :disabled="processing" @click="discardStack">{{ $t("discardStack") }}</button>
<button v-if="!isEditMode" class="btn btn-primary" :disabled="processing">{{ $t("startStack") }}</button>
<button v-if="!isEditMode" class="btn btn-primary " :disabled="processing">{{ $t("restartStack") }}</button>
<button v-if="!isEditMode" class="btn btn-danger" :disabled="processing">{{ $t("stopStack") }}</button>
<button v-if="!isEditMode" class="btn btn-danger" :disabled="processing">{{ $t("deleteStack") }}</button>
</div>
</div>
<!-- Progress Terminal -->
<transition name="slide-fade" appear>
<Terminal
v-show="showProgressTerminal"
ref="progressTerminal"
:allow-input="false"
class="mb-3 terminal"
:rows="progressTerminalRows"
@has-data="showProgressTerminal = true"
></Terminal>
</transition>
<div class="row">
<div class="col">
<h4 class="mb-3">General</h4>
<div class="shadow-box big-padding mb-3">
<!-- Stack Name -->
<div v-if="isAdd" class="mb-3">
<label for="name" class="form-label">{{ $t("stackName") }}</label>
<input id="name" v-model="stack.name" type="text" class="form-control" required>
</div>
</div>
<h4 class="mb-3">Containers</h4>
<div class="shadow-box big-padding mb-3">
<div v-for="(service, name) in jsonConfig.services" :key="name">
{{ name }} {{ service }}
</div>
</div>
</div>
<div class="col">
<h4 class="mb-3">compose.yaml</h4>
<div class="shadow-box mb-3">
<prism-editor v-model="stack.composeYAML" class="yaml-editor" :highlight="highlighter" line-numbers :readonly="!isEditMode" @input="yamlCodeChange"></prism-editor>
</div>
<div class="mb-3">
{{ yamlError }}
</div>
<!-- <div class="shadow-box big-padding mb-3">
<div class="mb-3">
<label for="name" class="form-label"> Search Templates</label>
<input id="name" v-model="name" type="text" class="form-control" placeholder="Search..." required>
</div>
<prism-editor v-if="false" v-model="yamlConfig" class="yaml-editor" :highlight="highlighter" line-numbers @input="yamlCodeChange"></prism-editor>
</div>-->
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
export default {
<script>
import { highlight, languages } from "prismjs/components/prism-core";
import { PrismEditor } from "vue-prism-editor";
import "prismjs/components/prism-yaml";
import * as yaml from "yaml";
import "prismjs/themes/prism-tomorrow.css";
import "vue-prism-editor/dist/prismeditor.min.css";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { getComposeTerminalName, PROGRESS_TERMINAL_ROWS } from "../../../backend/util-common";
const template = `version: "3.8"
services:
nginx:
image: nginx:latest
ports:
- "8080:8080"
`;
let yamlErrorTimeout = null;
export default {
components: {
FontAwesomeIcon,
PrismEditor,
},
data() {
return {
jsonConfig: {},
yamlError: "",
processing: true,
showProgressTerminal: false,
progressTerminalRows: PROGRESS_TERMINAL_ROWS,
stack: {},
isEditMode: false,
submitted: false,
};
},
computed: {
isAdd() {
return this.$route.path === "/compose" && !this.submitted;
},
},
watch: {
"stack.composeYAML": {
handler() {
this.yamlCodeChange();
},
deep: true,
},
},
mounted() {
if (this.isAdd) {
this.processing = false;
this.isEditMode = true;
// Default Values
this.stack = {
name: "",
composeYAML: template,
};
} else {
this.stack.name = this.$route.params.stackName;
this.loadStack();
}
},
methods: {
loadStack() {
this.$root.getSocket().emit("getStack", this.stack.name, (res) => {
if (res.ok) {
this.stack = res.stack;
this.processing = false;
} else {
this.$root.toastRes(res);
}
});
},
deployStack() {
this.processing = true;
// Bind Terminal output
const terminalName = getComposeTerminalName(this.stack.name);
this.$refs.progressTerminal.bind(terminalName);
this.$root.getSocket().emit("deployStack", this.stack.name, this.stack.composeYAML, this.isAdd, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.$router.push("/compose/" + this.stack.name);
} else {
this.submitted = true;
}
});
},
saveStack() {
this.processing = true;
this.$root.getSocket().emit("saveStack", this.stack.name, this.stack.composeYAML, this.isAdd, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.$router.push("/compose/" + this.stack.name);
}
});
},
discardStack() {
this.loadStack();
this.isEditMode = false;
},
highlighter(code) {
return highlight(code, languages.yaml);
},
yamlCodeChange() {
try {
this.jsonConfig = yaml.parse(this.stack.composeYAML) ?? {};
this.yamlError = "";
} catch (e) {
clearTimeout(yamlErrorTimeout);
if (this.yamlError) {
this.yamlError = e.message;
} else {
yamlErrorTimeout = setTimeout(() => {
this.yamlError = e.message;
}, 3000);
}
}
},
}
};
</script>
<style scoped lang="scss">
.terminal {
height: 200px;
}
</style>

View File

@@ -3,9 +3,13 @@
<div>
<h1 class="mb-3">Console</h1>
<div class="shadow-box">
<div v-pre id="terminal"></div>
<div>
<p>
Allowed commands: <code>docker</code>, <code>ls</code>, <code>cd</code>
</p>
</div>
<Terminal :allow-input="true" class="terminal"></Terminal>
</div>
</transition>
</template>
@@ -14,10 +18,9 @@
export default {
components: {
},
mounted() {
this.$root.getTerminal().open(document.querySelector("#terminal"));
this.$root.terminalFit(50);
},
methods: {
@@ -27,9 +30,6 @@ export default {
<style scoped lang="scss">
.terminal {
font-family: monospace;
font-size: 18px;
padding: 10px 15px;
height: calc(100vh - 200px);
}
</style>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
</script>
<template>
<transition name="slide-fade" appear>
<div>
</div>
</transition>
</template>
<style scoped lang="scss">
</style>

View File

@@ -4,8 +4,8 @@ import Layout from "./layouts/Layout.vue";
import Setup from "./pages/Setup.vue";
import Dashboard from "./pages/Dashboard.vue";
import DashboardHome from "./pages/DashboardHome.vue";
import EditStack from "./pages/EditStack.vue";
import Console from "./pages/Console.vue";
import Compose from "./pages/Compose.vue";
const routes = [
{
@@ -23,7 +23,13 @@ const routes = [
children: [
{
path: "/compose",
component: EditStack,
component: Compose,
},
{
path: "/compose/:stackName",
name: "compose",
component: Compose,
props: true,
},
]

View File

@@ -377,7 +377,7 @@ optgroup {
color: $dark-font-color;
}
.monitor-list {
.stack-list {
.item {
&:hover {
background-color: $dark-bg2;
@@ -474,7 +474,7 @@ optgroup {
opacity: 0;
}
.monitor-list {
.stack-list {
&.scrollbar {
overflow-y: auto;
}
@@ -653,12 +653,28 @@ $shadow-box-padding: 20px;
}
}
#terminal {
.main-terminal {
.xterm-viewport {
border-radius: 10px;
background-color: $dark-bg !important;
}
}
code {
padding: .2em .4em;
margin: 0;
font-size: 85%;
white-space: break-spaces;
background-color: rgba(239, 239, 239, 0.15);
border-radius: 6px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: black;
.dark & {
color: $dark-font-color;
}
}
// Localization
@import "localization.scss";

View File

@@ -158,7 +158,7 @@ export function colorOptions(self) {
export function loadToastSettings() {
return {
position: POSITION.BOTTOM_RIGHT,
containerClassName: "toast-container mb-5",
containerClassName: "toast-container",
showCloseButtonOnHover: true,
filterBeforeCreate: (toast, toasts) => {