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

30
frontend/components.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
About: typeof import('./src/components/settings/About.vue')['default']
Appearance: typeof import('./src/components/settings/Appearance.vue')['default']
ArrayInput: typeof import('./src/components/ArrayInput.vue')['default']
ArraySelect: typeof import('./src/components/ArraySelect.vue')['default']
BModal: typeof import('bootstrap-vue-next')['BModal']
Confirm: typeof import('./src/components/Confirm.vue')['default']
Container: typeof import('./src/components/Container.vue')['default']
General: typeof import('./src/components/settings/General.vue')['default']
HiddenInput: typeof import('./src/components/HiddenInput.vue')['default']
Login: typeof import('./src/components/Login.vue')['default']
NetworkInput: typeof import('./src/components/NetworkInput.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Security: typeof import('./src/components/settings/Security.vue')['default']
StackList: typeof import('./src/components/StackList.vue')['default']
StackListItem: typeof import('./src/components/StackListItem.vue')['default']
Terminal: typeof import('./src/components/Terminal.vue')['default']
TwoFADialog: typeof import('./src/components/TwoFADialog.vue')['default']
Uptime: typeof import('./src/components/Uptime.vue')['default']
}
}

33
frontend/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" id="theme-color" content="" />
<meta name="description" content="" />
<title>Dockge</title>
<style>
.noscript-message {
font-size: 20px;
text-align: center;
padding: 10px;
max-width: 500px;
margin: 0 auto;
}
</style>
</head>
<body>
<noscript>
<div class="noscript-message">
Sorry, you don't seem to have JavaScript enabled or your browser
doesn't support it.<br />This website requires JavaScript to function.
Please enable JavaScript in your browser settings to continue.
</div>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

14
frontend/public/icon.svg Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="640" height="640" viewBox="0 0 640 640" xml:space="preserve">
<desc>Created with Fabric.js 5.3.0</desc>
<defs>
</defs>
<g transform="matrix(0.9544918218 0 0 0.9544918218 320 325.5657767239)" id="0UAuLmXgnot4bJxVEVJCQ" >
<linearGradient id="SVGID_136_0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1 0 0 1 -236.6470440833 -213.9441386034)" x1="259.78" y1="261.15" x2="463.85" y2="456.49">
<stop offset="0%" style="stop-color:#74C2FF;stop-opacity: 1"/>
<stop offset="100%" style="stop-color:rgb(134,230,169);stop-opacity: 1"/>
</linearGradient>
<path style="stroke: rgb(242,242,242); stroke-opacity: 0.51; stroke-width: 190; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: url(#SVGID_136_0); fill-rule: nonzero; opacity: 1;" transform=" translate(0, 0)" d="M 131.8665 -139.04883 C 159.01022 -111.20969000000001 170.12421 -99.45396000000001 203.11849999999998 -51.72057000000001 C 236.1128 -3.9871800000000093 264.44147999999996 83.98416999999998 187.33995 144.05073 C 177.72728999999998 151.53955 166.73827 158.81189999999998 154.65932999999998 165.65812999999997 C 69.85514999999998 213.72433999999998 -68.67309000000003 240.78578 -161.79279 174.28328999999997 C -268.17583 98.30862999999997 -260.10282 -68.66557000000003 -144.35093 -170.50579000000005 C -28.599040000000002 -272.34602000000007 104.72278 -166.88797000000005 131.86649999999997 -139.04883000000004 z" stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "Dockge",
"short_name": "Dockge",
"start_url": "/",
"background_color": "#fff",
"display": "standalone",
"icons": [
{
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

9
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script>
export default {
};
</script>

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>

36
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,36 @@
// @ts-ignore Performance issue when using "vue-i18n", so we use "vue-i18n/dist/vue-i18n.esm-browser.prod.js", but typescript doesn't like that.
import { createI18n } from "vue-i18n/dist/vue-i18n.esm-browser.prod.js";
import en from "./lang/en.json";
const languageList = {
};
let messages = {
en,
};
for (let lang in languageList) {
messages[lang] = {
languageName: languageList[lang]
};
}
const rtlLangs = [ "fa", "ar-SY", "ur" ];
export const currentLocale = () => localStorage.locale
|| languageList[navigator.language] && navigator.language
|| languageList[navigator.language.substring(0, 2)] && navigator.language.substring(0, 2)
|| "en";
export const localeDirection = () => {
return rtlLangs.includes(currentLocale()) ? "rtl" : "ltr";
};
export const i18n = createI18n({
locale: currentLocale(),
fallbackLocale: "en",
silentFallbackWarn: true,
silentTranslationWarn: true,
messages: messages,
});

115
frontend/src/icon.ts Normal file
View File

@@ -0,0 +1,115 @@
import { library } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
// Add Free Font Awesome Icons
// https://fontawesome.com/v6/icons?d=gallery&p=2&s=solid&m=free
// In order to add an icon, you have to:
// 1) add the icon name in the import statement below;
// 2) add the icon name to the library.add() statement below.
import {
faArrowAltCircleUp,
faCog,
faEdit,
faEye,
faEyeSlash,
faList,
faPause,
faStop,
faPlay,
faPlus,
faSearch,
faTachometerAlt,
faTimes,
faTimesCircle,
faTrash,
faCheckCircle,
faStream,
faSave,
faExclamationCircle,
faBullhorn,
faArrowsAltV,
faUnlink,
faQuestionCircle,
faImages,
faUpload,
faCopy,
faCheck,
faFile,
faAward,
faLink,
faChevronDown,
faSignOutAlt,
faPen,
faExternalLinkSquareAlt,
faSpinner,
faUndo,
faPlusCircle,
faAngleDown,
faWrench,
faHeartbeat,
faFilter,
faInfoCircle,
faClone,
faCertificate,
faTerminal, faWarehouse, faHome, faRocket,
faRotate,
faCloudArrowDown, faArrowsRotate,
} from "@fortawesome/free-solid-svg-icons";
library.add(
faArrowAltCircleUp,
faCog,
faEdit,
faEye,
faEyeSlash,
faList,
faPause,
faStop,
faPlay,
faPlus,
faSearch,
faTachometerAlt,
faTimes,
faTimesCircle,
faTrash,
faCheckCircle,
faStream,
faSave,
faExclamationCircle,
faBullhorn,
faArrowsAltV,
faUnlink,
faQuestionCircle,
faImages,
faUpload,
faCopy,
faCheck,
faFile,
faAward,
faLink,
faChevronDown,
faSignOutAlt,
faPen,
faExternalLinkSquareAlt,
faSpinner,
faUndo,
faPlusCircle,
faAngleDown,
faLink,
faWrench,
faHeartbeat,
faFilter,
faInfoCircle,
faClone,
faCertificate,
faTerminal,
faWarehouse,
faHome,
faRocket,
faRotate,
faCloudArrowDown,
faArrowsRotate,
);
export { FontAwesomeIcon };

53
frontend/src/lang/en.json Normal file
View File

@@ -0,0 +1,53 @@
{
"languageName": "English",
"authIncorrectCreds": "Incorrect username or password.",
"PasswordsDoNotMatch": "Passwords do not match.",
"signedInDisp": "Signed in as {0}",
"signedInDispDisabled": "Auth Disabled.",
"home": "Home",
"console": "Console",
"registry": "Registry",
"compose": "Compose",
"addFirstStackMsg": "Compose your first stack!",
"stackName" : "Stack Name",
"deployStack": "Deploy",
"deleteStack": "Delete",
"stopStack": "Stop",
"restartStack": "Restart",
"updateStack": "Update",
"startStack": "Start",
"editStack": "Edit",
"discardStack": "Discard",
"saveStackDraft": "Save",
"notAvailableShort" : "N/A",
"deleteStackMsg": "Are you sure you want to delete this stack?",
"stackNotManagedByDockgeMsg": "This stack is not managed by Dockge.",
"primaryHostname": "Primary Hostname",
"general": "General",
"container": "Container | Containers",
"scanFolder": "Scan Stacks Folder",
"dockerImage": "Image",
"restartPolicyUnlessStopped": "Unless Stopped",
"restartPolicyAlways": "Always",
"restartPolicyOnFailure": "On Failure",
"restartPolicyNo": "No",
"environmentVariable": "Environment Variable | Environment Variables",
"restartPolicy": "Restart Policy",
"containerName": "Container Name",
"port": "Port | Ports",
"volume": "Volume | Volumes",
"network": "Network | Networks",
"dependsOn": "Container Dependency | Container Dependencies",
"addListItem": "Add {0}",
"deleteContainer": "Delete",
"addContainer": "Add Container",
"addNetwork": "Add Network",
"disableauth.message1": "Are you sure want to <strong>disable authentication</strong>?",
"disableauth.message2": "It is designed for scenarios <strong>where you intend to implement third-party authentication</strong> in front of Uptime Kuma such as Cloudflare Access, Authelia or other authentication mechanisms.",
"passwordNotMatchMsg": "The repeat password does not match.",
"autoGet": "Auto Get",
"add": "Add",
"applyToYAML": "Apply to YAML",
"createExternalNetwork": "Create",
"addInternalNetwork": "Add"
}

View File

@@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<script>
export default {};
</script>

View File

@@ -0,0 +1,304 @@
<template>
<div :class="classes">
<div v-if="! $root.socketIO.connected && ! $root.socketIO.firstConnect" class="lost-connection">
<div class="container-fluid">
{{ $root.socketIO.connectionErrorMsg }}
</div>
</div>
<!-- Desktop header -->
<header v-if="! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom">
<router-link to="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
<object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title">Dockge</span>
</router-link>
<a v-if="hasNewVersion" target="_blank" href="https://github.com/louislam/dockge/releases" class="btn btn-info me-3">
<font-awesome-icon icon="arrow-alt-circle-up" /> {{ $t("New Update") }}
</a>
<ul class="nav nav-pills">
<li v-if="$root.loggedIn" class="nav-item me-2">
<router-link to="/" class="nav-link">
<font-awesome-icon icon="home" /> {{ $t("home") }}
</router-link>
</li>
<li v-if="$root.loggedIn" class="nav-item me-2">
<router-link to="/console" class="nav-link">
<font-awesome-icon icon="terminal" /> {{ $t("console") }}
</router-link>
</li>
<li v-if="$root.loggedIn" class="nav-item">
<div class="dropdown dropdown-profile-pic">
<div class="nav-link" data-bs-toggle="dropdown">
<div class="profile-pic">{{ $root.usernameFirstChar }}</div>
<font-awesome-icon icon="angle-down" />
</div>
<!-- Header's Dropdown Menu -->
<ul class="dropdown-menu">
<!-- Username -->
<li>
<i18n-t v-if="$root.username != null" tag="span" keypath="signedInDisp" class="dropdown-item-text">
<strong>{{ $root.username }}</strong>
</i18n-t>
<span v-if="$root.username == null" class="dropdown-item-text">{{ $t("signedInDispDisabled") }}</span>
</li>
<li><hr class="dropdown-divider"></li>
<!-- Functions -->
<!--<li>
<router-link to="/registry" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<font-awesome-icon icon="warehouse" /> {{ $t("registry") }}
</router-link>
</li>-->
<li>
<button class="dropdown-item" @click="scanFolder">
<font-awesome-icon icon="arrows-rotate" /> {{ $t("scanFolder") }}
</button>
</li>
<li>
<router-link to="/settings/general" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<font-awesome-icon icon="cog" /> {{ $t("Settings") }}
</router-link>
</li>
<li>
<button class="dropdown-item" @click="$root.logout">
<font-awesome-icon icon="sign-out-alt" />
{{ $t("Logout") }}
</button>
</li>
</ul>
</div>
</li>
</ul>
</header>
<main>
<router-view v-if="$root.loggedIn" />
<Login v-if="! $root.loggedIn && $root.allowLoginDialog" />
</main>
</div>
</template>
<script>
import Login from "../components/Login.vue";
import { compareVersions } from "compare-versions";
export default {
components: {
Login,
},
data() {
return {
};
},
computed: {
// Theme or Mobile
classes() {
const classes = {};
classes[this.$root.theme] = true;
classes["mobile"] = this.$root.isMobile;
return classes;
},
hasNewVersion() {
if (this.$root.info.latestVersion && this.$root.info.version) {
return compareVersions(this.$root.info.latestVersion, this.$root.info.version) >= 1;
} else {
return false;
}
},
},
watch: {
},
mounted() {
},
beforeUnmount() {
},
methods: {
scanFolder() {
this.$root.getSocket().emit("requestStackList", (res) => {
this.$root.toastRes(res);
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.nav-link {
&.status-page {
background-color: rgba(255, 255, 255, 0.1);
}
}
.bottom-nav {
z-index: 1000;
position: fixed;
bottom: 0;
height: calc(60px + env(safe-area-inset-bottom));
width: 100%;
left: 0;
background-color: #fff;
box-shadow: 0 15px 47px 0 rgba(0, 0, 0, 0.05), 0 5px 14px 0 rgba(0, 0, 0, 0.05);
text-align: center;
white-space: nowrap;
padding: 0 10px env(safe-area-inset-bottom);
a {
text-align: center;
width: 25%;
display: inline-block;
height: 100%;
padding: 8px 10px 0;
font-size: 13px;
color: #c1c1c1;
overflow: hidden;
text-decoration: none;
&.router-link-exact-active, &.active {
color: $primary;
font-weight: bold;
}
div {
font-size: 20px;
}
}
}
main {
min-height: calc(100vh - 160px);
}
.title {
font-weight: bold;
}
.nav {
margin-right: 25px;
}
.lost-connection {
padding: 5px;
background-color: crimson;
color: white;
position: fixed;
width: 100%;
z-index: 99999;
}
// Profile Pic Button with Dropdown
.dropdown-profile-pic {
user-select: none;
.nav-link {
cursor: pointer;
display: flex;
gap: 6px;
align-items: center;
background-color: rgba(200, 200, 200, 0.2);
padding: 0.5rem 0.8rem;
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
.dropdown-menu {
transition: all 0.2s;
padding-left: 0;
padding-bottom: 0;
margin-top: 8px !important;
border-radius: 16px;
overflow: hidden;
.dropdown-divider {
margin: 0;
border-top: 1px solid rgba(0, 0, 0, 0.4);
background-color: transparent;
}
.dropdown-item-text {
font-size: 14px;
padding-bottom: 0.7rem;
}
.dropdown-item {
padding: 0.7rem 1rem;
}
.dark & {
background-color: $dark-bg;
color: $dark-font-color;
border-color: $dark-border-color;
.dropdown-item {
color: $dark-font-color;
&.active {
color: $dark-font-color2;
background-color: $highlight !important;
}
&:hover {
background-color: $dark-bg2;
}
}
}
}
.profile-pic {
display: flex;
align-items: center;
justify-content: center;
color: white;
background-color: $primary;
width: 24px;
height: 24px;
margin-right: 5px;
border-radius: 50rem;
font-weight: bold;
font-size: 10px;
}
}
.dark {
header {
background-color: $dark-header-bg;
border-bottom-color: $dark-header-bg !important;
span {
color: #f0f6fc;
}
}
.bottom-nav {
background-color: $dark-bg;
}
}
</style>

101
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,101 @@
// Dayjs init inside this, so it has to be the first import
import "../../backend/util-common";
import { createApp, defineComponent, h } from "vue";
import App from "./App.vue";
import { router } from "./router";
import { FontAwesomeIcon } from "./icon.js";
import { i18n } from "./i18n";
// Dependencies
import "bootstrap";
import Toast, { POSITION, useToast } from "vue-toastification";
import "xterm/lib/xterm.js";
// CSS
import "vue-toastification/dist/index.css";
import "xterm/css/xterm.css";
import "./styles/main.scss";
// Minxins
import socket from "./mixins/socket";
import lang from "./mixins/lang";
import theme from "./mixins/theme";
const app = createApp(rootApp());
app.use(Toast, {
position: POSITION.BOTTOM_RIGHT,
showCloseButtonOnHover: true,
});
app.use(router);
app.use(i18n);
app.component("FontAwesomeIcon", FontAwesomeIcon);
app.mount("#app");
/**
* Root Vue component
*/
function rootApp() {
const toast = useToast();
return defineComponent({
mixins: [
socket,
lang,
theme,
],
data() {
return {
loggedIn: false,
allowLoginDialog: false,
username: null,
};
},
computed: {
},
methods: {
/**
* Show success or error toast dependant on response status code
* @param {object} res Response object
* @returns {void}
*/
toastRes(res) {
let msg = res.msg;
if (res.msgi18n) {
if (msg != null && typeof msg === "object") {
msg = this.$t(msg.key, msg.values);
} else {
msg = this.$t(msg);
}
}
if (res.ok) {
toast.success(msg);
} else {
toast.error(msg);
}
},
/**
* Show a success toast
* @param {string} msg Message to show
* @returns {void}
*/
toastSuccess(msg : string) {
toast.success(this.$t(msg));
},
/**
* Show an error toast
* @param {string} msg Message to show
* @returns {void}
*/
toastError(msg : string) {
toast.error(this.$t(msg));
},
},
render: () => h(App),
});
}

View File

@@ -0,0 +1,39 @@
import { currentLocale } from "../i18n";
import { setPageLocale } from "../util-frontend";
import { defineComponent } from "vue";
const langModules = import.meta.glob("../lang/*.json");
export default defineComponent({
data() {
return {
language: currentLocale(),
};
},
watch: {
async language(lang) {
await this.changeLang(lang);
},
},
async created() {
if (this.language !== "en") {
await this.changeLang(this.language);
}
},
methods: {
/**
* Change the application language
* @param {string} lang Language code to switch to
* @returns {Promise<void>}
*/
async changeLang(lang : string) {
const message = (await langModules["../lang/" + lang + ".json"]()).default;
this.$i18n.setLocaleMessage(lang, message);
this.$i18n.locale = lang;
localStorage.locale = lang;
setPageLocale();
}
}
});

View File

@@ -0,0 +1,317 @@
import { io } from "socket.io-client";
import { Socket } from "socket.io-client";
import { defineComponent } from "vue";
import jwtDecode from "jwt-decode";
import { Terminal } from "xterm";
let socket : Socket;
let terminalMap : Map<string, Terminal> = new Map();
export default defineComponent({
data() {
return {
socketIO: {
token: null,
firstConnect: true,
connected: false,
connectCount: 0,
initedSocketIO: false,
connectionErrorMsg: `${this.$t("Cannot connect to the socket server.")} ${this.$t("Reconnecting...")}`,
showReverseProxyGuide: true,
},
info: {
},
remember: (localStorage.remember !== "0"),
loggedIn: false,
allowLoginDialog: false,
username: null,
stackList: {},
composeTemplate: "",
};
},
computed: {
usernameFirstChar() {
if (typeof this.username == "string" && this.username.length >= 1) {
return this.username.charAt(0).toUpperCase();
} else {
return "🐻";
}
},
/**
* Frontend Version
* It should be compiled to a static value while building the frontend.
* Please see ./frontend/vite.config.ts, it is defined via vite.js
* @returns {string}
*/
frontendVersion() {
// eslint-disable-next-line no-undef
return FRONTEND_VERSION;
},
/**
* Are both frontend and backend in the same version?
* @returns {boolean}
*/
isFrontendBackendVersionMatched() {
if (!this.info.version) {
return true;
}
return this.info.version === this.frontendVersion;
},
},
watch: {
remember() {
localStorage.remember = (this.remember) ? "1" : "0";
},
// Reload the SPA if the server version is changed.
"info.version"(to, from) {
if (from && from !== to) {
window.location.reload();
}
},
},
created() {
this.initSocketIO();
},
mounted() {
return;
},
methods: {
/**
* Initialize connection to socket server
* @param bypass Should the check for if we
* are on a status page be bypassed?
*/
initSocketIO(bypass = false) {
// No need to re-init
if (this.socketIO.initedSocketIO) {
return;
}
this.socketIO.initedSocketIO = true;
let url : string;
const env = process.env.NODE_ENV || "production";
if (env === "development" || localStorage.dev === "dev") {
url = location.protocol + "//" + location.hostname + ":5001";
} else {
url = location.protocol + "//" + location.host;
}
socket = io(url, {
transports: [ "websocket", "polling" ]
});
socket.on("connect", () => {
console.log("Connected to the socket server");
this.socketIO.connectCount++;
this.socketIO.connected = true;
this.socketIO.showReverseProxyGuide = false;
const token = this.storage().token;
if (token) {
if (token !== "autoLogin") {
console.log("Logging in by token");
this.loginByToken(token);
} else {
// Timeout if it is not actually auto login
setTimeout(() => {
if (! this.loggedIn) {
this.allowLoginDialog = true;
this.storage().removeItem("token");
}
}, 5000);
}
} else {
this.allowLoginDialog = true;
}
this.socketIO.firstConnect = false;
});
socket.on("disconnect", () => {
console.log("disconnect");
this.socketIO.connectionErrorMsg = "Lost connection to the socket server. Reconnecting...";
this.socketIO.connected = false;
});
socket.on("connect_error", (err) => {
console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
this.socketIO.connectionErrorMsg = `${this.$t("Cannot connect to the socket server.")} [${err}] ${this.$t("Reconnecting...")}`;
this.socketIO.showReverseProxyGuide = true;
this.socketIO.connected = false;
this.socketIO.firstConnect = false;
});
// Custom Events
socket.on("info", (info) => {
this.info = info;
});
socket.on("autoLogin", () => {
this.loggedIn = true;
this.storage().token = "autoLogin";
this.socketIO.token = "autoLogin";
this.allowLoginDialog = false;
this.afterLogin();
});
socket.on("setup", () => {
console.log("setup");
this.$router.push("/setup");
});
socket.on("terminalWrite", (terminalName, data) => {
const terminal = terminalMap.get(terminalName);
if (!terminal) {
//console.error("Terminal not found: " + terminalName);
return;
}
terminal.write(data);
});
socket.on("stackList", (res) => {
if (res.ok) {
this.stackList = res.stackList;
}
});
socket.on("stackStatusList", (res) => {
if (res.ok) {
for (let stackName in res.stackStatusList) {
const stackObj = this.stackList[stackName];
if (stackObj) {
stackObj.status = res.stackStatusList[stackName];
}
}
}
});
},
/**
* The storage currently in use
* @returns Current storage
*/
storage() : Storage {
return (this.remember) ? localStorage : sessionStorage;
},
getSocket() : Socket {
return socket;
},
/**
* Get payload of JWT cookie
* @returns {(object | undefined)} JWT payload
*/
getJWTPayload() {
const jwtToken = this.storage().token;
if (jwtToken && jwtToken !== "autoLogin") {
return jwtDecode(jwtToken);
}
return undefined;
},
/**
* Send request to log user in
* @param {string} username Username to log in with
* @param {string} password Password to log in with
* @param {string} token User token
* @param {loginCB} callback Callback to call with result
* @returns {void}
*/
login(username : string, password : string, token : string, callback) {
this.getSocket().emit("login", {
username,
password,
token,
}, (res) => {
if (res.tokenRequired) {
callback(res);
}
if (res.ok) {
this.storage().token = res.token;
this.socketIO.token = res.token;
this.loggedIn = true;
this.username = this.getJWTPayload()?.username;
this.afterLogin();
// Trigger Chrome Save Password
history.pushState({}, "");
}
callback(res);
});
},
/**
* Log in using a token
* @param {string} token Token to log in with
* @returns {void}
*/
loginByToken(token : string) {
socket.emit("loginByToken", token, (res) => {
this.allowLoginDialog = true;
if (! res.ok) {
this.logout();
} else {
this.loggedIn = true;
this.username = this.getJWTPayload()?.username;
this.afterLogin();
}
});
},
/**
* Log out of the web application
* @returns {void}
*/
logout() {
socket.emit("logout", () => { });
this.storage().removeItem("token");
this.socketIO.token = null;
this.loggedIn = false;
this.username = null;
this.clearData();
},
/**
* @returns {void}
*/
clearData() {
},
afterLogin() {
},
bindTerminal(terminalName : string, terminal : Terminal) {
// Load terminal, get terminal screen
socket.emit("terminalJoin", terminalName, (res) => {
if (res.ok) {
terminal.write(res.buffer);
terminalMap.set(terminalName, terminal);
} else {
this.toastRes(res);
}
});
},
unbindTerminal(terminalName : string) {
terminalMap.delete(terminalName);
},
}
});

View File

@@ -0,0 +1,80 @@
import { defineComponent } from "vue";
export default defineComponent({
data() {
return {
system: (window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light",
userTheme: localStorage.theme,
statusPageTheme: "light",
forceStatusPageTheme: false,
path: "",
};
},
computed: {
theme() {
if (this.userTheme === "auto") {
return this.system;
}
return this.userTheme;
},
isDark() {
return this.theme === "dark";
}
},
watch: {
"$route.fullPath"(path) {
this.path = path;
},
userTheme(to, from) {
localStorage.theme = to;
},
styleElapsedTime(to, from) {
localStorage.styleElapsedTime = to;
},
theme(to, from) {
document.body.classList.remove(from);
document.body.classList.add(this.theme);
this.updateThemeColorMeta();
},
userHeartbeatBar(to, from) {
localStorage.heartbeatBarTheme = to;
},
heartbeatBarTheme(to, from) {
document.body.classList.remove(from);
document.body.classList.add(this.heartbeatBarTheme);
}
},
mounted() {
// Default Dark
if (! this.userTheme) {
this.userTheme = "dark";
}
document.body.classList.add(this.theme);
this.updateThemeColorMeta();
},
methods: {
/**
* Update the theme color meta tag
* @returns {void}
*/
updateThemeColorMeta() {
if (this.theme === "dark") {
document.querySelector("#theme-color").setAttribute("content", "#161B22");
} else {
document.querySelector("#theme-color").setAttribute("content", "#5cdd8b");
}
}
}
});

View File

@@ -0,0 +1,599 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 v-if="isAdd" class="mb-3">Compose</h1>
<h1 v-else class="mb-3"><Uptime :stack="globalStack" :pill="true" /> {{ stack.name }}</h1>
<div v-if="stack.isManagedByDockge" class="mb-3">
<div class="btn-group me-2" role="group">
<button v-if="isEditMode" class="btn btn-primary" :disabled="processing" @click="deployStack">
<font-awesome-icon icon="rocket" class="me-1" />
{{ $t("deployStack") }}
</button>
<button v-if="isEditMode" class="btn btn-normal" :disabled="processing" @click="saveStack">
<font-awesome-icon icon="save" class="me-1" />
{{ $t("saveStackDraft") }}
</button>
<button v-if="!isEditMode" class="btn btn-secondary" :disabled="processing" @click="enableEditMode">
<font-awesome-icon icon="pen" class="me-1" />
{{ $t("editStack") }}
</button>
<button v-if="!isEditMode && !active" class="btn btn-primary" :disabled="processing" @click="startStack">
<font-awesome-icon icon="play" class="me-1" />
{{ $t("startStack") }}
</button>
<button v-if="!isEditMode && active" class="btn btn-normal " :disabled="processing" @click="restartStack">
<font-awesome-icon icon="rotate" class="me-1" />
{{ $t("restartStack") }}
</button>
<button v-if="!isEditMode" class="btn btn-normal" :disabled="processing" @click="updateStack">
<font-awesome-icon icon="cloud-arrow-down" class="me-1" />
{{ $t("updateStack") }}
</button>
<button v-if="!isEditMode && active" class="btn btn-normal" :disabled="processing" @click="stopStack">
<font-awesome-icon icon="stop" class="me-1" />
{{ $t("stopStack") }}
</button>
</div>
<button v-if="isEditMode && !isAdd" class="btn btn-normal" :disabled="processing" @click="discardStack">{{ $t("discardStack") }}</button>
<button v-if="!isEditMode" class="btn btn-danger" :disabled="processing" @click="showDeleteDialog = !showDeleteDialog">
<font-awesome-icon icon="trash" class="me-1" />
{{ $t("deleteStack") }}
</button>
</div>
<!-- Progress Terminal -->
<transition name="slide-fade" appear>
<Terminal
v-show="showProgressTerminal"
ref="progressTerminal"
class="mb-3 terminal"
:name="terminalName"
:rows="progressTerminalRows"
@has-data="showProgressTerminal = true; submitted = true;"
></Terminal>
</transition>
<div v-if="stack.isManagedByDockge" class="row">
<div class="col-lg-6">
<!-- General -->
<div v-if="isAdd">
<h4 class="mb-3">{{ $t("general") }}</h4>
<div class="shadow-box big-padding mb-3">
<!-- Stack Name -->
<div class="mb-3">
<label for="name" class="form-label">{{ $t("stackName") }}</label>
<input id="name" v-model="stack.name" type="text" class="form-control" required>
</div>
</div>
</div>
<!-- Containers -->
<h4 class="mb-3">{{ $tc("container", 2) }}</h4>
<div v-if="isEditMode" class="input-group mb-3">
<input
v-model="newContainerName"
placeholder="New Container Name..."
class="form-control"
@keyup.enter="addContainer"
/>
<button class="btn btn-primary" @click="addContainer">
{{ $t("addContainer") }}
</button>
</div>
<div ref="containerList">
<Container
v-for="(service, name) in jsonConfig.services"
:key="name"
:name="name"
:is-edit-mode="isEditMode"
:first="name === Object.keys(jsonConfig.services)[0]"
:status="serviceStatusList[name]"
/>
</div>
<button v-if="false && isEditMode && jsonConfig.services && Object.keys(jsonConfig.services).length > 0" class="btn btn-normal mb-3" @click="addContainer">{{ $t("addContainer") }}</button>
<!-- Combined Terminal Output -->
<div v-show="!isEditMode">
<h4 class="mb-3">Terminal</h4>
<Terminal
ref="combinedTerminal"
class="mb-3 terminal"
:name="combinedTerminalName"
:rows="combinedTerminalRows"
:cols="combinedTerminalCols"
style="height: 350px;"
></Terminal>
</div>
</div>
<div class="col-lg-6">
<h4 class="mb-3">compose.yaml</h4>
<!-- YAML editor -->
<div class="shadow-box mb-3 editor-box" :class="{'edit-mode' : isEditMode}">
<prism-editor
ref="editor"
v-model="stack.composeYAML"
class="yaml-editor"
:highlight="highlighter"
line-numbers :readonly="!isEditMode"
@input="yamlCodeChange"
@focus="editorFocus = true"
@blur="editorFocus = false"
></prism-editor>
</div>
<div v-if="isEditMode" class="mb-3">
{{ yamlError }}
</div>
<div v-if="isEditMode">
<!-- Volumes -->
<div v-if="false">
<h4 class="mb-3">{{ $tc("volume", 2) }}</h4>
<div class="shadow-box big-padding mb-3">
</div>
</div>
<!-- Networks -->
<h4 class="mb-3">{{ $tc("network", 2) }}</h4>
<div class="shadow-box big-padding mb-3">
<NetworkInput />
</div>
</div>
<!-- <div class="shadow-box big-padding mb-3">
<div class="mb-3">
<label for="name" class="form-label"> Search Templates</label>
<input id="name" v-model="name" type="text" class="form-control" placeholder="Search..." required>
</div>
<prism-editor v-if="false" v-model="yamlConfig" class="yaml-editor" :highlight="highlighter" line-numbers @input="yamlCodeChange"></prism-editor>
</div>-->
</div>
</div>
<div v-if="!stack.isManagedByDockge && !processing">
{{ $t("stackNotManagedByDockgeMsg") }}
</div>
<!-- Delete Dialog -->
<BModal v-model="showDeleteDialog" :okTitle="$t('deleteStack')" okVariant="danger" @ok="deleteDialog">
{{ $t("deleteStackMsg") }}
</BModal>
</div>
</transition>
</template>
<script>
import { highlight, languages } from "prismjs/components/prism-core";
import { PrismEditor } from "vue-prism-editor";
import "prismjs/components/prism-yaml";
import { parseDocument, Document } from "yaml";
import "prismjs/themes/prism-tomorrow.css";
import "vue-prism-editor/dist/prismeditor.min.css";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
COMBINED_TERMINAL_COLS,
COMBINED_TERMINAL_ROWS,
copyYAMLComments,
getCombinedTerminalName,
getComposeTerminalName,
PROGRESS_TERMINAL_ROWS,
RUNNING
} from "../../../backend/util-common";
import { BModal } from "bootstrap-vue-next";
import NetworkInput from "../components/NetworkInput.vue";
const template = `version: "3.8"
services:
nginx:
image: nginx:latest
restart: unless-stopped
ports:
- "8080:80"
`;
let yamlErrorTimeout = null;
let serviceStatusTimeout = null;
export default {
components: {
NetworkInput,
FontAwesomeIcon,
PrismEditor,
BModal,
},
beforeRouteUpdate(to, from, next) {
this.exitConfirm(next);
},
beforeRouteLeave(to, from, next) {
this.exitConfirm(next);
},
yamlDoc: null, // For keeping the yaml comments
data() {
return {
editorFocus: false,
jsonConfig: {},
yamlError: "",
processing: true,
showProgressTerminal: false,
progressTerminalRows: PROGRESS_TERMINAL_ROWS,
combinedTerminalRows: COMBINED_TERMINAL_ROWS,
combinedTerminalCols: COMBINED_TERMINAL_COLS,
stack: {
},
serviceStatusList: {},
isEditMode: false,
submitted: false,
showDeleteDialog: false,
newContainerName: "",
stopServiceStatusTimeout: false,
};
},
computed: {
isAdd() {
return this.$route.path === "/compose" && !this.submitted;
},
/**
* Get the stack from the global stack list, because it may contain more real-time data like status
* @return {*}
*/
globalStack() {
return this.$root.stackList[this.stack.name];
},
status() {
return this.globalStack?.status;
},
active() {
return this.status === RUNNING;
},
terminalName() {
if (!this.stack.name) {
return "";
}
return getComposeTerminalName(this.stack.name);
},
combinedTerminalName() {
if (!this.stack.name) {
return "";
}
return getCombinedTerminalName(this.stack.name);
},
networks() {
return this.jsonConfig.networks;
}
},
watch: {
"stack.composeYAML": {
handler() {
if (this.editorFocus) {
console.debug("yaml code changed");
this.yamlCodeChange();
}
},
deep: true,
},
jsonConfig: {
handler() {
if (!this.editorFocus) {
console.debug("jsonConfig changed");
let doc = new Document(this.jsonConfig);
// Stick back the yaml comments
if (this.yamlDoc) {
copyYAMLComments(doc, this.yamlDoc);
}
this.stack.composeYAML = doc.toString();
this.yamlDoc = doc;
}
},
deep: true,
},
},
mounted() {
if (this.isAdd) {
this.processing = false;
this.isEditMode = true;
let composeYAML;
if (this.$root.composeTemplate) {
composeYAML = this.$root.composeTemplate;
this.$root.composeTemplate = "";
} else {
composeYAML = template;
}
// Default Values
this.stack = {
name: "",
composeYAML,
isManagedByDockge: true,
};
this.yamlCodeChange();
} else {
this.stack.name = this.$route.params.stackName;
this.loadStack();
}
this.requestServiceStatus();
},
unmounted() {
this.stopServiceStatusTimeout = true;
clearTimeout(serviceStatusTimeout);
},
methods: {
startServiceStatusTimeout() {
clearTimeout(serviceStatusTimeout);
serviceStatusTimeout = setTimeout(async () => {
this.requestServiceStatus();
}, 2000);
},
requestServiceStatus() {
this.$root.getSocket().emit("serviceStatusList", this.stack.name, (res) => {
if (res.ok) {
this.serviceStatusList = res.serviceStatusList;
}
if (!this.stopServiceStatusTimeout) {
this.startServiceStatusTimeout();
}
});
},
exitConfirm(next) {
if (this.isEditMode) {
if (confirm("You are currently editing a stack. Are you sure you want to leave?")) {
next();
} else {
next(false);
}
} else {
next();
}
},
bindTerminal() {
this.$refs.progressTerminal?.bind(this.terminalName);
},
loadStack() {
this.processing = true;
this.$root.getSocket().emit("getStack", this.stack.name, (res) => {
if (res.ok) {
this.stack = res.stack;
this.yamlCodeChange();
this.processing = false;
this.bindTerminal();
} else {
this.$root.toastRes(res);
}
});
},
deployStack() {
this.processing = true;
if (!this.jsonConfig.services) {
this.$root.toastError("No services found in compose.yaml");
this.processing = false;
return;
}
// Check if services is object
if (typeof this.jsonConfig.services !== "object") {
this.$root.toastError("Services must be an object");
this.processing = false;
return;
}
let serviceNameList = Object.keys(this.jsonConfig.services);
// Set the stack name if empty, use the first container name
if (!this.stack.name && serviceNameList.length > 0) {
let serviceName = serviceNameList[0];
let service = this.jsonConfig.services[serviceName];
if (service && service.container_name) {
this.stack.name = service.container_name;
} else {
this.stack.name = serviceName;
}
}
this.bindTerminal(this.terminalName);
this.$root.getSocket().emit("deployStack", this.stack.name, this.stack.composeYAML, this.isAdd, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.isEditMode = false;
this.$router.push("/compose/" + this.stack.name);
}
});
},
saveStack() {
this.processing = true;
this.$root.getSocket().emit("saveStack", this.stack.name, this.stack.composeYAML, this.isAdd, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.isEditMode = false;
this.$router.push("/compose/" + this.stack.name);
}
});
},
startStack() {
this.processing = true;
this.$root.getSocket().emit("startStack", this.stack.name, (res) => {
this.processing = false;
this.$root.toastRes(res);
});
},
stopStack() {
this.processing = true;
this.$root.getSocket().emit("stopStack", this.stack.name, (res) => {
this.processing = false;
this.$root.toastRes(res);
});
},
restartStack() {
this.processing = true;
this.$root.getSocket().emit("restartStack", this.stack.name, (res) => {
this.processing = false;
this.$root.toastRes(res);
});
},
updateStack() {
this.processing = true;
this.$root.getSocket().emit("updateStack", this.stack.name, (res) => {
this.processing = false;
this.$root.toastRes(res);
});
},
deleteDialog() {
this.$root.getSocket().emit("deleteStack", this.stack.name, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.$router.push("/");
}
});
},
discardStack() {
this.loadStack();
this.isEditMode = false;
},
highlighter(code) {
return highlight(code, languages.yaml);
},
yamlCodeChange() {
try {
let doc = parseDocument(this.stack.composeYAML);
if (doc.errors.length > 0) {
throw doc.errors[0];
}
const config = doc.toJS() ?? {};
// Check data types
// "services" must be an object
if (!config.services) {
config.services = {};
}
if (Array.isArray(config.services) || typeof config.services !== "object") {
throw new Error("Services must be an object");
}
if (!config.version) {
config.version = "3.8";
}
this.yamlDoc = doc;
this.jsonConfig = config;
clearTimeout(yamlErrorTimeout);
this.yamlError = "";
} catch (e) {
clearTimeout(yamlErrorTimeout);
if (this.yamlError) {
this.yamlError = e.message;
} else {
yamlErrorTimeout = setTimeout(() => {
this.yamlError = e.message;
}, 3000);
}
}
},
enableEditMode() {
this.isEditMode = true;
},
checkYAML() {
},
addContainer() {
this.checkYAML();
if (this.jsonConfig.services[this.newContainerName]) {
this.$root.toastError("Container name already exists");
return;
}
if (!this.newContainerName) {
this.$root.toastError("Container name cannot be empty");
return;
}
this.jsonConfig.services[this.newContainerName] = {
restart: "unless-stopped",
};
this.newContainerName = "";
let element = this.$refs.containerList.lastElementChild;
element.scrollIntoView({
block: "start",
behavior: "smooth"
});
},
}
};
</script>
<style scoped lang="scss">
.terminal {
height: 200px;
}
.editor-box {
&.edit-mode {
background-color: #2c2f38 !important;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 class="mb-3">Console</h1>
<div>
<p>
Allowed commands:
<template v-for="(command, index) in allowedCommandList" :key="command">
<code>{{ command }}</code>
<!-- No comma at the end -->
<span v-if="index !== allowedCommandList.length - 1">, </span>
</template>
</p>
</div>
<Terminal class="terminal" :rows="20" mode="mainTerminal" name="console"></Terminal>
</div>
</transition>
</template>
<script>
import { allowedCommandList } from "../../../backend/util-common";
export default {
components: {
},
data() {
return {
allowedCommandList,
};
},
mounted() {
},
methods: {
}
};
</script>
<style scoped lang="scss">
.terminal {
height: 410px;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 class="mb-3">Terminal - {{ serviceName }} ({{ stackName }})</h1>
<div class="mb-3">
<router-link :to="sh" class="btn btn-normal me-2">Switch to sh</router-link>
</div>
<Terminal class="terminal" :rows="20" mode="interactive" :name="terminalName" :stack-name="stackName" :service-name="serviceName" :shell="shell"></Terminal>
</div>
</transition>
</template>
<script>
import { getContainerExecTerminalName } from "../../../backend/util-common";
export default {
components: {
},
data() {
return {
};
},
computed: {
stackName() {
return this.$route.params.stackName;
},
shell() {
return this.$route.params.type;
},
serviceName() {
return this.$route.params.serviceName;
},
terminalName() {
return getContainerExecTerminalName(this.stackName, this.serviceName, 0);
},
sh() {
return {
name: "containerTerminal",
params: {
stackName: this.stackName,
serviceName: this.serviceName,
type: "sh",
},
};
},
},
mounted() {
},
methods: {
}
};
</script>
<style scoped lang="scss">
.terminal {
height: 410px;
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div class="container-fluid">
<div class="row">
<div v-if="!$root.isMobile" class="col-12 col-md-4 col-xl-3">
<div>
<router-link to="/compose" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("compose") }}</router-link>
</div>
<StackList :scrollbar="true" />
</div>
<div ref="container" class="col-12 col-md-8 col-xl-9 mb-3">
<!-- Add :key to disable vue router re-use the same component -->
<router-view :key="$route.fullPath" :calculatedHeight="height" />
</div>
</div>
</div>
</template>
<script>
import StackList from "../components/StackList.vue";
export default {
components: {
StackList,
},
data() {
return {
height: 0
};
},
mounted() {
this.height = this.$refs.container.offsetHeight;
},
};
</script>
<style lang="scss" scoped>
.container-fluid {
width: 98%;
}
</style>

View File

@@ -0,0 +1,231 @@
<template>
<transition ref="tableContainer" name="slide-fade" appear>
<div v-if="$route.name === 'DashboardHome'">
<h1 class="mb-3">
{{ $t("home") }}
</h1>
<div class="shadow-box big-padding text-center mb-4">
<div class="row">
<div class="col">
<h3>{{ $t("active") }}</h3>
<span class="num active">{{ activeNum }}</span>
</div>
<div class="col">
<h3>{{ $t("exited") }}</h3>
<span class="num exited">{{ exitedNum }}</span>
</div>
<div class="col">
<h3>{{ $t("inactive") }}</h3>
<span class="num inactive">{{ inactiveNum }}</span>
</div>
</div>
</div>
<h2 class="mb-3">Docker Run</h2>
<div class="mb-3">
<textarea id="name" v-model="dockerRunCommand" type="text" class="form-control docker-run" required placeholder="docker run ..."></textarea>
</div>
<button class="btn-normal btn" @click="convertDockerRun">Convert to Compose</button>
</div>
</transition>
<router-view ref="child" />
</template>
<script>
import { statusNameShort } from "../../../backend/util-common";
export default {
components: {
},
props: {
calculatedHeight: {
type: Number,
default: 0
}
},
data() {
return {
page: 1,
perPage: 25,
initialPerPage: 25,
paginationConfig: {
hideCount: true,
chunksNavigation: "scroll",
},
importantHeartBeatListLength: 0,
displayedRecords: [],
dockerRunCommand: "",
};
},
computed: {
activeNum() {
return this.getStatusNum("active");
},
inactiveNum() {
return this.getStatusNum("inactive");
},
exitedNum() {
return this.getStatusNum("exited");
},
},
watch: {
perPage() {
this.$nextTick(() => {
this.getImportantHeartbeatListPaged();
});
},
page() {
this.getImportantHeartbeatListPaged();
},
},
mounted() {
this.initialPerPage = this.perPage;
window.addEventListener("resize", this.updatePerPage);
this.updatePerPage();
},
beforeUnmount() {
window.removeEventListener("resize", this.updatePerPage);
},
methods: {
getStatusNum(statusName) {
let num = 0;
for (let stackName in this.$root.stackList) {
const stack = this.$root.stackList[stackName];
if (statusNameShort(stack.status) === statusName) {
num += 1;
}
}
return num;
},
convertDockerRun() {
if (this.dockerRunCommand.trim() === "docker run") {
throw new Error("Please enter a docker run command");
}
// composerize is working in dev, but after "vite build", it is not working
// So pass to backend to do the conversion
this.$root.getSocket().emit("composerize", this.dockerRunCommand, (res) => {
if (res.ok) {
this.$root.composeTemplate = res.composeTemplate;
this.$router.push("/compose");
} else {
this.$root.toastRes(res);
}
});
},
/**
* Updates the displayed records when a new important heartbeat arrives.
* @param {object} heartbeat - The heartbeat object received.
* @returns {void}
*/
onNewImportantHeartbeat(heartbeat) {
if (this.page === 1) {
this.displayedRecords.unshift(heartbeat);
if (this.displayedRecords.length > this.perPage) {
this.displayedRecords.pop();
}
this.importantHeartBeatListLength += 1;
}
},
/**
* Retrieves the length of the important heartbeat list for all monitors.
* @returns {void}
*/
getImportantHeartbeatListLength() {
this.$root.getSocket().emit("monitorImportantHeartbeatListCount", null, (res) => {
if (res.ok) {
this.importantHeartBeatListLength = res.count;
this.getImportantHeartbeatListPaged();
}
});
},
/**
* Retrieves the important heartbeat list for the current page.
* @returns {void}
*/
getImportantHeartbeatListPaged() {
const offset = (this.page - 1) * this.perPage;
this.$root.getSocket().emit("monitorImportantHeartbeatListPaged", null, offset, this.perPage, (res) => {
if (res.ok) {
this.displayedRecords = res.data;
}
});
},
/**
* Updates the number of items shown per page based on the available height.
* @returns {void}
*/
updatePerPage() {
const tableContainer = this.$refs.tableContainer;
const tableContainerHeight = tableContainer.offsetHeight;
const availableHeight = window.innerHeight - tableContainerHeight;
const additionalPerPage = Math.floor(availableHeight / 58);
if (additionalPerPage > 0) {
this.perPage = Math.max(this.initialPerPage, this.perPage + additionalPerPage);
} else {
this.perPage = this.initialPerPage;
}
},
},
};
</script>
<style lang="scss" scoped>
@import "../styles/vars";
.num {
font-size: 30px;
font-weight: bold;
display: block;
&.active {
color: $primary;
}
&.exited {
color: $danger;
}
}
.shadow-box {
padding: 20px;
}
table {
font-size: 14px;
tr {
transition: all ease-in-out 0.2ms;
}
@media (max-width: 550px) {
table-layout: fixed;
overflow-wrap: break-word;
}
}
.docker-run {
background-color: $dark-bg !important;
border: none;
}
</style>

View File

@@ -0,0 +1,252 @@
<template>
<div>
<h1 v-show="show" class="mb-3">
{{ $t("Settings") }}
</h1>
<div class="shadow-box shadow-box-settings">
<div class="row">
<div v-if="showSubMenu" class="settings-menu col-lg-3 col-md-5">
<router-link
v-for="(item, key) in subMenus"
:key="key"
:to="`/settings/${key}`"
>
<div class="menu-item">
{{ item.title }}
</div>
</router-link>
<!-- Logout Button -->
<a v-if="$root.isMobile && $root.loggedIn && $root.socket.token !== 'autoLogin'" class="logout" @click.prevent="$root.logout">
<div class="menu-item">
<font-awesome-icon icon="sign-out-alt" />
{{ $t("Logout") }}
</div>
</a>
</div>
<div class="settings-content col-lg-9 col-md-7">
<div v-if="currentPage" class="settings-content-header">
{{ subMenus[currentPage].title }}
</div>
<div class="mx-3">
<router-view v-slot="{ Component }">
<transition name="slide-fade" appear>
<component :is="Component" />
</transition>
</router-view>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { useRoute } from "vue-router";
export default {
data() {
return {
show: true,
settings: {},
settingsLoaded: false,
};
},
computed: {
currentPage() {
let pathSplit = useRoute().path.split("/");
let pathEnd = pathSplit[pathSplit.length - 1];
if (!pathEnd || pathEnd === "settings") {
return null;
}
return pathEnd;
},
showSubMenu() {
if (this.$root.isMobile) {
return !this.currentPage;
} else {
return true;
}
},
subMenus() {
return {
general: {
title: this.$t("General"),
},
appearance: {
title: this.$t("Appearance"),
},
security: {
title: this.$t("Security"),
},
about: {
title: this.$t("About"),
},
};
},
},
watch: {
"$root.isMobile"() {
this.loadGeneralPage();
}
},
mounted() {
this.loadSettings();
this.loadGeneralPage();
},
methods: {
/**
* Load the general settings page
* For desktop only, on mobile do nothing
*/
loadGeneralPage() {
if (!this.currentPage && !this.$root.isMobile) {
this.$router.push("/settings/appearance");
}
},
/** Load settings from server */
loadSettings() {
this.$root.getSocket().emit("getSettings", (res) => {
this.settings = res.data;
if (this.settings.checkUpdate === undefined) {
this.settings.checkUpdate = true;
}
this.settingsLoaded = true;
});
},
/**
* Callback for saving settings
* @callback saveSettingsCB
* @param {Object} res Result of operation
*/
/**
* Save Settings
* @param {saveSettingsCB} [callback]
* @param {string} [currentPassword] Only need for disableAuth to true
*/
saveSettings(callback, currentPassword) {
let valid = this.validateSettings();
if (valid.success) {
this.$root.getSocket().emit("setSettings", this.settings, currentPassword, (res) => {
this.$root.toastRes(res);
this.loadSettings();
if (callback) {
callback();
}
});
} else {
this.$root.toastError(valid.msg);
}
},
/**
* Ensure settings are valid
* @returns {Object} Contains success state and error msg
*/
validateSettings() {
if (this.settings.keepDataPeriodDays < 0) {
return {
success: false,
msg: this.$t("dataRetentionTimeError"),
};
}
return {
success: true,
msg: "",
};
},
}
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.shadow-box-settings {
padding: 20px;
min-height: calc(100vh - 155px);
}
footer {
color: #aaa;
font-size: 13px;
margin-top: 20px;
padding-bottom: 30px;
text-align: center;
}
.settings-menu {
a {
text-decoration: none !important;
}
.menu-item {
border-radius: 10px;
margin: 0.5em;
padding: 0.7em 1em;
cursor: pointer;
border-left-width: 0;
transition: all ease-in-out 0.1s;
}
.menu-item:hover {
background: $highlight-white;
.dark & {
background: $dark-header-bg;
}
}
.active .menu-item {
background: $highlight-white;
border-left: 4px solid $primary;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
.dark & {
background: $dark-header-bg;
}
}
}
.settings-content {
.settings-content-header {
width: calc(100% + 20px);
border-bottom: 1px solid #dee2e6;
border-radius: 0 10px 0 0;
margin-top: -20px;
margin-right: -20px;
padding: 12.5px 1em;
font-size: 26px;
.dark & {
background: $dark-header-bg;
border-bottom: 0;
}
.mobile & {
padding: 15px 0 0 0;
.dark & {
background-color: transparent;
}
}
}
}
.logout {
color: $danger !important;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<div class="form-container" data-cy="setup-form">
<div class="form">
<form @submit.prevent="submit">
<div>
<object width="64" height="64" data="/icon.svg" />
<div style="font-size: 28px; font-weight: bold; margin-top: 5px;">
Dockge
</div>
</div>
<p class="mt-3">
{{ $t("Create your admin account") }}
</p>
<div class="form-floating">
<select id="language" v-model="$root.language" class="form-select">
<option v-for="(lang, i) in $i18n.availableLocales" :key="`Lang${i}`" :value="lang">
{{ $i18n.messages[lang].languageName }}
</option>
</select>
<label for="language" class="form-label">{{ $t("Language") }}</label>
</div>
<div class="form-floating mt-3">
<input id="floatingInput" v-model="username" type="text" class="form-control" :placeholder="$t('Username')" required data-cy="username-input">
<label for="floatingInput">{{ $t("Username") }}</label>
</div>
<div class="form-floating mt-3">
<input id="floatingPassword" v-model="password" type="password" class="form-control" :placeholder="$t('Password')" required data-cy="password-input">
<label for="floatingPassword">{{ $t("Password") }}</label>
</div>
<div class="form-floating mt-3">
<input id="repeat" v-model="repeatPassword" type="password" class="form-control" :placeholder="$t('Repeat Password')" required data-cy="password-repeat-input">
<label for="repeat">{{ $t("Repeat Password") }}</label>
</div>
<button class="w-100 btn btn-primary mt-3" type="submit" :disabled="processing" data-cy="submit-setup-form">
{{ $t("Create") }}
</button>
</form>
</div>
</div>
</template>
<script>
export default {
data() {
return {
processing: false,
username: "",
password: "",
repeatPassword: "",
};
},
watch: {
},
mounted() {
// TODO: Check if it is a database setup
this.$root.getSocket().emit("needSetup", (needSetup) => {
if (! needSetup) {
this.$router.push("/");
}
});
},
methods: {
/**
* Submit form data for processing
* @returns {void}
*/
submit() {
this.processing = true;
if (this.password !== this.repeatPassword) {
this.$root.toastError("PasswordsDoNotMatch");
this.processing = false;
return;
}
this.$root.getSocket().emit("setup", this.username, this.password, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.processing = true;
this.$root.login(this.username, this.password, "", () => {
this.processing = false;
this.$router.push("/");
});
}
});
},
},
};
</script>
<style lang="scss" scoped>
.form-container {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
}
.form-floating {
> .form-select {
padding-left: 1.3rem;
padding-top: 1.525rem;
line-height: 1.35;
~ label {
padding-left: 1.3rem;
}
}
> label {
padding-left: 1.3rem;
}
> .form-control {
padding-left: 1.3rem;
}
}
.form {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
text-align: center;
}
</style>

90
frontend/src/router.ts Normal file
View File

@@ -0,0 +1,90 @@
import { createRouter, createWebHistory } from "vue-router";
import Layout from "./layouts/Layout.vue";
import Setup from "./pages/Setup.vue";
import Dashboard from "./pages/Dashboard.vue";
import DashboardHome from "./pages/DashboardHome.vue";
import Console from "./pages/Console.vue";
import Compose from "./pages/Compose.vue";
import ContainerTerminal from "./pages/ContainerTerminal.vue";
const Settings = () => import("./pages/Settings.vue");
// Settings - Sub Pages
import Appearance from "./components/settings/Appearance.vue";
import General from "./components/settings/General.vue";
const Security = () => import("./components/settings/Security.vue");
import About from "./components/settings/About.vue";
const routes = [
{
path: "/empty",
component: Layout,
children: [
{
path: "",
component: Dashboard,
children: [
{
name: "DashboardHome",
path: "/",
component: DashboardHome,
children: [
{
path: "/compose",
component: Compose,
},
{
path: "/compose/:stackName",
name: "compose",
component: Compose,
props: true,
},
{
path: "/terminal/:stackName/:serviceName/:type",
component: ContainerTerminal,
name: "containerTerminal",
},
]
},
{
path: "/console",
component: Console,
},
{
path: "/settings",
component: Settings,
children: [
{
path: "general",
component: General,
},
{
path: "appearance",
component: Appearance,
},
{
path: "security",
component: Security,
},
{
path: "about",
component: About,
},
]
},
]
},
]
},
{
path: "/setup",
component: Setup,
},
];
export const router = createRouter({
linkActiveClass: "active",
history: createWebHistory(),
routes,
});

View File

@@ -0,0 +1,9 @@
html[lang='fa'] {
#app {
font-family: 'IRANSans', 'Iranian Sans','B Nazanin', 'Tahoma', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji;
}
}
ul.multiselect__content {
padding-left: 0 !important;
}

View File

@@ -0,0 +1,697 @@
@import "vars.scss";
@import "bootstrap/scss/bootstrap";
@import "bootstrap-vue-next/dist/bootstrap-vue-next.css";
#app {
font-family: BlinkMacSystemFont, segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji;
}
h1 {
font-size: 32px;
}
h2 {
font-size: 26px;
}
textarea.form-control {
border-radius: 19px;
}
::-webkit-scrollbar {
width: 10px;
}
.bg-maintenance {
color: white !important;
background-color: $maintenance !important;
}
.bg-dark {
color: white;
}
.text-maintenance {
color: $maintenance !important;
}
::placeholder {
color: $dark-font-color3 !important;
}
.incident a,
.bg-maintenance a {
color: inherit;
}
.list-group {
border-radius: 0.75rem;
.dark & {
.list-group-item {
background-color: $dark-bg2;
color: $dark-font-color;
border-color: $dark-border-color;
}
}
}
// optgroup
optgroup {
color: #b1b1b1;
option {
color: #212529;
}
}
.dark {
optgroup {
color: #535864;
option {
color: $dark-font-color;
}
}
}
// Scrollbar
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 20px;
}
.modal {
backdrop-filter: blur(3px);
}
.modal-content {
border-radius: 1rem;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
.dark & {
box-shadow: 0 15px 70px rgb(0 0 0);
background-color: $dark-bg;
}
}
.VuePagination__count {
font-size: 13px;
text-align: center;
}
.shadow-box {
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
padding: 10px;
border-radius: 10px;
&.big-padding {
padding: 20px;
}
}
.btn {
padding-left: 20px;
padding-right: 20px;
}
.btn-sm {
border-radius: 25px;
}
.btn-primary {
color: white;
background: $primary-gradient;
&:hover, &:active, &:focus, &.active {
color: white;
background: $primary-gradient-active;
border-color: $highlight;
}
.dark & {
color: $dark-font-color2;
}
}
.btn-normal {
$bg-color: #F5F5F5;
background-color: $bg-color;
border-color: $bg-color;
&:hover {
$hover-color: darken($bg-color, 3%);
background-color: $hover-color;
border-color: $hover-color;
}
}
.btn-warning {
color: white;
&:hover, &:active, &:focus, &.active {
color: white;
}
}
.btn-info {
color: white;
&:hover, &:active, &:focus, &.active {
color: white;
}
}
.btn-dark {
background-color: #161B22;
}
.btn-outline-normal {
padding: 4px 10px;
border: 1px solid #ced4da;
border-radius: 25px;
background-color: transparent;
.dark & {
color: $dark-font-color;
border: 1px solid $dark-font-color2;
}
&.active {
background-color: $highlight-white;
.dark & {
background-color: $dark-font-color2;
}
}
}
@media (max-width: 550px) {
.table-shadow-box {
padding: 10px !important;
thead {
display: none;
}
tbody {
.shadow-box {
background-color: white;
}
}
tr {
margin-top: 0 !important;
padding: 4px 10px !important;
display: block;
margin-bottom: 6px;
td:first-child {
font-weight: bold;
}
td:nth-child(-n+3) {
text-align: center;
}
td:last-child {
text-align: left;
}
td {
border-bottom: 1px solid $dark-font-color;
display: block;
padding: 4px;
.badge {
margin: auto;
display: block;
width: 30%;
}
}
}
}
}
// Dark Theme override here
.dark {
background-color: #090c10;
color: $dark-font-color;
mark, .mark {
background-color: #b6ad86;
}
&::-webkit-scrollbar-thumb, ::-webkit-scrollbar-thumb {
background: $dark-border-color;
}
.shadow-box {
&:not(.alert) {
background-color: $dark-bg;
}
}
.form-check-input {
background-color: $dark-bg2;
border-color: $dark-border-color;
}
.input-group-text {
background-color: #282f39;
border-color: $dark-border-color;
color: $dark-font-color;
}
.form-check-input:checked {
border-color: $primary; // Re-apply bootstrap border
}
.form-switch .form-check-input {
background-color: #232f3b;
}
a:not(.btn),
.table,
.nav-link {
color: $dark-font-color;
&.btn-info {
color: white;
}
}
.incident a,
.bg-maintenance a {
color: inherit;
}
.form-control,
.form-control:focus,
.form-select,
.form-select:focus {
color: $dark-font-color;
background-color: $dark-bg2;
}
.form-select:disabled {
color: rgba($dark-font-color, 0.7);
background-color: $dark-bg;
}
.form-control, .form-select {
border-color: $dark-border-color;
}
.form-control:disabled, .form-control[readonly] {
background-color: #232f3b;
opacity: 1;
}
.table-hover > tbody > tr:hover > * {
--bs-table-accent-bg: #070a10;
color: $dark-font-color;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: $dark-font-color2;
background: $primary-gradient;
&:hover {
background: $primary-gradient-active;
}
}
.bg-primary {
color: $dark-font-color2;
background: $primary-gradient;
}
.btn-secondary {
color: white;
}
.btn-normal {
$bg-color: $dark-header-bg;
color: $dark-font-color;
background-color: $bg-color;
border-color: $bg-color;
&:hover {
$hover-color: darken($bg-color, 3%);
background-color: $hover-color;
border-color: $hover-color;
}
}
.btn-warning {
color: $dark-font-color2;
&:hover, &:active, &:focus, &.active {
color: $dark-font-color2;
}
}
.btn-close {
box-shadow: none;
filter: invert(1);
&:hover {
opacity: 0.6;
}
}
.modal-header {
border-color: $dark-bg;
}
.modal-footer {
border-color: $dark-bg;
}
// Pagination
.page-item.disabled .page-link {
background-color: $dark-bg;
border-color: $dark-border-color;
}
.page-link {
background-color: $dark-bg;
border-color: $dark-border-color;
color: $dark-font-color;
}
.stack-list {
.item {
&:hover {
background-color: $dark-bg2;
}
&.active {
background-color: $dark-bg2;
}
}
}
@media (max-width: 550px) {
.table-shadow-box {
tbody {
.shadow-box {
background-color: $dark-bg2;
td {
border-bottom: 1px solid $dark-border-color;
}
}
}
}
}
.alert {
&.bg-info,
&.bg-warning,
&.bg-danger,
&.bg-maintenance,
&.bg-light {
color: $dark-font-color2;
}
}
}
// Floating Label
.form-floating > .form-control:focus ~ label::after, .form-floating > .form-control:not(:placeholder-shown) ~ label::after, .form-floating > .form-control-plaintext ~ label::after, .form-floating > .form-select ~ label::after {
background-color: transparent;
}
.form-floating > label {
.dark & {
color: $dark-font-color3 !important;
}
}
/*
* Transitions
*/
// page-change
.slide-fade-enter-active {
transition: all 0.2s $easing-in;
}
.slide-fade-leave-active {
transition: all 0.2s $easing-in;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(50px);
opacity: 0;
}
.slide-fade-right-enter-active {
transition: all 0.2s $easing-in;
}
.slide-fade-right-leave-active {
transition: all 0.2s $easing-in;
}
.slide-fade-right-enter-from,
.slide-fade-right-leave-to {
transform: translateX(50px);
opacity: 0;
}
.slide-fade-up-enter-active {
transition: all 0.2s $easing-in;
}
.slide-fade-up-leave-active {
transition: all 0.2s $easing-in;
}
.slide-fade-up-enter-from,
.slide-fade-up-leave-to {
transform: translateY(-50px);
opacity: 0;
}
.stack-list {
&.scrollbar {
overflow-y: auto;
}
@media (max-width: 770px) {
&.scrollbar {
height: calc(100% - 97px);
}
}
.item {
display: flex;
align-items: center;
height: 52px;
text-decoration: none;
border-radius: 10px;
transition: all ease-in-out 0.15s;
width: 100%;
padding: 0 8px;
&.disabled {
opacity: 0.3;
}
&:hover {
background-color: $highlight-white;
}
&.active {
background-color: #cdf8f4;
}
.title {
display: inline-block;
margin-top: -4px;
}
}
}
.alert-success {
color: #122f21;
background-color: $primary;
border-color: $primary;
}
.alert-info {
color: #055160;
background-color: #cff4fc;
border-color: #cff4fc;
}
.alert-danger {
color: #842029;
background-color: #f8d7da;
border-color: #f8d7da;
}
.btn-success {
color: #fff;
background-color: #4caf50;
border-color: #4caf50;
}
[contenteditable=true] {
transition: all $easing-in 0.2s;
background-color: rgba(239, 239, 239, 0.7);
border-radius: 8px;
&.no-bg {
background-color: transparent !important;
}
&:focus {
outline: 0 solid #eee;
background-color: rgba(245, 245, 245, 0.9);
}
&:hover {
background-color: rgba(239, 239, 239, 0.8);
}
.dark & {
background-color: rgba(239, 239, 239, 0.2);
}
/*
&::after {
margin-left: 5px;
content: "🖊️";
font-size: 13px;
color: #eee;
}
*/
}
.action {
transition: all $easing-in 0.2s;
&:hover {
cursor: pointer;
transform: scale(1.2);
}
}
.vue-image-crop-upload .vicp-wrap {
border-radius: 10px !important;
}
.spinner {
color: $primary;
}
.prism-editor__textarea {
outline: none !important;
}
h5.settings-subheading::after {
content: "";
display: block;
width: 50%;
padding-top: 8px;
border-bottom: 1px solid $dark-border-color;
}
/* required class */
.code-editor, .css-editor {
/* we dont use `language-` classes anymore so thats why we need to add background and text color manually */
border-radius: 1rem;
padding: 10px 5px;
border: 1px solid #ced4da;
.dark & {
background: $dark-bg2;
border: 1px solid $dark-border-color;
}
}
$shadow-box-padding: 20px;
.shadow-box-with-fixed-bottom-bar {
padding-top: $shadow-box-padding;
padding-bottom: 0;
padding-right: $shadow-box-padding;
padding-left: $shadow-box-padding;
}
.fixed-bottom-bar {
position: sticky;
bottom: 0;
margin-left: -$shadow-box-padding;
margin-right: -$shadow-box-padding;
z-index: 100;
background-color: rgba(white, 0.2);
backdrop-filter: blur(2px);
border-radius: 0 0 10px 10px;
.dark & {
background-color: rgba($dark-header-bg, 0.9);
}
}
@media (max-width: 770px) {
.toast-container {
margin-bottom: 100px !important;
}
}
@media (max-width: 550px) {
.toast-container {
margin-bottom: 126px !important;
}
}
.main-terminal {
.xterm-viewport {
border-radius: 10px;
background-color: $dark-bg !important;
}
}
code {
padding: .2em .4em;
margin: 0;
font-size: 85%;
white-space: break-spaces;
background-color: rgba(239, 239, 239, 0.15);
border-radius: 6px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: black;
.dark & {
color: $dark-font-color;
}
}
// Vue Prism Editor bug - workaround
// https://github.com/koca/vue-prism-editor/issues/87
/*
.prism-editor__textarea {
width: 999999px !important;
}
.prism-editor__editor {
white-space: pre !important;
}
.prism-editor__container {
overflow-x: scroll !important;
}*/
// Localization
@import "localization.scss";

View File

@@ -0,0 +1,26 @@
$primary: #74c2ff;
$danger: #dc3545;
$warning: #f8a306;
$maintenance: #1747f5;
$link-color: #111;
$border-radius: 50rem;
$highlight: #9dd1ff;
$highlight-white: #e7faec;
$dark-font-color: #b1b8c0;
$dark-font-color2: #020b05;
$dark-font-color3: #575c62;
$dark-bg: #0d1117;
$dark-bg2: #070a10;
$dark-border-color: #1d2634;
$dark-header-bg: #161b22;
$easing-in: cubic-bezier(0.54, 0.78, 0.55, 0.97);
$easing-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
$easing-in-out: cubic-bezier(0.79, 0.14, 0.15, 0.86);
$dropdown-border-radius: 0.5rem;
$primary-gradient: linear-gradient(135deg, #74c2ff 0%, #74c2ff 75%, #86e6a9);
$primary-gradient-active: linear-gradient(135deg, #74c2ff 0%, #74c2ff 50%, #86e6a9);

View File

@@ -0,0 +1,215 @@
import dayjs from "dayjs";
import timezones from "timezones-list";
import { localeDirection, currentLocale } from "./i18n";
import { POSITION } from "vue-toastification";
/**
* Returns the offset from UTC in hours for the current locale.
* @param {string} timeZone Timezone to get offset for
* @returns {number} The offset from UTC in hours.
*
* Generated by Trelent
*/
function getTimezoneOffset(timeZone) {
const now = new Date();
const tzString = now.toLocaleString("en-US", {
timeZone,
});
const localString = now.toLocaleString("en-US");
const diff = (Date.parse(localString) - Date.parse(tzString)) / 3600000;
const offset = diff + now.getTimezoneOffset() / 60;
return -offset;
}
/**
* Returns a list of timezones sorted by their offset from UTC.
* @returns {object[]} A list of the given timezones sorted by their offset from UTC.
*
* Generated by Trelent
*/
export function timezoneList() {
let result = [];
for (let timezone of timezones) {
try {
let display = dayjs().tz(timezone.tzCode).format("Z");
result.push({
name: `(UTC${display}) ${timezone.tzCode}`,
value: timezone.tzCode,
time: getTimezoneOffset(timezone.tzCode),
});
} catch (e) {
// Skipping not supported timezone.tzCode by dayjs
}
}
result.sort((a, b) => {
if (a.time > b.time) {
return 1;
}
if (b.time > a.time) {
return -1;
}
return 0;
});
return result;
}
/**
* Set the locale of the HTML page
* @returns {void}
*/
export function setPageLocale() {
const html = document.documentElement;
html.setAttribute("lang", currentLocale() );
html.setAttribute("dir", localeDirection() );
}
/**
* Get the base URL
* Mainly used for dev, because the backend and the frontend are in different ports.
* @returns {string} Base URL
*/
export function getResBaseURL() {
const env = process.env.NODE_ENV;
if (env === "development" && isDevContainer()) {
return location.protocol + "//" + getDevContainerServerHostname();
} else if (env === "development" || localStorage.dev === "dev") {
return location.protocol + "//" + location.hostname + ":3001";
} else {
return "";
}
}
/**
* Are we currently running in a dev container?
* @returns {boolean} Running in dev container?
*/
export function isDevContainer() {
// eslint-disable-next-line no-undef
return (typeof DEVCONTAINER === "string" && DEVCONTAINER === "1");
}
/**
* Supports GitHub Codespaces only currently
* @returns {string} Dev container server hostname
*/
export function getDevContainerServerHostname() {
if (!isDevContainer()) {
return "";
}
// eslint-disable-next-line no-undef
return CODESPACE_NAME + "-3001." + GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN;
}
/**
* Regex pattern fr identifying hostnames and IP addresses
* @param {boolean} mqtt whether or not the regex should take into
* account the fact that it is an mqtt uri
* @returns {RegExp} The requested regex
*/
export function hostNameRegexPattern(mqtt = false) {
// mqtt, mqtts, ws and wss schemes accepted by mqtt.js (https://github.com/mqttjs/MQTT.js/#connect)
const mqttSchemeRegexPattern = "((mqtt|ws)s?:\\/\\/)?";
// Source: https://digitalfortress.tech/tips/top-15-commonly-used-regex/
const ipRegexPattern = `((^${mqtt ? mqttSchemeRegexPattern : ""}((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))$)|(^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?$))`;
// Source: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
const hostNameRegexPattern = `^${mqtt ? mqttSchemeRegexPattern : ""}([a-zA-Z0-9])?(([a-zA-Z0-9_]|[a-zA-Z0-9_][a-zA-Z0-9\\-_]*[a-zA-Z0-9_])\\.)*([A-Za-z0-9_]|[A-Za-z0-9_][A-Za-z0-9\\-_]*[A-Za-z0-9_])(\\.)?$`;
return `${ipRegexPattern}|${hostNameRegexPattern}`;
}
/**
* Get the tag color options
* Shared between components
* @param {any} self Component
* @returns {object[]} Colour options
*/
export function colorOptions(self) {
return [
{ name: self.$t("Gray"),
color: "#4B5563" },
{ name: self.$t("Red"),
color: "#DC2626" },
{ name: self.$t("Orange"),
color: "#D97706" },
{ name: self.$t("Green"),
color: "#059669" },
{ name: self.$t("Blue"),
color: "#2563EB" },
{ name: self.$t("Indigo"),
color: "#4F46E5" },
{ name: self.$t("Purple"),
color: "#7C3AED" },
{ name: self.$t("Pink"),
color: "#DB2777" },
];
}
/**
* Loads the toast timeout settings from storage.
* @returns {object} The toast plugin options object.
*/
export function loadToastSettings() {
return {
position: POSITION.BOTTOM_RIGHT,
containerClassName: "toast-container",
showCloseButtonOnHover: true,
filterBeforeCreate: (toast, toasts) => {
if (toast.timeout === 0) {
return false;
} else {
return toast;
}
},
};
}
/**
* Get timeout for success toasts
* @returns {(number|boolean)} Timeout in ms. If false timeout disabled.
*/
export function getToastSuccessTimeout() {
let successTimeout = 20000;
if (localStorage.toastSuccessTimeout !== undefined) {
const parsedTimeout = parseInt(localStorage.toastSuccessTimeout);
if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {
successTimeout = parsedTimeout;
}
}
if (successTimeout === -1) {
successTimeout = false;
}
return successTimeout;
}
/**
* Get timeout for error toasts
* @returns {(number|boolean)} Timeout in ms. If false timeout disabled.
*/
export function getToastErrorTimeout() {
let errorTimeout = -1;
if (localStorage.toastErrorTimeout !== undefined) {
const parsedTimeout = parseInt(localStorage.toastErrorTimeout);
if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {
errorTimeout = parsedTimeout;
}
}
if (errorTimeout === -1) {
errorTimeout = false;
}
return errorTimeout;
}

7
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

36
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Components from "unplugin-vue-components/vite";
import { BootstrapVueNextResolver } from "unplugin-vue-components/resolvers";
import viteCompression from "vite-plugin-compression";
import "vue";
const viteCompressionFilter = /\.(js|mjs|json|css|html|svg)$/i;
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 5000,
},
define: {
"FRONTEND_VERSION": JSON.stringify(process.env.npm_package_version),
},
root: "./frontend",
build: {
outDir: "../frontend-dist",
},
plugins: [
vue(),
Components({
resolvers: [ BootstrapVueNextResolver() ],
}),
viteCompression({
algorithm: "gzip",
filter: viteCompressionFilter,
}),
viteCompression({
algorithm: "brotliCompress",
filter: viteCompressionFilter,
}),
],
});