This commit is contained in:
Louis Lam 2023-10-29 15:25:52 +08:00
parent 7d1da2ad99
commit e67d08b7b3
19 changed files with 483 additions and 104 deletions

View file

@ -13,6 +13,7 @@ A fancy, easy-to-use and reactive docker stack (`docker-compose.yml`) manager.
- Interactive web terminal for containers and any docker commands - Interactive web terminal for containers and any docker commands
- Reactive - Everything is just responsive. Progress and terminal output are in real-time - 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, 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`
## Installation ## Installation
@ -30,4 +31,6 @@ If you love this project, please consider giving this project a ⭐.
- Container stats - Container stats
- Get app icons - Get app icons
- Switch Docker context - Switch Docker context
- Support Dockerfile and build
- Zero-config private docker registry - Zero-config private docker registry
- Support Docker swarm

View file

@ -26,6 +26,8 @@ import expressStaticGzip from "express-static-gzip";
import path from "path"; import path from "path";
import { TerminalSocketHandler } from "./socket-handlers/terminal-socket-handler"; import { TerminalSocketHandler } from "./socket-handlers/terminal-socket-handler";
import { Stack } from "./stack"; import { Stack } from "./stack";
import { Cron } from "croner";
import childProcess from "child_process";
export class DockgeServer { export class DockgeServer {
app : Express; app : Express;
@ -248,6 +250,15 @@ export class DockgeServer {
} else { } else {
log.info("server", `Listening on ${this.config.port}`); log.info("server", `Listening on ${this.config.port}`);
} }
// Run every 5 seconds
const job = Cron("*/2 * * * * *", {
protect: true, // Enabled over-run protection.
}, () => {
log.debug("server", "Cron job running");
this.sendStackList(true);
});
}); });
} }
@ -412,18 +423,40 @@ export class DockgeServer {
return jwtSecretBean; return jwtSecretBean;
} }
sendStackList(socket : DockgeSocket) { sendStackList(useCache = false) {
let room = socket.userID.toString(); let stackList = Stack.getStackList(this, useCache);
let stackList = Stack.getStackList(this); let roomList = this.io.sockets.adapter.rooms.keys();
let list = {}; for (let room of roomList) {
// Check if the room is a number (user id)
for (let stack of stackList) { if (Number(room)) {
list[stack.name] = stack.toSimpleJSON(); this.io.to(room).emit("stackList", {
ok: true,
stackList: Object.fromEntries(stackList),
});
}
} }
}
this.io.to(room).emit("stackList", { sendStackStatusList() {
ok: true, let statusList = Stack.getStatusList();
stackList: list,
}); let roomList = this.io.sockets.adapter.rooms.keys();
for (let room of roomList) {
// Check if the room is a number (user id)
if (Number(room)) {
log.debug("server", "Send stack status list to room " + room);
this.io.to(room).emit("stackStatusList", {
ok: true,
stackStatusList: Object.fromEntries(statusList),
});
} else {
log.debug("server", "Skip sending stack status list to room " + room);
}
}
}
get stackDirFullPath() {
return path.resolve(this.stacksDir);
} }
} }

View file

@ -1,18 +1,6 @@
import { SocketHandler } from "../socket-handler.js"; import { SocketHandler } from "../socket-handler.js";
import { DockgeServer } from "../dockge-server"; import { DockgeServer } from "../dockge-server";
import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server"; import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server";
import { log } from "../log";
import yaml from "yaml";
import path from "path";
import fs from "fs";
import {
allowedCommandList,
allowedRawKeys,
getComposeTerminalName,
isDev,
PROGRESS_TERMINAL_ROWS
} from "../util-common";
import { Terminal } from "../terminal";
import { Stack } from "../stack"; import { Stack } from "../stack";
export class DockerSocketHandler extends SocketHandler { export class DockerSocketHandler extends SocketHandler {
@ -23,6 +11,7 @@ export class DockerSocketHandler extends SocketHandler {
checkLogin(socket); checkLogin(socket);
const stack = this.saveStack(socket, server, name, composeYAML, isAdd); const stack = this.saveStack(socket, server, name, composeYAML, isAdd);
await stack.deploy(socket); await stack.deploy(socket);
server.sendStackList();
callback({ callback({
ok: true, ok: true,
}); });
@ -39,7 +28,33 @@ export class DockerSocketHandler extends SocketHandler {
ok: true, ok: true,
"msg": "Saved" "msg": "Saved"
}); });
server.sendStackList(socket); server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
socket.on("deleteStack", async (name : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(name) !== "string") {
throw new ValidationError("Name must be a string");
}
const stack = Stack.getStack(server, name);
try {
await stack.delete(socket);
} catch (e) {
server.sendStackList();
throw e;
}
server.sendStackList();
callback({
ok: true,
msg: "Deleted"
});
} catch (e) { } catch (e) {
callbackError(e, callback); callbackError(e, callback);
} }

View file

@ -5,8 +5,14 @@ import { log } from "../log";
import yaml from "yaml"; import yaml from "yaml";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { allowedCommandList, allowedRawKeys, isDev } from "../util-common"; import {
import { Terminal } from "../terminal"; allowedCommandList,
allowedRawKeys,
getComposeTerminalName,
isDev,
PROGRESS_TERMINAL_ROWS
} from "../util-common";
import { MainTerminal, Terminal } from "../terminal";
export class TerminalSocketHandler extends SocketHandler { export class TerminalSocketHandler extends SocketHandler {
create(socket : DockgeSocket, server : DockgeServer) { create(socket : DockgeSocket, server : DockgeServer) {
@ -57,12 +63,36 @@ export class TerminalSocketHandler extends SocketHandler {
}); });
// Create Terminal // Create Terminal
socket.on("terminalCreate", async (terminalName : unknown, callback : unknown) => { socket.on("mainTerminal", async (terminalName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(terminalName) !== "string") {
throw new ValidationError("Terminal name must be a string.");
}
log.debug("deployStack", "Terminal name: " + terminalName);
let terminal = Terminal.getTerminal(terminalName);
if (!terminal) {
terminal = new MainTerminal(server, terminalName);
terminal.rows = 50;
log.debug("deployStack", "Terminal created");
}
terminal.join(socket);
terminal.start();
callback({
ok: true,
});
} catch (e) {
callbackError(e, callback);
}
}); });
// Join Terminal // Join Terminal
socket.on("terminalJoin", async (terminalName : unknown, callback : unknown) => { socket.on("terminalJoin", async (terminalName : unknown, callback) => {
if (typeof(callback) !== "function") { if (typeof(callback) !== "function") {
log.debug("console", "Callback is not a function."); log.debug("console", "Callback is not a function.");
return; return;

View file

@ -4,15 +4,28 @@ import { log } from "./log";
import yaml from "yaml"; import yaml from "yaml";
import { DockgeSocket, ValidationError } from "./util-server"; import { DockgeSocket, ValidationError } from "./util-server";
import path from "path"; import path from "path";
import { getComposeTerminalName, PROGRESS_TERMINAL_ROWS } from "./util-common"; import {
CREATED_FILE,
CREATED_STACK,
EXITED,
getComposeTerminalName,
PROGRESS_TERMINAL_ROWS,
RUNNING,
UNKNOWN
} from "./util-common";
import { Terminal } from "./terminal"; import { Terminal } from "./terminal";
import childProcess from "child_process";
export class Stack { export class Stack {
name: string; name: string;
protected _status: number = UNKNOWN;
protected _composeYAML?: string; protected _composeYAML?: string;
protected _configFilePath?: string;
protected server: DockgeServer; protected server: DockgeServer;
protected static managedStackList: Map<string, Stack> = new Map();
constructor(server : DockgeServer, name : string, composeYAML? : string) { constructor(server : DockgeServer, name : string, composeYAML? : string) {
this.name = name; this.name = name;
this.server = server; this.server = server;
@ -24,16 +37,30 @@ export class Stack {
return { return {
...obj, ...obj,
composeYAML: this.composeYAML, composeYAML: this.composeYAML,
isManagedByDockge: this.isManagedByDockge,
}; };
} }
toSimpleJSON() : object { toSimpleJSON() : object {
return { return {
name: this.name, name: this.name,
status: this._status,
tags: [], tags: [],
}; };
} }
get isManagedByDockge() : boolean {
if (this._configFilePath) {
return this._configFilePath.startsWith(this.server.stackDirFullPath) && fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();
} else {
return false;
}
}
get status() : number {
return this._status;
}
validate() { validate() {
// Check name, allows [a-z][A-Z][0-9] _ - only // Check name, allows [a-z][A-Z][0-9] _ - only
if (!this.name.match(/^[a-zA-Z0-9_-]+$/)) { if (!this.name.match(/^[a-zA-Z0-9_-]+$/)) {
@ -101,7 +128,7 @@ export class Stack {
fs.writeFileSync(path.join(dir, "compose.yaml"), this.composeYAML); fs.writeFileSync(path.join(dir, "compose.yaml"), this.composeYAML);
} }
async deploy(socket? : DockgeSocket) : Promise<number> { deploy(socket? : DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name); const terminalName = getComposeTerminalName(this.name);
log.debug("deployStack", "Terminal name: " + terminalName); log.debug("deployStack", "Terminal name: " + terminalName);
@ -129,30 +156,138 @@ export class Stack {
}); });
} }
static getStackList(server : DockgeServer) : Stack[] { delete(socket?: DockgeSocket) : Promise<number> {
let stacksDir = server.stacksDir; // Docker compose down
let stackList : Stack[] = []; const terminalName = getComposeTerminalName(this.name);
log.debug("deleteStack", "Terminal name: " + terminalName);
// Scan the stacks directory, and get the stack list const terminal = new Terminal(this.server, terminalName, "docker-compose", [ "down" ], this.path);
let filenameList = fs.readdirSync(stacksDir);
log.debug("stack", filenameList); terminal.rows = PROGRESS_TERMINAL_ROWS;
for (let filename of filenameList) { if (socket) {
let relativePath = path.join(stacksDir, filename); terminal.join(socket);
if (fs.statSync(relativePath).isDirectory()) { log.debug("deployStack", "Terminal joined");
let stack = new Stack(server, filename); } else {
stackList.push(stack); log.debug("deployStack", "No socket, not joining");
}
} }
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();
});
}
static getStackList(server : DockgeServer, useCacheForManaged = false) : Map<string, Stack> {
let stacksDir = server.stacksDir;
let stackList : Map<string, Stack>;
if (useCacheForManaged && this.managedStackList.size > 0) {
stackList = this.managedStackList;
} else {
stackList = new Map<string, Stack>();
// Scan the stacks directory, and get the stack list
let filenameList = fs.readdirSync(stacksDir);
for (let filename of filenameList) {
try {
let stack = this.getStack(server, filename);
stack._status = CREATED_FILE;
stackList.set(filename, stack);
} catch (e) {
log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`);
}
}
// Cache by copying
this.managedStackList = new Map(stackList);
}
// Also get the list from `docker compose ls --all --format json`
let res = childProcess.execSync("docker compose ls --all --format json");
let composeList = JSON.parse(res.toString());
for (let composeStack of composeList) {
let stack = stackList.get(composeStack.Name);
// This stack probably is not managed by Dockge, but we still want to show it
if (!stack) {
stack = new Stack(server, composeStack.Name);
stackList.set(composeStack.Name, stack);
}
stack._status = this.statusConvert(composeStack.Status);
stack._configFilePath = composeStack.ConfigFiles;
}
return stackList; return stackList;
} }
/**
* Get the status list, it will be used to update the status of the stacks
* Not all status will be returned, only the stack that is deployed or created to `docker compose` will be returned
*/
static getStatusList() : Map<string, number> {
let statusList = new Map<string, number>();
let res = childProcess.execSync("docker compose ls --all --format json");
let composeList = JSON.parse(res.toString());
for (let composeStack of composeList) {
statusList.set(composeStack.Name, this.statusConvert(composeStack.Status));
}
return statusList;
}
static statusConvert(status : string) : number {
if (status.startsWith("created")) {
return CREATED_STACK;
} else if (status.startsWith("running")) {
return RUNNING;
} else if (status.startsWith("exited")) {
return EXITED;
} else {
return UNKNOWN;
}
}
static getStack(server: DockgeServer, stackName: string) : Stack { static getStack(server: DockgeServer, stackName: string) : Stack {
let dir = path.join(server.stacksDir, stackName); let dir = path.join(server.stacksDir, stackName);
if (!fs.existsSync(dir)) {
throw new ValidationError("Stack not found"); if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
// Maybe it is a stack managed by docker compose directly
let stackList = this.getStackList(server);
let stack = stackList.get(stackName);
if (stack) {
return stack;
} else {
// Really not found
throw new ValidationError("Stack not found");
}
} }
return new Stack(server, stackName);
let stack = new Stack(server, stackName);
stack._status = UNKNOWN;
stack._configFilePath = path.resolve(dir);
return stack;
} }
} }

View file

@ -47,6 +47,10 @@ export class Terminal {
} }
public start() { public start() {
if (this._ptyProcess) {
return;
}
this._ptyProcess = pty.spawn(this.file, this.args, { this._ptyProcess = pty.spawn(this.file, this.args, {
name: this.name, name: this.name,
cwd: this.cwd, cwd: this.cwd,
@ -139,10 +143,12 @@ export class MainTerminal extends InteractiveTerminal {
constructor(server : DockgeServer, name : string, cwd : string = "./") { constructor(server : DockgeServer, name : string, cwd : string = "./") {
let shell; let shell;
if (commandExistsSync("pwsh")) { if (os.platform() === "win32") {
shell = "pwsh"; if (commandExistsSync("pwsh.exe")) {
} else if (os.platform() === "win32") { shell = "pwsh.exe";
shell = "powershell.exe"; } else {
shell = "powershell.exe";
}
} else { } else {
shell = "bash"; shell = "bash";
} }

View file

@ -1,7 +1,15 @@
// For loading dayjs plugins, don't remove event though it is not used in this file /*
* Common utilities for backend and frontend
*/
// Init dayjs
import dayjs from "dayjs"; import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
let randomBytes : (numBytes: number) => Uint8Array; let randomBytes : (numBytes: number) => Uint8Array;
@ -17,6 +25,58 @@ if (typeof window !== "undefined" && window.crypto) {
randomBytes = (await import("node:crypto")).randomBytes; randomBytes = (await import("node:crypto")).randomBytes;
} }
// Stack Status
export const UNKNOWN = 0;
export const CREATED_FILE = 1;
export const CREATED_STACK = 2;
export const RUNNING = 3;
export const EXITED = 4;
export function statusName(status : number) : string {
switch (status) {
case CREATED_FILE:
return "draft";
case CREATED_STACK:
return "created_stack";
case RUNNING:
return "running";
case EXITED:
return "exited";
default:
return "unknown";
}
}
export function statusNameShort(status : number) : string {
switch (status) {
case CREATED_FILE:
return "draft";
case CREATED_STACK:
return "inactive";
case RUNNING:
return "active";
case EXITED:
return "inactive";
default:
return "?";
}
}
export function statusColor(status : number) : string {
switch (status) {
case CREATED_FILE:
return "dark";
case CREATED_STACK:
return "danger";
case RUNNING:
return "primary";
case EXITED:
return "danger";
default:
return "secondary";
}
}
export const isDev = process.env.NODE_ENV === "development"; export const isDev = process.env.NODE_ENV === "development";
export const TERMINAL_COLS = 80; export const TERMINAL_COLS = 80;
export const TERMINAL_ROWS = 10; export const TERMINAL_ROWS = 10;
@ -30,6 +90,7 @@ export const allowedCommandList : string[] = [
"cd", "cd",
"dir", "dir",
]; ];
export const allowedRawKeys = [ export const allowedRawKeys = [
"\u0003", // Ctrl + C "\u0003", // Ctrl + C
]; ];

View file

@ -7,6 +7,8 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
BButton: typeof import('bootstrap-vue-next')['BButton']
BModal: typeof import('bootstrap-vue-next')['BModal']
Confirm: typeof import('./src/components/Confirm.vue')['default'] Confirm: typeof import('./src/components/Confirm.vue')['default']
Login: typeof import('./src/components/Login.vue')['default'] Login: typeof import('./src/components/Login.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

View file

@ -71,6 +71,7 @@
<script> <script>
import Confirm from "../components/Confirm.vue"; import Confirm from "../components/Confirm.vue";
import StackListItem from "../components/StackListItem.vue"; import StackListItem from "../components/StackListItem.vue";
import { CREATED_FILE, CREATED_STACK, EXITED, RUNNING, UNKNOWN } from "../../../backend/util-common";
export default { export default {
components: { components: {
@ -154,8 +155,30 @@ export default {
return searchTextMatch && activeMatch && tagsMatch; return searchTextMatch && activeMatch && tagsMatch;
}); });
// Filter result by active state, weight and alphabetical
result.sort((m1, m2) => { result.sort((m1, m2) => {
if (m1.status !== m2.status) {
if (m2.status === RUNNING) {
return 1;
} else if (m1.status === RUNNING) {
return -1;
} else if (m2.status === EXITED) {
return 1;
} else if (m1.status === EXITED) {
return -1;
} else if (m2.status === CREATED_STACK) {
return 1;
} else if (m1.status === CREATED_STACK) {
return -1;
} else if (m2.status === CREATED_FILE) {
return 1;
} else if (m1.status === CREATED_FILE) {
return -1;
} else if (m2.status === UNKNOWN) {
return 1;
} else if (m1.status === UNKNOWN) {
return -1;
}
}
return m1.name.localeCompare(m2.name); return m1.name.localeCompare(m2.name);
}); });

View file

@ -1,5 +1,5 @@
<template> <template>
<div> <div :class="{ 'dim' : !stack.isManagedByDockge }">
<div :style="depthMargin"> <div :style="depthMargin">
<!-- Checkbox --> <!-- Checkbox -->
<div v-if="isSelectMode" class="select-input-wrapper"> <div v-if="isSelectMode" class="select-input-wrapper">
@ -16,7 +16,7 @@
<div class="row"> <div class="row">
<div class="col-9 col-md-8 small-padding"> <div class="col-9 col-md-8 small-padding">
<div class="info"> <div class="info">
<Uptime :stack="stack" type="24" :pill="true" /> <Uptime :stack="stack" :fixed-width="true" />
{{ stackName }} {{ stackName }}
</div> </div>
<div v-if="stack.tags.length > 0" class="tags"> <div v-if="stack.tags.length > 0" class="tags">
@ -36,7 +36,6 @@ import Uptime from "./Uptime.vue";
export default { export default {
components: { components: {
Uptime Uptime
}, },
props: { props: {
/** Stack this represents */ /** Stack this represents */
@ -181,4 +180,8 @@ export default {
z-index: 15; z-index: 15;
} }
.dim {
opacity: 0.5;
}
</style> </style>

View file

@ -46,7 +46,7 @@ export default {
rows: this.rows, rows: this.rows,
}); });
this.terminal.loadAddon(new WebLinksAddon()); //this.terminal.loadAddon(new WebLinksAddon());
// Bind to a div // Bind to a div
this.terminal.open(this.$refs.terminal); this.terminal.open(this.$refs.terminal);

View file

@ -1,23 +1,17 @@
<template> <template>
<span :class="className" :title="title">{{ uptime }}</span> <span :class="className">{{ statusName }}</span>
</template> </template>
<script> <script>
import { statusColor, statusNameShort } from "../../../backend/util-common";
export default { export default {
props: { props: {
/** Monitor this represents */ stack: {
monitor: {
type: Object, type: Object,
default: null, default: null,
}, },
/** Type of monitor */ fixedWidth: {
type: {
type: String,
default: null,
},
/** Is this a pill? */
pill: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -25,28 +19,26 @@ export default {
computed: { computed: {
uptime() { uptime() {
return "0.00%";
return this.$t("notAvailableShort"); return this.$t("notAvailableShort");
}, },
color() { color() {
return "secondary"; return statusColor(this.stack?.status);
},
statusName() {
return this.$t(statusNameShort(this.stack?.status));
}, },
className() { className() {
if (this.pill) { let className = `badge rounded-pill bg-${this.color}`;
return `badge rounded-pill bg-${this.color}`;
}
return ""; if (this.fixedWidth) {
className += " fixed-width";
}
return className;
}, },
title() {
if (this.type === "720") {
return `30${this.$t("-day")}`;
}
return `24${this.$t("-hour")}`;
}
}, },
}; };
</script> </script>
@ -55,4 +47,8 @@ export default {
.badge { .badge {
min-width: 62px; min-width: 62px;
} }
.fixed-width {
width: 62px;
}
</style> </style>

View file

@ -18,5 +18,7 @@
"editStack": "Edit", "editStack": "Edit",
"discardStack": "Discard", "discardStack": "Discard",
"saveStackDraft": "Save", "saveStackDraft": "Save",
"notAvailableShort" : "N/A" "notAvailableShort" : "N/A",
"deleteStackMsg": "Are you sure you want to delete this stack?",
"stackNotManagedByDockgeMsg": "This stack is not managed by Dockge."
} }

View file

@ -3,6 +3,7 @@ import App from "./App.vue";
import { router } from "./router"; import { router } from "./router";
import { FontAwesomeIcon } from "./icon.js"; import { FontAwesomeIcon } from "./icon.js";
import { i18n } from "./i18n"; import { i18n } from "./i18n";
await import("../../backend/util-common");
// Dependencies // Dependencies
import "bootstrap"; import "bootstrap";
@ -25,10 +26,6 @@ import socket from "./mixins/socket";
import lang from "./mixins/lang"; import lang from "./mixins/lang";
import theme from "./mixins/theme"; import theme from "./mixins/theme";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
const app = createApp(rootApp()); const app = createApp(rootApp());
app.use(Toast, { app.use(Toast, {

View file

@ -203,6 +203,20 @@ export default defineComponent({
this.stackList = res.stackList; this.stackList = res.stackList;
} }
}); });
socket.on("stackStatusList", (res) => {
if (res.ok) {
console.log(res.stackStatusList);
for (let stackName in res.stackStatusList) {
const stackObj = this.stackList[stackName];
if (stackObj) {
stackObj.status = res.stackStatusList[stackName];
}
}
}
});
}, },
/** /**

View file

@ -2,9 +2,9 @@
<transition name="slide-fade" appear> <transition name="slide-fade" appear>
<div> <div>
<h1 v-if="isAdd" class="mb-3">Compose</h1> <h1 v-if="isAdd" class="mb-3">Compose</h1>
<h1 v-else class="mb-3">Stack: {{ stack.name }}</h1> <h1 v-else class="mb-3"><Uptime :stack="globalStack" :pill="true" /> {{ stack.name }}</h1>
<div class="mb-3"> <div v-if="stack.isManagedByDockge" class="mb-3">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button v-if="isEditMode" class="btn btn-primary" :disabled="processing" @click="deployStack"> <button v-if="isEditMode" class="btn btn-primary" :disabled="processing" @click="deployStack">
<font-awesome-icon icon="rocket" class="me-1" /> <font-awesome-icon icon="rocket" class="me-1" />
@ -18,10 +18,11 @@
<button v-if="!isEditMode" class="btn btn-normal" :disabled="processing" @click="isEditMode = true">{{ $t("editStack") }}</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 && !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("startStack") }}</button>
<button v-if="!isEditMode" class="btn btn-primary " :disabled="processing">{{ $t("restartStack") }}</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">{{ $t("stopStack") }}</button>
<button v-if="!isEditMode" class="btn btn-danger" :disabled="processing">{{ $t("deleteStack") }}</button> <button v-if="!isEditMode" class="btn btn-danger" :disabled="processing" @click="showDeleteDialog = !showDeleteDialog">{{ $t("deleteStack") }}</button>
</div> </div>
</div> </div>
@ -33,18 +34,20 @@
:allow-input="false" :allow-input="false"
class="mb-3 terminal" class="mb-3 terminal"
:rows="progressTerminalRows" :rows="progressTerminalRows"
@has-data="showProgressTerminal = true" @has-data="showProgressTerminal = true; submitted = true;"
></Terminal> ></Terminal>
</transition> </transition>
<div class="row"> <div v-if="stack.isManagedByDockge" class="row">
<div class="col"> <div class="col">
<h4 class="mb-3">General</h4> <div v-if="isAdd">
<div class="shadow-box big-padding mb-3"> <h4 class="mb-3">General</h4>
<!-- Stack Name --> <div class="shadow-box big-padding mb-3">
<div v-if="isAdd" class="mb-3"> <!-- Stack Name -->
<label for="name" class="form-label">{{ $t("stackName") }}</label> <div class="mb-3">
<input id="name" v-model="stack.name" type="text" class="form-control" required> <label for="name" class="form-label">{{ $t("stackName") }}</label>
<input id="name" v-model="stack.name" type="text" class="form-control" required>
</div>
</div> </div>
</div> </div>
@ -58,8 +61,9 @@
<div class="col"> <div class="col">
<h4 class="mb-3">compose.yaml</h4> <h4 class="mb-3">compose.yaml</h4>
<div class="shadow-box mb-3"> <!-- YAML editor -->
<prism-editor v-model="stack.composeYAML" class="yaml-editor" :highlight="highlighter" line-numbers :readonly="!isEditMode" @input="yamlCodeChange"></prism-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>
</div> </div>
<div class="mb-3"> <div class="mb-3">
{{ yamlError }} {{ yamlError }}
@ -75,6 +79,15 @@
</div>--> </div>-->
</div> </div>
</div> </div>
<div v-if="!stack.isManagedByDockge">
{{ $t("stackNotManagedByDockgeMsg") }}
</div>
<!-- Delete Dialog -->
<BModal v-model="showDeleteDialog" :okTitle="$t('deleteStack')" okVariant="danger" @ok="deleteDialog">
{{ $t("deleteStackMsg") }}
</BModal>
</div> </div>
</transition> </transition>
</template> </template>
@ -89,6 +102,7 @@ import "prismjs/themes/prism-tomorrow.css";
import "vue-prism-editor/dist/prismeditor.min.css"; import "vue-prism-editor/dist/prismeditor.min.css";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { getComposeTerminalName, PROGRESS_TERMINAL_ROWS } from "../../../backend/util-common"; import { getComposeTerminalName, PROGRESS_TERMINAL_ROWS } from "../../../backend/util-common";
import { BModal } from "bootstrap-vue-next";
const template = `version: "3.8" const template = `version: "3.8"
services: services:
@ -104,6 +118,7 @@ export default {
components: { components: {
FontAwesomeIcon, FontAwesomeIcon,
PrismEditor, PrismEditor,
BModal,
}, },
data() { data() {
return { return {
@ -112,15 +127,26 @@ export default {
processing: true, processing: true,
showProgressTerminal: false, showProgressTerminal: false,
progressTerminalRows: PROGRESS_TERMINAL_ROWS, progressTerminalRows: PROGRESS_TERMINAL_ROWS,
stack: {}, stack: {
},
isEditMode: false, isEditMode: false,
submitted: false, submitted: false,
showDeleteDialog: false,
}; };
}, },
computed: { computed: {
isAdd() { isAdd() {
return this.$route.path === "/compose" && !this.submitted; return this.$route.path === "/compose" && !this.submitted;
}, },
/**
* Get the stack from the global stack list, because it may contain more real-time data like status
* @return {*}
*/
globalStack() {
return this.$root.stackList[this.stack.name];
},
}, },
watch: { watch: {
"stack.composeYAML": { "stack.composeYAML": {
@ -139,6 +165,7 @@ export default {
this.stack = { this.stack = {
name: "", name: "",
composeYAML: template, composeYAML: template,
isManagedByDockge: true,
}; };
} else { } else {
@ -147,11 +174,17 @@ export default {
} }
}, },
methods: { methods: {
bindTerminal() {
// Bind Terminal output
const terminalName = getComposeTerminalName(this.stack.name);
this.$refs.progressTerminal.bind(terminalName);
},
loadStack() { loadStack() {
this.$root.getSocket().emit("getStack", this.stack.name, (res) => { this.$root.getSocket().emit("getStack", this.stack.name, (res) => {
if (res.ok) { if (res.ok) {
this.stack = res.stack; this.stack = res.stack;
this.processing = false; this.processing = false;
this.bindTerminal();
} else { } else {
this.$root.toastRes(res); this.$root.toastRes(res);
} }
@ -160,9 +193,7 @@ export default {
deployStack() { deployStack() {
this.processing = true; this.processing = true;
// Bind Terminal output this.bindTerminal();
const terminalName = getComposeTerminalName(this.stack.name);
this.$refs.progressTerminal.bind(terminalName);
this.$root.getSocket().emit("deployStack", this.stack.name, this.stack.composeYAML, this.isAdd, (res) => { this.$root.getSocket().emit("deployStack", this.stack.name, this.stack.composeYAML, this.isAdd, (res) => {
this.processing = false; this.processing = false;
@ -170,8 +201,6 @@ export default {
if (res.ok) { if (res.ok) {
this.$router.push("/compose/" + this.stack.name); this.$router.push("/compose/" + this.stack.name);
} else {
this.submitted = true;
} }
}); });
}, },
@ -187,6 +216,14 @@ export default {
} }
}); });
}, },
deleteDialog() {
this.$root.getSocket().emit("deleteStack", this.stack.name, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.$router.push("/");
}
});
},
discardStack() { discardStack() {
this.loadStack(); this.loadStack();
@ -221,4 +258,8 @@ export default {
.terminal { .terminal {
height: 200px; height: 200px;
} }
.editor-box.edit-mode {
background-color: #2c2f38 !important;
}
</style> </style>

View file

@ -9,7 +9,7 @@
</p> </p>
</div> </div>
<Terminal :allow-input="true" class="terminal"></Terminal> <Terminal ref="terminal" :allow-input="true" class="terminal" :rows="20"></Terminal>
</div> </div>
</transition> </transition>
</template> </template>
@ -20,7 +20,16 @@ export default {
components: { components: {
}, },
mounted() { mounted() {
this.$root.terminalFit(50); // 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: { methods: {

View file

@ -20,6 +20,7 @@
"check-password-strength": "~2.0.7", "check-password-strength": "~2.0.7",
"command-exists": "^1.2.9", "command-exists": "^1.2.9",
"compare-versions": "~6.1.0", "compare-versions": "~6.1.0",
"croner": "^7.0.4",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"express": "~4.18.2", "express": "~4.18.2",
"express-static-gzip": "~2.1.7", "express-static-gzip": "~2.1.7",

View file

@ -23,6 +23,9 @@ dependencies:
compare-versions: compare-versions:
specifier: ~6.1.0 specifier: ~6.1.0
version: 6.1.0 version: 6.1.0
croner:
specifier: ^7.0.4
version: 7.0.4
dayjs: dayjs:
specifier: ^1.11.10 specifier: ^1.11.10
version: 1.11.10 version: 1.11.10
@ -1783,6 +1786,11 @@ packages:
dev: false dev: false
optional: true optional: true
/croner@7.0.4:
resolution: {integrity: sha512-P8Zd88km8oQ0xH8Es0u75GtOnFyCNopuAhlFv5kAnbcTuXd0xNvRTgnxnJEs63FicCOsHTL7rpu4BHzY3cMq4w==}
engines: {node: '>=6.0'}
dev: false
/cross-env@7.0.3: /cross-env@7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}