123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606 |
- <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>
- <label for="name" class="form-label">{{ $t("stackName") }}</label>
- <input id="name" v-model="stack.name" type="text" class="form-control" required @blur="stackNameToLowercase">
- <div class="form-text">Lowercase only</div>
- </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">{{ stack.composeFileName }}</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"
- });
- },
- stackNameToLowercase() {
- this.stack.name = this.stack?.name?.toLowerCase();
- },
- }
- };
- </script>
- <style scoped lang="scss">
- .terminal {
- height: 200px;
- }
- .editor-box {
- font-family: 'JetBrains Mono', monospace;
- font-size: 14px;
- &.edit-mode {
- background-color: #2c2f38 !important;
- }
- }
- </style>
|