mirror of
https://github.com/louislam/dockge.git
synced 2026-05-21 14:02:17 +00:00
Init (#1)
This commit is contained in:
117
frontend/src/components/ArrayInput.vue
Normal file
117
frontend/src/components/ArrayInput.vue
Normal 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>
|
||||
125
frontend/src/components/ArraySelect.vue
Normal file
125
frontend/src/components/ArraySelect.vue
Normal 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>
|
||||
84
frontend/src/components/Confirm.vue
Normal file
84
frontend/src/components/Confirm.vue
Normal 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>
|
||||
273
frontend/src/components/Container.vue
Normal file
273
frontend/src/components/Container.vue
Normal 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>
|
||||
87
frontend/src/components/HiddenInput.vue
Normal file
87
frontend/src/components/HiddenInput.vue
Normal 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>
|
||||
114
frontend/src/components/Login.vue
Normal file
114
frontend/src/components/Login.vue
Normal 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>
|
||||
223
frontend/src/components/NetworkInput.vue
Normal file
223
frontend/src/components/NetworkInput.vue
Normal 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>
|
||||
438
frontend/src/components/StackList.vue
Normal file
438
frontend/src/components/StackList.vue
Normal 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>
|
||||
154
frontend/src/components/StackListItem.vue
Normal file
154
frontend/src/components/StackListItem.vue
Normal 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>
|
||||
228
frontend/src/components/Terminal.vue
Normal file
228
frontend/src/components/Terminal.vue
Normal 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>
|
||||
203
frontend/src/components/TwoFADialog.vue
Normal file
203
frontend/src/components/TwoFADialog.vue
Normal 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>
|
||||
54
frontend/src/components/Uptime.vue
Normal file
54
frontend/src/components/Uptime.vue
Normal 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>
|
||||
66
frontend/src/components/settings/About.vue
Normal file
66
frontend/src/components/settings/About.vue
Normal 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>
|
||||
94
frontend/src/components/settings/Appearance.vue
Normal file
94
frontend/src/components/settings/Appearance.vue
Normal 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>
|
||||
114
frontend/src/components/settings/General.vue
Normal file
114
frontend/src/components/settings/General.vue
Normal 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>
|
||||
|
||||
205
frontend/src/components/settings/Security.vue
Normal file
205
frontend/src/components/settings/Security.vue
Normal 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>
|
||||
Reference in New Issue
Block a user