Compare commits

..

26 Commits

Author SHA1 Message Date
cmcooper1980
f809ae192b fixed missed v-html xss warning vulnerability 2026-04-18 19:12:17 -05:00
cmcooper1980
749e2c5c88 npm audit fixes, eslint fixes, console warning v-html directive xss vulnerabilty warning fix, and code optimizations 2026-04-18 17:45:38 -05:00
Justin Wiebe
b02f5e092e Add resource usage stats to the compose page (#700)
Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com>
2026-04-11 23:42:38 -05:00
Lance Cain
078f762631 Container control buttons (#649)
Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com>
additional QOL commits by @Dracrius
2026-04-11 18:58:45 -05:00
Julian
e589d4ec7e feat: dockge set/update agent friendly name (#414)
Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com>
2026-04-11 14:46:45 -05:00
Matthew McConnell
7e324d9015 Improved stack list ui when using agents (#800) 2026-04-11 14:27:16 -05:00
cmcooper1980
72a941712d Fix formatting and capitalization in README.md
Corrected formatting and capitalization in README.
2026-04-10 17:21:23 -05:00
Richy HBM
46ce4228a5 Allow specifying which user the stack files should belong to (#83)
Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com>
2026-04-10 17:14:18 -05:00
Avish Jha
cc180562fc feat (i18n): add Magahi and Maithili languages (#876) 2026-01-21 18:51:44 +08:00
Copilot
27bc4cd25c Add automated nightly release workflow (#915)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: louislam <1336778+louislam@users.noreply.github.com>
Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2026-01-20 21:40:06 +08:00
Louis Lam
52605de5cd Revert package.json (#901) 2025-12-24 10:04:49 +08:00
Kevin
420c3af66d Global.env editor and usage in docker operations (#387)
Co-authored-by: Paco Culebras <69261057+pacoculebras@users.noreply.github.com>
Co-authored-by: Paco Culebras <pculebras@me.com>
Co-authored-by: Cyril59310 <70776486+cyril59310@users.noreply.github.com>
Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com>
2025-12-23 03:41:04 -06:00
Joshua Anderson
98cd537ba8 Replace editor with Codemirror (#786) 2025-12-23 02:00:49 -06:00
CampaniaGuy
33fb84b4db Theme options enabled in settings (#575) 2025-12-20 03:23:50 -06:00
Niraj Yadav
f2575d5c05 Remove logging of terminal buffer to console (#582)
Signed-off-by: Niraj Yadav <niryadav@redhat.com>
Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com>
2025-12-20 03:17:14 -06:00
cmcooper1980
65e2e26c43 Fix paste handling for lowercase 'v' key 2025-12-20 02:30:05 -06:00
Dimariqe
cbb6b87a37 Add clipboard copy/paste support to terminal component (#822)
Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com>
2025-12-20 01:44:30 -06:00
Lukáš Ondrejka
98cba39004 Implement RIGHT and LEFT keys for cursor navigation in terminal (#637)
Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com>
2025-12-19 17:09:46 -06:00
Davide Marcoli
e31f766516 Add swiss german language file (#783) 2025-04-17 11:58:09 +08:00
Cyril59310
27bfe723d7 Add Translation keys (#768) 2025-04-14 20:08:40 +08:00
Louis Lam
69818d665d Fix: npm ci omit dev now 2025-04-14 20:07:37 +08:00
Louis Lam
bac498f97f Update to 1.5.0 2025-03-31 01:37:19 +08:00
Louis Lam
3e37f38fc7 Fix: request service status during add mode 2025-03-30 17:51:47 +08:00
Louis Lam
6dff52cc73 Fix: generate compose without project name 2025-03-30 17:35:33 +08:00
Louis Lam
7fcc4c510c Update README.md 2025-03-30 07:19:28 +08:00
Louis Lam
0ceb6336dd Console Improvements (#767) 2025-03-30 07:14:33 +08:00
35 changed files with 1872 additions and 592 deletions

52
.github/workflows/nightly-release.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Nightly Release
on:
schedule:
# Runs at 2:00 AM UTC every day
- cron: "0 2 * * *"
workflow_dispatch: # Allow manual trigger
permissions: {}
jobs:
release-nightly:
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm clean-install --no-fund
- name: Run release-nightly
run: npm run release-nightly

View File

@@ -81,9 +81,52 @@ curl "https://dockge.kuma.pet/compose.yaml?port=5001&stacksPath=/opt/stacks" --o
- port=`5001`
- stacksPath=`/opt/stacks`
Also, once compose is generated/downloaded, add the `PUID` and `PGID` section below to your compose `environment:` section to set stack ownership, otherwise default is `root`
```
# Both PUID and PGID must be set for it to do anything
- PUID=1000 # Set the stack file/dir ownership to this user
- PGID=1000 # Set the stack file/dir ownership to this group
```
Interactive compose.yaml generator is available on:
https://dockge.kuma.pet
### -OR-
Copy and paste your compose from the following:
If you want to store your stacks in another directory, you can change the `DOCKGE_STACKS_DIR` environment variable and volumes.
compose:
```
services:
dockge:
image: louislam/dockge:1
restart: unless-stopped
ports:
# Host Port:Container Port
- 5001:5001
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/app/data
# If you want to use private registries, you need to share the auth file with Dockge:
# - /root/.docker/:/root/.docker
# Stacks Directory
# Your stacks directory in the host (The paths inside container must be the same as the host)
# ⚠️ If you did it wrong, your data could end up be written into a wrong path.
# ✔️ CORRECT EXAMPLE: - /my-stacks:/my-stacks (Both paths match)
# ❌ WRONG EXAMPLE: - /docker:/my-stacks (Both paths do not match)
- /opt/stacks:/opt/stacks
environment:
# Tell Dockge where your stacks directory is
- DOCKGE_STACKS_DIR=/opt/stacks
# Both PUID and PGID must be set for it to do anything
- PUID=1000 # Set the stack file/dir ownership to this user
- PGID=1000 # Set the stack file/dir ownership to this group
```
## How to Update
```bash
@@ -106,7 +149,7 @@ docker compose pull && docker compose up -d
## Motivations
- 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 don't have support for arm64, so I stepped back to Node.js)
- Try to develop with ES Module + TypeScript
If you love this project, please consider giving it a ⭐.

View File

@@ -76,12 +76,14 @@ export class AgentManager {
* @param url
* @param username
* @param password
* @param name
*/
async add(url : string, username : string, password : string) : Promise<Agent> {
async add(url: string, username: string, password: string, name: string): Promise<Agent> {
let bean = R.dispense("agent") as Agent;
bean.url = url;
bean.username = username;
bean.password = password;
bean.name = name;
await R.store(bean);
return bean;
}
@@ -106,6 +108,23 @@ export class AgentManager {
}
}
/**
*
* @param url
* @param updatedName
*/
async update(url: string, updatedName: string) {
const agent = await R.findOne("agent", " url = ? ", [
url,
]);
if (agent) {
agent.name = updatedName;
await R.store(agent);
} else {
throw new Error("Agent not found");
}
}
connect(url : string, username : string, password : string) {
let obj = new URL(url);
let endpoint = obj.host;
@@ -278,6 +297,8 @@ export class AgentManager {
url: "",
username: "",
endpoint: "",
name: "",
updatedName: "",
};
for (let endpoint in list) {

View File

@@ -147,6 +147,8 @@ export class DockerSocketHandler extends AgentSocketHandler {
msgi18n: true,
}, callback);
server.sendStackList();
stack.leaveCombinedTerminal(socket);
} catch (e) {
callbackError(e, callback);
}
@@ -238,6 +240,84 @@ export class DockerSocketHandler extends AgentSocketHandler {
}
});
// Docker stats
agentSocket.on("dockerStats", async (callback) => {
try {
checkLogin(socket);
const dockerStats = Object.fromEntries(await server.getDockerStats());
callbackResult({
ok: true,
dockerStats,
}, callback);
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
// Start a service
agentSocket.on("startService", async (stackName: unknown, serviceName: unknown, callback) => {
try {
checkLogin(socket);
if (typeof (stackName) !== "string" || typeof (serviceName) !== "string") {
throw new ValidationError("Stack name and service name must be strings");
}
const stack = await Stack.getStack(server, stackName);
await stack.startService(socket, serviceName);
stack.joinCombinedTerminal(socket); // Ensure the combined terminal is joined
callbackResult({
ok: true,
msg: "Service " + serviceName + " started"
}, callback);
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
// Stop a service
agentSocket.on("stopService", async (stackName: unknown, serviceName: unknown, callback) => {
try {
checkLogin(socket);
if (typeof (stackName) !== "string" || typeof (serviceName) !== "string") {
throw new ValidationError("Stack name and service name must be strings");
}
const stack = await Stack.getStack(server, stackName);
await stack.stopService(socket, serviceName);
callbackResult({
ok: true,
msg: "Service " + serviceName + " stopped"
}, callback);
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
agentSocket.on("restartService", async (stackName: unknown, serviceName: unknown, callback) => {
try {
checkLogin(socket);
if (typeof stackName !== "string" || typeof serviceName !== "string") {
throw new Error("Invalid stackName or serviceName");
}
const stack = await Stack.getStack(server, stackName, true);
await stack.restartService(socket, serviceName);
callbackResult({
ok: true,
msg: "Service " + serviceName + " restarted"
}, callback);
} catch (e) {
callbackError(e, callback);
}
});
// getExternalNetworkList
agentSocket.on("getDockerNetworkList", async (callback) => {
try {

View File

@@ -637,6 +637,35 @@ export class DockgeServer {
return list;
}
async getDockerStats() : Promise<Map<string, object>> {
let stats = new Map<string, object>();
try {
let res = await childProcessAsync.spawn("docker", [ "stats", "--format", "json", "--no-stream" ], {
encoding: "utf-8",
});
if (!res.stdout) {
return stats;
}
let lines = res.stdout?.toString().split("\n");
for (let line of lines) {
try {
let obj = JSON.parse(line);
stats.set(obj.Name, obj);
} catch (e) {
}
}
return stats;
} catch (e) {
log.error("getDockerStats", e);
return stats;
}
}
get stackDirFullPath() {
return path.resolve(this.stacksDir);
}

View File

@@ -7,6 +7,7 @@ export async function up(knex: Knex): Promise<void> {
table.string("url", 255).notNullable().unique();
table.string("username", 255).notNullable();
table.string("password", 255).notNullable();
table.string("name", 255);
table.boolean("active").notNullable().defaultTo(true);
});
}

View File

@@ -23,6 +23,7 @@ export class Agent extends BeanModel {
url: this.url,
username: this.username,
endpoint: this.endpoint,
name: this.name,
};
}

View File

@@ -18,6 +18,8 @@ import {
import { passwordStrength } from "check-password-strength";
import jwt from "jsonwebtoken";
import { Settings } from "../settings";
import fs, { promises as fsAsync } from "fs";
import path from "path";
export class MainSocketHandler extends SocketHandler {
create(socket : DockgeSocket, server : DockgeServer) {
@@ -242,6 +244,12 @@ export class MainSocketHandler extends SocketHandler {
checkLogin(socket);
const data = await Settings.getSettings("general");
if (fs.existsSync(path.join(server.stacksDir, "global.env"))) {
data.globalENV = fs.readFileSync(path.join(server.stacksDir, "global.env"), "utf-8");
} else {
data.globalENV = "# VARIABLE=value #comment";
}
callback({
ok: true,
data: data,
@@ -270,6 +278,16 @@ export class MainSocketHandler extends SocketHandler {
if (!currentDisabledAuth && data.disableAuth) {
await doubleCheckPassword(socket, currentPassword);
}
// Handle global.env
if (data.globalENV && data.globalENV != "# VARIABLE=value #comment") {
await fsAsync.writeFile(path.join(server.stacksDir, "global.env"), data.globalENV);
} else {
await fsAsync.rm(path.join(server.stacksDir, "global.env"), {
recursive: true,
force: true
});
}
delete data.globalENV;
await Settings.setSettings("general", data);
@@ -311,7 +329,12 @@ export class MainSocketHandler extends SocketHandler {
throw new ValidationError("dockerRunCommand must be a string");
}
const composeTemplate = composerize(dockerRunCommand);
// Option: 'latest' | 'v2x' | 'v3x'
let composeTemplate = composerize(dockerRunCommand, "", "latest");
// Remove the first line "name: <your project name>"
composeTemplate = composeTemplate.split("\n").slice(1).join("\n");
callback({
ok: true,
composeTemplate,

View File

@@ -20,7 +20,7 @@ export class ManageAgentSocketHandler extends SocketHandler {
let data = requestData as LooseObject;
let manager = socket.instanceManager;
await manager.test(data.url, data.username, data.password);
await manager.add(data.url, data.username, data.password);
await manager.add(data.url, data.username, data.password, data.name);
// connect to the agent
manager.connect(data.url, data.username, data.password);
@@ -66,5 +66,27 @@ export class ManageAgentSocketHandler extends SocketHandler {
callbackError(e, callback);
}
});
// updateAgent
socket.on("updateAgent", async (name : string, updatedName : string, callback : unknown) => {
try {
log.debug("manage-agent-socket-handler", "updateAgent");
checkLogin(socket);
let manager = socket.instanceManager;
await manager.update(name, updatedName);
server.disconnectAllSocketClients(undefined, socket.id);
manager.sendAgentList();
callbackResult({
ok: true,
msg: "agentUpdatedSuccessfully",
msgi18n: true,
}, callback);
} catch (e) {
callbackError(e, callback);
}
});
}
}

View File

@@ -93,7 +93,7 @@ export class Stack {
* Get the status of the stack from `docker compose ps --format json`
*/
async ps() : Promise<object> {
let res = await childProcessAsync.spawn("docker", [ "compose", "ps", "--format", "json" ], {
let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), {
cwd: this.path,
encoding: "utf-8",
});
@@ -195,20 +195,18 @@ export class Stack {
}
// Write or overwrite the compose.yaml
await fsAsync.writeFile(path.join(dir, this._composeFileName), this.composeYAML);
const envPath = path.join(dir, ".env");
// Write or overwrite the .env
// If .env is not existing and the composeENV is empty, we don't need to write it
if (await fileExists(envPath) || this.composeENV.trim() !== "") {
await fsAsync.writeFile(envPath, this.composeENV);
fs.writeFileSync(path.join(dir, this._composeFileName), this.composeYAML);
if (process.env.PUID && process.env.PGID) {
const uid = Number(process.env.PUID);
const gid = Number(process.env.PGID);
fs.lchownSync(dir, uid, gid);
fs.chownSync(path.join(dir, this._composeFileName), uid, gid);
}
}
async deploy(socket : DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, 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", this.getComposeOptions("up", "-d", "--remove-orphans"), this.path);
if (exitCode !== 0) {
throw new Error("Failed to deploy, please check the terminal output for more information.");
}
@@ -217,7 +215,7 @@ export class Stack {
async delete(socket: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans" ], this.path);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("down", "--remove-orphans"), this.path);
if (exitCode !== 0) {
throw new Error("Failed to delete, please check the terminal output for more information.");
}
@@ -407,9 +405,22 @@ export class Stack {
return stack;
}
getComposeOptions(command : string, ...extraOptions : string[]) {
//--env-file ./../global.env --env-file .env
let options = [ "compose", command, ...extraOptions ];
if (fs.existsSync(path.join(this.server.stacksDir, "global.env"))) {
if (fs.existsSync(path.join(this.path, ".env"))) {
options.splice(1, 0, "--env-file", "./.env");
}
options.splice(1, 0, "--env-file", "../global.env");
}
console.log(options);
return options;
}
async start(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(socket.endpoint, 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", this.getComposeOptions("up", "-d", "--remove-orphans"), this.path);
if (exitCode !== 0) {
throw new Error("Failed to start, please check the terminal output for more information.");
}
@@ -418,7 +429,7 @@ export class Stack {
async stop(socket: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, 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", this.getComposeOptions("stop"), this.path);
if (exitCode !== 0) {
throw new Error("Failed to stop, please check the terminal output for more information.");
}
@@ -427,7 +438,7 @@ export class Stack {
async restart(socket: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, 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", this.getComposeOptions("restart"), this.path);
if (exitCode !== 0) {
throw new Error("Failed to restart, please check the terminal output for more information.");
}
@@ -436,7 +447,7 @@ export class Stack {
async down(socket: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down" ], this.path);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("down"), this.path);
if (exitCode !== 0) {
throw new Error("Failed to down, please check the terminal output for more information.");
}
@@ -445,7 +456,7 @@ export class Stack {
async update(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(socket.endpoint, 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", this.getComposeOptions("pull"), this.path);
if (exitCode !== 0) {
throw new Error("Failed to pull, please check the terminal output for more information.");
}
@@ -457,7 +468,7 @@ export class Stack {
return exitCode;
}
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", this.getComposeOptions("up", "-d", "--remove-orphans"), this.path);
if (exitCode !== 0) {
throw new Error("Failed to restart, please check the terminal output for more information.");
}
@@ -466,7 +477,7 @@ export class Stack {
async joinCombinedTerminal(socket: DockgeSocket) {
const terminalName = getCombinedTerminalName(socket.endpoint, this.name);
const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", [ "compose", "logs", "-f", "--tail", "100" ], this.path);
const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", this.getComposeOptions("logs", "-f", "--tail", "100"), this.path);
terminal.enableKeepAlive = true;
terminal.rows = COMBINED_TERMINAL_ROWS;
terminal.cols = COMBINED_TERMINAL_COLS;
@@ -487,7 +498,7 @@ export class Stack {
let terminal = Terminal.getTerminal(terminalName);
if (!terminal) {
terminal = new InteractiveTerminal(this.server, terminalName, "docker", [ "compose", "exec", serviceName, shell ], this.path);
terminal = new InteractiveTerminal(this.server, terminalName, "docker", this.getComposeOptions("exec", serviceName, shell), this.path);
terminal.rows = TERMINAL_ROWS;
log.debug("joinContainerTerminal", "Terminal created");
}
@@ -497,10 +508,10 @@ export class Stack {
}
async getServiceStatusList() {
let statusList = new Map<string, number>();
let statusList = new Map<string, Array<object>>();
try {
let res = await childProcessAsync.spawn("docker", [ "compose", "ps", "--format", "json" ], {
let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), {
cwd: this.path,
encoding: "utf-8",
});
@@ -511,13 +522,23 @@ export class Stack {
let lines = res.stdout?.toString().split("\n");
const addLine = (obj: { Service: string, State: string, Name: string, Health: string }) => {
if (!statusList.has(obj.Service)) {
statusList.set(obj.Service, []);
}
statusList.get(obj.Service)?.push({
status: obj.Health || obj.State,
name: obj.Name
});
};
for (let line of lines) {
try {
let obj = JSON.parse(line);
if (obj.Health === "") {
statusList.set(obj.Service, obj.State);
if (obj instanceof Array) {
obj.forEach(addLine);
} else {
statusList.set(obj.Service, obj.Health);
addLine(obj);
}
} catch (e) {
}
@@ -528,6 +549,35 @@ export class Stack {
log.error("getServiceStatusList", e);
return statusList;
}
}
async startService(socket: DockgeSocket, serviceName: string) {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
const exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", serviceName ], this.path);
if (exitCode !== 0) {
throw new Error(`Failed to start service ${serviceName}, please check logs for more information.`);
}
return exitCode;
}
async stopService(socket: DockgeSocket, serviceName: string): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
const exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "stop", serviceName ], this.path);
if (exitCode !== 0) {
throw new Error(`Failed to stop service ${serviceName}, please check logs for more information.`);
}
return exitCode;
}
async restartService(socket: DockgeSocket, serviceName: string): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
const exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "restart", serviceName ], this.path);
if (exitCode !== 0) {
throw new Error(`Failed to restart service ${serviceName}, please check logs for more information.`);
}
return exitCode;
}
}

View File

@@ -299,6 +299,7 @@ function copyYAMLCommentsItems(items: any, srcItems: any) {
* - "8000-9000:80"
* - "127.0.0.1:8001:8001"
* - "127.0.0.1:5000-5010:5000-5010"
* - "0.0.0.0:8080->8080/tcp"
* - "6060:6060/udp"
* @param input
* @param hostname
@@ -308,9 +309,19 @@ export function parseDockerPort(input : string, hostname : string) {
let display;
const parts = input.split("/");
const part1 = parts[0];
let part1 = parts[0];
let protocol = parts[1] || "tcp";
// coming from docker ps, split host part
const arrow = part1.indexOf("->");
if (arrow >= 0) {
part1 = part1.split("->")[0];
const colon = part1.indexOf(":");
if (colon >= 0) {
part1 = part1.split(":")[1];
}
}
// Split the last ":"
const lastColon = part1.lastIndexOf(":");

View File

@@ -10,7 +10,7 @@ FROM louislam/dockge:base AS build
WORKDIR /app
COPY --chown=node:node ./package.json ./package.json
COPY --chown=node:node ./package-lock.json ./package-lock.json
RUN npm ci
RUN npm ci --omit=dev
############################################
# ⭐ Main Image

View File

@@ -11,12 +11,17 @@ declare module 'vue' {
Appearance: typeof import('./src/components/settings/Appearance.vue')['default']
ArrayInput: typeof import('./src/components/ArrayInput.vue')['default']
ArraySelect: typeof import('./src/components/ArraySelect.vue')['default']
BButton: typeof import('bootstrap-vue-next')['BButton']
BDropdown: typeof import('bootstrap-vue-next')['BDropdown']
BDropdownItem: typeof import('bootstrap-vue-next')['BDropdownItem']
BFormGroup: typeof import('bootstrap-vue-next')['BFormGroup']
BFormInput: typeof import('bootstrap-vue-next')['BFormInput']
BModal: typeof import('bootstrap-vue-next')['BModal']
Confirm: typeof import('./src/components/Confirm.vue')['default']
Container: typeof import('./src/components/Container.vue')['default']
DockerStat: typeof import('./src/components/DockerStat.vue')['default']
General: typeof import('./src/components/settings/General.vue')['default']
GlobalEnv: typeof import('./src/components/settings/GlobalEnv.vue')['default']
HiddenInput: typeof import('./src/components/HiddenInput.vue')['default']
Login: typeof import('./src/components/Login.vue')['default']
NetworkInput: typeof import('./src/components/NetworkInput.vue')['default']
@@ -29,4 +34,7 @@ declare module 'vue' {
TwoFADialog: typeof import('./src/components/TwoFADialog.vue')['default']
Uptime: typeof import('./src/components/Uptime.vue')['default']
}
export interface ComponentCustomProperties {
vBModal: typeof import('bootstrap-vue-next')['vBModal']
}
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="shadow-box big-padding mb-3 container">
<div class="row">
<div class="col-7">
<div class="col-5">
<h4>{{ name }}</h4>
<div class="image mb-2">
<span class="me-1">{{ imageName }}:</span><span class="tag">{{ imageTag }}</span>
@@ -9,17 +9,46 @@
<div v-if="!isEditMode">
<span class="badge me-1" :class="bgStyle">{{ status }}</span>
<a v-for="port in envsubstService.ports" :key="port" :href="parsePort(port).url" target="_blank">
<a v-for="port in (ports ?? envsubstService.ports)" :key="port" :href="parsePort(port).url" target="_blank">
<span class="badge me-1 bg-secondary">{{ parsePort(port).display }}</span>
</a>
</div>
</div>
<div class="col-5">
<div class="col-7">
<div class="function">
<router-link v-if="!isEditMode" class="btn btn-normal" :to="terminalRouteLink" disabled="">
<div class="btn-group me-2" role="group">
<router-link v-if="!isEditMode && (status === 'running' || status === 'healthy')" class="btn btn-normal" :to="terminalRouteLink" disabled="">
<font-awesome-icon icon="terminal" />
Bash
</router-link>
<button
v-if="serviceCount > 1 && !isEditMode && status !== 'running' && status !== 'healthy'"
class="btn btn-primary"
:disabled="processing"
@click="startService"
>
<font-awesome-icon icon="play" class="me-1" />
{{ $t("startStack") }}
</button>
<button
v-if="serviceCount > 1 && !isEditMode && (status === 'running' || status === 'healthy' || status === 'unhealthy')"
class="btn btn-normal"
:disabled="processing"
@click="restartService"
>
<font-awesome-icon icon="rotate" class="me-1" />
{{ $t("restartStack") }}
</button>
<button
v-if="serviceCount > 1 && !isEditMode && (status === 'running' || status === 'healthy' || status === 'unhealthy')"
class="btn btn-normal"
:disabled="processing"
@click="stopService"
>
<font-awesome-icon icon="stop" class="me-1" />
{{ $t("stopStack") }}
</button>
</div>
</div>
</div>
</div>
@@ -35,6 +64,32 @@
{{ $t("deleteContainer") }}
</button>
</div>
<div v-else-if="statsInstances.length > 0" class="mt-2">
<div class="d-flex align-items-center gap-3">
<template v-if="!expandedStats">
<div class="stats">
{{ $t('CPU') }}: {{ statsInstances[0].CPUPerc }}
</div>
<div class="stats">
{{ $t('memoryAbbreviated') }}: {{ statsInstances[0].MemUsage }}
</div>
</template>
<div class="d-flex flex-grow-1 justify-content-end">
<button class="btn btn-sm btn-normal" @click="expandedStats = !expandedStats">
<font-awesome-icon :icon="expandedStats ? 'chevron-up' : 'chevron-down'" />
</button>
</div>
</div>
<transition name="slide-fade" appear>
<div v-if="expandedStats" class="d-flex flex-column gap-3 mt-2">
<DockerStat
v-for="stat in statsInstances"
:key="stat.Name"
:stat="stat"
/>
</div>
</transition>
</div>
<transition name="slide-fade" appear>
<div v-if="isEditMode && showConfig" class="config mt-3">
@@ -138,10 +193,12 @@
import { defineComponent } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { parseDockerPort } from "../../../common/util-common";
import DockerStat from "./DockerStat.vue";
export default defineComponent({
components: {
FontAwesomeIcon,
DockerStat
},
props: {
name: {
@@ -156,16 +213,24 @@ export default defineComponent({
type: Boolean,
default: false,
},
status: {
type: String,
default: "N/A",
serviceStatus: {
type: Object,
default: null,
},
dockerStats: {
type: Object,
default: null
}
},
emits: [
"start-service",
"stop-service",
"restart-service"
],
data() {
return {
showConfig: false,
expandedStats: false,
};
},
computed: {
@@ -230,6 +295,10 @@ export default defineComponent({
return this.jsonObject.services[this.name];
},
serviceCount() {
return Object.keys(this.jsonObject.services).length;
},
jsonObject() {
return this.$parent.$parent.jsonConfig;
},
@@ -266,6 +335,22 @@ export default defineComponent({
return "";
}
},
statsInstances() {
if (!this.serviceStatus) {
return [];
}
return this.serviceStatus
.map(s => this.dockerStats[s.name])
.filter(s => !!s)
.sort((a, b) => a.Name.localeCompare(b.Name));
},
status() {
if (!this.serviceStatus) {
return "N/A";
}
return this.serviceStatus[0].status;
}
},
mounted() {
if (this.first) {
@@ -284,6 +369,16 @@ export default defineComponent({
remove() {
delete this.jsonObject.services[this.name];
},
startService() {
this.$emit("start-service", this.name);
},
stopService() {
this.$emit("stop-service", this.name);
},
restartService() {
this.$emit("restart-service", this.name);
}
}
});
</script>
@@ -308,5 +403,10 @@ export default defineComponent({
align-items: center;
justify-content: end;
}
.stats {
font-size: 0.8rem;
color: #6c757d;
}
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="stats-container">
<div class="stats-title">
{{ stat.Name }}
</div>
<div class="d-flex justify-content-between stats gap-2 mt-1">
<div class="stat">
<div class="stat-label">
{{ $t('CPU') }}
</div>
<div>
{{ stat.CPUPerc }}
</div>
</div>
<div class="stat">
<div class="stat-label">
{{ $t('memory') }}
</div>
<div>
{{ stat.MemUsage }} ({{ stat.MemPerc }})
</div>
</div>
<div class="stat">
<div class="stat-label">
{{ $t('networkIO') }}
</div>
<div>
{{ stat.NetIO }}
</div>
</div>
<div class="stat">
<div class="stat-label">
{{ $t('blockIO') }}
</div>
<div>
{{ stat.BlockIO }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
stat: {
type: Object,
required: true
}
},
};
</script>
<style lang="scss" scoped>
.stats-container {
container-type: inline-size;
.stats {
container-type: inline-size;
.stat {
display: flex;
flex-direction: column;
gap: 4px;
}
@container (width < 420px) {
flex-direction: column;
.stat {
flex-direction: row;
}
.stat-label::after {
content: ':'
}
}
}
}
.stats {
font-size: 0.8rem;
color: #6c757d;
}
.stat-label {
font-weight: bold;
}
.stats-title {
font-size: 0.9rem;
color: var(--bs-heading-color);
}
</style>

View File

@@ -3,7 +3,10 @@
<div class="list-header">
<div class="header-top">
<!-- TODO -->
<button v-if="false" class="btn btn-outline-normal ms-2" :class="{ 'active': selectMode }" type="button" @click="selectMode = !selectMode">
<button
v-if="false" class="btn btn-outline-normal ms-2" :class="{ 'active': selectMode }" type="button"
@click="selectMode = !selectMode"
>
{{ $t("Select") }}
</button>
@@ -28,14 +31,16 @@
<!-- TODO: Selection Controls -->
<div v-if="selectMode && false" class="selection-controls px-2 pt-2">
<input
v-model="selectAll"
class="form-check-input select-input"
type="checkbox"
/>
<input v-model="selectAll" class="form-check-input select-input" type="checkbox" />
<button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ $t("Pause") }}</button>
<button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ $t("Resume") }}</button>
<button class="btn-outline-normal" @click="pauseDialog">
<font-awesome-icon icon="pause" size="sm" /> {{
$t("Pause") }}
</button>
<button class="btn-outline-normal" @click="resumeSelected">
<font-awesome-icon icon="play" size="sm" />
{{ $t("Resume") }}
</button>
<span v-if="selectedStackCount > 0">
{{ $t("selectedStackCount", [selectedStackCount]) }}
@@ -43,21 +48,29 @@
</div>
</div>
<div ref="stackList" class="stack-list" :class="{ scrollbar: scrollbar }" :style="stackListStyle">
<div v-if="Object.keys(sortedStackList).length === 0" class="text-center mt-3">
<div v-if="agentStackList[0] && agentStackList[0].stacks.length === 0" class="text-center mt-3">
<router-link to="/compose">{{ $t("addFirstStackMsg") }}</router-link>
</div>
<div v-for="(agent, agentIndex) in agentStackList" :key="agentIndex" class="stack-list-inner">
<div
v-if="$root.agentCount > 1" class="p-2 agent-select"
@click="closedAgents.set(agent.endpoint, !closedAgents.get(agent.endpoint))"
>
<span class="me-1">
<font-awesome-icon v-show="closedAgents.get(agent.endpoint)" icon="chevron-circle-right" />
<font-awesome-icon v-show="!closedAgents.get(agent.endpoint)" icon="chevron-circle-down" />
</span>
<span v-if="agent.endpoint === 'current'">{{ $t("currentEndpoint") }}</span>
<span v-else>{{ agent.endpoint }}</span>
</div>
<StackListItem
v-for="(item, index) in sortedStackList"
:key="index"
:stack="item"
:isSelectMode="selectMode"
:isSelected="isSelected"
:select="select"
:deselect="deselect"
v-for="(item, index) in agent.stacks"
v-show="$root.agentCount === 1 || !closedAgents.get(agent.endpoint)" :key="index" :stack="item" :isSelectMode="selectMode"
:isSelected="isSelected" :select="select" :deselect="deselect"
/>
</div>
</div>
</div>
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
{{ $t("pauseStackMsg") }}
@@ -92,7 +105,8 @@ export default {
status: null,
active: null,
tags: null,
}
},
closedAgents: new Map(),
};
},
computed: {
@@ -119,7 +133,7 @@ export default {
* Returns a sorted list of stacks based on the applied filters and search text.
* @returns {Array} The sorted list of stacks.
*/
sortedStackList() {
agentStackList() {
let result = Object.values(this.$root.completeStackList);
result = result.filter(stack => {
@@ -187,6 +201,29 @@ export default {
return m1.name.localeCompare(m2.name);
});
// Group stacks by endpoint, sorting them so the local endpoint is first
// and the rest are sorted alphabetically
result = [
...result.reduce((acc, stack) => {
const endpoint = stack.endpoint || "current";
if (!acc.has(endpoint)) {
acc.set(endpoint, []);
}
acc.get(endpoint).push(stack);
return acc;
}, new Map()).entries()
].map(([ endpoint, stacks ]) => ({
endpoint,
stacks
})).sort((a, b) => {
if (a.endpoint === "current" && b.endpoint !== "current") {
return -1;
} else if (a.endpoint !== "current" && b.endpoint === "current") {
return 1;
}
return a.endpoint.localeCompare(b.endpoint);
});
return result;
},
@@ -221,7 +258,7 @@ export default {
},
watch: {
searchText() {
for (let stack of this.sortedStackList) {
for (let stack of this.agentStackList) {
if (!this.selectedStacks[stack.id]) {
if (this.selectAll) {
this.disableSelectAllWatcher = true;
@@ -236,7 +273,7 @@ export default {
this.selectedStacks = {};
if (this.selectAll) {
this.sortedStackList.forEach((item) => {
this.agentStackList.forEach((item) => {
this.selectedStacks[item.id] = true;
});
}
@@ -444,4 +481,15 @@ export default {
gap: 10px;
}
.agent-select {
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: $dark-font-color3;
padding-left: 10px;
padding-right: 10px;
display: flex;
align-items: center;
user-select: none;
}
</style>

View File

@@ -3,7 +3,6 @@
<Uptime :stack="stack" :fixed-width="true" class="me-2" />
<div class="title">
<span>{{ stackName }}</span>
<div v-if="$root.agentCount > 1" class="endpoint">{{ endpointDisplay }}</div>
</div>
</router-link>
</template>

View File

@@ -20,22 +20,24 @@ export default {
props: {
name: {
type: String,
require: true,
required: true,
},
endpoint: {
type: String,
require: true,
required: true,
},
// Require if mode is interactive
stackName: {
type: String,
default: "",
},
// Require if mode is interactive
serviceName: {
type: String,
default: "",
},
// Require if mode is interactive
@@ -101,6 +103,14 @@ export default {
this.terminal.open(this.$refs.terminal);
this.terminal.focus();
// Add right-click context menu handler for paste
this.$refs.terminal.addEventListener("contextmenu", this.handleContextMenu);
// Add selection handler for copy to clipboard
this.terminal.onSelectionChange(() => {
this.handleSelection();
});
// Notify parent component when data is received
this.terminal.onCursorMove(() => {
console.debug("onData triggered");
@@ -135,6 +145,7 @@ export default {
window.removeEventListener("resize", this.onResizeEvent); // Remove the resize event listener from the window object.
this.$root.unbindTerminal(this.name);
this.terminal.dispose();
this.$refs.terminal?.removeEventListener("contextmenu", this.handleContextMenu);
},
methods: {
@@ -154,17 +165,27 @@ export default {
},
removeInput() {
const textAfterCursorLength = this.terminalInputBuffer.length - this.cursorPosition;
const spaces = " ".repeat(textAfterCursorLength);
const backspaceCount = this.terminalInputBuffer.length;
const backspaces = "\b \b".repeat(backspaceCount);
this.cursorPosition = 0;
this.terminal.write(backspaces);
this.terminal.write(spaces + backspaces);
this.terminalInputBuffer = "";
},
clearCurrentLine() {
// Move cursor to the beginning of the input and clear it
const backspaces = "\b".repeat(this.cursorPosition);
const spaces = " ".repeat(this.terminalInputBuffer.length);
const moreBackspaces = "\b".repeat(this.terminalInputBuffer.length);
this.terminal.write(backspaces + spaces + moreBackspaces);
},
mainTerminalConfig() {
this.terminal.onKey(e => {
const code = e.key.charCodeAt(0);
console.debug("Encode: " + JSON.stringify(e.key));
// Optional: keep for debugging
// console.debug("Encode: " + JSON.stringify(e.key));
if (e.key === "\r") {
// Return if no input
@@ -180,34 +201,65 @@ export default {
this.$root.emitAgent(this.endpoint, "terminalInput", this.name, buffer + e.key, (err) => {
this.$root.toastError(err.msg);
});
} else if (code === 127) { // Backspace
} else if (e.key === "\u007F") { // Backspace
if (this.cursorPosition > 0) {
this.terminal.write("\b \b");
// Remove character to the left of cursor
const beforeCursor = this.terminalInputBuffer.slice(0, this.cursorPosition - 1);
const afterCursor = this.terminalInputBuffer.slice(this.cursorPosition);
this.terminalInputBuffer = beforeCursor + afterCursor;
this.cursorPosition--;
this.terminalInputBuffer = this.terminalInputBuffer.slice(0, -1);
// Redraw the line
this.terminal.write("\b" + afterCursor + " \b".repeat(afterCursor.length + 1));
}
} else if (e.key === "\u001B\u005B\u0033\u007E") { // Delete key
if (this.cursorPosition < this.terminalInputBuffer.length) {
// Remove character to the right of cursor
const beforeCursor = this.terminalInputBuffer.slice(0, this.cursorPosition);
const afterCursor = this.terminalInputBuffer.slice(this.cursorPosition + 1);
this.terminalInputBuffer = beforeCursor + afterCursor;
// Redraw the line from cursor position
this.terminal.write(afterCursor + " \b".repeat(afterCursor.length + 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
if (this.cursorPosition < this.terminalInputBuffer.length) {
this.terminal.write(this.terminalInputBuffer[this.cursorPosition]);
this.cursorPosition++;
}
} else if (e.key === "\u001B\u005B\u0044") { // LEFT
// TODO
if (this.cursorPosition > 0) {
this.terminal.write("\b");
this.cursorPosition--;
}
} else if (e.key === "\u0003") { // Ctrl + C
console.debug("Ctrl + C");
this.$root.emitAgent(this.endpoint, "terminalInput", this.name, e.key);
this.removeInput();
} else if (e.key === "\u0016" || (e.domEvent?.ctrlKey && e.key.toLowerCase() === "v")) { // Ctrl + V
this.handlePaste();
} else if (e.key === "\u0009" || e.key.startsWith("\u001B")) { // TAB or other special keys
// Do nothing
} else {
const textBeforeCursor = this.terminalInputBuffer.slice(0, this.cursorPosition);
const textAfterCursor = this.terminalInputBuffer.slice(this.cursorPosition);
this.terminalInputBuffer = textBeforeCursor + e.key + textAfterCursor;
this.terminal.write(e.key + textAfterCursor + "\b".repeat(textAfterCursor.length));
this.cursorPosition++;
this.terminalInputBuffer += e.key;
this.terminal.write(e.key);
}
});
},
interactiveTerminalConfig() {
this.terminal.onKey(e => {
// Handle Ctrl+V for paste
if (e.key === "\u0016" || (e.domEvent?.ctrlKey && e.key.toLowerCase() === "v")) {
this.handlePaste();
return;
}
this.$root.emitAgent(this.endpoint, "terminalInput", this.name, e.key, (res) => {
if (!res.ok) {
this.$root.toastRes(res);
@@ -238,7 +290,87 @@ export default {
let rows = this.terminal.rows;
let cols = this.terminal.cols;
this.$root.emitAgent(this.endpoint, "terminalResize", this.name, rows, cols);
},
/**
* Handle clipboard paste operation
*/
async handlePaste() {
try {
const text = await navigator.clipboard.readText();
if (text) {
this.pasteText(text);
}
} catch (error) {
console.error("Failed to read from clipboard:", error);
}
},
/**
* Paste text into the terminal based on current mode
*/
pasteText(text) {
if (this.mode === "mainTerminal") {
// For main terminal, insert text at current cursor position
const beforeCursor = this.terminalInputBuffer.slice(0, this.cursorPosition);
const afterCursor = this.terminalInputBuffer.slice(this.cursorPosition);
// Update the buffer with inserted text
this.terminalInputBuffer = beforeCursor + text + afterCursor;
// Clear the current line and rewrite it
this.clearCurrentLine();
this.terminal.write(this.terminalInputBuffer);
// Move cursor to the correct position (after the pasted text)
this.cursorPosition += text.length;
const backspaces = "\b".repeat(afterCursor.length);
this.terminal.write(backspaces);
} else if (this.mode === "interactive") {
// For interactive terminal, send directly to server
this.$root.emitAgent(this.endpoint, "terminalInput", this.name, text, (res) => {
if (!res.ok) {
this.$root.toastRes(res);
}
});
}
},
/**
* Handle right-click context menu for paste operation
*/
handleContextMenu(event) {
// Prevent default context menu
event.preventDefault();
// Only handle paste for modes that support input
if (this.mode === "mainTerminal" || this.mode === "interactive") {
this.handlePaste();
}
},
/**
* Handle text selection in terminal - copy to clipboard
*/
handleSelection() {
const selectedText = this.terminal.getSelection();
if (selectedText && selectedText.length > 0) {
this.copyToClipboard(selectedText);
}
},
/**
* Copy text to clipboard
*/
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
console.debug("Text copied to clipboard:", text);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
},
}
};
</script>

View File

@@ -14,7 +14,7 @@
</option>
</select>
</div>
<div v-show="false" class="my-4">
<div v-show="true" class="my-4">
<label for="timezone" class="form-label">{{ $t("Theme") }}</label>
<div>
<div

View File

@@ -0,0 +1,98 @@
<template>
<div>
<div v-if="settingsLoaded" class="my-4">
<form class="my-4" autocomplete="off" @submit.prevent="saveGeneral">
<div class="shadow-box mb-3 editor-box edit-mode">
<code-mirror
ref="editor"
v-model="settings.globalENV"
:extensions="extensionsEnv"
minimal
wrap="true"
dark="true"
tab="true"
:hasFocus="editorFocus"
@change="onChange"
/>
</div>
<div class="my-4">
<!-- Save Button -->
<div>
<button class="btn btn-primary" type="submit">
{{ $t("Save") }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
import CodeMirror from "vue-codemirror6";
import { python } from "@codemirror/lang-python"; // good enough for .env key=value highlighting
import { dracula as editorTheme } from "thememirror";
import { lineNumbers, EditorView } from "@codemirror/view";
import { ref } from "vue";
export default {
name: "GlobalEnv",
components: {
CodeMirror,
},
setup() {
const editorFocus = ref(false);
const focusEffectHandler = (state, focusing) => {
editorFocus.value = focusing;
return null;
};
const extensionsEnv = [
editorTheme,
python(),
lineNumbers(),
EditorView.focusChangeEffect.of(focusEffectHandler),
];
return { editorFocus,
extensionsEnv };
},
computed: {
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
},
settingsLoaded() {
return this.$parent.$parent.$parent.settingsLoaded;
},
},
methods: {
/** Save the settings */
saveGeneral() {
this.saveSettings();
},
onChange() {
// hook for future live validation if desired
},
},
};
</script>
<style scoped lang="scss">
.editor-box {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
&.edit-mode {
background-color: #2c2f38 !important;
}
}
</style>

View File

@@ -94,10 +94,18 @@
<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>
<i18n-t keypath="disableauth.message1" tag="p">
<template #disableAuth>
<strong>{{ $t('disableAuth') }}</strong>
</template>
</i18n-t>
<i18n-t keypath="disableauth.message2" tag="p">
<template #scenarios>
<strong>{{ $t('scenarios') }}</strong>
</template>
</i18n-t>
<p>{{ $t("Please use this option carefully!") }}</p>
<div class="mb-3">

View File

@@ -32,6 +32,9 @@ const languageList = {
"hu": "Magyar",
"ca": "Català",
"ga": "Gaeilge",
"de-CH": "Schwiizerdütsch",
"mag": "मगही",
"mai": "मैथिली",
};
let messages = {

View File

@@ -38,6 +38,7 @@ import {
faAward,
faLink,
faChevronDown,
faChevronUp,
faSignOutAlt,
faPen,
faExternalLinkSquareAlt,
@@ -54,6 +55,8 @@ import {
faTerminal, faWarehouse, faHome, faRocket,
faRotate,
faCloudArrowDown, faArrowsRotate,
faChevronCircleRight,
faChevronCircleDown,
} from "@fortawesome/free-solid-svg-icons";
library.add(
@@ -88,6 +91,7 @@ library.add(
faAward,
faLink,
faChevronDown,
faChevronUp,
faSignOutAlt,
faPen,
faExternalLinkSquareAlt,
@@ -109,6 +113,8 @@ library.add(
faRotate,
faCloudArrowDown,
faArrowsRotate,
faChevronCircleRight,
faChevronCircleDown,
);
export { FontAwesomeIcon };

View File

@@ -0,0 +1,132 @@
{
"languageName": "Schwiizerdütsch",
"Create your admin account": "Erstell dis Admin-Konto",
"authIncorrectCreds": "Falsche Benutzername oder falsches Passwort.",
"PasswordsDoNotMatch": "Passwörter stimmed nöd überein.",
"Repeat Password": "Passwort wiederhole",
"Create": "Erstelle",
"signedInDisp": "Agmeldet als {0}",
"signedInDispDisabled": "Ameldig deaktiviert.",
"home": "Startsiite",
"console": "Konsole",
"registry": "Container Registry",
"compose": "Compose",
"addFirstStackMsg": "Stell din erste Stack zämme!",
"stackName": "Stack-Name",
"deployStack": "Deploye",
"deleteStack": "Lösche",
"stopStack": "Ahalte",
"restartStack": "Neustarte",
"updateStack": "Aktualisiere",
"startStack": "Starte",
"editStack": "Bearbeite",
"discardStack": "Verwerfe",
"saveStackDraft": "Speicher",
"notAvailableShort": "N/V",
"deleteStackMsg": "Wotsch de Stack würklich lösche?",
"stackNotManagedByDockgeMsg": "De Stack wird nöd vo Dockge verwaltet.",
"primaryHostname": "Primäre Hostname",
"general": "Allgemein",
"container": "Container",
"scanFolder": "Stacks-Ordner durchsueche",
"dockerImage": "Image",
"restartPolicyUnlessStopped": "Falls nöd gstoppt",
"restartPolicyAlways": "Immer",
"restartPolicyOnFailure": "Bimene Fehler",
"restartPolicyNo": "Kein Neustart",
"environmentVariable": "Umgebigsvariable",
"restartPolicy": "Neustart Richtlinie",
"containerName": "Container-Name",
"port": "Port / Ports",
"volume": "Volume / Volumes",
"network": "Netzwerk | Netzwerke",
"dependsOn": "Container-Abhängigkeit/e",
"addListItem": "{0} hinzuefüege",
"deleteContainer": "Lösche",
"addContainer": "Container hinzuefüege",
"addNetwork": "Netzwerk hinzuefüege",
"disableauth.message1": "Bisch der sicher, dass du d'<strong>Ameldung deaktiviere</strong> wotsch?",
"disableauth.message2": "Es isch für Szenarien vorgseh, <strong>in dene du beabsichtigsch, e Drittabüter-Authentifizierig</strong> vor Dockge z'implementiere, wie zum Bispiel Cloudflare Access, Authelia oder anderi Authentifizierigsmechanisme.",
"passwordNotMatchMsg": "s'wiederholte Passwort stimmt nöd überein.",
"autoGet": "Automatisch lade",
"add": "Hinzuefüege",
"Edit": "Bearbeite",
"applyToYAML": "Uf s'YAML awende",
"createExternalNetwork": "Erstelle",
"addInternalNetwork": "Hinzuefüege",
"Save": "Speichere",
"Language": "Sprach",
"Current User": "Aktuelle Benutzer",
"Change Password": "Passwort ändere",
"Current Password": "Aktuells Passwort",
"New Password": "Neus Passwort",
"Repeat New Password": "Neus Passwort wiederhole",
"Update Password": "Passwort aktualisiere",
"Advanced": "Erwiitert",
"Please use this option carefully!": "Bitte verwend die Option sorgfältig!",
"Enable Auth": "Ameldig aktiviere",
"Disable Auth": "Ameldig deaktiviere",
"I understand, please disable": "Ich verstah, bitte deaktiviere",
"Leave": "Verlah",
"Frontend Version": "Frontend Version",
"Check Update On GitHub": "Update uf GitHub überprüefe",
"Show update if available": "Update azeige, wenn verfüegbar",
"Also check beta release": "Au Beta-Version überprüefe",
"Remember me": "Agmeldet blibe",
"Login": "Amelde",
"Username": "Benutzername",
"Password": "Passwort",
"Settings": "Istellige",
"Logout": "Abmelde",
"Lowercase only": "Nur Chliibuechstabe",
"Convert to Compose": "In Compose-Syntax umwandle",
"Docker Run": "Docker Run",
"active": "aktiv",
"exited": "beendet",
"inactive": "inaktiv",
"Appearance": "Erschiinigsbild",
"Security": "Sicherheit",
"About": "Über",
"Allowed commands:": "Zueglasseni Befehl:",
"Internal Networks": "Interni Netzwerk",
"External Networks": "Externi Netzwerk",
"No External Networks": "Kei externi Netzwerk",
"Cannot connect to the socket server.": "Kei Verbindig zum Socket Server.",
"reverseProxyMsg1": "Wird en Reverse Proxy benutzt?",
"reconnecting...": "Erneute Verbindigsufbau…",
"downStack": "Stoppe & Deaktiviere",
"extra": "Extra",
"url": "URL / URLs",
"reverseProxyMsg2": "Lern wie er für WebSockets z'konfiguriere isch.",
"connecting...": "Verbindigsufbau zum Socket Server…",
"newUpdate": "Neues Update",
"dockgeAgent": "Dockge Agent | Dockge Agente",
"currentEndpoint": "Aktuell",
"dockgeURL": "Dockge URL (z. B. http://127.0.0.1:5001)",
"agentOnline": "Online",
"agentOffline": "Offline",
"connecting": "Verbinde",
"connect": "Verbinde",
"addAgent": "Agent Hinzuefüege",
"agentAddedSuccessfully": "Agent erfolgriich hinzuegfüegt.",
"agentRemovedSuccessfully": "Agent erfolgriich entfernt.",
"removeAgent": "Agent Entferne",
"removeAgentMsg": "Bisch der sicher, dass du de Agent entferne wotsch?",
"LongSyntaxNotSupported": "Lange Syntax wird nöd unterstützt. Bitte verwend de YAML-Editor.",
"Lost connection to the socket server. Reconnecting...": "Verbindig zum Socket Server verlore. Verbinde...",
"Saved": "Gspeicheret",
"Deleted": "Glöscht",
"Started": "Gstartet",
"Stopped": "Gstoppt",
"Restarted": "Neugstartet",
"New Container Name...": "Neue Container Name...",
"Network name...": "Netzwerkname...",
"Select a network...": "Netzwerk uswähle...",
"Updated": "Aktualisiert",
"Deployed": "Deployed",
"Switch to sh": "Zu sh wechsle",
"terminal": "Terminal",
"CurrentHostname": "(nöd gsetzt: verwendet aktuelli Hostname)",
"Downed": "Abegfahre",
"NoNetworksAvailable": "Kei Netzwerk verfüegbar. Du muesch zersch interni Netzwerk hinzuefüege oder externi Netzwerk uf de rechte Siite aktiviere."
}

View File

@@ -25,6 +25,7 @@
"saveStackDraft": "Save",
"notAvailableShort": "N/A",
"deleteStackMsg": "Are you sure you want to delete this stack?",
"cancel": "Cancel",
"stackNotManagedByDockgeMsg": "This stack is not managed by Dockge.",
"primaryHostname": "Primary Hostname",
"general": "General",
@@ -46,8 +47,10 @@
"deleteContainer": "Delete",
"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 Dockge such as Cloudflare Access, Authelia or other authentication mechanisms.",
"disableauth.message1": "Are you sure want to {disableAuth}?",
"disableauth.message2": "It is designed for scenarios {scenarios}",
"disableAuth": "disable authentication",
"scenarios": "where you intend to implement third-party authentication",
"passwordNotMatchMsg": "The repeat password does not match.",
"autoGet": "Auto Get",
"add": "Add",
@@ -113,7 +116,10 @@
"agentRemovedSuccessfully": "Agent removed successfully.",
"removeAgent": "Remove Agent",
"removeAgentMsg": "Are you sure you want to remove this agent?",
"GlobalEnv": "Global .env",
"LongSyntaxNotSupported": "Long syntax is not supported here. Please use the YAML editor.",
"name": "Dockge Agent Display name",
"updatedName": "New Dockge Agent Display name",
"Saved": "Saved",
"Deployed": "Deployed",
"Deleted": "Deleted",
@@ -128,5 +134,19 @@
"New Container Name...": "New Container Name...",
"Network name...": "Network name...",
"Select a network...": "Select a network...",
"NoNetworksAvailable": "No networks available. You need to add internal networks or enable external networks in the right side first."
"NoNetworksAvailable": "No networks available. You need to add internal networks or enable external networks in the right side first.",
"CPU": "CPU",
"memory": "Memory",
"memoryAbbreviated": "Mem",
"networkIO": "Network I/O",
"blockIO": "Block I/O",
"Console is not enabled": "Console is not enabled",
"ConsoleNotEnabledMSG1": "Console is a powerful tool that allows you to execute any commands such as {docker}, {rm} within the Dockge's container in this Web UI.",
"ConsoleNotEnabledMSG2": "It might be dangerous since this Dockge container is connecting to the host's Docker daemon. Also Dockge could be possibly taken down by commands like {rmRf}",
"ConsoleNotEnabledMSG3": "If you understand the risk, you can enable it by setting {envVar} in the environment variables.",
"dockerCode": "docker",
"rmCode": "rm",
"rmRfCode": "rm -rf",
"envVarCode": "DOCKGE_ENABLE_CONSOLE=true",
"confirmLeaveStack": "You are currently editing a stack. Are you sure you want to leave?"
}

View File

@@ -131,10 +131,15 @@ export default defineComponent({
methods: {
endpointDisplayFunction(endpoint : string) {
for (const [ k, v ] of Object.entries(this.$data.agentList)) {
if (endpoint) {
if (endpoint === v["endpoint"] && v["name"] !== "") {
return v["name"];
}
if (endpoint === v["endpoint"] && v["name"] === "" ) {
return endpoint;
} else {
return this.$t("currentEndpoint");
}
}
}
},

View File

@@ -4,7 +4,7 @@
<h1 v-if="isAdd" class="mb-3">{{ $t("compose") }}</h1>
<h1 v-else class="mb-3">
<Uptime :stack="globalStack" :pill="true" /> {{ stack.name }}
<span v-if="$root.agentCount > 1" class="agent-name">
<span v-if="$root.agentCount > 1 && endpoint !== ''" class="agent-name">
({{ endpointDisplay }})
</span>
</h1>
@@ -63,8 +63,8 @@
<!-- URLs -->
<div v-if="urls.length > 0" class="mb-3">
<a v-for="(url, index) in urls" :key="index" target="_blank" :href="url.url">
<span class="badge bg-secondary me-2">{{ url.display }}</span>
<a v-for="(urlItem, index) in urls" :key="index" target="_blank" :href="urlItem.url">
<span class="badge bg-secondary me-2">{{ urlItem.display }}</span>
</a>
</div>
@@ -98,8 +98,8 @@
<div class="mt-3">
<label for="name" class="form-label">{{ $t("dockgeAgent") }}</label>
<select v-model="stack.endpoint" class="form-select">
<option v-for="(agent, endpoint) in $root.agentList" :key="endpoint" :value="endpoint" :disabled="$root.agentStatusList[endpoint] != 'online'">
({{ $root.agentStatusList[endpoint] }}) {{ (endpoint) ? endpoint : $t("currentEndpoint") }}
<option v-for="(agent, agentEndpoint) in $root.agentList" :key="agentEndpoint" :value="agentEndpoint" :disabled="$root.agentStatusList[agentEndpoint] != 'online'">
({{ $root.agentStatusList[agentEndpoint] }}) {{ (agent.name !== '') ? agent.name : agent.url || $t("Current") }}
</option>
</select>
</div>
@@ -128,7 +128,11 @@
:name="name"
:is-edit-mode="isEditMode"
:first="name === Object.keys(jsonConfig.services)[0]"
:status="serviceStatusList[name]"
:serviceStatus="serviceStatusList[name]"
:dockerStats="dockerStats"
@start-service="startService"
@stop-service="stopService"
@restart-service="restartService"
/>
</div>
@@ -167,16 +171,18 @@
<!-- YAML editor -->
<div class="shadow-box mb-3 editor-box" :class="{'edit-mode' : isEditMode}">
<prism-editor
<code-mirror
ref="editor"
v-model="stack.composeYAML"
class="yaml-editor"
:highlight="highlighterYAML"
line-numbers :readonly="!isEditMode"
@input="yamlCodeChange"
@focus="editorFocus = true"
@blur="editorFocus = false"
></prism-editor>
:extensions="extensions"
minimal
wrap="true"
dark="true"
tab="true"
:disabled="!isEditMode"
:hasFocus="editorFocus"
@change="yamlCodeChange"
/>
</div>
<div v-if="isEditMode" class="mb-3">
{{ yamlError }}
@@ -186,15 +192,18 @@
<div v-if="isEditMode">
<h4 class="mb-3">.env</h4>
<div class="shadow-box mb-3 editor-box" :class="{'edit-mode' : isEditMode}">
<prism-editor
<code-mirror
ref="editor"
v-model="stack.composeENV"
class="env-editor"
:highlight="highlighterENV"
line-numbers :readonly="!isEditMode"
@focus="editorFocus = true"
@blur="editorFocus = false"
></prism-editor>
:extensions="extensionsEnv"
minimal
wrap="true"
dark="true"
tab="true"
:disabled="!isEditMode"
:hasFocus="editorFocus"
@change="yamlCodeChange"
/>
</div>
</div>
@@ -229,7 +238,7 @@
</div>
<!-- Delete Dialog -->
<BModal v-model="showDeleteDialog" :okTitle="$t('deleteStack')" okVariant="danger" @ok="deleteDialog">
<BModal v-model="showDeleteDialog" :cancelTitle="$t('cancel')" :okTitle="$t('deleteStack')" okVariant="danger" @ok="deleteDialog">
{{ $t("deleteStackMsg") }}
</BModal>
</div>
@@ -237,13 +246,13 @@
</template>
<script>
import { highlight, languages } from "prismjs/components/prism-core";
import { PrismEditor } from "vue-prism-editor";
import "prismjs/components/prism-yaml";
import CodeMirror from "vue-codemirror6";
import { yaml } from "@codemirror/lang-yaml";
import { python } from "@codemirror/lang-python";
import { dracula as editorTheme } from "thememirror";
import { lineNumbers, EditorView } from "@codemirror/view";
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 {
COMBINED_TERMINAL_COLS,
@@ -257,6 +266,7 @@ import {
import { BModal } from "bootstrap-vue-next";
import NetworkInput from "../components/NetworkInput.vue";
import dotenv from "dotenv";
import { ref } from "vue";
const template = `
services:
@@ -271,17 +281,13 @@ const envDefault = "# VARIABLE=value #comment";
let yamlErrorTimeout = null;
let serviceStatusTimeout = null;
let prismjsSymbolDefinition = {
"symbol": {
pattern: /(?<!\$)\$(\{[^{}]*\}|\w+)/,
}
};
let dockerStatsTimeout = null;
export default {
components: {
NetworkInput,
FontAwesomeIcon,
PrismEditor,
CodeMirror,
BModal,
},
beforeRouteUpdate(to, from, next) {
@@ -290,10 +296,35 @@ export default {
beforeRouteLeave(to, from, next) {
this.exitConfirm(next);
},
setup() {
const editorFocus = ref(false);
const focusEffectHandler = (state, focusing) => {
editorFocus.value = focusing;
return null;
};
const extensions = [
editorTheme,
yaml(),
lineNumbers(),
EditorView.focusChangeEffect.of(focusEffectHandler)
];
const extensionsEnv = [
editorTheme,
python(),
lineNumbers(),
EditorView.focusChangeEffect.of(focusEffectHandler)
];
return { extensions,
extensionsEnv,
editorFocus };
},
yamlDoc: null, // For keeping the yaml comments
data() {
return {
editorFocus: false,
jsonConfig: {},
envsubstJSONConfig: {},
yamlError: "",
@@ -306,15 +337,16 @@ export default {
},
serviceStatusList: {},
dockerStats: {},
isEditMode: false,
submitted: false,
showDeleteDialog: false,
newContainerName: "",
stopServiceStatusTimeout: false,
stopDockerStatsTimeout: false,
};
},
computed: {
endpointDisplay() {
return this.$root.endpointDisplayFunction(this.endpoint);
},
@@ -478,6 +510,7 @@ export default {
}
this.requestServiceStatus();
this.requestDockerStats();
},
unmounted() {
@@ -490,7 +523,19 @@ export default {
}, 5000);
},
startDockerStatsTimeout() {
clearTimeout(dockerStatsTimeout);
dockerStatsTimeout = setTimeout(async () => {
this.requestDockerStats();
}, 5000);
},
requestServiceStatus() {
// Do not request if it is add mode
if (this.isAdd) {
return;
}
this.$root.emitAgent(this.endpoint, "serviceStatusList", this.stack.name, (res) => {
if (res.ok) {
this.serviceStatusList = res.serviceStatusList;
@@ -501,9 +546,20 @@ export default {
});
},
requestDockerStats() {
this.$root.emitAgent(this.endpoint, "dockerStats", (res) => {
if (res.ok) {
this.dockerStats = res.dockerStats;
}
if (!this.stopDockerStatsTimeout) {
this.startDockerStatsTimeout();
}
});
},
exitConfirm(next) {
if (this.isEditMode) {
if (confirm("You are currently editing a stack. Are you sure you want to leave?")) {
if (confirm(this.$t("confirmLeaveStack"))) {
this.exitAction();
next();
} else {
@@ -518,7 +574,9 @@ export default {
exitAction() {
console.log("exitAction");
this.stopServiceStatusTimeout = true;
this.stopDockerStatsTimeout = true;
clearTimeout(serviceStatusTimeout);
clearTimeout(dockerStatsTimeout);
// Leave Combined Terminal
console.debug("leaveCombinedTerminal", this.endpoint, this.stack.name);
@@ -659,46 +717,6 @@ export default {
this.isEditMode = false;
},
highlighterYAML(code) {
if (!languages.yaml_with_symbols) {
languages.yaml_with_symbols = languages.insertBefore("yaml", "punctuation", {
"symbol": prismjsSymbolDefinition["symbol"]
});
}
return highlight(code, languages.yaml_with_symbols);
},
highlighterENV(code) {
if (!languages.docker_env) {
languages.docker_env = {
"comment": {
pattern: /(^#| #).*$/m,
greedy: true
},
"keyword": {
pattern: /^\w*(?=[:=])/m,
greedy: true
},
"value": {
pattern: /(?<=[:=]).*?((?= #)|$)/m,
greedy: true,
inside: {
"string": [
{
pattern: /^ *'.*?(?<!\\)'/m,
},
{
pattern: /^ *".*?(?<!\\)"|^.*$/m,
inside: prismjsSymbolDefinition
},
],
},
},
};
}
return highlight(code, languages.docker_env);
},
yamlToJSON(yaml) {
let doc = parseDocument(yaml);
if (doc.errors.length > 0) {
@@ -786,6 +804,44 @@ export default {
this.stack.name = this.stack?.name?.toLowerCase();
},
startService(serviceName) {
this.processing = true;
this.$root.emitAgent(this.endpoint, "startService", this.stack.name, serviceName, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.requestServiceStatus(); // Refresh service status
}
});
},
stopService(serviceName) {
this.processing = true;
this.$root.emitAgent(this.endpoint, "stopService", this.stack.name, serviceName, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.requestServiceStatus(); // Refresh service status
}
});
},
restartService(serviceName) {
this.processing = true;
this.$root.emitAgent(this.endpoint, "restartService", this.stack.name, serviceName, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.requestServiceStatus(); // Refresh service status
}
});
},
}
};
</script>
@@ -800,9 +856,6 @@ export default {
.editor-box {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
&.edit-mode {
background-color: #2c2f38 !important;
}
}
.agent-name {

View File

@@ -1,23 +1,28 @@
<template>
<transition name="slide-fade" appear>
<div v-if="!processing">
<h1 class="mb-3">Console</h1>
<h1 class="mb-3">{{ $t("console") }}</h1>
<Terminal v-if="enableConsole" class="terminal" :rows="20" mode="mainTerminal" name="console" :endpoint="endpoint"></Terminal>
<div v-else class="alert alert-warning shadow-box" role="alert">
<h4 class="alert-heading">Console is not enabled</h4>
<p>
Console is a powerful tool that allows you to execute any commands such as <code>docker</code>, <code>rm</code> within the Dockge's container in this Web UI.
</p>
<h4 class="alert-heading">{{ $t("Console is not enabled") }}</h4>
<i18n-t keypath="ConsoleNotEnabledMSG1" tag="p">
<template #docker><code>{{ $t('dockerCode') }}</code></template>
<template #rm><code>{{ $t('rmCode') }}</code></template>
</i18n-t>
<p>
It might be dangerous since this Dockge container is connecting to the host's Docker daemon. Also Dockge could be possibly taken down by commands like <code>rm -rf</code>.
</p>
<i18n-t keypath="ConsoleNotEnabledMSG2" tag="p">
<template #rmRf>
<code>{{ $t('rmRfCode') }}</code>
</template>
</i18n-t>
<p>
If you understand the risk, you can enable it by setting <code>DOCKGE_ENABLE_CONSOLE=true</code> in the environment variables.
</p>
<i18n-t keypath="ConsoleNotEnabledMSG3" tag="p">
<template #envVar>
<code>{{ $t('envVarCode') }}</code>
</template>
</i18n-t>
</div>
</div>
</transition>

View File

@@ -29,7 +29,7 @@
<!-- Docker Run -->
<h2 class="mb-3">{{ $t("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>
<textarea id="name" v-model="dockerRunCommand" type="text" class="form-control docker-run shadow-box" required placeholder="docker run ..."></textarea>
</div>
<button class="btn-normal btn mb-4" @click="convertDockerRun">{{ $t("Convert to Compose") }}</button>
@@ -40,7 +40,7 @@
<div class="shadow-box big-padding">
<h4 class="mb-3">{{ $tc("dockgeAgent", 2) }} <span class="badge bg-warning" style="font-size: 12px;">beta</span></h4>
<div v-for="(agent, endpoint) in $root.agentList" :key="endpoint" class="mb-3 agent">
<div v-for="(agentItem, endpoint) in $root.agentList" :key="endpoint" class="mb-3 agent">
<!-- Agent Status -->
<template v-if="$root.agentStatusList[endpoint]">
<span v-if="$root.agentStatusList[endpoint] === 'online'" class="badge bg-primary me-2">{{ $t("agentOnline") }}</span>
@@ -49,15 +49,27 @@
</template>
<!-- Agent Display Name -->
<span v-if="endpoint === ''">{{ $t("currentEndpoint") }}</span>
<a v-else :href="agent.url" target="_blank">{{ endpoint }}</a>
<template v-if="$root.agentStatusList[endpoint]">
<span v-if="endpoint === '' && agentItem.name === ''" class="badge bg-secondary me-2">Current</span>
<span v-else-if="agentItem.name === ''" :href="agentItem.url" class="me-2">{{ endpoint }}</span>
<span v-else :href="agentItem.url" class="me-2">{{ agentItem.name }}</span>
</template>
<!-- Edit Name -->
<font-awesome-icon v-if="agentItem.name !== ''" icon="pen-to-square" @click="showEditAgentNameDialog[agentItem.name] = !showEditAgentNameDialog[agentItem.Name]" />
<!-- Edit Dialog -->
<BModal v-model="showEditAgentNameDialog[agentItem.name]" :no-close-on-backdrop="true" :close-on-esc="true" :okTitle="$t('Update Name')" okVariant="info" @ok="updateName(agentItem.url, agentItem.updatedName)">
<label for="Update Name" class="form-label">Current value: {{ $t(agentItem.name) }}</label>
<input id="updatedName" v-model="agentItem.updatedName" type="text" class="form-control" optional>
</BModal>
<!-- Remove Button -->
<font-awesome-icon v-if="endpoint !== ''" class="ms-2 remove-agent" icon="trash" @click="showRemoveAgentDialog[agent.url] = !showRemoveAgentDialog[agent.url]" />
<font-awesome-icon v-if="endpoint !== ''" class="ms-2 remove-agent" icon="trash" @click="showRemoveAgentDialog[agentItem.url] = !showRemoveAgentDialog[agentItem.url]" />
<!-- Remoe Agent Dialog -->
<BModal v-model="showRemoveAgentDialog[agent.url]" :okTitle="$t('removeAgent')" okVariant="danger" @ok="removeAgent(agent.url)">
<p>{{ agent.url }}</p>
<!-- Remove Agent Dialog -->
<BModal v-model="showRemoveAgentDialog[agentItem.url]" :okTitle="$t('removeAgent')" okVariant="danger" @ok="removeAgent(agentItem.url)">
<p>{{ agentItem.url }}</p>
{{ $t("removeAgentMsg") }}
</BModal>
</div>
@@ -81,6 +93,11 @@
<input id="password" v-model="agent.password" type="password" class="form-control" required autocomplete="new-password">
</div>
<div class="mb-3">
<label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
<input id="name" v-model="agent.name" type="text" class="form-control" optional>
</div>
<button type="submit" class="btn btn-primary" :disabled="connectingAgent">
<template v-if="connectingAgent">{{ $t("connecting") }}</template>
<template v-else>{{ $t("connect") }}</template>
@@ -121,11 +138,14 @@ export default {
dockerRunCommand: "",
showAgentForm: false,
showRemoveAgentDialog: {},
showEditAgentNameDialog: {},
connectingAgent: false,
agent: {
url: "http://",
username: "",
password: "",
name: "",
updatedName: "",
}
};
},
@@ -199,6 +219,19 @@ export default {
});
},
updateName(url, updatedName) {
this.$root.getSocket().emit("updateAgent", url, updatedName, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.showAgentForm = false;
this.agent = {
updatedName: "",
};
}
});
},
getStatusNum(statusName) {
let num = 0;
@@ -286,7 +319,7 @@ export default {
}
},
},
}
};
</script>
@@ -326,7 +359,6 @@ table {
}
.docker-run {
background-color: $dark-bg !important;
border: none;
font-family: 'JetBrains Mono', monospace;
font-size: 15px;

View File

@@ -83,6 +83,9 @@ export default {
security: {
title: this.$t("Security"),
},
globalEnv: {
title: this.$t("GlobalEnv"),
},
about: {
title: this.$t("About"),
},

View File

@@ -14,6 +14,7 @@ const Settings = () => import("./pages/Settings.vue");
import Appearance from "./components/settings/Appearance.vue";
import General from "./components/settings/General.vue";
const Security = () => import("./components/settings/Security.vue");
const GlobalEnv = () => import("./components/settings/GlobalEnv.vue");
import About from "./components/settings/About.vue";
const routes = [
@@ -78,6 +79,10 @@ const routes = [
path: "security",
component: Security,
},
{
path: "globalEnv",
component: GlobalEnv,
},
{
path: "about",
component: About,

View File

@@ -593,9 +593,6 @@ optgroup {
color: $primary;
}
.prism-editor__textarea {
outline: none !important;
}
h5.settings-subheading::after {
content: "";
@@ -676,18 +673,25 @@ code {
color: $dark-font-color3;
}
// Vue Prism Editor bug - workaround
// https://github.com/koca/vue-prism-editor/issues/87
/*
.prism-editor__textarea {
width: 999999px !important;
.cm-gutters {
background-color: transparent !important;
}
.prism-editor__editor {
white-space: pre !important;
.dark [contenteditable="true"] {
background-color: transparent !important;
}
.cm-editor {
background-color: transparent !important;
}
.cm-activeLine, .cm-activeLineGutter {
background-color: transparent !important;
}
.cm-selectionBackground {
background-color: #74c2ff3d !important;
}
.cm-focused {
outline: none !important;
}
.prism-editor__container {
overflow-x: scroll !important;
}*/
// Localization
@import "localization.scss";

945
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "dockge",
"version": "1.4.2",
"version": "1.5.0",
"type": "module",
"engines": {
"node": ">= 22.14.0"
@@ -19,8 +19,8 @@
"build:docker-base": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:base -f ./docker/Base.Dockerfile . --push",
"build:docker": "node ./extra/env2arg.js docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:latest -t louislam/dockge:1 -t louislam/dockge:$VERSION -t louislam/dockge:beta -t louislam/dockge:nightly --target release -f ./docker/Dockerfile . --push",
"build:docker-beta": "node ./extra/env2arg.js docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:beta -t louislam/dockge:$VERSION --target release -f ./docker/Dockerfile . --push",
"build:docker-nightly": "npm run build:frontend && docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:nightly --target nightly -f ./docker/Dockerfile . --push",
"build:healthcheck": "docker buildx build -f docker/BuildHealthCheck.Dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:build-healthcheck . --push",
"release-nightly": "npm run build:frontend && docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:nightly -t ghcr.io/louislam/dockge:nightly --target nightly -f ./docker/Dockerfile . --push",
"start-docker": "docker run --rm -p 5001:5001 --name dockge louislam/dockge:latest",
"mark-as-nightly": "tsx ./extra/mark-as-nightly.ts",
"reformat-changelog": "tsx ./extra/reformat-changelog.ts",
@@ -59,6 +59,8 @@
},
"devDependencies": {
"@actions/github": "^6.0.0",
"@codemirror/lang-python": "^6.1.7",
"@codemirror/lang-yaml": "^6.1.2",
"@fontsource/jetbrains-mono": "^5.2.5",
"@fortawesome/fontawesome-svg-core": "6.4.2",
"@fortawesome/free-regular-svg-icons": "6.4.2",
@@ -77,21 +79,22 @@
"@xterm/xterm": "beta",
"bootstrap": "5.3.2",
"bootstrap-vue-next": "~0.14.10",
"codemirror": "^6.0.1",
"concurrently": "^8.2.2",
"cross-env": "~7.0.3",
"eslint": "~8.50.0",
"eslint-plugin-jsdoc": "~46.8.2",
"eslint-plugin-vue": "~9.32.0",
"prismjs": "~1.30.0",
"sass": "~1.68.0",
"thememirror": "^2.0.1",
"typescript": "~5.2.2",
"unplugin-vue-components": "~0.25.2",
"vite": "~5.4.15",
"vite-plugin-compression": "~0.5.1",
"vue": "~3.5.13",
"vue-codemirror6": "^1.3.13",
"vue-eslint-parser": "~9.3.2",
"vue-i18n": "~10.0.6",
"vue-prism-editor": "2.0.0-alpha.2",
"vue-qrcode": "~2.2.2",
"vue-router": "~4.5.0",
"vue-toastification": "2.0.0-rc.5",