This commit is contained in:
Louis Lam 2023-11-06 01:18:02 +08:00
parent e67d08b7b3
commit 314630724b
40 changed files with 2873 additions and 351 deletions

View File

@ -9,28 +9,33 @@ A fancy, easy-to-use and reactive docker stack (`docker-compose.yml`) manager.
## ⭐ Features
- Focus on `docker-compose.yml` stack management
- Interactive editor for `docker-compose.yml` files
- Interactive editor for `docker-compose.yml`
- Interactive web terminal for containers and any docker commands
- Reactive - Everything is just responsive. Progress and terminal output are in real-time
- Easy-to-use & fancy UI - If you love Uptime Kuma's UI, you will love this too
- Easy-to-use & fancy UI - If you love Uptime Kuma's UI/UX, you will love this too
- Build on top of [Compose V2](https://docs.docker.com/compose/migrate/), as known as `compose.yaml` and `docker compose`
- Convert `docker run ...` command into `docker-compose.yml` file
## Installation
## Motivations
- Try ES Module and TypeScript in 2023
- I have been using Portainer for some time, but I am sometimes not satisfied with it. For example, sometimes when I deploy a stack, it keeps spinning the loading icon for a few minutes, and I don't know what's going on.
- I have been using Portainer for some time, but I am sometimes not satisfied with it. For example, sometimes when I deploy a stack, the loading icon keeps spinning for a few minutes without progress. And sometimes error messages are not clear.
- Try to develop with ES Module + TypeScript (Originally, I planned to use Deno or Bun.js, but they do not support for arm64, so I stepped back to Node.js)
If you love this project, please consider giving this project a ⭐.
## More Ideas?
- Container file manager
- Stats
- File manager
- App store for yaml templates
- Container stats
- Get app icons
- Switch Docker context
- Support Dockerfile and build
- Zero-config private docker registry
- Support Docker swarm

View File

@ -424,14 +424,27 @@ export class DockgeServer {
}
sendStackList(useCache = false) {
let stackList = Stack.getStackList(this, useCache);
let roomList = this.io.sockets.adapter.rooms.keys();
let map : Map<string, object> | undefined;
for (let room of roomList) {
// Check if the room is a number (user id)
if (Number(room)) {
// Get the list only if there is a room
if (!map) {
map = new Map();
let stackList = Stack.getStackList(this, useCache);
for (let [ stackName, stack ] of stackList) {
map.set(stackName, stack.toSimpleJSON());
}
}
log.debug("server", "Send stack list to room " + room);
this.io.to(room).emit("stackList", {
ok: true,
stackList: Object.fromEntries(stackList),
stackList: Object.fromEntries(map),
});
}
}

View File

@ -10,6 +10,13 @@ export class MainRouter extends Router {
res.send(server.indexHTML);
});
// Robots.txt
router.get("/robots.txt", async (_request, response) => {
let txt = "User-agent: *\nDisallow: /";
response.setHeader("Content-Type", "text/plain");
response.send(txt);
});
return router;
}

View File

@ -14,6 +14,7 @@ export class DockerSocketHandler extends SocketHandler {
server.sendStackList();
callback({
ok: true,
msg: "Deployed",
});
} catch (e) {
callbackError(e, callback);
@ -69,6 +70,9 @@ export class DockerSocketHandler extends SocketHandler {
}
const stack = Stack.getStack(server, stackName);
stack.startCombinedTerminal(socket);
callback({
ok: true,
stack: stack.toJSON(),
@ -77,6 +81,107 @@ export class DockerSocketHandler extends SocketHandler {
callbackError(e, callback);
}
});
// requestStackList
socket.on("requestStackList", async (callback) => {
try {
checkLogin(socket);
server.sendStackList();
callback({
ok: true,
msg: "Updated"
});
} catch (e) {
callbackError(e, callback);
}
});
// startStack
socket.on("startStack", async (stackName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string");
}
const stack = Stack.getStack(server, stackName);
await stack.start(socket);
callback({
ok: true,
msg: "Started"
});
server.sendStackList();
stack.startCombinedTerminal(socket);
} catch (e) {
callbackError(e, callback);
}
});
// stopStack
socket.on("stopStack", async (stackName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string");
}
const stack = Stack.getStack(server, stackName);
await stack.stop(socket);
callback({
ok: true,
msg: "Stopped"
});
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
// restartStack
socket.on("restartStack", async (stackName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string");
}
const stack = Stack.getStack(server, stackName);
await stack.restart(socket);
callback({
ok: true,
msg: "Restarted"
});
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
// updateStack
socket.on("updateStack", async (stackName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string");
}
const stack = Stack.getStack(server, stackName);
await stack.update(socket);
callback({
ok: true,
msg: "Updated"
});
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
}
saveStack(socket : DockgeSocket, server : DockgeServer, name : unknown, composeYAML : unknown, isAdd : unknown) : Stack {
@ -95,5 +200,6 @@ export class DockerSocketHandler extends SocketHandler {
stack.save(isAdd);
return stack;
}
}

View File

@ -8,64 +8,49 @@ import fs from "fs";
import {
allowedCommandList,
allowedRawKeys,
getComposeTerminalName,
getComposeTerminalName, getContainerExecTerminalName,
isDev,
PROGRESS_TERMINAL_ROWS
} from "../util-common";
import { MainTerminal, Terminal } from "../terminal";
import { InteractiveTerminal, MainTerminal, Terminal } from "../terminal";
export class TerminalSocketHandler extends SocketHandler {
create(socket : DockgeSocket, server : DockgeServer) {
socket.on("terminalInputRaw", async (key : unknown) => {
socket.on("terminalInput", async (terminalName : unknown, cmd : unknown, errorCallback) => {
try {
checkLogin(socket);
if (typeof(key) !== "string") {
throw new Error("Key must be a string.");
if (typeof(terminalName) !== "string") {
throw new Error("Terminal name must be a string.");
}
if (allowedRawKeys.includes(key)) {
server.terminal.write(key);
}
} catch (e) {
}
});
socket.on("terminalInput", async (terminalName : unknown, cmd : unknown, errorCallback : unknown) => {
try {
checkLogin(socket);
if (typeof(cmd) !== "string") {
throw new Error("Command must be a string.");
}
// Check if the command is allowed
const cmdParts = cmd.split(" ");
const executable = cmdParts[0].trim();
log.debug("console", "Executable: " + executable);
log.debug("console", "Executable length: " + executable.length);
if (!allowedCommandList.includes(executable)) {
throw new Error("Command not allowed.");
let terminal = Terminal.getTerminal(terminalName);
if (terminal instanceof InteractiveTerminal) {
terminal.write(cmd);
} else {
throw new Error("Terminal not found or it is not a Interactive Terminal.");
}
server.terminal.write(cmd);
} catch (e) {
if (typeof(errorCallback) === "function") {
errorCallback({
ok: false,
msg: e.message,
});
}
errorCallback({
ok: false,
msg: e.message,
});
}
});
// Create Terminal
// Main Terminal
socket.on("mainTerminal", async (terminalName : unknown, callback) => {
try {
checkLogin(socket);
// TODO: Reset the name here, force one main terminal for now
terminalName = "console";
if (typeof(terminalName) !== "string") {
throw new ValidationError("Terminal name must be a string.");
}
@ -91,7 +76,40 @@ export class TerminalSocketHandler extends SocketHandler {
}
});
// Join Terminal
// Interactive Terminal for containers
socket.on("interactiveTerminal", async (stackName : unknown, serviceName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string.");
}
if (typeof(serviceName) !== "string") {
throw new ValidationError("Service name must be a string.");
}
const terminalName = getContainerExecTerminalName(stackName, serviceName, 0);
let terminal = Terminal.getTerminal(terminalName);
if (!terminal) {
terminal = new InteractiveTerminal(server, terminalName);
terminal.rows = 50;
log.debug("deployStack", "Terminal created");
}
terminal.join(socket);
terminal.start();
callback({
ok: true,
});
} catch (e) {
callbackError(e, callback);
}
});
// Join Output Terminal
socket.on("terminalJoin", async (terminalName : unknown, callback) => {
if (typeof(callback) !== "function") {
log.debug("console", "Callback is not a function.");
@ -124,18 +142,9 @@ export class TerminalSocketHandler extends SocketHandler {
});
// Resize Terminal
// TODO: Resize Terminal
socket.on("terminalResize", async (rows : unknown) => {
try {
checkLogin(socket);
if (typeof(rows) !== "number") {
throw new Error("Rows must be a number.");
}
log.debug("console", "Resize terminal to " + rows + " rows.");
server.terminal.resize(rows);
} catch (e) {
}
});
}
}

View File

@ -5,9 +5,11 @@ import yaml from "yaml";
import { DockgeSocket, ValidationError } from "./util-server";
import path from "path";
import {
COMBINED_TERMINAL_COLS,
COMBINED_TERMINAL_ROWS,
CREATED_FILE,
CREATED_STACK,
EXITED,
EXITED, getCombinedTerminalName,
getComposeTerminalName,
PROGRESS_TERMINAL_ROWS,
RUNNING,
@ -24,6 +26,8 @@ export class Stack {
protected _configFilePath?: string;
protected server: DockgeServer;
protected combinedTerminal? : Terminal;
protected static managedStackList: Map<string, Stack> = new Map();
constructor(server : DockgeServer, name : string, composeYAML? : string) {
@ -37,7 +41,6 @@ export class Stack {
return {
...obj,
composeYAML: this.composeYAML,
isManagedByDockge: this.isManagedByDockge,
};
}
@ -46,9 +49,20 @@ export class Stack {
name: this.name,
status: this._status,
tags: [],
isManagedByDockge: this.isManagedByDockge,
};
}
/**
* Get the status of the stack from `docker compose ps --format json`
*/
ps() : object {
let res = childProcess.execSync("docker compose ps --format json", {
cwd: this.path
});
return JSON.parse(res.toString());
}
get isManagedByDockge() : boolean {
if (this._configFilePath) {
return this._configFilePath.startsWith(this.server.stackDirFullPath) && fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();
@ -128,70 +142,29 @@ export class Stack {
fs.writeFileSync(path.join(dir, "compose.yaml"), this.composeYAML);
}
deploy(socket? : DockgeSocket) : Promise<number> {
async deploy(socket? : DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name);
log.debug("deployStack", "Terminal name: " + terminalName);
const terminal = new Terminal(this.server, terminalName, "docker-compose", [ "up", "-d" ], this.path);
log.debug("deployStack", "Terminal created");
terminal.rows = PROGRESS_TERMINAL_ROWS;
if (socket) {
terminal.join(socket);
log.debug("deployStack", "Terminal joined");
} else {
log.debug("deployStack", "No socket, not joining");
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "up", "-d", "--remove-orphans" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to deploy, please check the terminal output for more information.");
}
return new Promise((resolve, reject) => {
terminal.onExit((exitCode : number) => {
if (exitCode === 0) {
resolve(exitCode);
} else {
reject(new Error("Failed to deploy, please check the terminal output for more information."));
}
});
terminal.start();
});
return exitCode;
}
delete(socket?: DockgeSocket) : Promise<number> {
// Docker compose down
async delete(socket?: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name);
log.debug("deleteStack", "Terminal name: " + terminalName);
const terminal = new Terminal(this.server, terminalName, "docker-compose", [ "down" ], this.path);
terminal.rows = PROGRESS_TERMINAL_ROWS;
if (socket) {
terminal.join(socket);
log.debug("deployStack", "Terminal joined");
} else {
log.debug("deployStack", "No socket, not joining");
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "down", "--remove-orphans", "--rmi", "all" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to delete, please check the terminal output for more information.");
}
return new Promise((resolve, reject) => {
terminal.onExit((exitCode : number) => {
if (exitCode === 0) {
// Remove the stack folder
try {
fs.rmSync(this.path, {
recursive: true,
force: true
});
resolve(exitCode);
} catch (e) {
reject(e);
}
} else {
reject(new Error("Failed to delete, please check the terminal output for more information."));
}
});
terminal.start();
// Remove the stack folder
fs.rmSync(this.path, {
recursive: true,
force: true
});
return exitCode;
}
static getStackList(server : DockgeServer, useCacheForManaged = false) : Map<string, Stack> {
@ -257,6 +230,10 @@ export class Stack {
return statusList;
}
/**
* Convert the status string from `docker compose ls` to the status number
* @param status
*/
static statusConvert(status : string) : number {
if (status.startsWith("created")) {
return CREATED_STACK;
@ -290,4 +267,53 @@ export class Stack {
stack._configFilePath = path.resolve(dir);
return stack;
}
async start(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "up", "-d", "--remove-orphans" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to start, please check the terminal output for more information.");
}
return exitCode;
}
async stop(socket: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "stop" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to stop, please check the terminal output for more information.");
}
return exitCode;
}
async restart(socket: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "restart" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to restart, please check the terminal output for more information.");
}
return exitCode;
}
async update(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "pull" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to pull, please check the terminal output for more information.");
}
exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "up", "-d", "--remove-orphans" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to restart, please check the terminal output for more information.");
}
return exitCode;
}
async startCombinedTerminal(socket: DockgeSocket) {
const terminalName = getCombinedTerminalName(this.name);
const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker-compose", [ "logs", "-f" ], this.path);
terminal.rows = COMBINED_TERMINAL_ROWS;
terminal.cols = COMBINED_TERMINAL_COLS;
terminal.join(socket);
terminal.start();
}
}

View File

@ -3,7 +3,14 @@ import * as os from "node:os";
import * as pty from "@homebridge/node-pty-prebuilt-multiarch";
import { LimitQueue } from "./utils/limit-queue";
import { DockgeSocket } from "./util-server";
import { getCryptoRandomInt, TERMINAL_COLS, TERMINAL_ROWS } from "./util-common";
import {
allowedCommandList, allowedRawKeys,
getComposeTerminalName,
getCryptoRandomInt,
PROGRESS_TERMINAL_ROWS,
TERMINAL_COLS,
TERMINAL_ROWS
} from "./util-common";
import { sync as commandExistsSync } from "command-exists";
import { log } from "./log";
@ -25,6 +32,7 @@ export class Terminal {
protected callback? : (exitCode : number) => void;
protected _rows : number = TERMINAL_ROWS;
protected _cols : number = TERMINAL_COLS;
constructor(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) {
this.server = server;
@ -43,7 +51,16 @@ export class Terminal {
set rows(rows : number) {
this._rows = rows;
this.ptyProcess?.resize(TERMINAL_COLS, rows);
this.ptyProcess?.resize(this.cols, this.rows);
}
get cols() {
return this._cols;
}
set cols(cols : number) {
this._cols = cols;
this.ptyProcess?.resize(this.cols, this.rows);
}
public start() {
@ -119,6 +136,30 @@ export class Terminal {
public static getTerminal(name : string) : Terminal | undefined {
return Terminal.terminalMap.get(name);
}
public static getOrCreateTerminal(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) : Terminal {
let terminal = Terminal.getTerminal(name);
if (!terminal) {
terminal = new Terminal(server, name, file, args, cwd);
}
return terminal;
}
public static exec(server : DockgeServer, socket : DockgeSocket | undefined, terminalName : string, file : string, args : string | string[], cwd : string) : Promise<number> {
const terminal = new Terminal(server, terminalName, file, args, cwd);
terminal.rows = PROGRESS_TERMINAL_ROWS;
if (socket) {
terminal.join(socket);
}
return new Promise((resolve) => {
terminal.onExit((exitCode : number) => {
resolve(exitCode);
});
terminal.start();
});
}
}
/**
@ -140,7 +181,7 @@ export class InteractiveTerminal extends Terminal {
* User interactive terminal that use bash or powershell with limited commands such as docker, ls, cd, dir
*/
export class MainTerminal extends InteractiveTerminal {
constructor(server : DockgeServer, name : string, cwd : string = "./") {
constructor(server : DockgeServer, name : string) {
let shell;
if (os.platform() === "win32") {
@ -152,6 +193,25 @@ export class MainTerminal extends InteractiveTerminal {
} else {
shell = "bash";
}
super(server, name, shell, [], cwd);
super(server, name, shell, [], server.stacksDir);
}
public write(input : string) {
// For like Ctrl + C
if (allowedRawKeys.includes(input)) {
super.write(input);
return;
}
// Check if the command is allowed
const cmdParts = input.split(" ");
const executable = cmdParts[0].trim();
log.debug("console", "Executable: " + executable);
log.debug("console", "Executable length: " + executable.length);
if (!allowedCommandList.includes(executable)) {
throw new Error("Command not allowed.");
}
super.write(input);
}
}

View File

@ -11,6 +11,8 @@ dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
import { parseDocument, Document } from "yaml";
let randomBytes : (numBytes: number) => Uint8Array;
if (typeof window !== "undefined" && window.crypto) {
@ -50,13 +52,13 @@ export function statusName(status : number) : string {
export function statusNameShort(status : number) : string {
switch (status) {
case CREATED_FILE:
return "draft";
return "inactive";
case CREATED_STACK:
return "inactive";
case RUNNING:
return "active";
case EXITED:
return "inactive";
return "exited";
default:
return "?";
}
@ -67,7 +69,7 @@ export function statusColor(status : number) : string {
case CREATED_FILE:
return "dark";
case CREATED_STACK:
return "danger";
return "dark";
case RUNNING:
return "primary";
case EXITED:
@ -78,10 +80,13 @@ export function statusColor(status : number) : string {
}
export const isDev = process.env.NODE_ENV === "development";
export const TERMINAL_COLS = 80;
export const TERMINAL_COLS = 105;
export const TERMINAL_ROWS = 10;
export const PROGRESS_TERMINAL_ROWS = 8;
export const COMBINED_TERMINAL_COLS = 50;
export const COMBINED_TERMINAL_ROWS = 15;
export const ERROR_TYPE_VALIDATION = 1;
export const allowedCommandList : string[] = [
@ -182,11 +187,68 @@ export function getComposeTerminalName(stack : string) {
return "compose-" + stack;
}
export function getCombinedTerminalName(stack : string) {
return "combined-" + stack;
}
export function getContainerTerminalName(container : string) {
return "container-" + container;
}
export function getContainerExecTerminalName(container : string, index : number) {
export function getContainerExecTerminalName(stackName : string, container : string, index : number) {
return "container-exec-" + container + "-" + index;
}
export function copyYAMLComments(doc : Document, src : Document) {
doc.comment = src.comment;
doc.commentBefore = src.commentBefore;
if (doc && doc.contents && src && src.contents) {
// @ts-ignore
copyYAMLCommentsItems(doc.contents.items, src.contents.items);
}
}
/**
* Copy yaml comments from srcItems to items
* Typescript is super annoying here, so I have to use any here
* TODO: Since comments are belong to the array index, the comments will be lost if the order of the items is changed or removed or added.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function copyYAMLCommentsItems(items : any, srcItems : any) {
if (!items || !srcItems) {
return;
}
for (let i = 0; i < items.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const item : any = items[i];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const srcItem : any = srcItems[i];
if (!srcItem) {
continue;
}
if (item.key && srcItem.key) {
item.key.comment = srcItem.key.comment;
item.key.commentBefore = srcItem.key.commentBefore;
}
if (srcItem.comment) {
item.comment = srcItem.comment;
}
if (item.value && srcItem.value) {
if (typeof item.value === "object" && typeof srcItem.value === "object") {
item.value.comment = srcItem.value.comment;
item.value.commentBefore = srcItem.value.commentBefore;
if (item.value.items && srcItem.value.items) {
copyYAMLCommentsItems(item.value.items, srcItem.value.items);
}
}
}
}
}

View File

@ -0,0 +1,12 @@
version: '3.8'
services:
nginx-proxy-manager:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
ports:
- '80:80'
- '81:81'
- '443:443'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt

View File

@ -0,0 +1,10 @@
version: '3.8'
services:
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
volumes:
- ./data:/app/data
ports:
- "3001:3001"
restart: always

View File

@ -7,15 +7,34 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
About: typeof import('./src/components/settings/About.vue')['default']
APIKeys: typeof import('./src/components/settings/APIKeys.vue')['default']
Appearance: typeof import('./src/components/settings/Appearance.vue')['default']
ArrayInput: typeof import('./src/components/ArrayInput.vue')['default']
ArrayInputWithOptions: typeof import('./src/components/ArrayInputWithOptions.vue')['default']
Backup: typeof import('./src/components/settings/Backup.vue')['default']
BButton: typeof import('bootstrap-vue-next')['BButton']
BModal: typeof import('bootstrap-vue-next')['BModal']
Confirm: typeof import('./src/components/Confirm.vue')['default']
Container: typeof import('./src/components/Container.vue')['default']
ContainerDialog: typeof import('./src/components/ContainerDialog.vue')['default']
Docker: typeof import('./src/components/settings/Docker.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']
MonitorHistory: typeof import('./src/components/settings/MonitorHistory.vue')['default']
NetworkInput: typeof import('./src/components/NetworkInput.vue')['default']
Notifications: typeof import('./src/components/settings/Notifications.vue')['default']
Proxies: typeof import('./src/components/settings/Proxies.vue')['default']
ReverseProxy: typeof import('./src/components/settings/ReverseProxy.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']
Tags: typeof import('./src/components/settings/Tags.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']
}
}

View File

@ -0,0 +1,97 @@
<template>
<div>
<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>
</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;
},
},
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,231 @@
<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 bg-primary me-1">Running</span>
</div>
</div>
<div class="col-5">
<div class="function">
<router-link v-if="!isEditMode" class="btn btn-normal" :to="terminalRouteLink">
<font-awesome-icon icon="terminal" />
Terminal
</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>
<ArrayInput name="networks" :display-name="$t('network')" placeholder="Network Name" />
</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";
export default defineComponent({
components: {
FontAwesomeIcon,
},
props: {
name: {
type: String,
required: true,
},
isEditMode: {
type: Boolean,
default: false,
},
first: {
type: Boolean,
default: false,
},
},
emits: [
],
data() {
return {
showConfig: false,
};
},
computed: {
terminalRouteLink() {
return {
name: "containerTerminal",
params: {
serviceName: this.name,
type: "logs",
},
};
},
service() {
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: {
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,106 @@
<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="networkList[index].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" @click="addField">{{ $t("addNetwork") }}</button>
<h5 class="mt-3">External Networks</h5>
</div>
</template>
<script>
export default {
props: {
},
data() {
return {
networkList: [],
};
},
computed: {
isInited() {
return this.networks !== undefined;
},
networks() {
return this.$parent.$parent.networks;
},
},
watch: {
networks: {
handler() {
this.loadNetworkList();
},
deep: true,
},
networkList: {
handler() {
},
deep: true,
}
},
mounted() {
this.loadNetworkList();
},
methods: {
loadNetworkList() {
console.debug("loadNetworkList", this.networks);
this.networkList = [];
for (const key in this.networks) {
this.networkList.push({
key: key,
value: this.networks[key],
});
}
},
addField() {
this.networkList.push({
key: "",
value: {},
});
},
remove(index) {
this.networkList.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

@ -12,14 +12,11 @@
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
</a>
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
<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" :placeholder="$t('Search...')"
autocomplete="off"
/>
<input v-model="searchText" class="form-control search-input" autocomplete="off" />
</form>
</div>
</div>
@ -54,7 +51,6 @@
v-for="(item, index) in sortedStackList"
:key="index"
:stack="item"
:showPathName="filtersActive"
:isSelectMode="selectMode"
:isSelected="isSelected"
:select="select"

View File

@ -1,32 +1,8 @@
<template>
<div :class="{ 'dim' : !stack.isManagedByDockge }">
<div :style="depthMargin">
<!-- Checkbox -->
<div v-if="isSelectMode" class="select-input-wrapper">
<input
class="form-check-input select-input"
type="checkbox"
:aria-label="$t('Check/Uncheck')"
:checked="isSelected(stack.id)"
@click.stop="toggleSelection"
/>
</div>
<router-link :to="`/compose/${stack.name}`" class="item">
<div class="row">
<div class="col-9 col-md-8 small-padding">
<div class="info">
<Uptime :stack="stack" :fixed-width="true" />
{{ stackName }}
</div>
<div v-if="stack.tags.length > 0" class="tags">
<!--<Tag v-for="tag in stack.tags" :key="tag" :item="tag" :size="'sm'" />-->
</div>
</div>
</div>
</router-link>
</div>
</div>
<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>
@ -43,11 +19,6 @@ export default {
type: Object,
default: null,
},
/** Should the stack name show it's parent */
showPathName: {
type: Boolean,
default: false,
},
/** If the user is in select mode */
isSelectMode: {
type: Boolean,
@ -86,11 +57,7 @@ export default {
};
},
stackName() {
if (this.showPathName) {
return this.stack.pathName;
} else {
return this.stack.name;
}
return this.stack.name;
}
},
watch: {

View File

@ -18,34 +18,62 @@ export default {
},
props: {
allowInput: {
type: Boolean,
default: true,
name: {
type: String,
required: true,
},
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 {
name: null,
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: this.allowInput,
cols: TERMINAL_COLS,
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
@ -60,6 +88,24 @@ export default {
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") {
this.$root.getSocket().emit("interactiveTerminal", this.name, (res) => {
if (!res.ok) {
this.$root.toastRes(res);
}
});
}
},
unmounted() {
@ -69,12 +115,77 @@ export default {
methods: {
bind(name) {
if (this.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");
}
this.name = name;
this.$root.bindTerminal(this.name, this.terminal);
},
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() {
}
}
};
</script>

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,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/uptime-kuma/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,117 @@
<template>
<div v-show="false">
<form class="my-4" autocomplete="off" @submit.prevent="saveGeneral">
<!-- Client side Timezone -->
<div 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 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"
/>
</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 */
autoGetPrimaryBaseURL() {
this.settings.primaryBaseURL = location.protocol + "//" + location.host;
},
testChrome() {
this.$root.getSocket().emit("testChrome", this.settings.chromeExecutable, (res) => {
this.$root.toastRes(res);
});
},
},
};
</script>

View File

@ -0,0 +1,204 @@
<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>
<div v-if="! settings.disableAuth" 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.socket.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>

View File

@ -1,4 +1,5 @@
import { createI18n } from "vue-i18n";
// @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 = {

View File

@ -14,6 +14,7 @@ import {
faEyeSlash,
faList,
faPause,
faStop,
faPlay,
faPlus,
faSearch,
@ -51,6 +52,8 @@ import {
faClone,
faCertificate,
faTerminal, faWarehouse, faHome, faRocket,
faRotate,
faCloudArrowDown, faArrowsRotate,
} from "@fortawesome/free-solid-svg-icons";
library.add(
@ -61,6 +64,7 @@ library.add(
faEyeSlash,
faList,
faPause,
faStop,
faPlay,
faPlus,
faSearch,
@ -102,6 +106,9 @@ library.add(
faWarehouse,
faHome,
faRocket,
faRotate,
faCloudArrowDown,
faArrowsRotate,
);
export { FontAwesomeIcon };

View File

@ -14,11 +14,32 @@
"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."
"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"
}

View File

@ -58,7 +58,13 @@
</li>-->
<li>
<router-link to="/settings/general" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<button class="dropdown-item" @click="scanFolder">
<font-awesome-icon icon="arrows-rotate" /> {{ $t("scanFolder") }}
</button>
</li>
<li>
<router-link to="/settings/appearance" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<font-awesome-icon icon="cog" /> {{ $t("Settings") }}
</router-link>
</li>
@ -75,42 +81,10 @@
</ul>
</header>
<!-- Mobile header -->
<header v-else class="d-flex flex-wrap justify-content-center pt-2 pb-2 mb-3">
<router-link to="/dashboard" class="d-flex align-items-center text-dark text-decoration-none">
<object class="bi" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title ms-2">Dockge</span>
</router-link>
</header>
<main>
<router-view v-if="$root.loggedIn" />
<Login v-if="! $root.loggedIn && $root.allowLoginDialog" />
</main>
<!-- Mobile Only -->
<div v-if="$root.isMobile" style="width: 100%; height: calc(60px + env(safe-area-inset-bottom));" />
<nav v-if="$root.isMobile && $root.loggedIn" class="bottom-nav">
<router-link to="/dashboard" class="nav-link">
<div><font-awesome-icon icon="tachometer-alt" /></div>
{{ $t("Home") }}
</router-link>
<router-link to="/list" class="nav-link">
<div><font-awesome-icon icon="list" /></div>
{{ $t("List") }}
</router-link>
<router-link to="/add" class="nav-link">
<div><font-awesome-icon icon="plus" /></div>
{{ $t("Add") }}
</router-link>
<router-link to="/settings" class="nav-link">
<div><font-awesome-icon icon="cog" /></div>
{{ $t("Settings") }}
</router-link>
</nav>
</div>
</template>
@ -163,7 +137,11 @@ export default {
},
methods: {
scanFolder() {
this.$root.getSocket().emit("requestStackList", (res) => {
this.$root.toastRes(res);
});
},
},
};

View File

@ -1,9 +1,11 @@
// Dayjs init inside this, so it has to be the first import
await 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";
await import("../../backend/util-common");
// Dependencies
import "bootstrap";
@ -15,12 +17,6 @@ import "vue-toastification/dist/index.css";
import "xterm/css/xterm.css";
import "./styles/main.scss";
// Dayjs
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import relativeTime from "dayjs/plugin/relativeTime";
// Minxins
import socket from "./mixins/socket";
import lang from "./mixins/lang";
@ -30,18 +26,8 @@ const app = createApp(rootApp());
app.use(Toast, {
position: POSITION.BOTTOM_RIGHT,
containerClassName: "toast-container mb-5",
showCloseButtonOnHover: true,
filterBeforeCreate: (toast, toasts) => {
if (toast.timeout === 0) {
return false;
} else {
return toast;
}
},
});
app.use(router);
app.use(i18n);
app.component("FontAwesomeIcon", FontAwesomeIcon);

View File

@ -4,20 +4,10 @@ import { defineComponent } from "vue";
import jwtDecode from "jwt-decode";
import { Terminal } from "xterm";
let terminalInputBuffer = "";
let cursorPosition = 0;
let socket : Socket;
let terminalMap : Map<string, Terminal> = new Map();
function removeInput() {
const backspaceCount = terminalInputBuffer.length;
const backspaces = "\b \b".repeat(backspaceCount);
cursorPosition = 0;
terminal.write(backspaces);
terminalInputBuffer = "";
}
export default defineComponent({
data() {
return {
@ -38,6 +28,7 @@ export default defineComponent({
allowLoginDialog: false,
username: null,
stackList: {},
composeTemplate: "",
};
},
computed: {
@ -59,49 +50,7 @@ export default defineComponent({
},
mounted() {
return;
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 (terminalInputBuffer.length === 0) {
return;
}
const buffer = terminalInputBuffer;
// Remove the input from the terminal
removeInput();
socket.emit("terminalInput", buffer + e.key, (err) => {
this.toastError(err.msg);
});
} else if (code === 127) { // Backspace
if (cursorPosition > 0) {
terminal.write("\b \b");
cursorPosition--;
terminalInputBuffer = 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");
socket.emit("terminalInputRaw", e.key);
removeInput();
} else {
cursorPosition++;
terminalInputBuffer += e.key;
console.log(terminalInputBuffer);
terminal.write(e.key);
}
});
},
methods: {
/**
@ -206,9 +155,6 @@ export default defineComponent({
socket.on("stackStatusList", (res) => {
if (res.ok) {
console.log(res.stackStatusList);
for (let stackName in res.stackStatusList) {
const stackObj = this.stackList[stackName];
if (stackObj) {

View File

@ -5,7 +5,7 @@
<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" role="group">
<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") }}
@ -16,14 +16,37 @@
{{ $t("saveStackDraft") }}
</button>
<button v-if="!isEditMode" class="btn btn-normal" :disabled="processing" @click="isEditMode = true">{{ $t("editStack") }}</button>
<button v-if="isEditMode && !isAdd" class="btn btn-normal" :disabled="processing" @click="discardStack">{{ $t("discardStack") }}</button>
<button v-if="!isEditMode" class="btn btn-primary" :disabled="processing">{{ $t("updateStack") }}</button>
<button v-if="!isEditMode" class="btn btn-primary" :disabled="processing">{{ $t("startStack") }}</button>
<button v-if="!isEditMode" class="btn btn-primary " :disabled="processing">{{ $t("restartStack") }}</button>
<button v-if="!isEditMode" class="btn btn-danger" :disabled="processing">{{ $t("stopStack") }}</button>
<button v-if="!isEditMode" class="btn btn-danger" :disabled="processing" @click="showDeleteDialog = !showDeleteDialog">{{ $t("deleteStack") }}</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 -->
@ -31,17 +54,18 @@
<Terminal
v-show="showProgressTerminal"
ref="progressTerminal"
:allow-input="false"
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">
<div class="col-lg-6">
<!-- General -->
<div v-if="isAdd">
<h4 class="mb-3">General</h4>
<h4 class="mb-3">{{ $t("general") }}</h4>
<div class="shadow-box big-padding mb-3">
<!-- Stack Name -->
<div class="mb-3">
@ -51,24 +75,75 @@
</div>
</div>
<h4 class="mb-3">Containers</h4>
<div class="shadow-box big-padding mb-3">
<div v-for="(service, name) in jsonConfig.services" :key="name">
{{ name }} {{ service }}
</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]"
/>
</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">Logs</h4>
<Terminal
ref="combinedTerminal"
class="mb-3 terminal"
:name="combinedTerminalName"
:rows="combinedTerminalRows"
:cols="combinedTerminalCols"
style="height: 350px;"
></Terminal>
</div>
</div>
<div class="col">
<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"></prism-editor>
<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 class="mb-3">
<div v-if="isEditMode" class="mb-3">
{{ yamlError }}
</div>
<h4 class="mb-3">{{ $tc("network", 2) }}</h4>
<div class="shadow-box big-padding mb-3">
<NetworkInput />
</div>
<h4 class="mb-3">{{ $tc("volume", 2) }}</h4>
<div class="shadow-box big-padding mb-3">
</div>
<!-- <div class="shadow-box big-padding mb-3">
<div class="mb-3">
<label for="name" class="form-label"> Search Templates</label>
@ -80,7 +155,7 @@
</div>
</div>
<div v-if="!stack.isManagedByDockge">
<div v-if="!stack.isManagedByDockge && !processing">
{{ $t("stackNotManagedByDockgeMsg") }}
</div>
@ -96,43 +171,65 @@
import { highlight, languages } from "prismjs/components/prism-core";
import { PrismEditor } from "vue-prism-editor";
import "prismjs/components/prism-yaml";
import * as yaml from "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 { getComposeTerminalName, PROGRESS_TERMINAL_ROWS } from "../../../backend/util-common";
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: always
ports:
- "8080:8080"
- "8080:80"
`;
let yamlErrorTimeout = 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: {
},
isEditMode: false,
submitted: false,
showDeleteDialog: false,
newContainerName: "",
};
},
computed: {
@ -147,11 +244,59 @@ export default {
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() {
this.yamlCodeChange();
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,
},
@ -161,28 +306,53 @@ export default {
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: template,
composeYAML,
isManagedByDockge: true,
};
this.yamlCodeChange();
} else {
this.stack.name = this.$route.params.stackName;
this.loadStack();
}
},
methods: {
bindTerminal() {
// Bind Terminal output
const terminalName = getComposeTerminalName(this.stack.name);
this.$refs.progressTerminal.bind(terminalName);
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 {
@ -190,20 +360,41 @@ export default {
}
});
},
deployStack() {
this.processing = true;
this.bindTerminal();
if (!this.jsonConfig.services) {
this.$root.toastError("No services found in compose.yaml");
this.processing = false;
return;
}
// Set the stack name if empty, use the first container name
if (!this.stack.name) {
let serviceName = Object.keys(this.jsonConfig.services)[0];
let service = this.jsonConfig.services[serviceName];
if (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;
@ -212,10 +403,48 @@ export default {
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);
@ -233,9 +462,28 @@ export default {
highlighter(code) {
return highlight(code, languages.yaml);
},
yamlCodeChange() {
try {
this.jsonConfig = yaml.parse(this.stack.composeYAML) ?? {};
let doc = parseDocument(this.stack.composeYAML);
if (doc.errors.length > 0) {
throw doc.errors[0];
}
this.yamlDoc = doc;
console.log(this.yamlDoc);
this.jsonConfig = doc.toJS() ?? {};
if (!this.jsonConfig.version) {
this.jsonConfig.version = "3.8";
}
if (!this.jsonConfig.services) {
this.jsonConfig.services = {};
}
clearTimeout(yamlErrorTimeout);
this.yamlError = "";
} catch (e) {
clearTimeout(yamlErrorTimeout);
@ -250,6 +498,76 @@ export default {
}
}
},
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: "always",
};
this.newContainerName = "";
let element = this.$refs.containerList.lastElementChild;
element.scrollIntoView({
block: "start",
behavior: "smooth"
});
},
combineNetworks() {
let networks = this.jsonConfig.networks;
if (!networks) {
networks = {};
}
for (let serviceName in this.jsonConfig.services) {
let service = this.jsonConfig.services[serviceName];
let serviceNetworks = service.networks;
if (!networks) {
continue;
}
// If it is an array, it should be array of string
if (Array.isArray(serviceNetworks)) {
for (let n of serviceNetworks) {
console.log(n);
if (!n) {
continue;
}
if (!networks[n]) {
networks[n] = {};
}
}
} else if (typeof serviceNetworks === "object") {
}
}
console.debug(networks);
return networks;
}
}
};
</script>
@ -259,7 +577,9 @@ export default {
height: 200px;
}
.editor-box.edit-mode {
background-color: #2c2f38 !important;
.editor-box {
&.edit-mode {
background-color: #2c2f38 !important;
}
}
</style>

View File

@ -5,31 +5,35 @@
<div>
<p>
Allowed commands: <code>docker</code>, <code>ls</code>, <code>cd</code>
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 ref="terminal" :allow-input="true" class="terminal" :rows="20"></Terminal>
<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() {
// Bind Terminal Component to Socket.io
const terminalName = "console";
this.$refs.terminal.bind(terminalName);
// Create a new Terminal
this.$root.getSocket().emit("mainTerminal", terminalName, (res) => {
if (!res.ok) {
this.$root.toastRes(res);
}
});
},
methods: {
@ -39,6 +43,6 @@ export default {
<style scoped lang="scss">
.terminal {
height: calc(100vh - 200px);
height: 410px;
}
</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

@ -1,14 +1,14 @@
<template>
<div class="container-fluid">
<div class="row">
<div v-if="!$root.isMobile" class="col-12 col-md-5 col-xl-4">
<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-7 col-xl-8 mb-3">
<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>

View File

@ -8,17 +8,34 @@
<div class="shadow-box big-padding text-center mb-4">
<div class="row">
<div class="col">
<h3>{{ $t("Up") }}</h3>
<span class="num">123</span>
<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 * as convertDockerRunToCompose from "composerize";
import { statusNameShort } from "../../../backend/util-common";
export default {
components: {
@ -41,8 +58,22 @@ export default {
},
importantHeartBeatListLength: 0,
displayedRecords: [],
dockerRunCommand: "",
};
},
computed: {
activeNum() {
return this.getStatusNum("active");
},
inactiveNum() {
return this.getStatusNum("inactive");
},
exitedNum() {
return this.getStatusNum("exited");
},
},
watch: {
perPage() {
this.$nextTick(() => {
@ -67,6 +98,31 @@ export default {
},
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() {
try {
if (this.dockerRunCommand.trim() === "docker run") {
throw new Error("Please enter a docker run command");
}
this.$root.composeTemplate = convertDockerRunToCompose(this.dockerRunCommand);
this.$router.push("/compose");
} catch (e) {
this.$root.toastError(e.message);
}
},
/**
* Updates the displayed records when a new important heartbeat arrives.
* @param {object} heartbeat - The heartbeat object received.
@ -134,9 +190,17 @@ export default {
.num {
font-size: 30px;
color: $primary;
font-weight: bold;
display: block;
&.active {
color: $primary;
}
&.exited {
color: $danger;
}
}
.shadow-box {
@ -155,4 +219,9 @@ table {
overflow-wrap: break-word;
}
}
.docker-run {
background-color: $dark-bg !important;
border: none;
}
</style>

View File

@ -0,0 +1,283 @@
<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;
}
if (this.settings.searchEngineIndex === undefined) {
this.settings.searchEngineIndex = false;
}
if (this.settings.entryPage === undefined) {
this.settings.entryPage = "dashboard";
}
if (this.settings.nscd === undefined) {
this.settings.nscd = true;
}
if (this.settings.dnsCache === undefined) {
this.settings.dnsCache = false;
}
if (this.settings.keepDataPeriodDays === undefined) {
this.settings.keepDataPeriodDays = 180;
}
if (this.settings.tlsExpiryNotifyDays === undefined) {
this.settings.tlsExpiryNotifyDays = [ 7, 14, 21 ];
}
if (this.settings.trustProxy === undefined) {
this.settings.trustProxy = false;
}
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

@ -6,6 +6,15 @@ 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 = [
{
@ -30,14 +39,42 @@ const routes = [
name: "compose",
component: Compose,
props: true,
children: [
{
path: "/compose/:stackName/terminal/: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,
},
]
},
]
},
]

View File

@ -35,6 +35,10 @@ textarea.form-control {
color: $maintenance !important;
}
::placeholder {
color: $dark-font-color3 !important;
}
.incident a,
.bg-maintenance a {
color: inherit;
@ -45,7 +49,7 @@ textarea.form-control {
.dark & {
.list-group-item {
background-color: $dark-bg;
background-color: $dark-bg2;
color: $dark-font-color;
border-color: $dark-border-color;
}
@ -320,6 +324,7 @@ optgroup {
.bg-primary {
color: $dark-font-color2;
background: $primary-gradient;
}
.btn-secondary {
@ -486,21 +491,19 @@ optgroup {
}
.item {
display: block;
display: flex;
align-items: center;
height: 52px;
text-decoration: none;
padding: 13px 15px 10px 15px;
border-radius: 10px;
transition: all ease-in-out 0.15s;
width: 100%;
padding: 0 8px;
&.disabled {
opacity: 0.3;
}
.info {
white-space: nowrap;
overflow: hidden;
}
&:hover {
background-color: $highlight-white;
}
@ -508,9 +511,10 @@ optgroup {
&.active {
background-color: #cdf8f4;
}
.tags {
// Removes margin to line up tags list with uptime percentage
margin-left: -0.25rem;
.title {
display: inline-block;
margin-top: -4px;
}
}
}
@ -676,5 +680,18 @@ code {
}
}
// 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

@ -212,3 +212,4 @@ export function getToastErrorTimeout() {
return errorTimeout;
}

View File

@ -20,6 +20,7 @@
"check-password-strength": "~2.0.7",
"command-exists": "^1.2.9",
"compare-versions": "~6.1.0",
"composerize": "^1.4.1",
"croner": "^7.0.4",
"dayjs": "^1.11.10",
"express": "~4.18.2",
@ -46,6 +47,7 @@
"@fortawesome/free-regular-svg-icons": "6.4.2",
"@fortawesome/free-solid-svg-icons": "6.4.2",
"@fortawesome/vue-fontawesome": "3.0.3",
"@types/bootstrap": "^5.2.8",
"@types/command-exists": "~1.2.2",
"@types/express": "~4.17.20",
"@types/jsonwebtoken": "~9.0.4",
@ -70,6 +72,7 @@
"vue-eslint-parser": "^9.3.2",
"vue-i18n": "~9.5.0",
"vue-prism-editor": "~2.0.0-alpha.2",
"vue-qrcode": "^2.2.0",
"vue-router": "~4.2.5",
"vue-toastification": "~2.0.0-rc.5",
"xterm": "~5.3.0",

211
pnpm-lock.yaml generated
View File

@ -23,6 +23,9 @@ dependencies:
compare-versions:
specifier: ~6.1.0
version: 6.1.0
composerize:
specifier: ^1.4.1
version: 1.4.1
croner:
specifier: ^7.0.4
version: 7.0.4
@ -93,6 +96,9 @@ devDependencies:
'@fortawesome/vue-fontawesome':
specifier: 3.0.3
version: 3.0.3(@fortawesome/fontawesome-svg-core@6.4.2)(vue@3.3.6)
'@types/bootstrap':
specifier: ^5.2.8
version: 5.2.8
'@types/command-exists':
specifier: ~1.2.2
version: 1.2.2
@ -165,6 +171,9 @@ devDependencies:
vue-prism-editor:
specifier: ~2.0.0-alpha.2
version: 2.0.0-alpha.2(vue@3.3.6)
vue-qrcode:
specifier: ^2.2.0
version: 2.2.0(qrcode@1.5.3)(vue@3.3.6)
vue-router:
specifier: ~4.2.5
version: 4.2.5(vue@3.3.6)
@ -809,6 +818,12 @@ packages:
'@types/node': 20.8.7
dev: true
/@types/bootstrap@5.2.8:
resolution: {integrity: sha512-14do+aWZPc1w3G+YevSsy8eas1XEPhTOUNBhQX/r12YKn7ySssATJusBQ/HCQAd2nq54U8vvrftHSb1YpeJUXg==}
dependencies:
'@popperjs/core': 2.11.8
dev: true
/@types/command-exists@1.2.2:
resolution: {integrity: sha512-1qKPTkjLmghE5C7UUHXGcLaG8MNftchOcLAIryUXNKahRO5beS+iJ9rIL8XD4+B8K2phjYUsPQDox1FRX4KMTQ==}
dev: true
@ -1330,7 +1345,6 @@ packages:
dependencies:
sprintf-js: 1.0.3
dev: false
optional: true
/argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@ -1578,6 +1592,10 @@ packages:
engines: {node: '>=6'}
dev: true
/camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
/chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
@ -1653,6 +1671,14 @@ packages:
dev: false
optional: true
/cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
dev: true
/color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies:
@ -1730,6 +1756,17 @@ packages:
resolution: {integrity: sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==}
dev: false
/composerize@1.4.1:
resolution: {integrity: sha512-63bKZMgOE8Fd6jBSmkZFdOtKM5A46TafrWgNHVXIBfTu/YBBZw91xkjALG3JiuOatX4tMK04uM37O4HGypnkfQ==}
hasBin: true
dependencies:
core-js: 2.6.12
deepmerge: 2.2.1
invariant: 2.2.4
yamljs: 0.3.0
yargs-parser: 13.1.2
dev: false
/concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@ -1772,6 +1809,12 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/core-js@2.6.12:
resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
requiresBuild: true
dev: false
/cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
@ -1875,6 +1918,10 @@ packages:
dependencies:
ms: 2.1.2
/decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
/decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@ -1891,6 +1938,11 @@ packages:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
/deepmerge@2.2.1:
resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==}
engines: {node: '>=0.10.0'}
dev: false
/define-data-property@1.1.1:
resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==}
engines: {node: '>= 0.4'}
@ -1935,6 +1987,10 @@ packages:
engines: {node: '>=8'}
dev: false
/dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dev: true
/dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@ -1974,12 +2030,15 @@ packages:
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
requiresBuild: true
dev: false
/emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
dev: false
/encode-utf8@1.0.3:
resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
dev: true
/encodeurl@1.0.2:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
@ -2417,6 +2476,14 @@ packages:
array-back: 3.1.0
dev: false
/find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
dev: true
/find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@ -2552,6 +2619,11 @@ packages:
is-property: 1.0.2
dev: false
/get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
dev: true
/get-intrinsic@1.2.2:
resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==}
dependencies:
@ -2861,6 +2933,12 @@ packages:
engines: {node: '>= 0.10'}
dev: false
/invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
dependencies:
loose-envify: 1.4.0
dev: false
/ip@1.1.8:
resolution: {integrity: sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==}
requiresBuild: true
@ -2904,7 +2982,6 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
requiresBuild: true
dev: false
/is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
@ -2955,6 +3032,10 @@ packages:
dev: false
optional: true
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
dev: false
/js-yaml@4.1.0:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
@ -3170,6 +3251,13 @@ packages:
engines: {node: '>=14'}
dev: true
/locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
dependencies:
p-locate: 4.1.0
dev: true
/locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@ -3227,6 +3315,13 @@ packages:
resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
dev: false
/loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
dependencies:
js-tokens: 4.0.0
dev: false
/lru-cache@10.0.1:
resolution: {integrity: sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==}
engines: {node: 14 || >=16.14}
@ -3706,6 +3801,13 @@ packages:
type-check: 0.4.0
dev: true
/p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
dependencies:
p-try: 2.2.0
dev: true
/p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@ -3713,6 +3815,13 @@ packages:
yocto-queue: 0.1.0
dev: true
/p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
dependencies:
p-limit: 2.3.0
dev: true
/p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@ -3729,6 +3838,11 @@ packages:
dev: false
optional: true
/p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
dev: true
/pac-proxy-agent@7.0.1:
resolution: {integrity: sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==}
engines: {node: '>= 14'}
@ -3949,6 +4063,11 @@ packages:
dev: false
optional: true
/pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
dev: true
/postcss-selector-parser@6.0.13:
resolution: {integrity: sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==}
engines: {node: '>=4'}
@ -4074,6 +4193,17 @@ packages:
engines: {node: '>=6'}
dev: true
/qrcode@1.5.3:
resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
engines: {node: '>=10.13.0'}
hasBin: true
dependencies:
dijkstrajs: 1.0.3
encode-utf8: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
dev: true
/qs@6.11.0:
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
engines: {node: '>=0.6'}
@ -4166,6 +4296,11 @@ packages:
engines: {node: '>=6'}
dev: false
/require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
dev: true
/require-in-the-middle@5.2.0:
resolution: {integrity: sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==}
engines: {node: '>=6'}
@ -4179,6 +4314,10 @@ packages:
dev: false
optional: true
/require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
dev: true
/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -4321,7 +4460,6 @@ packages:
/set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
dev: false
/set-function-length@1.1.1:
resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==}
@ -4516,7 +4654,6 @@ packages:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
requiresBuild: true
dev: false
optional: true
/sprintf-js@1.1.2:
resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==}
@ -4554,7 +4691,6 @@ packages:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
dev: false
/string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
@ -4729,8 +4865,6 @@ packages:
/tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
requiresBuild: true
dev: false
optional: true
/tsx@3.14.0:
resolution: {integrity: sha512-xHtFaKtHxM9LOklMmJdI3BEnQq/D5F73Of2E1GDrITi9sgoVkvIsrQUTY1G8FlmGtA+awCI4EBlTRRYxkL2sRg==}
@ -5045,6 +5179,17 @@ packages:
vue: 3.3.6(typescript@5.2.2)
dev: true
/vue-qrcode@2.2.0(qrcode@1.5.3)(vue@3.3.6):
resolution: {integrity: sha512-pEwy/IznxEY5MXptFLaxbGdeDWIJRgU5VhBcFmg1avDjD2z2jjWAGE5dlDwqagXtUjcgkvFSSQ40boog1maLuw==}
peerDependencies:
qrcode: ^1.5.0
vue: ^2.7.0 || ^3.0.0
dependencies:
qrcode: 1.5.3
tslib: 2.6.2
vue: 3.3.6(typescript@5.2.2)
dev: true
/vue-router@4.2.5(vue@3.3.6):
resolution: {integrity: sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==}
peerDependencies:
@ -5098,6 +5243,10 @@ packages:
webidl-conversions: 3.0.1
dev: false
/which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
dev: true
/which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -5119,6 +5268,15 @@ packages:
typical: 5.2.0
dev: false
/wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
dev: true
/wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -5206,6 +5364,10 @@ packages:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
dev: true
/y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
dev: true
/yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
@ -5221,7 +5383,38 @@ packages:
argparse: 1.0.10
glob: 7.2.3
dev: false
optional: true
/yargs-parser@13.1.2:
resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==}
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
dev: false
/yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
dev: true
/yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
dev: true
/yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}