mirror of
https://github.com/louislam/dockge.git
synced 2026-05-21 22:12:17 +00:00
Init (#1)
This commit is contained in:
599
frontend/src/pages/Compose.vue
Normal file
599
frontend/src/pages/Compose.vue
Normal file
@@ -0,0 +1,599 @@
|
||||
<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>
|
||||
48
frontend/src/pages/Console.vue
Normal file
48
frontend/src/pages/Console.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<transition name="slide-fade" appear>
|
||||
<div>
|
||||
<h1 class="mb-3">Console</h1>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
Allowed commands:
|
||||
<template v-for="(command, index) in allowedCommandList" :key="command">
|
||||
<code>{{ command }}</code>
|
||||
|
||||
<!-- No comma at the end -->
|
||||
<span v-if="index !== allowedCommandList.length - 1">, </span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Terminal class="terminal" :rows="20" mode="mainTerminal" name="console"></Terminal>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { allowedCommandList } from "../../../backend/util-common";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
allowedCommandList,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
|
||||
},
|
||||
methods: {
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.terminal {
|
||||
height: 410px;
|
||||
}
|
||||
</style>
|
||||
63
frontend/src/pages/ContainerTerminal.vue
Normal file
63
frontend/src/pages/ContainerTerminal.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<transition name="slide-fade" appear>
|
||||
<div>
|
||||
<h1 class="mb-3">Terminal - {{ serviceName }} ({{ stackName }})</h1>
|
||||
|
||||
<div class="mb-3">
|
||||
<router-link :to="sh" class="btn btn-normal me-2">Switch to sh</router-link>
|
||||
</div>
|
||||
|
||||
<Terminal class="terminal" :rows="20" mode="interactive" :name="terminalName" :stack-name="stackName" :service-name="serviceName" :shell="shell"></Terminal>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getContainerExecTerminalName } from "../../../backend/util-common";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
stackName() {
|
||||
return this.$route.params.stackName;
|
||||
},
|
||||
shell() {
|
||||
return this.$route.params.type;
|
||||
},
|
||||
serviceName() {
|
||||
return this.$route.params.serviceName;
|
||||
},
|
||||
terminalName() {
|
||||
return getContainerExecTerminalName(this.stackName, this.serviceName, 0);
|
||||
},
|
||||
sh() {
|
||||
return {
|
||||
name: "containerTerminal",
|
||||
params: {
|
||||
stackName: this.stackName,
|
||||
serviceName: this.serviceName,
|
||||
type: "sh",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
},
|
||||
methods: {
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.terminal {
|
||||
height: 410px;
|
||||
}
|
||||
</style>
|
||||
42
frontend/src/pages/Dashboard.vue
Normal file
42
frontend/src/pages/Dashboard.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div v-if="!$root.isMobile" class="col-12 col-md-4 col-xl-3">
|
||||
<div>
|
||||
<router-link to="/compose" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("compose") }}</router-link>
|
||||
</div>
|
||||
<StackList :scrollbar="true" />
|
||||
</div>
|
||||
|
||||
<div ref="container" class="col-12 col-md-8 col-xl-9 mb-3">
|
||||
<!-- Add :key to disable vue router re-use the same component -->
|
||||
<router-view :key="$route.fullPath" :calculatedHeight="height" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import StackList from "../components/StackList.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
StackList,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
height: 0
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.height = this.$refs.container.offsetHeight;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container-fluid {
|
||||
width: 98%;
|
||||
}
|
||||
</style>
|
||||
231
frontend/src/pages/DashboardHome.vue
Normal file
231
frontend/src/pages/DashboardHome.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<transition ref="tableContainer" name="slide-fade" appear>
|
||||
<div v-if="$route.name === 'DashboardHome'">
|
||||
<h1 class="mb-3">
|
||||
{{ $t("home") }}
|
||||
</h1>
|
||||
|
||||
<div class="shadow-box big-padding text-center mb-4">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3>{{ $t("active") }}</h3>
|
||||
<span class="num active">{{ activeNum }}</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h3>{{ $t("exited") }}</h3>
|
||||
<span class="num exited">{{ exitedNum }}</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h3>{{ $t("inactive") }}</h3>
|
||||
<span class="num inactive">{{ inactiveNum }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-3">Docker Run</h2>
|
||||
<div class="mb-3">
|
||||
<textarea id="name" v-model="dockerRunCommand" type="text" class="form-control docker-run" required placeholder="docker run ..."></textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn-normal btn" @click="convertDockerRun">Convert to Compose</button>
|
||||
</div>
|
||||
</transition>
|
||||
<router-view ref="child" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { statusNameShort } from "../../../backend/util-common";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
},
|
||||
props: {
|
||||
calculatedHeight: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
initialPerPage: 25,
|
||||
paginationConfig: {
|
||||
hideCount: true,
|
||||
chunksNavigation: "scroll",
|
||||
},
|
||||
importantHeartBeatListLength: 0,
|
||||
displayedRecords: [],
|
||||
dockerRunCommand: "",
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
activeNum() {
|
||||
return this.getStatusNum("active");
|
||||
},
|
||||
inactiveNum() {
|
||||
return this.getStatusNum("inactive");
|
||||
},
|
||||
exitedNum() {
|
||||
return this.getStatusNum("exited");
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
perPage() {
|
||||
this.$nextTick(() => {
|
||||
this.getImportantHeartbeatListPaged();
|
||||
});
|
||||
},
|
||||
|
||||
page() {
|
||||
this.getImportantHeartbeatListPaged();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.initialPerPage = this.perPage;
|
||||
|
||||
window.addEventListener("resize", this.updatePerPage);
|
||||
this.updatePerPage();
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("resize", this.updatePerPage);
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
getStatusNum(statusName) {
|
||||
let num = 0;
|
||||
|
||||
for (let stackName in this.$root.stackList) {
|
||||
const stack = this.$root.stackList[stackName];
|
||||
if (statusNameShort(stack.status) === statusName) {
|
||||
num += 1;
|
||||
}
|
||||
}
|
||||
return num;
|
||||
},
|
||||
|
||||
convertDockerRun() {
|
||||
if (this.dockerRunCommand.trim() === "docker run") {
|
||||
throw new Error("Please enter a docker run command");
|
||||
}
|
||||
|
||||
// composerize is working in dev, but after "vite build", it is not working
|
||||
// So pass to backend to do the conversion
|
||||
this.$root.getSocket().emit("composerize", this.dockerRunCommand, (res) => {
|
||||
if (res.ok) {
|
||||
this.$root.composeTemplate = res.composeTemplate;
|
||||
this.$router.push("/compose");
|
||||
} else {
|
||||
this.$root.toastRes(res);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates the displayed records when a new important heartbeat arrives.
|
||||
* @param {object} heartbeat - The heartbeat object received.
|
||||
* @returns {void}
|
||||
*/
|
||||
onNewImportantHeartbeat(heartbeat) {
|
||||
if (this.page === 1) {
|
||||
this.displayedRecords.unshift(heartbeat);
|
||||
if (this.displayedRecords.length > this.perPage) {
|
||||
this.displayedRecords.pop();
|
||||
}
|
||||
this.importantHeartBeatListLength += 1;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieves the length of the important heartbeat list for all monitors.
|
||||
* @returns {void}
|
||||
*/
|
||||
getImportantHeartbeatListLength() {
|
||||
this.$root.getSocket().emit("monitorImportantHeartbeatListCount", null, (res) => {
|
||||
if (res.ok) {
|
||||
this.importantHeartBeatListLength = res.count;
|
||||
this.getImportantHeartbeatListPaged();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieves the important heartbeat list for the current page.
|
||||
* @returns {void}
|
||||
*/
|
||||
getImportantHeartbeatListPaged() {
|
||||
const offset = (this.page - 1) * this.perPage;
|
||||
this.$root.getSocket().emit("monitorImportantHeartbeatListPaged", null, offset, this.perPage, (res) => {
|
||||
if (res.ok) {
|
||||
this.displayedRecords = res.data;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates the number of items shown per page based on the available height.
|
||||
* @returns {void}
|
||||
*/
|
||||
updatePerPage() {
|
||||
const tableContainer = this.$refs.tableContainer;
|
||||
const tableContainerHeight = tableContainer.offsetHeight;
|
||||
const availableHeight = window.innerHeight - tableContainerHeight;
|
||||
const additionalPerPage = Math.floor(availableHeight / 58);
|
||||
|
||||
if (additionalPerPage > 0) {
|
||||
this.perPage = Math.max(this.initialPerPage, this.perPage + additionalPerPage);
|
||||
} else {
|
||||
this.perPage = this.initialPerPage;
|
||||
}
|
||||
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../styles/vars";
|
||||
|
||||
.num {
|
||||
font-size: 30px;
|
||||
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
|
||||
&.active {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
&.exited {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
.shadow-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 14px;
|
||||
|
||||
tr {
|
||||
transition: all ease-in-out 0.2ms;
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
table-layout: fixed;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.docker-run {
|
||||
background-color: $dark-bg !important;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
252
frontend/src/pages/Settings.vue
Normal file
252
frontend/src/pages/Settings.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 v-show="show" class="mb-3">
|
||||
{{ $t("Settings") }}
|
||||
</h1>
|
||||
|
||||
<div class="shadow-box shadow-box-settings">
|
||||
<div class="row">
|
||||
<div v-if="showSubMenu" class="settings-menu col-lg-3 col-md-5">
|
||||
<router-link
|
||||
v-for="(item, key) in subMenus"
|
||||
:key="key"
|
||||
:to="`/settings/${key}`"
|
||||
>
|
||||
<div class="menu-item">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<a v-if="$root.isMobile && $root.loggedIn && $root.socket.token !== 'autoLogin'" class="logout" @click.prevent="$root.logout">
|
||||
<div class="menu-item">
|
||||
<font-awesome-icon icon="sign-out-alt" />
|
||||
{{ $t("Logout") }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="settings-content col-lg-9 col-md-7">
|
||||
<div v-if="currentPage" class="settings-content-header">
|
||||
{{ subMenus[currentPage].title }}
|
||||
</div>
|
||||
<div class="mx-3">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="slide-fade" appear>
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
settings: {},
|
||||
settingsLoaded: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
currentPage() {
|
||||
let pathSplit = useRoute().path.split("/");
|
||||
let pathEnd = pathSplit[pathSplit.length - 1];
|
||||
if (!pathEnd || pathEnd === "settings") {
|
||||
return null;
|
||||
}
|
||||
return pathEnd;
|
||||
},
|
||||
|
||||
showSubMenu() {
|
||||
if (this.$root.isMobile) {
|
||||
return !this.currentPage;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
subMenus() {
|
||||
return {
|
||||
general: {
|
||||
title: this.$t("General"),
|
||||
},
|
||||
appearance: {
|
||||
title: this.$t("Appearance"),
|
||||
},
|
||||
security: {
|
||||
title: this.$t("Security"),
|
||||
},
|
||||
about: {
|
||||
title: this.$t("About"),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
"$root.isMobile"() {
|
||||
this.loadGeneralPage();
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadSettings();
|
||||
this.loadGeneralPage();
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
/**
|
||||
* Load the general settings page
|
||||
* For desktop only, on mobile do nothing
|
||||
*/
|
||||
loadGeneralPage() {
|
||||
if (!this.currentPage && !this.$root.isMobile) {
|
||||
this.$router.push("/settings/appearance");
|
||||
}
|
||||
},
|
||||
|
||||
/** Load settings from server */
|
||||
loadSettings() {
|
||||
this.$root.getSocket().emit("getSettings", (res) => {
|
||||
this.settings = res.data;
|
||||
if (this.settings.checkUpdate === undefined) {
|
||||
this.settings.checkUpdate = true;
|
||||
}
|
||||
this.settingsLoaded = true;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Callback for saving settings
|
||||
* @callback saveSettingsCB
|
||||
* @param {Object} res Result of operation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Save Settings
|
||||
* @param {saveSettingsCB} [callback]
|
||||
* @param {string} [currentPassword] Only need for disableAuth to true
|
||||
*/
|
||||
saveSettings(callback, currentPassword) {
|
||||
let valid = this.validateSettings();
|
||||
if (valid.success) {
|
||||
this.$root.getSocket().emit("setSettings", this.settings, currentPassword, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
this.loadSettings();
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.$root.toastError(valid.msg);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure settings are valid
|
||||
* @returns {Object} Contains success state and error msg
|
||||
*/
|
||||
validateSettings() {
|
||||
if (this.settings.keepDataPeriodDays < 0) {
|
||||
return {
|
||||
success: false,
|
||||
msg: this.$t("dataRetentionTimeError"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
msg: "",
|
||||
};
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../styles/vars.scss";
|
||||
|
||||
.shadow-box-settings {
|
||||
padding: 20px;
|
||||
min-height: calc(100vh - 155px);
|
||||
}
|
||||
|
||||
footer {
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
margin-top: 20px;
|
||||
padding-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-menu {
|
||||
a {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
border-radius: 10px;
|
||||
margin: 0.5em;
|
||||
padding: 0.7em 1em;
|
||||
cursor: pointer;
|
||||
border-left-width: 0;
|
||||
transition: all ease-in-out 0.1s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: $highlight-white;
|
||||
|
||||
.dark & {
|
||||
background: $dark-header-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.active .menu-item {
|
||||
background: $highlight-white;
|
||||
border-left: 4px solid $primary;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
.dark & {
|
||||
background: $dark-header-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
.settings-content-header {
|
||||
width: calc(100% + 20px);
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
border-radius: 0 10px 0 0;
|
||||
margin-top: -20px;
|
||||
margin-right: -20px;
|
||||
padding: 12.5px 1em;
|
||||
font-size: 26px;
|
||||
|
||||
.dark & {
|
||||
background: $dark-header-bg;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.mobile & {
|
||||
padding: 15px 0 0 0;
|
||||
|
||||
.dark & {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logout {
|
||||
color: $danger !important;
|
||||
}
|
||||
</style>
|
||||
138
frontend/src/pages/Setup.vue
Normal file
138
frontend/src/pages/Setup.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="form-container" data-cy="setup-form">
|
||||
<div class="form">
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<object width="64" height="64" data="/icon.svg" />
|
||||
<div style="font-size: 28px; font-weight: bold; margin-top: 5px;">
|
||||
Dockge
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-3">
|
||||
{{ $t("Create your admin account") }}
|
||||
</p>
|
||||
|
||||
<div class="form-floating">
|
||||
<select id="language" v-model="$root.language" class="form-select">
|
||||
<option v-for="(lang, i) in $i18n.availableLocales" :key="`Lang${i}`" :value="lang">
|
||||
{{ $i18n.messages[lang].languageName }}
|
||||
</option>
|
||||
</select>
|
||||
<label for="language" class="form-label">{{ $t("Language") }}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mt-3">
|
||||
<input id="floatingInput" v-model="username" type="text" class="form-control" :placeholder="$t('Username')" required data-cy="username-input">
|
||||
<label for="floatingInput">{{ $t("Username") }}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mt-3">
|
||||
<input id="floatingPassword" v-model="password" type="password" class="form-control" :placeholder="$t('Password')" required data-cy="password-input">
|
||||
<label for="floatingPassword">{{ $t("Password") }}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mt-3">
|
||||
<input id="repeat" v-model="repeatPassword" type="password" class="form-control" :placeholder="$t('Repeat Password')" required data-cy="password-repeat-input">
|
||||
<label for="repeat">{{ $t("Repeat Password") }}</label>
|
||||
</div>
|
||||
|
||||
<button class="w-100 btn btn-primary mt-3" type="submit" :disabled="processing" data-cy="submit-setup-form">
|
||||
{{ $t("Create") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
username: "",
|
||||
password: "",
|
||||
repeatPassword: "",
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
|
||||
},
|
||||
mounted() {
|
||||
// TODO: Check if it is a database setup
|
||||
|
||||
this.$root.getSocket().emit("needSetup", (needSetup) => {
|
||||
if (! needSetup) {
|
||||
this.$router.push("/");
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Submit form data for processing
|
||||
* @returns {void}
|
||||
*/
|
||||
submit() {
|
||||
this.processing = true;
|
||||
|
||||
if (this.password !== this.repeatPassword) {
|
||||
this.$root.toastError("PasswordsDoNotMatch");
|
||||
this.processing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.$root.getSocket().emit("setup", this.username, this.password, (res) => {
|
||||
this.processing = false;
|
||||
this.$root.toastRes(res);
|
||||
|
||||
if (res.ok) {
|
||||
this.processing = true;
|
||||
|
||||
this.$root.login(this.username, this.password, "", () => {
|
||||
this.processing = false;
|
||||
this.$router.push("/");
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-floating {
|
||||
> .form-select {
|
||||
padding-left: 1.3rem;
|
||||
padding-top: 1.525rem;
|
||||
line-height: 1.35;
|
||||
|
||||
~ label {
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
> label {
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
|
||||
> .form-control {
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
|
||||
width: 100%;
|
||||
max-width: 330px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user