This commit is contained in:
Louis Lam 2023-11-10 21:52:38 +08:00
parent a12c6dc033
commit 9b9234434e
16 changed files with 241 additions and 73 deletions

View file

@ -4,7 +4,7 @@
# Dockge
A fancy, easy-to-use and reactive docker `compose.yaml` stack manager.
A fancy, easy-to-use and reactive docker `compose.yaml` stack oriented manager.
<img src="https://github.com/louislam/dockge/assets/1336778/26a583e1-ecb1-4a8d-aedf-76157d714ad7" width="900" alt="" />
@ -21,23 +21,51 @@ A fancy, easy-to-use and reactive docker `compose.yaml` stack manager.
## 🔧 How to Install
1. Create a directory `dockge`
2. Create or download [`compose.yaml`](https://raw.githubusercontent.com/louislam/dockge/master/compose.yaml) and put it inside `dockge`:
### Basic
Default stacks directory is `/opt/stacks`.
```
# Create a directory that stores your stacks
mkdir -p /opt/stacks
# Create a directory that stores dockge's compose.yaml
mkdir -p /opt/dockge
cd /opt/dockge
# Download the compose.yaml
wget https://raw.githubusercontent.com/louislam/dockge/master/compose.yaml
# Start Server
docker-compose up -d
```
### Advanced
If you want to store your stacks in another directory, you can change the `DOCKGE_STACKS_DIR` environment variable and volumes.
For exmaples, if you want to store your stacks in `/my-stacks`:
```yaml
version: "3.8"
services:
dockge:
image: louislam/dockge:nightly
image: louislam/dockge:1
restart: unless-stopped
ports:
- 5001:5001
volumes:
- ./data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
```
3. `docker-compose up -d`
- ./data:/app/data
# Your stacks directory in the host
# (The paths inside container must be the same as the host)
- /my-stacks:/my-stacks
environment:
# Tell Dockge where is your stacks directory
- DOCKGE_STACKS_DIR=/my-stacks
```
Dockge is now running on http://localhost:5001
## Motivations
@ -57,6 +85,10 @@ The naming idea was coming from Twitch emotes like `sadge`, `bedge` or `wokege`.
If you are not comfortable with the pronunciation, you can call it `Dockage`
#### Can I manage a single container without `compose.yaml`?
The main objective of Dockge is that try to use docker `compose.yaml` for everything. If you want to manage a single container, you can just use Portainer or Docker CLI.
## More Ideas?
- Stats

View file

@ -134,7 +134,7 @@ export class Database {
R.freeze(true);
if (autoloadModels) {
await R.autoloadModels("./server/model");
R.autoloadModels("./backend/models", "ts");
}
if (dbConfig.type === "sqlite") {

View file

@ -28,6 +28,7 @@ import { TerminalSocketHandler } from "./socket-handlers/terminal-socket-handler
import { Stack } from "./stack";
import { Cron } from "croner";
import gracefulShutdown from "http-graceful-shutdown";
import User from "./models/user";
export class DockgeServer {
app : Express;
@ -194,7 +195,7 @@ export class DockgeServer {
cors,
});
this.io.on("connection", (socket: Socket) => {
this.io.on("connection", async (socket: Socket) => {
log.info("server", "Socket connected!");
this.sendInfo(socket, true);
@ -208,6 +209,20 @@ export class DockgeServer {
for (const socketHandler of this.socketHandlerList) {
socketHandler.create(socket as DockgeSocket, this);
}
// ***************************
// Better do anything after added all socket handlers here
// ***************************
log.debug("auth", "check auto login");
if (await Settings.get("disableAuth")) {
log.info("auth", "Disabled Auth: auto login to admin");
this.afterLogin(socket as DockgeSocket, await R.findOne("user"));
socket.emit("autoLogin");
} else {
log.debug("auth", "need auth");
}
});
this.io.on("disconnect", () => {
@ -216,8 +231,17 @@ export class DockgeServer {
}
prepareServer() {
async afterLogin(socket : DockgeSocket, user : User) {
socket.userID = user.id;
socket.join(user.id.toString());
this.sendInfo(socket);
try {
this.sendStackList();
} catch (e) {
log.error("server", e);
}
}
/**

View file

@ -20,7 +20,7 @@ export class User extends BeanModel {
/**
* Reset this users password
* @param {string} newPassword Users new password
* @param {string} newPassword
* @returns {Promise<void>}
*/
async resetPassword(newPassword : string) {
@ -42,3 +42,5 @@ export class User extends BeanModel {
}
}
export default User;

View file

@ -183,6 +183,26 @@ export class DockerSocketHandler extends SocketHandler {
callbackError(e, callback);
}
});
// Services status
socket.on("serviceStatusList", 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);
const serviceStatusList = Object.fromEntries(await stack.getServiceStatusList());
callback({
ok: true,
serviceStatusList,
});
} catch (e) {
callbackError(e, callback);
}
});
}
saveStack(socket : DockgeSocket, server : DockgeServer, name : unknown, composeYAML : unknown, isAdd : unknown) : Stack {

View file

@ -72,7 +72,7 @@ export class MainSocketHandler extends SocketHandler {
}
log.debug("auth", "afterLogin");
await this.afterLogin(server, socket, user);
await server.afterLogin(socket, user);
log.debug("auth", "afterLogin ok");
log.info("auth", `Successfully logged in user ${decoded.username}. IP=${clientIP}`);
@ -129,7 +129,7 @@ export class MainSocketHandler extends SocketHandler {
if (user) {
if (user.twofa_status === 0) {
this.afterLogin(server, socket, user);
server.afterLogin(socket, user);
log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);
@ -152,7 +152,7 @@ export class MainSocketHandler extends SocketHandler {
const verify = notp.totp.verify(data.token, user.twofa_secret, twoFAVerifyOptions);
if (user.twofa_last_token !== data.token && verify) {
this.afterLogin(server, socket, user);
server.afterLogin(socket, user);
await R.exec("UPDATE `user` SET twofa_last_token = ? WHERE id = ? ", [
data.token,
@ -189,6 +189,35 @@ export class MainSocketHandler extends SocketHandler {
});
// Change Password
socket.on("changePassword", async (password, callback) => {
try {
checkLogin(socket);
if (! password.newPassword) {
throw new Error("Invalid new password");
}
if (passwordStrength(password.newPassword).value === "Too weak") {
throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
}
let user = await doubleCheckPassword(socket, password.currentPassword);
await user.resetPassword(password.newPassword);
callback({
ok: true,
msg: "Password has been updated successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getSettings", async (callback) => {
try {
checkLogin(socket);
@ -221,6 +250,8 @@ export class MainSocketHandler extends SocketHandler {
await doubleCheckPassword(socket, currentPassword);
}
console.log(data);
await Settings.setSettings("general", data);
callback({
@ -239,19 +270,6 @@ export class MainSocketHandler extends SocketHandler {
});
}
async afterLogin(server: DockgeServer, socket : DockgeSocket, user : User) {
socket.userID = user.id;
socket.join(user.id.toString());
server.sendInfo(socket);
try {
server.sendStackList(socket);
} catch (e) {
log.error("server", e);
}
}
async login(username : string, password : string) {
if (typeof username !== "string" || typeof password !== "string") {
return null;

View file

@ -333,4 +333,26 @@ export class Stack {
terminal.join(socket);
terminal.start();
}
async getServiceStatusList() {
let statusList = new Map<string, number>();
let res = childProcess.execSync("docker compose ps --format json", {
cwd: this.path,
});
let lines = res.toString().split("\n");
console.log(lines);
for (let line of lines) {
try {
let obj = JSON.parse(line);
statusList.set(obj.Service, obj.State);
} catch (e) {
}
}
return statusList;
}
}

View file

@ -1,12 +1,18 @@
version: "3.8"
services:
dockge:
image: louislam/dockge:nightly
image: louislam/dockge:1
restart: unless-stopped
ports:
# Host Port : Container Port
- 5001:5001
volumes:
# Docker Socket
- /var/run/docker.sock:/var/run/docker.sock
# Dockge Config
- ./data:/app/data
# Your stacks directory in the host (The paths inside container must be the same as the host)
- /opt/stacks:/opt/stacks
environment:
# Tell Dockge where is your stacks directory
- DOCKGE_STACKS_DIR=/opt/stacks

View file

@ -7,7 +7,7 @@
<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>
<span class="badge me-1" :class="bgStyle">{{ status }}</span>
</div>
</div>
<div class="col-5">
@ -146,6 +146,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
status: {
type: String,
default: "N/A",
}
},
emits: [
],
@ -156,6 +160,14 @@ export default defineComponent({
},
computed: {
bgStyle() {
if (this.status === "running") {
return "bg-primary";
} else {
return "bg-secondary";
}
},
terminalRouteLink() {
return {
name: "containerTerminal",

View file

@ -71,7 +71,7 @@ export default {
submit() {
this.processing = true;
this.login(this.username, this.password, this.token, (res) => {
this.$root.login(this.username, this.password, this.token, (res) => {
this.processing = false;
if (res.tokenRequired) {
@ -82,40 +82,6 @@ export default {
});
},
/**
* Send request to log user in
* @param {string} username Username to log in with
* @param {string} password Password to log in with
* @param {string} token User token
* @param {loginCB} callback Callback to call with result
* @returns {void}
*/
login(username, password, token, callback) {
this.$root.getSocket().emit("login", {
username,
password,
token,
}, (res) => {
if (res.tokenRequired) {
callback(res);
}
if (res.ok) {
this.$root.storage().token = res.token;
this.$root.socketIO.token = res.token;
this.$root.loggedIn = true;
this.$root.username = this.$root.getJWTPayload()?.username;
this.$root.afterLogin();
// Trigger Chrome Save Password
history.pushState({}, "");
}
callback(res);
});
}
},
};
</script>

View file

@ -64,7 +64,8 @@
</form>
</template>
<div v-if="! settings.disableAuth" class="mt-5 mb-3">
<!-- TODO: Hidden for now -->
<div v-if="! settings.disableAuth && false" class="mt-5 mb-3">
<h5 class="my-4 settings-subheading">
{{ $t("Two Factor Authentication") }}
</h5>
@ -182,7 +183,7 @@ export default {
this.saveSettings(() => {
this.password.currentPassword = "";
this.$root.username = null;
this.$root.socket.token = "autoLogin";
this.$root.socketIO.token = "autoLogin";
}, this.password.currentPassword);
},

View file

@ -43,5 +43,6 @@
"addContainer": "Add Container",
"addNetwork": "Add Network",
"disableauth.message1": "Are you sure want to <strong>disable authentication</strong>?",
"disableauth.message2": "It is designed for scenarios <strong>where you intend to implement third-party authentication</strong> in front of Uptime Kuma such as Cloudflare Access, Authelia or other authentication mechanisms."
"disableauth.message2": "It is designed for scenarios <strong>where you intend to implement third-party authentication</strong> in front of Uptime Kuma such as Cloudflare Access, Authelia or other authentication mechanisms.",
"passwordNotMatchMsg": "The repeat password does not match."
}

View file

@ -220,6 +220,40 @@ export default defineComponent({
return undefined;
},
/**
* Send request to log user in
* @param {string} username Username to log in with
* @param {string} password Password to log in with
* @param {string} token User token
* @param {loginCB} callback Callback to call with result
* @returns {void}
*/
login(username : string, password : string, token : string, callback) {
this.getSocket().emit("login", {
username,
password,
token,
}, (res) => {
if (res.tokenRequired) {
callback(res);
}
if (res.ok) {
this.storage().token = res.token;
this.socketIO.token = res.token;
this.loggedIn = true;
this.username = this.getJWTPayload()?.username;
this.afterLogin();
// Trigger Chrome Save Password
history.pushState({}, "");
}
callback(res);
});
},
/**
* Log in using a token
* @param {string} token Token to log in with

View file

@ -97,6 +97,7 @@
:name="name"
:is-edit-mode="isEditMode"
:first="name === Object.keys(jsonConfig.services)[0]"
:status="serviceStatusList[name]"
/>
</div>
@ -201,6 +202,8 @@ services:
let yamlErrorTimeout = null;
let serviceStatusTimeout = null;
export default {
components: {
NetworkInput,
@ -228,10 +231,12 @@ export default {
stack: {
},
serviceStatusList: {},
isEditMode: false,
submitted: false,
showDeleteDialog: false,
newContainerName: "",
stopServiceStatusTimeout: false,
};
},
computed: {
@ -331,8 +336,33 @@ export default {
this.stack.name = this.$route.params.stackName;
this.loadStack();
}
this.requestServiceStatus();
},
unmounted() {
this.stopServiceStatusTimeout = true;
clearTimeout(serviceStatusTimeout);
},
methods: {
startServiceStatusTimeout() {
clearTimeout(serviceStatusTimeout);
serviceStatusTimeout = setTimeout(async () => {
this.requestServiceStatus();
}, 2000);
},
requestServiceStatus() {
this.$root.getSocket().emit("serviceStatusList", this.stack.name, (res) => {
if (res.ok) {
this.serviceStatusList = res.serviceStatusList;
}
if (!this.stopServiceStatusTimeout) {
this.startServiceStatusTimeout();
}
});
},
exitConfirm(next) {
if (this.isEditMode) {
if (confirm("You are currently editing a stack. Are you sure you want to leave?")) {

View file

@ -33,7 +33,7 @@
"knex": "~2.5.1",
"limiter-es6-compat": "~2.1.2",
"mysql2": "^3.6.3",
"redbean-node": "0.3.1",
"redbean-node": "0.3.2",
"socket.io": "~4.7.2",
"socket.io-client": "~4.7.2",
"timezones-list": "~3.0.2",

View file

@ -57,8 +57,8 @@ dependencies:
specifier: ^3.6.3
version: 3.6.3
redbean-node:
specifier: 0.3.1
version: 0.3.1(mysql2@3.6.3)
specifier: 0.3.2
version: 0.3.2(mysql2@3.6.3)
socket.io:
specifier: ~4.7.2
version: 4.7.2
@ -3491,8 +3491,8 @@ packages:
resolve: 1.22.8
dev: false
/redbean-node@0.3.1(mysql2@3.6.3):
resolution: {integrity: sha512-rz71vF7UtJQ14ttZ9I0QuaJ9TOwBCnZb+qHUBiU05f2fLaiQC79liisL3xgkHI8uE9et6HAkG8Z8VPkZbhgxKw==}
/redbean-node@0.3.2(mysql2@3.6.3):
resolution: {integrity: sha512-39VMxPWPpPicRlU4FSJJnJuUMoxw5/4envFthHtKnLe+3qWTBje3RMrJTFZcQGLruWQ/s2LgeYzdd+d0O+p+uQ==}
dependencies:
'@types/node': 20.3.3
await-lock: 2.2.2