This commit is contained in:
Louis Lam 2023-11-06 15:15:55 +08:00
parent 314630724b
commit 2ed739b1b9
12 changed files with 114 additions and 104 deletions

View file

@ -20,7 +20,7 @@ A fancy, easy-to-use and reactive docker stack (`docker-compose.yml`) manager.
## Motivations ## Motivations
- 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. - I have been using Portainer for some time, but for the stack management, I am sometimes not satisfied with it. For example, sometimes when I try to 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) - 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)
@ -34,8 +34,6 @@ If you love this project, please consider giving this project a ⭐.
- Get app icons - Get app icons
- Switch Docker context - Switch Docker context
- Support Dockerfile and build - Support Dockerfile and build
- Zero-config private docker registry
- Support Docker swarm - Support Docker swarm

View file

@ -71,7 +71,7 @@ export class DockerSocketHandler extends SocketHandler {
const stack = Stack.getStack(server, stackName); const stack = Stack.getStack(server, stackName);
stack.startCombinedTerminal(socket); stack.joinCombinedTerminal(socket);
callback({ callback({
ok: true, ok: true,
@ -113,7 +113,7 @@ export class DockerSocketHandler extends SocketHandler {
}); });
server.sendStackList(); server.sendStackList();
stack.startCombinedTerminal(socket); stack.joinCombinedTerminal(socket);
} catch (e) { } catch (e) {
callbackError(e, callback); callbackError(e, callback);

View file

@ -13,6 +13,7 @@ import {
PROGRESS_TERMINAL_ROWS PROGRESS_TERMINAL_ROWS
} from "../util-common"; } from "../util-common";
import { InteractiveTerminal, MainTerminal, Terminal } from "../terminal"; import { InteractiveTerminal, MainTerminal, Terminal } from "../terminal";
import { Stack } from "../stack";
export class TerminalSocketHandler extends SocketHandler { export class TerminalSocketHandler extends SocketHandler {
create(socket : DockgeSocket, server : DockgeServer) { create(socket : DockgeSocket, server : DockgeServer) {
@ -31,6 +32,7 @@ export class TerminalSocketHandler extends SocketHandler {
let terminal = Terminal.getTerminal(terminalName); let terminal = Terminal.getTerminal(terminalName);
if (terminal instanceof InteractiveTerminal) { if (terminal instanceof InteractiveTerminal) {
log.debug("terminalInput", "Terminal found, writing to terminal.");
terminal.write(cmd); terminal.write(cmd);
} else { } else {
throw new Error("Terminal not found or it is not a Interactive Terminal."); throw new Error("Terminal not found or it is not a Interactive Terminal.");
@ -89,17 +91,12 @@ export class TerminalSocketHandler extends SocketHandler {
throw new ValidationError("Service name must be a string."); throw new ValidationError("Service name must be a string.");
} }
const terminalName = getContainerExecTerminalName(stackName, serviceName, 0); log.debug("interactiveTerminal", "Stack name: " + stackName);
let terminal = Terminal.getTerminal(terminalName); log.debug("interactiveTerminal", "Service name: " + serviceName);
if (!terminal) { // Get stack
terminal = new InteractiveTerminal(server, terminalName); const stack = Stack.getStack(server, stackName);
terminal.rows = 50; stack.joinContainerTerminal(socket, serviceName);
log.debug("deployStack", "Terminal created");
}
terminal.join(socket);
terminal.start();
callback({ callback({
ok: true, ok: true,

View file

@ -10,12 +10,12 @@ import {
CREATED_FILE, CREATED_FILE,
CREATED_STACK, CREATED_STACK,
EXITED, getCombinedTerminalName, EXITED, getCombinedTerminalName,
getComposeTerminalName, getComposeTerminalName, getContainerExecTerminalName,
PROGRESS_TERMINAL_ROWS, PROGRESS_TERMINAL_ROWS,
RUNNING, RUNNING, TERMINAL_ROWS,
UNKNOWN UNKNOWN
} from "./util-common"; } from "./util-common";
import { Terminal } from "./terminal"; import { InteractiveTerminal, Terminal } from "./terminal";
import childProcess from "child_process"; import childProcess from "child_process";
export class Stack { export class Stack {
@ -144,7 +144,7 @@ export class Stack {
async deploy(socket? : DockgeSocket) : Promise<number> { async deploy(socket? : DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name); const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "up", "-d", "--remove-orphans" ], this.path); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to deploy, please check the terminal output for more information."); throw new Error("Failed to deploy, please check the terminal output for more information.");
} }
@ -153,7 +153,7 @@ export class Stack {
async delete(socket?: DockgeSocket) : Promise<number> { async delete(socket?: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name); const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "down", "--remove-orphans", "--rmi", "all" ], this.path); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans", "--rmi", "all" ], this.path);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to delete, please check the terminal output for more information."); throw new Error("Failed to delete, please check the terminal output for more information.");
} }
@ -270,7 +270,7 @@ export class Stack {
async start(socket: DockgeSocket) { async start(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(this.name); const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "up", "-d", "--remove-orphans" ], this.path); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to start, please check the terminal output for more information."); throw new Error("Failed to start, please check the terminal output for more information.");
} }
@ -279,7 +279,7 @@ export class Stack {
async stop(socket: DockgeSocket) : Promise<number> { async stop(socket: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name); const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "stop" ], this.path); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "stop" ], this.path);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to stop, please check the terminal output for more information."); throw new Error("Failed to stop, please check the terminal output for more information.");
} }
@ -288,7 +288,7 @@ export class Stack {
async restart(socket: DockgeSocket) : Promise<number> { async restart(socket: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name); const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "restart" ], this.path); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "restart" ], this.path);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to restart, please check the terminal output for more information."); throw new Error("Failed to restart, please check the terminal output for more information.");
} }
@ -297,23 +297,37 @@ export class Stack {
async update(socket: DockgeSocket) { async update(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(this.name); const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "pull" ], this.path); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "pull" ], this.path);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to pull, please check the terminal output for more information."); 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); exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to restart, please check the terminal output for more information."); throw new Error("Failed to restart, please check the terminal output for more information.");
} }
return exitCode; return exitCode;
} }
async startCombinedTerminal(socket: DockgeSocket) { async joinCombinedTerminal(socket: DockgeSocket) {
const terminalName = getCombinedTerminalName(this.name); const terminalName = getCombinedTerminalName(this.name);
const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker-compose", [ "logs", "-f" ], this.path); const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", [ "compose", "logs", "-f" ], this.path);
terminal.rows = COMBINED_TERMINAL_ROWS; terminal.rows = COMBINED_TERMINAL_ROWS;
terminal.cols = COMBINED_TERMINAL_COLS; terminal.cols = COMBINED_TERMINAL_COLS;
terminal.join(socket); terminal.join(socket);
terminal.start(); terminal.start();
} }
async joinContainerTerminal(socket: DockgeSocket, serviceName: string, index: number = 0) {
const terminalName = getContainerExecTerminalName(this.name, serviceName, index);
let terminal = Terminal.getTerminal(terminalName);
if (!terminal) {
terminal = new InteractiveTerminal(this.server, terminalName, "docker", [ "compose", "exec", serviceName, "bash" ], this.path);
terminal.rows = TERMINAL_ROWS;
log.debug("deployStack", "Terminal created");
}
terminal.join(socket);
terminal.start();
}
} }

View file

@ -51,7 +51,11 @@ export class Terminal {
set rows(rows : number) { set rows(rows : number) {
this._rows = rows; this._rows = rows;
this.ptyProcess?.resize(this.cols, this.rows); try {
this.ptyProcess?.resize(this.cols, this.rows);
} catch (e) {
log.debug("Terminal", "Failed to resize terminal: " + e.message);
}
} }
get cols() { get cols() {
@ -60,7 +64,11 @@ export class Terminal {
set cols(cols : number) { set cols(cols : number) {
this._cols = cols; this._cols = cols;
this.ptyProcess?.resize(this.cols, this.rows); try {
this.ptyProcess?.resize(this.cols, this.rows);
} catch (e) {
log.debug("Terminal", "Failed to resize terminal: " + e.message);
}
} }
public start() { public start() {
@ -133,11 +141,16 @@ export class Terminal {
this._ptyProcess?.kill(); this._ptyProcess?.kill();
} }
/**
* Get a running and non-exited terminal
* @param name
*/
public static getTerminal(name : string) : Terminal | undefined { public static getTerminal(name : string) : Terminal | undefined {
return Terminal.terminalMap.get(name); return Terminal.terminalMap.get(name);
} }
public static getOrCreateTerminal(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) : Terminal { public static getOrCreateTerminal(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) : Terminal {
// Since exited terminal will be removed from the map, it is safe to get the terminal from the map
let terminal = Terminal.getTerminal(name); let terminal = Terminal.getTerminal(name);
if (!terminal) { if (!terminal) {
terminal = new Terminal(server, name, file, args, cwd); terminal = new Terminal(server, name, file, args, cwd);

View file

@ -84,7 +84,7 @@ export const TERMINAL_COLS = 105;
export const TERMINAL_ROWS = 10; export const TERMINAL_ROWS = 10;
export const PROGRESS_TERMINAL_ROWS = 8; export const PROGRESS_TERMINAL_ROWS = 8;
export const COMBINED_TERMINAL_COLS = 50; export const COMBINED_TERMINAL_COLS = 56;
export const COMBINED_TERMINAL_ROWS = 15; export const COMBINED_TERMINAL_ROWS = 15;
export const ERROR_TYPE_VALIDATION = 1; export const ERROR_TYPE_VALIDATION = 1;

View file

@ -14,7 +14,7 @@
<div class="function"> <div class="function">
<router-link v-if="!isEditMode" class="btn btn-normal" :to="terminalRouteLink"> <router-link v-if="!isEditMode" class="btn btn-normal" :to="terminalRouteLink">
<font-awesome-icon icon="terminal" /> <font-awesome-icon icon="terminal" />
Terminal Bash
</router-link> </router-link>
</div> </div>
</div> </div>
@ -160,12 +160,17 @@ export default defineComponent({
return { return {
name: "containerTerminal", name: "containerTerminal",
params: { params: {
stackName: this.stackName,
serviceName: this.name, serviceName: this.name,
type: "logs", type: "bash",
}, },
}; };
}, },
stackName() {
return this.$parent.$parent.stack.name;
},
service() { service() {
return this.jsonObject.services[this.name]; return this.jsonObject.services[this.name];
}, },

View file

@ -20,7 +20,17 @@ export default {
props: { props: {
name: { name: {
type: String, type: String,
required: true, require: true,
},
// Require if mode is interactive
stackName: {
type: String,
},
// Require if mode is interactive
serviceName: {
type: String,
}, },
rows: { rows: {
@ -99,7 +109,8 @@ export default {
} }
}); });
} else if (this.mode === "interactive") { } else if (this.mode === "interactive") {
this.$root.getSocket().emit("interactiveTerminal", this.name, (res) => { console.debug("Create Interactive terminal:", this.name);
this.$root.getSocket().emit("interactiveTerminal", this.stackName, this.serviceName, (res) => {
if (!res.ok) { if (!res.ok) {
this.$root.toastRes(res); this.$root.toastRes(res);
} }
@ -184,7 +195,13 @@ export default {
}, },
interactiveTerminalConfig() { interactiveTerminalConfig() {
this.terminal.onKey(e => {
this.$root.getSocket().emit("terminalInput", this.name, e.key, (res) => {
if (!res.ok) {
this.$root.toastRes(res);
}
});
});
} }
} }
}; };
@ -193,6 +210,7 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
.main-terminal { .main-terminal {
height: 100%; height: 100%;
overflow-x: scroll;
} }
</style> </style>

View file

@ -141,7 +141,7 @@ export default defineComponent({
socket.on("terminalWrite", (terminalName, data) => { socket.on("terminalWrite", (terminalName, data) => {
const terminal = terminalMap.get(terminalName); const terminal = terminalMap.get(terminalName);
if (!terminal) { if (!terminal) {
console.error("Terminal not found: " + terminalName); //console.error("Terminal not found: " + terminalName);
return; return;
} }
terminal.write(data); terminal.write(data);

View file

@ -104,7 +104,7 @@
<!-- Combined Terminal Output --> <!-- Combined Terminal Output -->
<div v-show="!isEditMode"> <div v-show="!isEditMode">
<h4 class="mb-3">Logs</h4> <h4 class="mb-3">Terminal</h4>
<Terminal <Terminal
ref="combinedTerminal" ref="combinedTerminal"
class="mb-3 terminal" class="mb-3 terminal"
@ -135,13 +135,15 @@
{{ yamlError }} {{ yamlError }}
</div> </div>
<h4 class="mb-3">{{ $tc("network", 2) }}</h4> <div v-if="isEditMode">
<div class="shadow-box big-padding mb-3"> <h4 class="mb-3">{{ $tc("network", 2) }}</h4>
<NetworkInput /> <div class="shadow-box big-padding mb-3">
</div> <NetworkInput />
</div>
<h4 class="mb-3">{{ $tc("volume", 2) }}</h4> <h4 class="mb-3">{{ $tc("volume", 2) }}</h4>
<div class="shadow-box big-padding mb-3"> <div class="shadow-box big-padding mb-3">
</div>
</div> </div>
<!-- <div class="shadow-box big-padding mb-3"> <!-- <div class="shadow-box big-padding mb-3">
@ -531,43 +533,6 @@ export default {
}); });
}, },
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> </script>

View file

@ -1,37 +1,39 @@
<template> <template>
<transition name="slide-fade" appear> <transition name="slide-fade" appear>
<div> <div>
<h1 class="mb-3">Console</h1> <h1 class="mb-3">Bash</h1>
<p>
Stack: {{ stackName }}<br />
Container: {{ serviceName }}
</p>
<div> <Terminal class="terminal" :rows="20" mode="interactive" :name="terminalName" :stack-name="stackName" :service-name="serviceName"></Terminal>
<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> </div>
</transition> </transition>
</template> </template>
<script> <script>
import { getContainerExecTerminalName } from "../../../backend/util-common";
import { allowedCommandList } from "../../../backend/util-common";
export default { export default {
components: { components: {
}, },
data() { data() {
return { return {
allowedCommandList,
}; };
}, },
computed: {
stackName() {
return this.$route.params.stackName;
},
serviceName() {
return this.$route.params.serviceName;
},
terminalName() {
return getContainerExecTerminalName(this.stackName, this.serviceName, 0);
}
},
mounted() { mounted() {
}, },

View file

@ -39,13 +39,11 @@ const routes = [
name: "compose", name: "compose",
component: Compose, component: Compose,
props: true, props: true,
children: [ },
{ {
path: "/compose/:stackName/terminal/:serviceName/:type", path: "/terminal/:stackName/:serviceName/:type",
component: ContainerTerminal, component: ContainerTerminal,
name: "containerTerminal", name: "containerTerminal",
},
]
}, },
] ]
}, },