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,117 @@
<template>
<div>
<div v-if="valid">
<ul v-if="isArrayInited" class="list-group">
<li v-for="(value, index) in array" :key="index" class="list-group-item">
<input v-model="array[index]" type="text" class="no-bg domain-input" :placeholder="placeholder" />
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" @click="remove(index)" />
</li>
</ul>
<button class="btn btn-normal btn-sm mt-3" @click="addField">{{ $t("addListItem", [ displayName ]) }}</button>
</div>
<div v-else>
Long syntax is not supported here. Please use the YAML editor.
</div>
</div>
</template>
<script>
export default {
props: {
name: {
type: String,
required: true,
},
placeholder: {
type: String,
default: "",
},
displayName: {
type: String,
required: true,
}
},
data() {
return {
};
},
computed: {
array() {
// Create the array if not exists, it should be safe.
if (!this.service[this.name]) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.service[this.name] = [];
}
return this.service[this.name];
},
/**
* Check if the array is inited before called v-for.
* Prevent empty arrays inserted to the YAML file.
* @return {boolean}
*/
isArrayInited() {
return this.service[this.name] !== undefined;
},
service() {
return this.$parent.$parent.service;
},
valid() {
// Check if the array is actually an array
if (!Array.isArray(this.array)) {
return false;
}
// Check if the array contains non-object only.
for (let item of this.array) {
if (typeof item === "object") {
return false;
}
}
return true;
}
},
created() {
},
methods: {
addField() {
this.array.push("");
},
remove(index) {
this.array.splice(index, 1);
},
}
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.list-group {
background-color: $dark-bg2;
li {
display: flex;
align-items: center;
padding: 10px 0 10px 10px;
.domain-input {
flex-grow: 1;
background-color: $dark-bg2;
border: none;
color: $dark-font-color;
outline: none;
&::placeholder {
color: #1d2634;
}
}
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div>
<div v-if="valid">
<ul v-if="isArrayInited" class="list-group">
<li v-for="(value, index) in array" :key="index" class="list-group-item">
<select v-model="array[index]" class="no-bg domain-input">
<option value="">Select a network...</option>
<option v-for="option in options" :value="option">{{ option }}</option>
</select>
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" @click="remove(index)" />
</li>
</ul>
<button class="btn btn-normal btn-sm mt-3" @click="addField">{{ $t("addListItem", [ displayName ]) }}</button>
</div>
<div v-else>
Long syntax is not supported here. Please use the YAML editor.
</div>
</div>
</template>
<script>
export default {
props: {
name: {
type: String,
required: true,
},
placeholder: {
type: String,
default: "",
},
displayName: {
type: String,
required: true,
},
options: {
type: Array,
required: true,
},
},
data() {
return {
};
},
computed: {
array() {
// Create the array if not exists, it should be safe.
if (!this.service[this.name]) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.service[this.name] = [];
}
return this.service[this.name];
},
/**
* Check if the array is inited before called v-for.
* Prevent empty arrays inserted to the YAML file.
* @return {boolean}
*/
isArrayInited() {
return this.service[this.name] !== undefined;
},
service() {
return this.$parent.$parent.service;
},
valid() {
// Check if the array is actually an array
if (!Array.isArray(this.array)) {
return false;
}
// Check if the array contains non-object only.
for (let item of this.array) {
if (typeof item === "object") {
return false;
}
}
return true;
}
},
created() {
},
methods: {
addField() {
this.array.push("");
},
remove(index) {
this.array.splice(index, 1);
},
}
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.list-group {
background-color: $dark-bg2;
li {
display: flex;
align-items: center;
padding: 10px 0 10px 10px;
.domain-input {
flex-grow: 1;
background-color: $dark-bg2;
border: none;
color: $dark-font-color;
outline: none;
&::placeholder {
color: #1d2634;
}
}
}
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div ref="modal" class="modal fade" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title">
{{ title || $t("Confirm") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<slot />
</div>
<div class="modal-footer">
<button type="button" class="btn" :class="btnStyle" data-bs-dismiss="modal" @click="yes">
{{ yesText }}
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="no">
{{ noText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from "bootstrap";
export default {
props: {
/** Style of button */
btnStyle: {
type: String,
default: "btn-primary",
},
/** Text to use as yes */
yesText: {
type: String,
default: "Yes", // TODO: No idea what to translate this
},
/** Text to use as no */
noText: {
type: String,
default: "No",
},
/** Title to show on modal. Defaults to translated version of "Config" */
title: {
type: String,
default: null,
}
},
emits: [ "yes", "no" ],
data: () => ({
modal: null,
}),
mounted() {
this.modal = new Modal(this.$refs.modal);
},
methods: {
/**
* Show the confirm dialog
* @returns {void}
*/
show() {
this.modal.show();
},
/**
* @fires string "yes" Notify the parent when Yes is pressed
* @returns {void}
*/
yes() {
this.$emit("yes");
},
/**
* @fires string "no" Notify the parent when No is pressed
* @returns {void}
*/
no() {
this.$emit("no");
}
},
};
</script>

View File

@@ -0,0 +1,273 @@
<template>
<div class="shadow-box big-padding mb-3 container">
<div class="row">
<div class="col-7">
<h4>{{ name }}</h4>
<div class="image mb-2">
<span class="me-1">{{ imageName }}:</span><span class="tag">{{ imageTag }}</span>
</div>
<div v-if="!isEditMode">
<span class="badge me-1" :class="bgStyle">{{ status }}</span>
<a v-for="port in service.ports" :href="parsePort(port).url" target="_blank">
<span class="badge me-1 bg-secondary">{{ parsePort(port).display }}</span>
</a>
</div>
</div>
<div class="col-5">
<div class="function">
<router-link v-if="!isEditMode" class="btn btn-normal" :to="terminalRouteLink" disabled="">
<font-awesome-icon icon="terminal" />
Bash
</router-link>
</div>
</div>
</div>
<div v-if="isEditMode" class="mt-2">
<button class="btn btn-normal me-2" @click="showConfig = !showConfig">
<font-awesome-icon icon="edit" />
Edit
</button>
<button v-if="false" class="btn btn-normal me-2">Rename</button>
<button class="btn btn-danger me-2" @click="remove">
<font-awesome-icon icon="trash" />
{{ $t("deleteContainer") }}
</button>
</div>
<transition name="slide-fade" appear>
<div v-if="isEditMode && showConfig" class="config mt-3">
<!-- Image -->
<div class="mb-4">
<label class="form-label">
{{ $t("dockerImage") }}
</label>
<div class="input-group mb-3">
<input
v-model="service.image"
class="form-control"
list="image-datalist"
/>
</div>
<!-- TODO: Search online: https://hub.docker.com/api/content/v1/products/search?q=louislam%2Fuptime&source=community&page=1&page_size=4 -->
<datalist id="image-datalist">
<option value="louislam/uptime-kuma:1" />
</datalist>
<div class="form-text"></div>
</div>
<!-- Ports -->
<div class="mb-4">
<label class="form-label">
{{ $tc("port", 2) }}
</label>
<ArrayInput name="ports" :display-name="$t('port')" placeholder="HOST:CONTAINER" />
</div>
<!-- Volumes -->
<div class="mb-4">
<label class="form-label">
{{ $tc("volume", 2) }}
</label>
<ArrayInput name="volumes" :display-name="$t('volume')" placeholder="HOST:CONTAINER" />
</div>
<!-- Restart Policy -->
<div class="mb-4">
<label class="form-label">
{{ $t("restartPolicy") }}
</label>
<select v-model="service.restart" class="form-select">
<option value="always">{{ $t("restartPolicyAlways") }}</option>
<option value="unless-stopped">{{ $t("restartPolicyUnlessStopped") }}</option>
<option value="on-failure">{{ $t("restartPolicyOnFailure") }}</option>
<option value="no">{{ $t("restartPolicyNo") }}</option>
</select>
</div>
<!-- Environment Variables -->
<div class="mb-4">
<label class="form-label">
{{ $tc("environmentVariable", 2) }}
</label>
<ArrayInput name="environment" :display-name="$t('environmentVariable')" placeholder="KEY=VALUE" />
</div>
<!-- Container Name -->
<div v-if="false" class="mb-4">
<label class="form-label">
{{ $t("containerName") }}
</label>
<div class="input-group mb-3">
<input
v-model="service.container_name"
class="form-control"
/>
</div>
<div class="form-text"></div>
</div>
<!-- Network -->
<div class="mb-4">
<label class="form-label">
{{ $tc("network", 2) }}
</label>
<div v-if="networkList.length === 0 && service.networks.length > 0" class="text-warning mb-3">
No networks available. You need to add internal networks or enable external networks in the right side first.
</div>
<ArraySelect name="networks" :display-name="$t('network')" placeholder="Network Name" :options="networkList" />
</div>
<!-- Depends on -->
<div class="mb-4">
<label class="form-label">
{{ $t("dependsOn") }}
</label>
<ArrayInput name="depends_on" :display-name="$t('dependsOn')" placeholder="Container Name" />
</div>
</div>
</transition>
</div>
</template>
<script>
import { defineComponent } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { parseDockerPort } from "../../../backend/util-common";
export default defineComponent({
components: {
FontAwesomeIcon,
},
props: {
name: {
type: String,
required: true,
},
isEditMode: {
type: Boolean,
default: false,
},
first: {
type: Boolean,
default: false,
},
status: {
type: String,
default: "N/A",
}
},
emits: [
],
data() {
return {
showConfig: false,
};
},
computed: {
networkList() {
let list = [];
for (const networkName in this.jsonObject.networks) {
list.push(networkName);
}
return list;
},
bgStyle() {
if (this.status === "running") {
return "bg-primary";
} else {
return "bg-secondary";
}
},
terminalRouteLink() {
return {
name: "containerTerminal",
params: {
stackName: this.stackName,
serviceName: this.name,
type: "bash",
},
};
},
stackName() {
return this.$parent.$parent.stack.name;
},
service() {
if (!this.jsonObject.services[this.name]) {
return {};
}
return this.jsonObject.services[this.name];
},
jsonObject() {
return this.$parent.$parent.jsonConfig;
},
imageName() {
if (this.service.image) {
return this.service.image.split(":")[0];
} else {
return "";
}
},
imageTag() {
if (this.service.image) {
let tag = this.service.image.split(":")[1];
if (tag) {
return tag;
} else {
return "latest";
}
} else {
return "";
}
},
},
mounted() {
if (this.first) {
//this.showConfig = true;
}
},
methods: {
parsePort(port) {
let hostname = this.$root.info.primaryHostname || location.hostname;
return parseDockerPort(port, hostname);
},
remove() {
delete this.jsonObject.services[this.name];
},
}
});
</script>
<style scoped lang="scss">
@import "../styles/vars";
.container {
.image {
font-size: 0.8rem;
color: #6c757d;
.tag {
color: #33383b;
}
}
.function {
align-content: center;
display: flex;
height: 100%;
width: 100%;
align-items: center;
justify-content: end;
}
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="input-group mb-3">
<input
ref="input"
v-model="model"
:type="visibility"
class="form-control"
:placeholder="placeholder"
:maxlength="maxlength"
:autocomplete="autocomplete"
:required="required"
:readonly="readonly"
>
<a v-if="visibility == 'password'" class="btn btn-outline-primary" @click="showInput()">
<font-awesome-icon icon="eye" />
</a>
<a v-if="visibility == 'text'" class="btn btn-outline-primary" @click="hideInput()">
<font-awesome-icon icon="eye-slash" />
</a>
</div>
</template>
<script>
export default {
props: {
/** The value of the input */
modelValue: {
type: String,
default: ""
},
/** A placeholder to use */
placeholder: {
type: String,
default: ""
},
/** Maximum length of the input */
maxlength: {
type: Number,
default: 255
},
/** Should the field auto complete */
autocomplete: {
type: String,
default: "new-password",
},
/** Is the input required? */
required: {
type: Boolean
},
/** Should the input be read only? */
readonly: {
type: String,
default: undefined,
},
},
emits: [ "update:modelValue" ],
data() {
return {
visibility: "password",
};
},
computed: {
model: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
}
}
},
created() {
},
methods: {
/** Show users input in plain text */
showInput() {
this.visibility = "text";
},
/** Censor users input */
hideInput() {
this.visibility = "password";
},
}
};
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div class="form-container">
<div class="form">
<form @submit.prevent="submit">
<h1 class="h3 mb-3 fw-normal" />
<div v-if="!tokenRequired" class="form-floating">
<input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username" autocomplete="username" required>
<label for="floatingInput">{{ $t("Username") }}</label>
</div>
<div v-if="!tokenRequired" class="form-floating mt-3">
<input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="Password" autocomplete="current-password" required>
<label for="floatingPassword">{{ $t("Password") }}</label>
</div>
<div v-if="tokenRequired">
<div class="form-floating mt-3">
<input id="otp" v-model="token" type="text" maxlength="6" class="form-control" placeholder="123456" autocomplete="one-time-code" required>
<label for="otp">{{ $t("Token") }}</label>
</div>
</div>
<div class="form-check mb-3 mt-3 d-flex justify-content-center pe-4">
<div class="form-check">
<input id="remember" v-model="$root.remember" type="checkbox" value="remember-me" class="form-check-input">
<label class="form-check-label" for="remember">
{{ $t("Remember me") }}
</label>
</div>
</div>
<button class="w-100 btn btn-primary" type="submit" :disabled="processing">
{{ $t("Login") }}
</button>
<div v-if="res && !res.ok" class="alert alert-danger mt-3" role="alert">
{{ $t(res.msg) }}
</div>
</form>
</div>
</div>
</template>
<script>
export default {
data() {
return {
processing: false,
username: "",
password: "",
token: "",
res: null,
tokenRequired: false,
};
},
mounted() {
document.title += " - Login";
},
unmounted() {
document.title = document.title.replace(" - Login", "");
},
methods: {
/**
* Submit the user details and attempt to log in
* @returns {void}
*/
submit() {
this.processing = true;
this.$root.login(this.username, this.password, this.token, (res) => {
this.processing = false;
if (res.tokenRequired) {
this.tokenRequired = true;
} else {
this.res = res;
}
});
},
},
};
</script>
<style lang="scss" scoped>
.form-container {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
}
.form-floating {
> 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>

View File

@@ -0,0 +1,223 @@
<template>
<div>
<h5>Internal Networks</h5>
<ul class="list-group">
<li v-for="(networkRow, index) in networkList" :key="index" class="list-group-item">
<input v-model="networkRow.key" type="text" class="no-bg domain-input" placeholder="Network name..." />
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" @click="remove(index)" />
</li>
</ul>
<button class="btn btn-normal btn-sm mt-3 me-2" @click="addField">{{ $t("addInternalNetwork") }}</button>
<h5 class="mt-3">External Networks</h5>
<div v-if="externalNetworkList.length === 0">
No External Networks
</div>
<div v-for="(networkName, index) in externalNetworkList" :key="networkName" class="form-check form-switch my-3">
<input :id=" 'external-network' + index" v-model="selectedExternalList[networkName]" class="form-check-input" type="checkbox">
<label class="form-check-label" :for=" 'external-network' +index">
{{ networkName }}
</label>
<span v-if="false" class="text-danger ms-2 delete">Delete</span>
</div>
<div v-if="false" class="input-group mb-3">
<input
placeholder="New external network name..."
class="form-control"
@keyup.enter="createExternelNetwork"
/>
<button class="btn btn-normal btn-sm me-2" type="button" @click="">
{{ $t("createExternalNetwork") }}
</button>
</div>
<div v-if="false">
<button class="btn btn-primary btn-sm mt-3 me-2" @click="applyToYAML">{{ $t("applyToYAML") }}</button>
</div>
</div>
</template>
<script>
export default {
data() {
return {
networkList: [],
externalList: {},
selectedExternalList: {},
externalNetworkList: [],
};
},
computed: {
jsonConfig() {
return this.$parent.$parent.jsonConfig;
},
stack() {
return this.$parent.$parent.stack;
},
editorFocus() {
return this.$parent.$parent.editorFocus;
},
},
watch: {
"jsonConfig.networks": {
handler() {
if (this.editorFocus) {
console.debug("jsonConfig.networks changed");
this.loadNetworkList();
}
},
deep: true,
},
"selectedExternalList": {
handler() {
for (const networkName in this.selectedExternalList) {
const enable = this.selectedExternalList[networkName];
if (enable) {
if (!this.externalList[networkName]) {
this.externalList[networkName] = {};
}
this.externalList[networkName].external = true;
} else {
delete this.externalList[networkName];
}
}
this.applyToYAML();
},
deep: true,
},
"networkList": {
handler() {
this.applyToYAML();
},
deep: true,
}
},
mounted() {
this.loadNetworkList();
this.loadExternalNetworkList();
},
methods: {
loadNetworkList() {
this.networkList = [];
this.externalList = {};
for (const key in this.jsonConfig.networks) {
let obj = {
key: key,
value: this.jsonConfig.networks[key],
};
if (obj.value && obj.value.external) {
this.externalList[key] = Object.assign({}, obj.value);
} else {
this.networkList.push(obj);
}
}
// Restore selectedExternalList
this.selectedExternalList = {};
for (const networkName in this.externalList) {
this.selectedExternalList[networkName] = true;
}
},
loadExternalNetworkList() {
this.$root.getSocket().emit("getDockerNetworkList", (res) => {
if (res.ok) {
this.externalNetworkList = res.dockerNetworkList.filter((n) => {
// Filter out this stack networks
if (n.startsWith(this.stack.name + "_")) {
return false;
}
// They should be not supported.
// https://docs.docker.com/compose/compose-file/06-networks/#host-or-none
if (n === "none" || n === "host" || n === "bridge") {
return false;
}
return true;
});
} else {
this.$root.toastRes(res);
}
});
},
addField() {
this.networkList.push({
key: "",
value: {},
});
},
remove(index) {
this.networkList.splice(index, 1);
this.applyToYAML();
},
applyToYAML() {
if (this.editorFocus) {
return;
}
this.jsonConfig.networks = {};
// Internal networks
for (const networkRow of this.networkList) {
this.jsonConfig.networks[networkRow.key] = networkRow.value;
}
// External networks
for (const networkName in this.externalList) {
this.jsonConfig.networks[networkName] = this.externalList[networkName];
}
console.debug("applyToYAML", this.jsonConfig.networks);
}
},
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.list-group {
background-color: $dark-bg2;
li {
display: flex;
align-items: center;
padding: 10px 0 10px 10px;
.domain-input {
flex-grow: 1;
background-color: $dark-bg2;
border: none;
color: $dark-font-color;
outline: none;
&::placeholder {
color: #1d2634;
}
}
}
}
.delete {
text-decoration: underline;
font-size: 13px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<div class="shadow-box mb-3" :style="boxStyle">
<div class="list-header">
<div class="header-top">
<!-- TODO -->
<button v-if="false" class="btn btn-outline-normal ms-2" :class="{ 'active': selectMode }" type="button" @click="selectMode = !selectMode">
{{ $t("Select") }}
</button>
<div class="placeholder"></div>
<div class="search-wrapper">
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
</a>
<a v-if="searchText != ''" class="search-icon" style="cursor: pointer" @click="clearSearchText">
<font-awesome-icon icon="times" />
</a>
<form>
<input v-model="searchText" class="form-control search-input" autocomplete="off" />
</form>
</div>
</div>
<!-- TODO -->
<div v-if="false" class="header-filter">
<!--<StackListFilter :filterState="filterState" @update-filter="updateFilter" />-->
</div>
<!-- TODO: Selection Controls -->
<div v-if="selectMode && false" class="selection-controls px-2 pt-2">
<input
v-model="selectAll"
class="form-check-input select-input"
type="checkbox"
/>
<button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ $t("Pause") }}</button>
<button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ $t("Resume") }}</button>
<span v-if="selectedStackCount > 0">
{{ $t("selectedStackCount", [ selectedStackCount ]) }}
</span>
</div>
</div>
<div ref="stackList" class="stack-list" :class="{ scrollbar: scrollbar }" :style="stackListStyle">
<div v-if="Object.keys($root.stackList).length === 0" class="text-center mt-3">
<router-link to="/compose">{{ $t("addFirstStackMsg") }}</router-link>
</div>
<StackListItem
v-for="(item, index) in sortedStackList"
:key="index"
:stack="item"
:isSelectMode="selectMode"
:isSelected="isSelected"
:select="select"
:deselect="deselect"
/>
</div>
</div>
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
{{ $t("pauseStackMsg") }}
</Confirm>
</template>
<script>
import Confirm from "../components/Confirm.vue";
import StackListItem from "../components/StackListItem.vue";
import { CREATED_FILE, CREATED_STACK, EXITED, RUNNING, UNKNOWN } from "../../../backend/util-common";
export default {
components: {
Confirm,
StackListItem,
},
props: {
/** Should the scrollbar be shown */
scrollbar: {
type: Boolean,
},
},
data() {
return {
searchText: "",
selectMode: false,
selectAll: false,
disableSelectAllWatcher: false,
selectedStacks: {},
windowTop: 0,
filterState: {
status: null,
active: null,
tags: null,
}
};
},
computed: {
/**
* Improve the sticky appearance of the list by increasing its
* height as user scrolls down.
* Not used on mobile.
* @returns {object} Style for stack list
*/
boxStyle() {
if (window.innerWidth > 550) {
return {
height: `calc(100vh - 160px + ${this.windowTop}px)`,
};
} else {
return {
height: "calc(100vh - 160px)",
};
}
},
/**
* Returns a sorted list of stacks based on the applied filters and search text.
* @returns {Array} The sorted list of stacks.
*/
sortedStackList() {
let result = Object.values(this.$root.stackList);
result = result.filter(stack => {
// filter by search text
// finds stack name, tag name or tag value
let searchTextMatch = true;
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
searchTextMatch =
stack.name.toLowerCase().includes(loweredSearchText)
|| stack.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
}
// filter by active
let activeMatch = true;
if (this.filterState.active != null && this.filterState.active.length > 0) {
activeMatch = this.filterState.active.includes(stack.active);
}
// filter by tags
let tagsMatch = true;
if (this.filterState.tags != null && this.filterState.tags.length > 0) {
tagsMatch = stack.tags.map(tag => tag.tag_id) // convert to array of tag IDs
.filter(stackTagId => this.filterState.tags.includes(stackTagId)) // perform Array Intersaction between filter and stack's tags
.length > 0;
}
return searchTextMatch && activeMatch && tagsMatch;
});
result.sort((m1, m2) => {
if (m1.status !== m2.status) {
if (m2.status === RUNNING) {
return 1;
} else if (m1.status === RUNNING) {
return -1;
} else if (m2.status === EXITED) {
return 1;
} else if (m1.status === EXITED) {
return -1;
} else if (m2.status === CREATED_STACK) {
return 1;
} else if (m1.status === CREATED_STACK) {
return -1;
} else if (m2.status === CREATED_FILE) {
return 1;
} else if (m1.status === CREATED_FILE) {
return -1;
} else if (m2.status === UNKNOWN) {
return 1;
} else if (m1.status === UNKNOWN) {
return -1;
}
}
return m1.name.localeCompare(m2.name);
});
return result;
},
isDarkTheme() {
return document.body.classList.contains("dark");
},
stackListStyle() {
//let listHeaderHeight = 107;
let listHeaderHeight = 60;
if (this.selectMode) {
listHeaderHeight += 42;
}
return {
"height": `calc(100% - ${listHeaderHeight}px)`
};
},
selectedStackCount() {
return Object.keys(this.selectedStacks).length;
},
/**
* Determines if any filters are active.
* @returns {boolean} True if any filter is active, false otherwise.
*/
filtersActive() {
return this.filterState.status != null || this.filterState.active != null || this.filterState.tags != null || this.searchText !== "";
}
},
watch: {
searchText() {
for (let stack of this.sortedStackList) {
if (!this.selectedStacks[stack.id]) {
if (this.selectAll) {
this.disableSelectAllWatcher = true;
this.selectAll = false;
}
break;
}
}
},
selectAll() {
if (!this.disableSelectAllWatcher) {
this.selectedStacks = {};
if (this.selectAll) {
this.sortedStackList.forEach((item) => {
this.selectedStacks[item.id] = true;
});
}
} else {
this.disableSelectAllWatcher = false;
}
},
selectMode() {
if (!this.selectMode) {
this.selectAll = false;
this.selectedStacks = {};
}
},
},
mounted() {
window.addEventListener("scroll", this.onScroll);
},
beforeUnmount() {
window.removeEventListener("scroll", this.onScroll);
},
methods: {
/**
* Handle user scroll
* @returns {void}
*/
onScroll() {
if (window.top.scrollY <= 133) {
this.windowTop = window.top.scrollY;
} else {
this.windowTop = 133;
}
},
/**
* Clear the search bar
* @returns {void}
*/
clearSearchText() {
this.searchText = "";
},
/**
* Update the StackList Filter
* @param {object} newFilter Object with new filter
* @returns {void}
*/
updateFilter(newFilter) {
this.filterState = newFilter;
},
/**
* Deselect a stack
* @param {number} id ID of stack
* @returns {void}
*/
deselect(id) {
delete this.selectedStacks[id];
},
/**
* Select a stack
* @param {number} id ID of stack
* @returns {void}
*/
select(id) {
this.selectedStacks[id] = true;
},
/**
* Determine if stack is selected
* @param {number} id ID of stack
* @returns {bool} Is the stack selected?
*/
isSelected(id) {
return id in this.selectedStacks;
},
/**
* Disable select mode and reset selection
* @returns {void}
*/
cancelSelectMode() {
this.selectMode = false;
this.selectedStacks = {};
},
/**
* Show dialog to confirm pause
* @returns {void}
*/
pauseDialog() {
this.$refs.confirmPause.show();
},
/**
* Pause each selected stack
* @returns {void}
*/
pauseSelected() {
Object.keys(this.selectedStacks)
.filter(id => this.$root.stackList[id].active)
.forEach(id => this.$root.getSocket().emit("pauseStack", id, () => {}));
this.cancelSelectMode();
},
/**
* Resume each selected stack
* @returns {void}
*/
resumeSelected() {
Object.keys(this.selectedStacks)
.filter(id => !this.$root.stackList[id].active)
.forEach(id => this.$root.getSocket().emit("resumeStack", id, () => {}));
this.cancelSelectMode();
},
},
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.shadow-box {
height: calc(100vh - 150px);
position: sticky;
top: 10px;
}
.small-padding {
padding-left: 5px !important;
padding-right: 5px !important;
}
.list-header {
border-bottom: 1px solid #dee2e6;
border-radius: 10px 10px 0 0;
margin: -10px;
margin-bottom: 10px;
padding: 10px;
.dark & {
background-color: $dark-header-bg;
border-bottom: 0;
}
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-filter {
display: flex;
align-items: center;
}
@media (max-width: 770px) {
.list-header {
margin: -20px;
margin-bottom: 10px;
padding: 5px;
}
}
.search-wrapper {
display: flex;
align-items: center;
}
.search-icon {
padding: 10px;
color: #c0c0c0;
// Clear filter button (X)
svg[data-icon="times"] {
cursor: pointer;
transition: all ease-in-out 0.1s;
&:hover {
opacity: 0.5;
}
}
}
.search-input {
max-width: 15em;
}
.stack-item {
width: 100%;
}
.tags {
margin-top: 4px;
padding-left: 67px;
display: flex;
flex-wrap: wrap;
gap: 0;
}
.bottom-style {
padding-left: 67px;
margin-top: 5px;
}
.selection-controls {
margin-top: 5px;
display: flex;
align-items: center;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<router-link :to="`/compose/${stack.name}`" :class="{ 'dim' : !stack.isManagedByDockge }" class="item">
<Uptime :stack="stack" :fixed-width="true" class="me-2" />
<span class="title">{{ stackName }}</span>
</router-link>
</template>
<script>
import Uptime from "./Uptime.vue";
export default {
components: {
Uptime
},
props: {
/** Stack this represents */
stack: {
type: Object,
default: null,
},
/** If the user is in select mode */
isSelectMode: {
type: Boolean,
default: false,
},
/** How many ancestors are above this stack */
depth: {
type: Number,
default: 0,
},
/** Callback to determine if stack is selected */
isSelected: {
type: Function,
default: () => {}
},
/** Callback fired when stack is selected */
select: {
type: Function,
default: () => {}
},
/** Callback fired when stack is deselected */
deselect: {
type: Function,
default: () => {}
},
},
data() {
return {
isCollapsed: true,
};
},
computed: {
depthMargin() {
return {
marginLeft: `${31 * this.depth}px`,
};
},
stackName() {
return this.stack.name;
}
},
watch: {
isSelectMode() {
// TODO: Resize the heartbeat bar, but too slow
// this.$refs.heartbeatBar.resize();
}
},
beforeMount() {
},
methods: {
/**
* Changes the collapsed value of the current stack and saves
* it to local storage
* @returns {void}
*/
changeCollapsed() {
this.isCollapsed = !this.isCollapsed;
// Save collapsed value into local storage
let storage = window.localStorage.getItem("stackCollapsed");
let storageObject = {};
if (storage !== null) {
storageObject = JSON.parse(storage);
}
storageObject[`stack_${this.stack.id}`] = this.isCollapsed;
window.localStorage.setItem("stackCollapsed", JSON.stringify(storageObject));
},
/**
* Toggle selection of stack
* @returns {void}
*/
toggleSelection() {
if (this.isSelected(this.stack.id)) {
this.deselect(this.stack.id);
} else {
this.select(this.stack.id);
}
},
},
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.small-padding {
padding-left: 5px !important;
padding-right: 5px !important;
}
.collapse-padding {
padding-left: 8px !important;
padding-right: 2px !important;
}
// .stack-item {
// width: 100%;
// }
.tags {
margin-top: 4px;
padding-left: 67px;
display: flex;
flex-wrap: wrap;
gap: 0;
}
.collapsed {
transform: rotate(-90deg);
}
.animated {
transition: all 0.2s $easing-in;
}
.select-input-wrapper {
float: left;
margin-top: 15px;
margin-left: 3px;
margin-right: 10px;
padding-left: 4px;
position: relative;
z-index: 15;
}
.dim {
opacity: 0.5;
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div class="shadow-box">
<div v-pre ref="terminal" class="main-terminal"></div>
</div>
</template>
<script>
import { Terminal } from "xterm";
import { WebLinksAddon } from "xterm-addon-web-links";
import { TERMINAL_COLS, TERMINAL_ROWS } from "../../../backend/util-common";
export default {
/**
* @type {Terminal}
*/
terminal: null,
components: {
},
props: {
name: {
type: String,
require: true,
},
// Require if mode is interactive
stackName: {
type: String,
},
// Require if mode is interactive
serviceName: {
type: String,
},
// Require if mode is interactive
shell: {
type: String,
default: "bash",
},
rows: {
type: Number,
default: TERMINAL_ROWS,
},
cols: {
type: Number,
default: TERMINAL_COLS,
},
// Mode
// displayOnly: Only display terminal output
// mainTerminal: Allow input limited commands and output
// interactive: Free input and output
mode: {
type: String,
default: "displayOnly",
}
},
emits: [ "has-data" ],
data() {
return {
first: true,
terminalInputBuffer: "",
cursorPosition: 0,
};
},
created() {
},
mounted() {
let cursorBlink = true;
if (this.mode === "displayOnly") {
cursorBlink = false;
}
this.terminal = new Terminal({
fontSize: 16,
fontFamily: "monospace",
cursorBlink,
cols: this.cols,
rows: this.rows,
});
if (this.mode === "mainTerminal") {
this.mainTerminalConfig();
} else if (this.mode === "interactive") {
this.interactiveTerminalConfig();
}
//this.terminal.loadAddon(new WebLinksAddon());
// Bind to a div
this.terminal.open(this.$refs.terminal);
this.terminal.focus();
// Notify parent component when data is received
this.terminal.onCursorMove(() => {
console.debug("onData triggered");
if (this.first) {
this.$emit("has-data");
this.first = false;
}
});
this.bind();
// Create a new Terminal
if (this.mode === "mainTerminal") {
this.$root.getSocket().emit("mainTerminal", this.name, (res) => {
if (!res.ok) {
this.$root.toastRes(res);
}
});
} else if (this.mode === "interactive") {
console.debug("Create Interactive terminal:", this.name);
this.$root.getSocket().emit("interactiveTerminal", this.stackName, this.serviceName, this.shell, (res) => {
if (!res.ok) {
this.$root.toastRes(res);
}
});
}
},
unmounted() {
this.$root.unbindTerminal(this.name);
this.terminal.dispose();
},
methods: {
bind(name) {
// Workaround: normally this.name should be set, but it is not sometimes, so we use the parameter, but eventually this.name and name must be the same name
if (name) {
this.$root.unbindTerminal(name);
this.$root.bindTerminal(name, this.terminal);
console.debug("Terminal bound via parameter: " + name);
} else if (this.name) {
this.$root.unbindTerminal(this.name);
this.$root.bindTerminal(this.name, this.terminal);
console.debug("Terminal bound: " + this.name);
} else {
console.debug("Terminal name not set");
}
},
removeInput() {
const backspaceCount = this.terminalInputBuffer.length;
const backspaces = "\b \b".repeat(backspaceCount);
this.cursorPosition = 0;
this.terminal.write(backspaces);
this.terminalInputBuffer = "";
},
mainTerminalConfig() {
this.terminal.onKey(e => {
const code = e.key.charCodeAt(0);
console.debug("Encode: " + JSON.stringify(e.key));
if (e.key === "\r") {
// Return if no input
if (this.terminalInputBuffer.length === 0) {
return;
}
const buffer = this.terminalInputBuffer;
// Remove the input from the terminal
this.removeInput();
this.$root.getSocket().emit("terminalInput", this.name, buffer + e.key, (err) => {
this.$root.toastError(err.msg);
});
} else if (code === 127) { // Backspace
if (this.cursorPosition > 0) {
this.terminal.write("\b \b");
this.cursorPosition--;
this.terminalInputBuffer = this.terminalInputBuffer.slice(0, -1);
}
} else if (e.key === "\u001B\u005B\u0041" || e.key === "\u001B\u005B\u0042") { // UP OR DOWN
// Do nothing
} else if (e.key === "\u001B\u005B\u0043") { // RIGHT
// TODO
} else if (e.key === "\u001B\u005B\u0044") { // LEFT
// TODO
} else if (e.key === "\u0003") { // Ctrl + C
console.debug("Ctrl + C");
this.$root.getSocket().emit("terminalInput", this.name, e.key);
this.removeInput();
} else {
this.cursorPosition++;
this.terminalInputBuffer += e.key;
console.log(this.terminalInputBuffer);
this.terminal.write(e.key);
}
});
},
interactiveTerminalConfig() {
this.terminal.onKey(e => {
this.$root.getSocket().emit("terminalInput", this.name, e.key, (res) => {
if (!res.ok) {
this.$root.toastRes(res);
}
});
});
}
}
};
</script>
<style scoped lang="scss">
.main-terminal {
height: 100%;
overflow-x: scroll;
}
</style>
<style lang="scss">
.terminal {
padding: 10px 15px;
height: 100%;
}
</style>

View File

@@ -0,0 +1,203 @@
<template>
<form @submit.prevent="submit">
<div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("Setup 2FA") }}
<span v-if="twoFAStatus == true" class="badge bg-primary">{{ $t("Active") }}</span>
<span v-if="twoFAStatus == false" class="badge bg-primary">{{ $t("Inactive") }}</span>
</h5>
<button :disabled="processing" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<div class="mb-3">
<div v-if="uri && twoFAStatus == false" class="mx-auto text-center" style="width: 210px;">
<vue-qrcode :key="uri" :value="uri" type="image/png" :quality="1" :color="{ light: '#ffffffff' }" />
<button v-show="!showURI" type="button" class="btn btn-outline-primary btn-sm mt-2" @click="showURI = true">{{ $t("Show URI") }}</button>
</div>
<p v-if="showURI && twoFAStatus == false" class="text-break mt-2">{{ uri }}</p>
<div v-if="!(uri && twoFAStatus == false)" class="mb-3">
<label for="current-password" class="form-label">
{{ $t("Current Password") }}
</label>
<input
id="current-password"
v-model="currentPassword"
type="password"
class="form-control"
autocomplete="current-password"
required
/>
</div>
<button v-if="uri == null && twoFAStatus == false" class="btn btn-primary" type="button" @click="prepare2FA()">
{{ $t("Enable 2FA") }}
</button>
<button v-if="twoFAStatus == true" class="btn btn-danger" type="button" :disabled="processing" @click="confirmDisableTwoFA()">
{{ $t("Disable 2FA") }}
</button>
<div v-if="uri && twoFAStatus == false" class="mt-3">
<label for="basic-url" class="form-label">{{ $t("twoFAVerifyLabel") }}</label>
<div class="input-group">
<input v-model="token" type="text" maxlength="6" class="form-control" autocomplete="one-time-code" required>
<button class="btn btn-outline-primary" type="button" @click="verifyToken()">{{ $t("Verify Token") }}</button>
</div>
<p v-show="tokenValid" class="mt-2" style="color: green;">{{ $t("tokenValidSettingsMsg") }}</p>
</div>
</div>
</div>
<div v-if="uri && twoFAStatus == false" class="modal-footer">
<button type="submit" class="btn btn-primary" :disabled="processing || tokenValid == false" @click="confirmEnableTwoFA()">
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
{{ $t("Save") }}
</button>
</div>
</div>
</div>
</div>
</form>
<Confirm ref="confirmEnableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="save2FA">
{{ $t("confirmEnableTwoFAMsg") }}
</Confirm>
<Confirm ref="confirmDisableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="disable2FA">
{{ $t("confirmDisableTwoFAMsg") }}
</Confirm>
</template>
<script lang="ts">
import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue";
import VueQrcode from "vue-qrcode";
import { useToast } from "vue-toastification";
const toast = useToast();
export default {
components: {
Confirm,
VueQrcode,
},
props: {},
data() {
return {
currentPassword: "",
processing: false,
uri: null,
tokenValid: false,
twoFAStatus: null,
token: null,
showURI: false,
};
},
mounted() {
this.modal = new Modal(this.$refs.modal);
this.getStatus();
},
methods: {
/** Show the dialog */
show() {
this.modal.show();
},
/** Show dialog to confirm enabling 2FA */
confirmEnableTwoFA() {
this.$refs.confirmEnableTwoFA.show();
},
/** Show dialog to confirm disabling 2FA */
confirmDisableTwoFA() {
this.$refs.confirmDisableTwoFA.show();
},
/** Prepare 2FA configuration */
prepare2FA() {
this.processing = true;
this.$root.getSocket().emit("prepare2FA", this.currentPassword, (res) => {
this.processing = false;
if (res.ok) {
this.uri = res.uri;
} else {
toast.error(res.msg);
}
});
},
/** Save the current 2FA configuration */
save2FA() {
this.processing = true;
this.$root.getSocket().emit("save2FA", this.currentPassword, (res) => {
this.processing = false;
if (res.ok) {
this.$root.toastRes(res);
this.getStatus();
this.currentPassword = "";
this.modal.hide();
} else {
toast.error(res.msg);
}
});
},
/** Disable 2FA for this user */
disable2FA() {
this.processing = true;
this.$root.getSocket().emit("disable2FA", this.currentPassword, (res) => {
this.processing = false;
if (res.ok) {
this.$root.toastRes(res);
this.getStatus();
this.currentPassword = "";
this.modal.hide();
} else {
toast.error(res.msg);
}
});
},
/** Verify the token generated by the user */
verifyToken() {
this.$root.getSocket().emit("verifyToken", this.token, this.currentPassword, (res) => {
if (res.ok) {
this.tokenValid = res.valid;
} else {
toast.error(res.msg);
}
});
},
/** Get current status of 2FA */
getStatus() {
this.$root.getSocket().emit("twoFAStatus", (res) => {
if (res.ok) {
this.twoFAStatus = res.status;
} else {
toast.error(res.msg);
}
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.dark {
.modal-dialog .form-text, .modal-dialog p {
color: $dark-font-color;
}
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<span :class="className">{{ statusName }}</span>
</template>
<script>
import { statusColor, statusNameShort } from "../../../backend/util-common";
export default {
props: {
stack: {
type: Object,
default: null,
},
fixedWidth: {
type: Boolean,
default: false,
},
},
computed: {
uptime() {
return "0.00%";
return this.$t("notAvailableShort");
},
color() {
return statusColor(this.stack?.status);
},
statusName() {
return this.$t(statusNameShort(this.stack?.status));
},
className() {
let className = `badge rounded-pill bg-${this.color}`;
if (this.fixedWidth) {
className += " fixed-width";
}
return className;
},
},
};
</script>
<style scoped>
.badge {
min-width: 62px;
}
.fixed-width {
width: 62px;
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div class="d-flex justify-content-center align-items-center">
<div class="logo d-flex flex-column justify-content-center align-items-center">
<object class="my-4" width="200" height="200" data="/icon.svg" />
<div class="fs-4 fw-bold">Dockge</div>
<div>{{ $t("Version") }}: {{ $root.info.version }}</div>
<div class="frontend-version">{{ $t("Frontend Version") }}: {{ $root.frontendVersion }}</div>
<div v-if="!$root.isFrontendBackendVersionMatched" class="alert alert-warning mt-4" role="alert">
{{ $t("Frontend Version do not match backend version!") }}
</div>
<div class="my-3 update-link"><a href="https://github.com/louislam/dockge/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a></div>
<div class="mt-1">
<div class="form-check">
<label><input v-model="settings.checkUpdate" type="checkbox" @change="saveSettings()" /> {{ $t("Show update if available") }}</label>
</div>
<div class="form-check">
<label><input v-model="settings.checkBeta" type="checkbox" :disabled="!settings.checkUpdate" @change="saveSettings()" /> {{ $t("Also check beta release") }}</label>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
computed: {
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
},
settingsLoaded() {
return this.$parent.$parent.$parent.settingsLoaded;
},
},
watch: {
}
};
</script>
<style lang="scss" scoped>
.logo {
margin: 4em 1em;
}
.update-link {
font-size: 0.8em;
}
.frontend-version {
font-size: 0.9em;
color: #cccccc;
.dark & {
color: #333333;
}
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div>
<div class="my-4">
<label for="language" class="form-label">
{{ $t("Language") }}
</label>
<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>
</div>
<div v-show="false" class="my-4">
<label for="timezone" class="form-label">{{ $t("Theme") }}</label>
<div>
<div
class="btn-group"
role="group"
aria-label="Basic checkbox toggle button group"
>
<input
id="btncheck1"
v-model="$root.userTheme"
type="radio"
class="btn-check"
name="theme"
autocomplete="off"
value="light"
/>
<label class="btn btn-outline-primary" for="btncheck1">
{{ $t("Light") }}
</label>
<input
id="btncheck2"
v-model="$root.userTheme"
type="radio"
class="btn-check"
name="theme"
autocomplete="off"
value="dark"
/>
<label class="btn btn-outline-primary" for="btncheck2">
{{ $t("Dark") }}
</label>
<input
id="btncheck3"
v-model="$root.userTheme"
type="radio"
class="btn-check"
name="theme"
autocomplete="off"
value="auto"
/>
<label class="btn btn-outline-primary" for="btncheck3">
{{ $t("Auto") }}
</label>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
};
</script>
<style lang="scss" scoped>
@import "../../styles/vars.scss";
.btn-check:active + .btn-outline-primary,
.btn-check:checked + .btn-outline-primary,
.btn-check:hover + .btn-outline-primary {
color: #fff;
.dark & {
color: #000;
}
}
.dark {
.list-group-item {
background-color: $dark-bg2;
color: $dark-font-color;
}
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div>
<form class="my-4" autocomplete="off" @submit.prevent="saveGeneral">
<!-- Client side Timezone -->
<div v-if="false" class="mb-4">
<label for="timezone" class="form-label">
{{ $t("Display Timezone") }}
</label>
<select id="timezone" v-model="$root.userTimezone" class="form-select">
<option value="auto">
{{ $t("Auto") }}: {{ guessTimezone }}
</option>
<option
v-for="(timezone, index) in timezoneList"
:key="index"
:value="timezone.value"
>
{{ timezone.name }}
</option>
</select>
</div>
<!-- Server Timezone -->
<div v-if="false" class="mb-4">
<label for="timezone" class="form-label">
{{ $t("Server Timezone") }}
</label>
<select id="timezone" v-model="settings.serverTimezone" class="form-select">
<option value="UTC">UTC</option>
<option
v-for="(timezone, index) in timezoneList"
:key="index"
:value="timezone.value"
>
{{ timezone.name }}
</option>
</select>
</div>
<!-- Primary Hostname -->
<div class="mb-4">
<label class="form-label" for="primaryBaseURL">
{{ $t("primaryHostname") }}
</label>
<div class="input-group mb-3">
<input
v-model="settings.primaryHostname"
class="form-control"
placeholder="localhost"
/>
<button class="btn btn-outline-primary" type="button" @click="autoGetPrimaryHostname">
{{ $t("Auto Get") }}
</button>
</div>
<div class="form-text"></div>
</div>
<!-- Save Button -->
<div>
<button class="btn btn-primary" type="submit">
{{ $t("Save") }}
</button>
</div>
</form>
</div>
</template>
<script>
import HiddenInput from "../../components/HiddenInput.vue";
import dayjs from "dayjs";
import { timezoneList } from "../../util-frontend";
export default {
components: {
HiddenInput,
},
data() {
return {
timezoneList: timezoneList(),
};
},
computed: {
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
},
settingsLoaded() {
return this.$parent.$parent.$parent.settingsLoaded;
},
guessTimezone() {
return dayjs.tz.guess();
}
},
methods: {
/** Save the settings */
saveGeneral() {
localStorage.timezone = this.$root.userTimezone;
this.saveSettings();
},
/** Get the base URL of the application */
autoGetPrimaryHostname() {
this.settings.primaryHostname = location.hostname;
},
},
};
</script>

View File

@@ -0,0 +1,205 @@
<template>
<div>
<div v-if="settingsLoaded" class="my-4">
<!-- Change Password -->
<template v-if="!settings.disableAuth">
<p>
{{ $t("Current User") }}: <strong>{{ $root.username }}</strong>
<button v-if="! settings.disableAuth" id="logout-btn" class="btn btn-danger ms-4 me-2 mb-2" @click="$root.logout">{{ $t("Logout") }}</button>
</p>
<h5 class="my-4 settings-subheading">{{ $t("Change Password") }}</h5>
<form class="mb-3" @submit.prevent="savePassword">
<div class="mb-3">
<label for="current-password" class="form-label">
{{ $t("Current Password") }}
</label>
<input
id="current-password"
v-model="password.currentPassword"
type="password"
class="form-control"
autocomplete="current-password"
required
/>
</div>
<div class="mb-3">
<label for="new-password" class="form-label">
{{ $t("New Password") }}
</label>
<input
id="new-password"
v-model="password.newPassword"
type="password"
class="form-control"
autocomplete="new-password"
required
/>
</div>
<div class="mb-3">
<label for="repeat-new-password" class="form-label">
{{ $t("Repeat New Password") }}
</label>
<input
id="repeat-new-password"
v-model="password.repeatNewPassword"
type="password"
class="form-control"
:class="{ 'is-invalid': invalidPassword }"
autocomplete="new-password"
required
/>
<div class="invalid-feedback">
{{ $t("passwordNotMatchMsg") }}
</div>
</div>
<div>
<button class="btn btn-primary" type="submit">
{{ $t("Update Password") }}
</button>
</div>
</form>
</template>
<!-- TODO: Hidden for now -->
<div v-if="! settings.disableAuth && false" class="mt-5 mb-3">
<h5 class="my-4 settings-subheading">
{{ $t("Two Factor Authentication") }}
</h5>
<div class="mb-4">
<button
class="btn btn-primary me-2"
type="button"
@click="$refs.TwoFADialog.show()"
>
{{ $t("2FA Settings") }}
</button>
</div>
</div>
<div class="my-4">
<!-- Advanced -->
<h5 class="my-4 settings-subheading">{{ $t("Advanced") }}</h5>
<div class="mb-4">
<button v-if="settings.disableAuth" id="enableAuth-btn" class="btn btn-outline-primary me-2 mb-2" @click="enableAuth">{{ $t("Enable Auth") }}</button>
<button v-if="! settings.disableAuth" id="disableAuth-btn" class="btn btn-primary me-2 mb-2" @click="confirmDisableAuth">{{ $t("Disable Auth") }}</button>
</div>
</div>
</div>
<TwoFADialog ref="TwoFADialog" />
<Confirm ref="confirmDisableAuth" btn-style="btn-danger" :yes-text="$t('I understand, please disable')" :no-text="$t('Leave')" @yes="disableAuth">
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="$t('disableauth.message1')"></p>
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="$t('disableauth.message2')"></p>
<p>{{ $t("Please use this option carefully!") }}</p>
<div class="mb-3">
<label for="current-password2" class="form-label">
{{ $t("Current Password") }}
</label>
<input
id="current-password2"
v-model="password.currentPassword"
type="password"
class="form-control"
required
/>
</div>
</Confirm>
</div>
</template>
<script>
import Confirm from "../../components/Confirm.vue";
import TwoFADialog from "../../components/TwoFADialog.vue";
export default {
components: {
Confirm,
TwoFADialog
},
data() {
return {
invalidPassword: false,
password: {
currentPassword: "",
newPassword: "",
repeatNewPassword: "",
}
};
},
computed: {
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
},
settingsLoaded() {
return this.$parent.$parent.$parent.settingsLoaded;
}
},
watch: {
"password.repeatNewPassword"() {
this.invalidPassword = false;
},
},
methods: {
/** Check new passwords match before saving them */
savePassword() {
if (this.password.newPassword !== this.password.repeatNewPassword) {
this.invalidPassword = true;
} else {
this.$root
.getSocket()
.emit("changePassword", this.password, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.password.currentPassword = "";
this.password.newPassword = "";
this.password.repeatNewPassword = "";
}
});
}
},
/** Disable authentication for web app access */
disableAuth() {
this.settings.disableAuth = true;
// Need current password to disable auth
// Set it to empty if done
this.saveSettings(() => {
this.password.currentPassword = "";
this.$root.username = null;
this.$root.socketIO.token = "autoLogin";
}, this.password.currentPassword);
},
/** Enable authentication for web app access */
enableAuth() {
this.settings.disableAuth = false;
this.saveSettings();
this.$root.storage().removeItem("token");
location.reload();
},
/** Show confirmation dialog for disable auth */
confirmDisableAuth() {
this.$refs.confirmDisableAuth.show();
},
},
};
</script>