mirror of
https://github.com/louislam/dockge.git
synced 2024-11-28 21:44:04 +00:00
600 lines
20 KiB
Vue
600 lines
20 KiB
Vue
|
<template>
|
||
|
<transition name="slide-fade" appear>
|
||
|
<div>
|
||
|
<h1 v-if="isAdd" class="mb-3">Compose</h1>
|
||
|
<h1 v-else class="mb-3"><Uptime :stack="globalStack" :pill="true" /> {{ stack.name }}</h1>
|
||
|
|
||
|
<div v-if="stack.isManagedByDockge" class="mb-3">
|
||
|
<div class="btn-group me-2" 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-secondary" :disabled="processing" @click="enableEditMode">
|
||
|
<font-awesome-icon icon="pen" class="me-1" />
|
||
|
{{ $t("editStack") }}
|
||
|
</button>
|
||
|
|
||
|
<button v-if="!isEditMode && !active" class="btn btn-primary" :disabled="processing" @click="startStack">
|
||
|
<font-awesome-icon icon="play" class="me-1" />
|
||
|
{{ $t("startStack") }}
|
||
|
</button>
|
||
|
|
||
|
<button v-if="!isEditMode && active" class="btn btn-normal " :disabled="processing" @click="restartStack">
|
||
|
<font-awesome-icon icon="rotate" class="me-1" />
|
||
|
{{ $t("restartStack") }}
|
||
|
</button>
|
||
|
|
||
|
<button v-if="!isEditMode" class="btn btn-normal" :disabled="processing" @click="updateStack">
|
||
|
<font-awesome-icon icon="cloud-arrow-down" class="me-1" />
|
||
|
{{ $t("updateStack") }}
|
||
|
</button>
|
||
|
|
||
|
<button v-if="!isEditMode && active" class="btn btn-normal" :disabled="processing" @click="stopStack">
|
||
|
<font-awesome-icon icon="stop" class="me-1" />
|
||
|
{{ $t("stopStack") }}
|
||
|
</button>
|
||
|
</div>
|
||
|
|
||
|
<button v-if="isEditMode && !isAdd" class="btn btn-normal" :disabled="processing" @click="discardStack">{{ $t("discardStack") }}</button>
|
||
|
<button v-if="!isEditMode" class="btn btn-danger" :disabled="processing" @click="showDeleteDialog = !showDeleteDialog">
|
||
|
<font-awesome-icon icon="trash" class="me-1" />
|
||
|
{{ $t("deleteStack") }}
|
||
|
</button>
|
||
|
</div>
|
||
|
|
||
|
<!-- Progress Terminal -->
|
||
|
<transition name="slide-fade" appear>
|
||
|
<Terminal
|
||
|
v-show="showProgressTerminal"
|
||
|
ref="progressTerminal"
|
||
|
class="mb-3 terminal"
|
||
|
:name="terminalName"
|
||
|
:rows="progressTerminalRows"
|
||
|
@has-data="showProgressTerminal = true; submitted = true;"
|
||
|
></Terminal>
|
||
|
</transition>
|
||
|
|
||
|
<div v-if="stack.isManagedByDockge" class="row">
|
||
|
<div class="col-lg-6">
|
||
|
<!-- General -->
|
||
|
<div v-if="isAdd">
|
||
|
<h4 class="mb-3">{{ $t("general") }}</h4>
|
||
|
<div class="shadow-box big-padding mb-3">
|
||
|
<!-- Stack Name -->
|
||
|
<div 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>
|
||
|
</div>
|
||
|
|
||
|
<!-- Containers -->
|
||
|
<h4 class="mb-3">{{ $tc("container", 2) }}</h4>
|
||
|
|
||
|
<div v-if="isEditMode" class="input-group mb-3">
|
||
|
<input
|
||
|
v-model="newContainerName"
|
||
|
placeholder="New Container Name..."
|
||
|
class="form-control"
|
||
|
@keyup.enter="addContainer"
|
||
|
/>
|
||
|
<button class="btn btn-primary" @click="addContainer">
|
||
|
{{ $t("addContainer") }}
|
||
|
</button>
|
||
|
</div>
|
||
|
|
||
|
<div ref="containerList">
|
||
|
<Container
|
||
|
v-for="(service, name) in jsonConfig.services"
|
||
|
:key="name"
|
||
|
:name="name"
|
||
|
:is-edit-mode="isEditMode"
|
||
|
:first="name === Object.keys(jsonConfig.services)[0]"
|
||
|
:status="serviceStatusList[name]"
|
||
|
/>
|
||
|
</div>
|
||
|
|
||
|
<button v-if="false && isEditMode && jsonConfig.services && Object.keys(jsonConfig.services).length > 0" class="btn btn-normal mb-3" @click="addContainer">{{ $t("addContainer") }}</button>
|
||
|
|
||
|
<!-- Combined Terminal Output -->
|
||
|
<div v-show="!isEditMode">
|
||
|
<h4 class="mb-3">Terminal</h4>
|
||
|
<Terminal
|
||
|
ref="combinedTerminal"
|
||
|
class="mb-3 terminal"
|
||
|
:name="combinedTerminalName"
|
||
|
:rows="combinedTerminalRows"
|
||
|
:cols="combinedTerminalCols"
|
||
|
style="height: 350px;"
|
||
|
></Terminal>
|
||
|
</div>
|
||
|
</div>
|
||
|
<div class="col-lg-6">
|
||
|
<h4 class="mb-3">compose.yaml</h4>
|
||
|
|
||
|
<!-- YAML editor -->
|
||
|
<div class="shadow-box mb-3 editor-box" :class="{'edit-mode' : isEditMode}">
|
||
|
<prism-editor
|
||
|
ref="editor"
|
||
|
v-model="stack.composeYAML"
|
||
|
class="yaml-editor"
|
||
|
:highlight="highlighter"
|
||
|
line-numbers :readonly="!isEditMode"
|
||
|
@input="yamlCodeChange"
|
||
|
@focus="editorFocus = true"
|
||
|
@blur="editorFocus = false"
|
||
|
></prism-editor>
|
||
|
</div>
|
||
|
<div v-if="isEditMode" class="mb-3">
|
||
|
{{ yamlError }}
|
||
|
</div>
|
||
|
|
||
|
<div v-if="isEditMode">
|
||
|
<!-- Volumes -->
|
||
|
<div v-if="false">
|
||
|
<h4 class="mb-3">{{ $tc("volume", 2) }}</h4>
|
||
|
<div class="shadow-box big-padding mb-3">
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
<!-- Networks -->
|
||
|
<h4 class="mb-3">{{ $tc("network", 2) }}</h4>
|
||
|
<div class="shadow-box big-padding mb-3">
|
||
|
<NetworkInput />
|
||
|
</div>
|
||
|
</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 v-if="!stack.isManagedByDockge && !processing">
|
||
|
{{ $t("stackNotManagedByDockgeMsg") }}
|
||
|
</div>
|
||
|
|
||
|
<!-- Delete Dialog -->
|
||
|
<BModal v-model="showDeleteDialog" :okTitle="$t('deleteStack')" okVariant="danger" @ok="deleteDialog">
|
||
|
{{ $t("deleteStackMsg") }}
|
||
|
</BModal>
|
||
|
</div>
|
||
|
</transition>
|
||
|
</template>
|
||
|
|
||
|
<script>
|
||
|
import { highlight, languages } from "prismjs/components/prism-core";
|
||
|
import { PrismEditor } from "vue-prism-editor";
|
||
|
import "prismjs/components/prism-yaml";
|
||
|
import { parseDocument, Document } from "yaml";
|
||
|
|
||
|
import "prismjs/themes/prism-tomorrow.css";
|
||
|
import "vue-prism-editor/dist/prismeditor.min.css";
|
||
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||
|
import {
|
||
|
COMBINED_TERMINAL_COLS,
|
||
|
COMBINED_TERMINAL_ROWS,
|
||
|
copyYAMLComments,
|
||
|
getCombinedTerminalName,
|
||
|
getComposeTerminalName,
|
||
|
PROGRESS_TERMINAL_ROWS,
|
||
|
RUNNING
|
||
|
} from "../../../backend/util-common";
|
||
|
import { BModal } from "bootstrap-vue-next";
|
||
|
import NetworkInput from "../components/NetworkInput.vue";
|
||
|
|
||
|
const template = `version: "3.8"
|
||
|
services:
|
||
|
nginx:
|
||
|
image: nginx:latest
|
||
|
restart: unless-stopped
|
||
|
ports:
|
||
|
- "8080:80"
|
||
|
`;
|
||
|
|
||
|
let yamlErrorTimeout = null;
|
||
|
|
||
|
let serviceStatusTimeout = null;
|
||
|
|
||
|
export default {
|
||
|
components: {
|
||
|
NetworkInput,
|
||
|
FontAwesomeIcon,
|
||
|
PrismEditor,
|
||
|
BModal,
|
||
|
},
|
||
|
beforeRouteUpdate(to, from, next) {
|
||
|
this.exitConfirm(next);
|
||
|
},
|
||
|
beforeRouteLeave(to, from, next) {
|
||
|
this.exitConfirm(next);
|
||
|
},
|
||
|
yamlDoc: null, // For keeping the yaml comments
|
||
|
data() {
|
||
|
return {
|
||
|
editorFocus: false,
|
||
|
jsonConfig: {},
|
||
|
yamlError: "",
|
||
|
processing: true,
|
||
|
showProgressTerminal: false,
|
||
|
progressTerminalRows: PROGRESS_TERMINAL_ROWS,
|
||
|
combinedTerminalRows: COMBINED_TERMINAL_ROWS,
|
||
|
combinedTerminalCols: COMBINED_TERMINAL_COLS,
|
||
|
stack: {
|
||
|
|
||
|
},
|
||
|
serviceStatusList: {},
|
||
|
isEditMode: false,
|
||
|
submitted: false,
|
||
|
showDeleteDialog: false,
|
||
|
newContainerName: "",
|
||
|
stopServiceStatusTimeout: false,
|
||
|
};
|
||
|
},
|
||
|
computed: {
|
||
|
isAdd() {
|
||
|
return this.$route.path === "/compose" && !this.submitted;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Get the stack from the global stack list, because it may contain more real-time data like status
|
||
|
* @return {*}
|
||
|
*/
|
||
|
globalStack() {
|
||
|
return this.$root.stackList[this.stack.name];
|
||
|
},
|
||
|
|
||
|
status() {
|
||
|
return this.globalStack?.status;
|
||
|
},
|
||
|
|
||
|
active() {
|
||
|
return this.status === RUNNING;
|
||
|
},
|
||
|
|
||
|
terminalName() {
|
||
|
if (!this.stack.name) {
|
||
|
return "";
|
||
|
}
|
||
|
return getComposeTerminalName(this.stack.name);
|
||
|
},
|
||
|
|
||
|
combinedTerminalName() {
|
||
|
if (!this.stack.name) {
|
||
|
return "";
|
||
|
}
|
||
|
return getCombinedTerminalName(this.stack.name);
|
||
|
},
|
||
|
|
||
|
networks() {
|
||
|
return this.jsonConfig.networks;
|
||
|
}
|
||
|
|
||
|
},
|
||
|
watch: {
|
||
|
"stack.composeYAML": {
|
||
|
handler() {
|
||
|
if (this.editorFocus) {
|
||
|
console.debug("yaml code changed");
|
||
|
this.yamlCodeChange();
|
||
|
}
|
||
|
},
|
||
|
deep: true,
|
||
|
},
|
||
|
jsonConfig: {
|
||
|
handler() {
|
||
|
if (!this.editorFocus) {
|
||
|
console.debug("jsonConfig changed");
|
||
|
|
||
|
let doc = new Document(this.jsonConfig);
|
||
|
|
||
|
// Stick back the yaml comments
|
||
|
if (this.yamlDoc) {
|
||
|
copyYAMLComments(doc, this.yamlDoc);
|
||
|
}
|
||
|
|
||
|
this.stack.composeYAML = doc.toString();
|
||
|
this.yamlDoc = doc;
|
||
|
}
|
||
|
},
|
||
|
deep: true,
|
||
|
},
|
||
|
},
|
||
|
mounted() {
|
||
|
if (this.isAdd) {
|
||
|
this.processing = false;
|
||
|
this.isEditMode = true;
|
||
|
|
||
|
let composeYAML;
|
||
|
|
||
|
if (this.$root.composeTemplate) {
|
||
|
composeYAML = this.$root.composeTemplate;
|
||
|
this.$root.composeTemplate = "";
|
||
|
|
||
|
} else {
|
||
|
composeYAML = template;
|
||
|
}
|
||
|
|
||
|
// Default Values
|
||
|
this.stack = {
|
||
|
name: "",
|
||
|
composeYAML,
|
||
|
isManagedByDockge: true,
|
||
|
};
|
||
|
|
||
|
this.yamlCodeChange();
|
||
|
|
||
|
} else {
|
||
|
this.stack.name = this.$route.params.stackName;
|
||
|
this.loadStack();
|
||
|
}
|
||
|
|
||
|
this.requestServiceStatus();
|
||
|
},
|
||
|
unmounted() {
|
||
|
this.stopServiceStatusTimeout = true;
|
||
|
clearTimeout(serviceStatusTimeout);
|
||
|
},
|
||
|
methods: {
|
||
|
|
||
|
startServiceStatusTimeout() {
|
||
|
clearTimeout(serviceStatusTimeout);
|
||
|
serviceStatusTimeout = setTimeout(async () => {
|
||
|
this.requestServiceStatus();
|
||
|
}, 2000);
|
||
|
},
|
||
|
|
||
|
requestServiceStatus() {
|
||
|
this.$root.getSocket().emit("serviceStatusList", this.stack.name, (res) => {
|
||
|
if (res.ok) {
|
||
|
this.serviceStatusList = res.serviceStatusList;
|
||
|
}
|
||
|
if (!this.stopServiceStatusTimeout) {
|
||
|
this.startServiceStatusTimeout();
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
|
||
|
exitConfirm(next) {
|
||
|
if (this.isEditMode) {
|
||
|
if (confirm("You are currently editing a stack. Are you sure you want to leave?")) {
|
||
|
next();
|
||
|
} else {
|
||
|
next(false);
|
||
|
}
|
||
|
} else {
|
||
|
next();
|
||
|
}
|
||
|
},
|
||
|
|
||
|
bindTerminal() {
|
||
|
this.$refs.progressTerminal?.bind(this.terminalName);
|
||
|
},
|
||
|
|
||
|
loadStack() {
|
||
|
this.processing = true;
|
||
|
this.$root.getSocket().emit("getStack", this.stack.name, (res) => {
|
||
|
if (res.ok) {
|
||
|
this.stack = res.stack;
|
||
|
this.yamlCodeChange();
|
||
|
this.processing = false;
|
||
|
this.bindTerminal();
|
||
|
} else {
|
||
|
this.$root.toastRes(res);
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
|
||
|
deployStack() {
|
||
|
this.processing = true;
|
||
|
|
||
|
if (!this.jsonConfig.services) {
|
||
|
this.$root.toastError("No services found in compose.yaml");
|
||
|
this.processing = false;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Check if services is object
|
||
|
if (typeof this.jsonConfig.services !== "object") {
|
||
|
this.$root.toastError("Services must be an object");
|
||
|
this.processing = false;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let serviceNameList = Object.keys(this.jsonConfig.services);
|
||
|
|
||
|
// Set the stack name if empty, use the first container name
|
||
|
if (!this.stack.name && serviceNameList.length > 0) {
|
||
|
let serviceName = serviceNameList[0];
|
||
|
let service = this.jsonConfig.services[serviceName];
|
||
|
|
||
|
if (service && service.container_name) {
|
||
|
this.stack.name = service.container_name;
|
||
|
} else {
|
||
|
this.stack.name = serviceName;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.bindTerminal(this.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.isEditMode = false;
|
||
|
this.$router.push("/compose/" + this.stack.name);
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
|
||
|
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.isEditMode = false;
|
||
|
this.$router.push("/compose/" + this.stack.name);
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
|
||
|
startStack() {
|
||
|
this.processing = true;
|
||
|
|
||
|
this.$root.getSocket().emit("startStack", this.stack.name, (res) => {
|
||
|
this.processing = false;
|
||
|
this.$root.toastRes(res);
|
||
|
});
|
||
|
},
|
||
|
|
||
|
stopStack() {
|
||
|
this.processing = true;
|
||
|
|
||
|
this.$root.getSocket().emit("stopStack", this.stack.name, (res) => {
|
||
|
this.processing = false;
|
||
|
this.$root.toastRes(res);
|
||
|
});
|
||
|
},
|
||
|
|
||
|
restartStack() {
|
||
|
this.processing = true;
|
||
|
|
||
|
this.$root.getSocket().emit("restartStack", this.stack.name, (res) => {
|
||
|
this.processing = false;
|
||
|
this.$root.toastRes(res);
|
||
|
});
|
||
|
},
|
||
|
|
||
|
updateStack() {
|
||
|
this.processing = true;
|
||
|
|
||
|
this.$root.getSocket().emit("updateStack", this.stack.name, (res) => {
|
||
|
this.processing = false;
|
||
|
this.$root.toastRes(res);
|
||
|
});
|
||
|
},
|
||
|
|
||
|
deleteDialog() {
|
||
|
this.$root.getSocket().emit("deleteStack", this.stack.name, (res) => {
|
||
|
this.$root.toastRes(res);
|
||
|
if (res.ok) {
|
||
|
this.$router.push("/");
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
|
||
|
discardStack() {
|
||
|
this.loadStack();
|
||
|
this.isEditMode = false;
|
||
|
},
|
||
|
|
||
|
highlighter(code) {
|
||
|
return highlight(code, languages.yaml);
|
||
|
},
|
||
|
|
||
|
yamlCodeChange() {
|
||
|
try {
|
||
|
let doc = parseDocument(this.stack.composeYAML);
|
||
|
if (doc.errors.length > 0) {
|
||
|
throw doc.errors[0];
|
||
|
}
|
||
|
|
||
|
const config = doc.toJS() ?? {};
|
||
|
|
||
|
// Check data types
|
||
|
// "services" must be an object
|
||
|
if (!config.services) {
|
||
|
config.services = {};
|
||
|
}
|
||
|
|
||
|
if (Array.isArray(config.services) || typeof config.services !== "object") {
|
||
|
throw new Error("Services must be an object");
|
||
|
}
|
||
|
|
||
|
if (!config.version) {
|
||
|
config.version = "3.8";
|
||
|
}
|
||
|
|
||
|
this.yamlDoc = doc;
|
||
|
this.jsonConfig = config;
|
||
|
|
||
|
clearTimeout(yamlErrorTimeout);
|
||
|
this.yamlError = "";
|
||
|
} catch (e) {
|
||
|
clearTimeout(yamlErrorTimeout);
|
||
|
|
||
|
if (this.yamlError) {
|
||
|
this.yamlError = e.message;
|
||
|
|
||
|
} else {
|
||
|
yamlErrorTimeout = setTimeout(() => {
|
||
|
this.yamlError = e.message;
|
||
|
}, 3000);
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
enableEditMode() {
|
||
|
this.isEditMode = true;
|
||
|
},
|
||
|
|
||
|
checkYAML() {
|
||
|
|
||
|
},
|
||
|
|
||
|
addContainer() {
|
||
|
this.checkYAML();
|
||
|
|
||
|
if (this.jsonConfig.services[this.newContainerName]) {
|
||
|
this.$root.toastError("Container name already exists");
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (!this.newContainerName) {
|
||
|
this.$root.toastError("Container name cannot be empty");
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.jsonConfig.services[this.newContainerName] = {
|
||
|
restart: "unless-stopped",
|
||
|
};
|
||
|
this.newContainerName = "";
|
||
|
let element = this.$refs.containerList.lastElementChild;
|
||
|
element.scrollIntoView({
|
||
|
block: "start",
|
||
|
behavior: "smooth"
|
||
|
});
|
||
|
},
|
||
|
|
||
|
}
|
||
|
};
|
||
|
</script>
|
||
|
|
||
|
<style scoped lang="scss">
|
||
|
.terminal {
|
||
|
height: 200px;
|
||
|
}
|
||
|
|
||
|
.editor-box {
|
||
|
&.edit-mode {
|
||
|
background-color: #2c2f38 !important;
|
||
|
}
|
||
|
}
|
||
|
</style>
|