Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiple Dockge instances #200

Merged
merged 15 commits into from
Dec 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,17 @@ View Video: https://youtu.be/AWAlOQeNpgU?t=48

## ⭐ Features

- Manage `compose.yaml`
- 🧑‍💼 Manage your `compose.yaml` files
- Create/Edit/Start/Stop/Restart/Delete
- Update Docker Images
- Interactive Editor for `compose.yaml`
- Interactive Web Terminal
- Reactive
- Everything is just responsive. Progress (Pull/Up/Down) and terminal output are in real-time
- Easy-to-use & fancy UI
- If you love Uptime Kuma's UI/UX, you will love this one too
- Convert `docker run ...` commands into `compose.yaml`
- File based structure
- Dockge won't kidnap your compose files, they are stored on your drive as usual. You can interact with them using normal `docker compose` commands
- ⌨️ Interactive Editor for `compose.yaml`
- 🦦 Interactive Web Terminal
- 🕷️ (1.4.0 🆕) Multiple agents support - You can manage multiple stacks from different Docker hosts in one single interface
- 🏪 Convert `docker run ...` commands into `compose.yaml`
- 📙 File based structure - Dockge won't kidnap your compose files, they are stored on your drive as usual. You can interact with them using normal `docker compose` commands
<img src="https://github.com/louislam/dockge/assets/1336778/cc071864-592e-4909-b73a-343a57494002" width=300 />

- 🚄 Reactive - Everything is just responsive. Progress (Pull/Up/Down) and terminal output are in real-time
- 🐣 Easy-to-use & fancy UI - If you love Uptime Kuma's UI/UX, you will love this one too

![](https://github.com/louislam/dockge/assets/1336778/89fc1023-b069-42c0-a01c-918c495f1a6a)

Expand Down
291 changes: 291 additions & 0 deletions backend/agent-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { DockgeSocket } from "./util-server";
import { io, Socket as SocketClient } from "socket.io-client";
import { log } from "./log";
import { Agent } from "./models/agent";
import { isDev, LooseObject, sleep } from "../common/util-common";
import semver from "semver";
import { R } from "redbean-node";
import dayjs, { Dayjs } from "dayjs";

/**
* Dockge Instance Manager
* One AgentManager per Socket connection
*/
export class AgentManager {

protected socket : DockgeSocket;
protected agentSocketList : Record<string, SocketClient> = {};
protected agentLoggedInList : Record<string, boolean> = {};
protected _firstConnectTime : Dayjs = dayjs();

constructor(socket: DockgeSocket) {
this.socket = socket;
}

get firstConnectTime() : Dayjs {
return this._firstConnectTime;
}

test(url : string, username : string, password : string) : Promise<void> {
return new Promise((resolve, reject) => {
let obj = new URL(url);
let endpoint = obj.host;

if (!endpoint) {
reject(new Error("Invalid Dockge URL"));
}

if (this.agentSocketList[endpoint]) {
reject(new Error("The Dockge URL already exists"));
}

let client = io(url, {
reconnection: false,
extraHeaders: {
endpoint,
}
});

client.on("connect", () => {
client.emit("login", {
username: username,
password: password,
}, (res : LooseObject) => {
if (res.ok) {
resolve();
} else {
reject(new Error(res.msg));
}
client.disconnect();
});
});

client.on("connect_error", (err) => {
if (err.message === "xhr poll error") {
reject(new Error("Unable to connect to the Dockge instance"));
} else {
reject(err);
}
client.disconnect();
});
});
}

/**
*
* @param url
* @param username
* @param password
*/
async add(url : string, username : string, password : string) : Promise<Agent> {
let bean = R.dispense("agent") as Agent;
bean.url = url;
bean.username = username;
bean.password = password;
await R.store(bean);
return bean;
}

/**
*
* @param url
*/
async remove(url : string) {
let bean = await R.findOne("agent", " url = ? ", [
url,
]);

if (bean) {
await R.trash(bean);
let endpoint = bean.endpoint;
delete this.agentSocketList[endpoint];
} else {
throw new Error("Agent not found");
}
}

connect(url : string, username : string, password : string) {
let obj = new URL(url);
let endpoint = obj.host;

this.socket.emit("agentStatus", {
endpoint: endpoint,
status: "connecting",
});

if (!endpoint) {
log.error("agent-manager", "Invalid endpoint: " + endpoint + " URL: " + url);
return;
}

if (this.agentSocketList[endpoint]) {
log.debug("agent-manager", "Already connected to the socket server: " + endpoint);
return;
}

log.info("agent-manager", "Connecting to the socket server: " + endpoint);
let client = io(url, {
extraHeaders: {
endpoint,
}
});

client.on("connect", () => {
log.info("agent-manager", "Connected to the socket server: " + endpoint);

client.emit("login", {
username: username,
password: password,
}, (res : LooseObject) => {
if (res.ok) {
log.info("agent-manager", "Logged in to the socket server: " + endpoint);
this.agentLoggedInList[endpoint] = true;
this.socket.emit("agentStatus", {
endpoint: endpoint,
status: "online",
});
} else {
log.error("agent-manager", "Failed to login to the socket server: " + endpoint);
this.agentLoggedInList[endpoint] = false;
this.socket.emit("agentStatus", {
endpoint: endpoint,
status: "offline",
});
}
});
});

client.on("connect_error", (err) => {
log.error("agent-manager", "Error from the socket server: " + endpoint);
this.socket.emit("agentStatus", {
endpoint: endpoint,
status: "offline",
});
});

client.on("disconnect", () => {
log.info("agent-manager", "Disconnected from the socket server: " + endpoint);
this.socket.emit("agentStatus", {
endpoint: endpoint,
status: "offline",
});
});

client.on("agent", (...args : unknown[]) => {
this.socket.emit("agent", ...args);
});

client.on("info", (res) => {
log.debug("agent-manager", res);

// Disconnect if the version is lower than 1.4.0
if (!isDev && semver.satisfies(res.version, "< 1.4.0")) {
this.socket.emit("agentStatus", {
endpoint: endpoint,
status: "offline",
msg: `${endpoint}: Unsupported version: ` + res.version,
});
client.disconnect();
}
});

this.agentSocketList[endpoint] = client;
}

disconnect(endpoint : string) {
let client = this.agentSocketList[endpoint];
client?.disconnect();
}

async connectAll() {
this._firstConnectTime = dayjs();

if (this.socket.endpoint) {
log.info("agent-manager", "This connection is connected as an agent, skip connectAll()");
return;
}

let list : Record<string, Agent> = await Agent.getAgentList();

if (Object.keys(list).length !== 0) {
log.info("agent-manager", "Connecting to all instance socket server(s)...");
}

for (let endpoint in list) {
let agent = list[endpoint];
this.connect(agent.url, agent.username, agent.password);
}
}

disconnectAll() {
for (let endpoint in this.agentSocketList) {
this.disconnect(endpoint);
}
}

async emitToEndpoint(endpoint: string, eventName: string, ...args : unknown[]) {
log.debug("agent-manager", "Emitting event to endpoint: " + endpoint);
let client = this.agentSocketList[endpoint];

if (!client) {
log.error("agent-manager", "Socket client not found for endpoint: " + endpoint);
throw new Error("Socket client not found for endpoint: " + endpoint);
}

if (!client.connected || !this.agentLoggedInList[endpoint]) {
// Maybe the request is too quick, the socket is not connected yet, check firstConnectTime
// If it is within 10 seconds, we should apply retry logic here
let diff = dayjs().diff(this.firstConnectTime, "second");
log.debug("agent-manager", endpoint + ": diff: " + diff);
let ok = false;
while (diff < 10) {
if (client.connected && this.agentLoggedInList[endpoint]) {
log.debug("agent-manager", `${endpoint}: Connected & Logged in`);
ok = true;
break;
}
log.debug("agent-manager", endpoint + ": not ready yet, retrying in 1 second...");
await sleep(1000);
diff = dayjs().diff(this.firstConnectTime, "second");
}

if (!ok) {
log.error("agent-manager", `${endpoint}: Socket client not connected`);
throw new Error("Socket client not connected for endpoint: " + endpoint);
}
}

client.emit("agent", endpoint, eventName, ...args);
}

emitToAllEndpoints(eventName: string, ...args : unknown[]) {
log.debug("agent-manager", "Emitting event to all endpoints");
for (let endpoint in this.agentSocketList) {
this.emitToEndpoint(endpoint, eventName, ...args).catch((e) => {
log.warn("agent-manager", e.message);
});
}
}

async sendAgentList() {
let list = await Agent.getAgentList();
let result : Record<string, LooseObject> = {};

// Myself
result[""] = {
url: "",
username: "",
endpoint: "",
};

for (let endpoint in list) {
let agent = list[endpoint];
result[endpoint] = agent.toJSON();
}

this.socket.emit("agentList", {
ok: true,
agentList: result,
});
}
}
7 changes: 7 additions & 0 deletions backend/agent-socket-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { DockgeServer } from "./dockge-server";
import { AgentSocket } from "../common/agent-socket";
import { DockgeSocket } from "./util-server";

export abstract class AgentSocketHandler {
abstract create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket): void;
}
Loading