This commit is contained in:
Louis Lam
2023-11-11 22:18:37 +08:00
committed by GitHub
parent 205bff2359
commit 6749e343ba
88 changed files with 14443 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>