From e86beea7725e4b9f857dd6a021258bdf2267d533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=9D=E8=BF=BD=EF=BC=88shaco=EF=BC=89?= <1679924785@qq.com> Date: Fri, 18 Sep 2026 15:49:58 +0800 Subject: [PATCH 1/8] feat: add container details and Docker publishing --- .github/DISCUSSION_TEMPLATE/ask-for-help.yml | 4 +- .../DISCUSSION_TEMPLATE/feature-request.yml | 2 +- .github/FUNDING.yml | 13 +- .github/ISSUE_TEMPLATE/ask-for-help.yaml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yaml | 4 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- .github/ISSUE_TEMPLATE/security.md | 11 +- .github/PULL_REQUEST_TEMPLATE.md | 4 +- .github/workflows/docker-image.yml | 132 +++++ .github/workflows/nightly-release.yml | 6 +- CONTRIBUTING.md | 158 ++--- README.md | 242 +++----- SECURITY.md | 9 +- .../docker-socket-handler.ts | 62 +- .../terminal-socket-handler.ts | 83 +++ backend/stack.ts | 171 +++++- backend/terminal.ts | 11 +- common/util-common.ts | 13 +- compose.yaml | 2 +- frontend/components.d.ts | 6 - frontend/src/components/Container.vue | 162 ++--- frontend/src/components/Terminal.vue | 163 ++++-- frontend/src/icon.ts | 4 + frontend/src/lang/en.json | 22 +- frontend/src/lang/zh-CN.json | 22 +- frontend/src/mixins/socket.ts | 3 +- frontend/src/pages/Compose.vue | 149 +++-- frontend/src/pages/ContainerDetails.vue | 552 ++++++++++++++++++ frontend/src/pages/ContainerTerminal.vue | 2 +- frontend/src/router.ts | 11 + package.json | 8 +- 31 files changed, 1548 insertions(+), 487 deletions(-) create mode 100644 .github/workflows/docker-image.yml create mode 100644 frontend/src/pages/ContainerDetails.vue diff --git a/.github/DISCUSSION_TEMPLATE/ask-for-help.yml b/.github/DISCUSSION_TEMPLATE/ask-for-help.yml index 192e5865b..cf3806c98 100644 --- a/.github/DISCUSSION_TEMPLATE/ask-for-help.yml +++ b/.github/DISCUSSION_TEMPLATE/ask-for-help.yml @@ -4,7 +4,7 @@ body: id: no-duplicate-issues attributes: label: "⚠️ Please verify that this bug has NOT been raised before." - description: "Search in the issues sections by clicking [HERE](https://github.com/louislam/dockge/discussions/categories/ask-for-help)" + description: "Search existing discussions by clicking [HERE](https://github.com/Lorwell/dockge/discussions/categories/ask-for-help)" options: - label: "I checked and didn't find similar issue" required: true @@ -13,7 +13,7 @@ body: label: "🛡️ Security Policy" description: Please review the security policy before reporting security related issues/bugs. options: - - label: I agree to have read this project [Security Policy](https://github.com/louislam/dockge/security/policy) + - label: I agree to have read this project [Security Policy](https://github.com/Lorwell/dockge/security/policy) required: true - type: textarea id: steps-to-reproduce diff --git a/.github/DISCUSSION_TEMPLATE/feature-request.yml b/.github/DISCUSSION_TEMPLATE/feature-request.yml index 60189a7e2..da6e2d60e 100644 --- a/.github/DISCUSSION_TEMPLATE/feature-request.yml +++ b/.github/DISCUSSION_TEMPLATE/feature-request.yml @@ -4,7 +4,7 @@ body: id: no-duplicate-issues attributes: label: "⚠️ Please verify that this feature request has NOT been suggested before." - description: "Search in the issues sections by clicking [HERE](https://github.com/louislam/dockge/discussions/categories/feature-request)" + description: "Search existing discussions by clicking [HERE](https://github.com/Lorwell/dockge/discussions/categories/feature-request)" options: - label: "I checked and didn't find similar feature request" required: true diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index d55fbd4fc..22fb3dbb9 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,12 +1 @@ -# These are supported funding model platforms - -github: louislam # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -#patreon: # Replace with a single Patreon username -open_collective: uptime-kuma # Replace with a single Open Collective username -#ko_fi: # Replace with a single Ko-fi username -#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -#liberapay: # Replace with a single Liberapay username -#issuehunt: # Replace with a single IssueHunt username -#otechie: # Replace with a single Otechie username -#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] +# Funding links can be added here if this fork enables a sponsorship program. diff --git a/.github/ISSUE_TEMPLATE/ask-for-help.yaml b/.github/ISSUE_TEMPLATE/ask-for-help.yaml index 108cd8d93..6488607ec 100644 --- a/.github/ISSUE_TEMPLATE/ask-for-help.yaml +++ b/.github/ISSUE_TEMPLATE/ask-for-help.yaml @@ -4,7 +4,7 @@ body: - type: markdown attributes: value: | - ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ Please go to https://github.com/louislam/dockge/discussions/new?category=ask-for-help + Please use https://github.com/Lorwell/dockge/discussions/new?category=ask-for-help for support requests. - type: checkboxes id: no-duplicate-issues attributes: diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index a5acf3d87..69a6d51b1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -7,7 +7,7 @@ body: id: no-duplicate-issues attributes: label: "⚠️ Please verify that this bug has NOT been reported before." - description: "Search in the issues sections by clicking [HERE](https://github.com/louislam/dockge/issues?q=)" + description: "Search existing issues by clicking [HERE](https://github.com/Lorwell/dockge/issues?q=)" options: - label: "I checked and didn't find similar issue" required: true @@ -16,7 +16,7 @@ body: label: "🛡️ Security Policy" description: Please review the security policy before reporting security related issues/bugs. options: - - label: I agree to have read this project [Security Policy](https://github.com/louislam/dockge/security/policy) + - label: I agree to have read this project [Security Policy](https://github.com/Lorwell/dockge/security/policy) required: true - type: textarea id: description diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index a8dbe6c7a..e17327bc2 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -4,7 +4,7 @@ body: - type: markdown attributes: value: | - ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ Please go to https://github.com/louislam/dockge/discussions/new?category=ask-for-help + Please use https://github.com/Lorwell/dockge/discussions/new?category=feature-request for feature requests. - type: checkboxes id: no-duplicate-issues attributes: diff --git a/.github/ISSUE_TEMPLATE/security.md b/.github/ISSUE_TEMPLATE/security.md index ab03b4500..1b238f961 100644 --- a/.github/ISSUE_TEMPLATE/security.md +++ b/.github/ISSUE_TEMPLATE/security.md @@ -1,19 +1,18 @@ --- name: "Security Issue" -about: "Just for alerting @louislam, do not provide any details here" +about: "Do not disclose vulnerability details in a public issue" title: "Security Issue" -ref: "main" +ref: "master" labels: - security --- -DO NOT PROVIDE ANY DETAILS HERE. Please privately report to https://github.com/louislam/dockge/security/advisories/new. +DO NOT PROVIDE ANY DETAILS HERE. Please report the vulnerability privately at +https://github.com/Lorwell/dockge/security/advisories/new. -Why need this issue? It is because GitHub Advisory do not send a notification to @louislam, it is a workaround to do so. - -Your GitHub Advisory URL: +There is no need to create a public issue after submitting the advisory. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c820f960b..398f0c976 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,5 @@ -⚠️⚠️⚠️ Since we do not accept all types of pull requests and do not want to waste your time. Please be sure that you have read pull request rules: -https://github.com/louislam/dockge/blob/master/CONTRIBUTING.md +Please read the contribution guidelines before opening a pull request: +https://github.com/Lorwell/dockge/blob/master/CONTRIBUTING.md Tick the checkbox if you understand [x]: - [ ] I have read and understand the pull request rules. diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000..aa92e415e --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,132 @@ +name: Build and push Docker image + +on: + workflow_dispatch: + +concurrency: + group: dockerhub-dockge-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + IMAGE_NAME: docker.io/moailaozi/dockge + +jobs: + quality: + name: Application quality gate + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --no-fund + + - name: Validate and build application + run: | + npm run lint + npm run check-ts + npm run build:frontend + + - name: Upload frontend bundle + uses: actions/upload-artifact@v4 + with: + name: frontend-dist + path: frontend-dist/ + if-no-files-found: error + retention-days: 1 + + build-and-push: + name: Build and publish multi-platform image + needs: quality + runs-on: ubuntu-24.04 + timeout-minutes: 120 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download frontend bundle + uses: actions/download-artifact@v4 + with: + name: frontend-dist + path: frontend-dist + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Read and validate image version + id: version + shell: bash + run: | + if [[ "$GITHUB_REF" != "refs/heads/master" ]]; then + echo "Stable images must be published from the master branch." + exit 1 + fi + + VERSION="$(node --input-type=module -e "import packageJson from './package.json' with { type: 'json' }; process.stdout.write(packageJson.version);")" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "package.json version must use major.minor.patch format (for example: 1.5.0)." + exit 1 + fi + + IFS=. read -r MAJOR MINOR PATCH <<< "$VERSION" + echo "full=$VERSION" >> "$GITHUB_OUTPUT" + echo "minor=$MAJOR.$MINOR" >> "$GITHUB_OUTPUT" + echo "major=$MAJOR" >> "$GITHUB_OUTPUT" + + - name: Set build metadata + id: metadata + shell: bash + run: | + echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "date=$(date --utc '+%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: docker-container + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + target: release + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.full }} + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.minor }} + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.major }} + ${{ env.IMAGE_NAME }}:latest + labels: | + org.opencontainers.image.title=Dockge Lorwell Fork + org.opencontainers.image.description=A compose.yaml stack manager with container-level details, logs, and terminals + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.version.outputs.full }} + org.opencontainers.image.created=${{ steps.metadata.outputs.date }} + org.opencontainers.image.licenses=MIT + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 2b28672db..78c03912b 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -30,15 +30,15 @@ jobs: - name: Login to Docker Hub uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} - password: ${{ secrets.GHCR_TOKEN }} + password: ${{ github.token }} - name: Use Node.js 22 uses: actions/setup-node@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6b4ccac3..5622d4c7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,140 +1,76 @@ -## Can I create a pull request for Dockge? +# Contributing -Yes or no, it depends on what you will try to do. Since I don't want to waste your time, be sure to **create open a discussion, so we can have a discussion first**. Especially for a large pull request or you don't know if it will be merged or not. +This repository is an independently maintained fork of Dockge. Open issues and pull requests against +`Lorwell/dockge`; do not send fork-specific changes to the upstream project. -Here are some references: +## Before opening a pull request -### ✅ Usually accepted: -- Bug fix -- Security fix -- Adding new language files (see [these instructions](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md)) -- Adding new language keys: `$t("...")` +- Keep each change focused and avoid unrelated refactors. +- Discuss large features or breaking changes in a repository issue first. +- Add user-facing English text to `frontend/src/lang/en.json`. Do not update unrelated translations in the same + change. +- Include screenshots stored on GitHub for visible UI changes. +- Describe manual coverage for affected UI, Socket.IO, database, and Docker Compose behavior. -### ⚠️ Discussion required: -- Large pull requests -- New features +## Local development -### ❌ Won't be merged: -- A dedicated PR for translating existing languages (see [these instructions](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md)) -- Do not pass the auto-test -- Any breaking changes -- Duplicated pull requests -- Buggy -- UI/UX is not close to Dockge -- Modifications or deletions of existing logic without a valid reason. -- Adding functions that is completely out of scope -- Converting existing code into other programming languages -- Unnecessarily large code changes that are hard to review and cause conflicts with other PRs. +Requirements: -The above cases may not cover all possible situations. +- Node.js 22.14 or newer +- npm +- Git +- Docker Engine with Docker Compose V2 for integration testing -I (@louislam) have the final say. If your pull request does not meet my expectations, I will reject it, no matter how much time you spend on it. Therefore, it is essential to have a discussion beforehand. - -I will assign your pull request to a [milestone](https://github.com/louislam/dockge/milestones), if I plan to review and merge it. - -Also, please don't rush or ask for an ETA, because I have to understand the pull request, make sure it is no breaking changes and stick to my vision of this project, especially for large pull requests. - -## Project Styles - -I personally do not like something that requires so many configurations before you can finally start the app. - -- Settings should be configurable in the frontend. Environment variables are discouraged, unless it is related to startup such as `DOCKGE_STACKS_DIR` -- Easy to use -- The web UI styling should be consistent and nice -- No native build dependency - -## Coding Styles - -- 4 spaces indentation -- Follow `.editorconfig` -- Follow ESLint -- Methods and functions should be documented with JSDoc - -## Name Conventions - -- Javascript/Typescript: camelCaseType -- SQLite: snake_case (Underscore) -- CSS/SCSS: kebab-case (Dash) - -## Tools - -- [`Node.js`](https://nodejs.org/) >= 22.14.0 -- [`git`](https://git-scm.com/) -- IDE that supports [`ESLint`](https://eslint.org/) and EditorConfig (I am using [`IntelliJ IDEA`](https://www.jetbrains.com/idea/)) -- A SQLite GUI tool (f.ex. [`SQLite Expert Personal`](https://www.sqliteexpert.com/download.html) or [`DBeaver Community`](https://dbeaver.io/download/)) - -## Install Dependencies for Development +Install dependencies and start the frontend and backend development servers: ```bash npm install +npm run dev ``` -## Dev Server +The frontend runs on and waits for the backend on port `5001`. The processes can also be +started independently: -``` +```bash npm run dev:frontend npm run dev:backend ``` -## Backend Dev Server - -It binds to `0.0.0.0:5001` by default. +Both applications share the root `package.json`. Frontend-only packages belong in `devDependencies`; runtime +backend packages belong in `dependencies`. -It is mainly a socket.io app + express.js. +## Project layout -## Frontend Dev Server +- `backend/`: Node.js server, models, migrations, and Socket.IO handlers +- `common/`: utilities shared by the frontend and backend +- `frontend/src/`: Vue 3 components, pages, layouts, styles, and translations +- `frontend/public/`: static frontend assets +- `docker/`: production image definitions +- `extra/`: maintenance and release scripts -It binds to `0.0.0.0:5000` by default. The frontend dev server is used for development only. +## Code style -For production, it is not used. It will be compiled to `frontend-dist` directory instead. +- Use four-space indentation for TypeScript and Vue, and two spaces for YAML. +- Use double quotes and semicolons in TypeScript. +- Use `camelCase` for TypeScript identifiers, `snake_case` for SQLite fields, and `kebab-case` for CSS classes. +- Add JSDoc to methods and functions. +- Preserve existing behavior unless the change explicitly requires otherwise. -You can use Vue.js devtools Chrome extension for debugging. +## Required checks -### Build the frontend +Run the same validation used by CI before submitting: ```bash -npm run build +npm run lint +npm run check-ts +npm run build:frontend ``` -## Database Migration - -TODO - -## Dependencies - -Both frontend and backend share the same package.json. However, the frontend dependencies are eventually not used in the production environment, because it is usually also baked into dist files. So: - -- Frontend dependencies = "devDependencies" - - Examples: vue, chart.js -- Backend dependencies = "dependencies" - - Examples: socket.io, sqlite3 -- Development dependencies = "devDependencies" - - Examples: eslint, sass - -### Update Dependencies - -Should only be done by the maintainer. - -```bash -npm update -```` - -It should update the patch release version only. - -Patch release = the third digit ([Semantic Versioning](https://semver.org/)) - -If for security / bug / other reasons, a library must be updated, breaking changes need to be checked by the person proposing the change. - -## Translations - -Please add **all** the strings which are translatable to `src/lang/en.json` (If translation keys are omitted, they can not be translated). - -**Don't include any other languages in your initial Pull-Request** (even if this is your mother tongue), to avoid merge-conflicts between weblate and `master`. -The translations can then (after merging a PR into `master`) be translated by awesome people donating their language skills. - -If you want to help by translating Uptime Kuma into your language, please visit the [instructions on how to translate using weblate](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md). +There is no dedicated unit-test suite yet. Add focused automated tests when introducing independently testable +logic, and document the manual tests performed for the rest. -## Spelling & Grammar +## Docker releases -Feel free to correct the grammar in the documentation or code. -My mother language is not English and my grammar is not that great. +Stable Docker images are published from `master` with the `Build and push Docker image` workflow. The release +version is read from `package.json`; version changes must keep `package.json` and `package-lock.json` synchronized. +Publishing requires the repository's Docker Hub secrets and is a maintainer operation. diff --git a/README.md b/README.md index 21f6de819..e92e67c2b 100644 --- a/README.md +++ b/README.md @@ -1,207 +1,151 @@
- + Dockge
-# Dockge +# Dockge — Lorwell Fork -A fancy, easy-to-use and reactive self-hosted docker compose.yaml stack-oriented manager. +[![Version](https://img.shields.io/badge/version-1.5.0-green.svg)](./package.json) +[![Docker pulls](https://img.shields.io/docker/pulls/moailaozi/dockge.svg)](https://hub.docker.com/r/moailaozi/dockge) +[![Build Docker image](https://github.com/Lorwell/dockge/actions/workflows/docker-image.yml/badge.svg)](https://github.com/Lorwell/dockge/actions/workflows/docker-image.yml) -[![GitHub Repo stars](https://img.shields.io/github/stars/louislam/dockge?logo=github&style=flat)](https://github.com/louislam/dockge) [![Docker Pulls](https://img.shields.io/docker/pulls/louislam/dockge?logo=docker)](https://hub.docker.com/r/louislam/dockge/tags) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/louislam/dockge/latest?label=docker%20image%20ver.)](https://hub.docker.com/r/louislam/dockge/tags) [![GitHub last commit (branch)](https://img.shields.io/github/last-commit/louislam/dockge/master?logo=github)](https://github.com/louislam/dockge/commits/master/) +This repository is an independently maintained fork of +[Dockge](https://github.com/louislam/dockge). It has its own Docker images, release workflow, documentation, and +upgrade path. Use this repository and the `moailaozi/dockge` image when installing or upgrading this fork. - +Dockge is a responsive, self-hosted manager for Docker Compose stacks. Compose files remain ordinary files on the +host and can still be managed with the Docker Compose CLI. -View Video: https://youtu.be/AWAlOQeNpgU?t=48 +## What is different in this fork -## ⭐ Features +- Container instance details generated from the live Compose state, including stacks that use `include` +- Dedicated Overview, Logs, and Terminal views for each created container +- Large, viewport-aware log panels with follow, clear, copy, and in-page fullscreen controls +- Container-level start, stop, and restart actions with stack ownership validation +- Fully interactive Bash/sh terminals with Tab, control-key, resize, and paste support +- The original Compose editor, stack lifecycle controls, multi-agent support, and responsive UI -- 🧑‍💼 Manage your `compose.yaml` files - - Create/Edit/Start/Stop/Restart/Delete - - Update Docker Images -- ⌨️ 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 - - - -- 🚄 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) - -## 🔧 How to Install +## Quick start Requirements: -- [Docker](https://docs.docker.com/engine/install/) 20+ / Podman -- (Podman only) podman-docker (Debian: `apt install podman-docker`) -- OS: - - Major Linux distros that can run Docker/Podman such as: - - ✅ Ubuntu - - ✅ Debian (Bullseye or newer) - - ✅ Raspbian (Bullseye or newer) - - ✅ CentOS - - ✅ Fedora - - ✅ ArchLinux - - ❌ Debian/Raspbian Buster or lower is not supported - - ❌ Windows (Will be supported later) -- Arch: armv7, arm64, amd64 (a.k.a x86_64) - -### Basic - -- Default Stacks Directory: `/opt/stacks` -- Default Port: 5001 - -``` -# Create directories that store your stacks and stores Dockge's stack -mkdir -p /opt/stacks /opt/dockge -cd /opt/dockge - -# Download the compose.yaml -curl https://raw.githubusercontent.com/louislam/dockge/master/compose.yaml --output compose.yaml - -# Start the server -docker compose up -d - -# If you are using docker-compose V1 or Podman -# docker-compose up -d -``` - -Dockge is now running on http://localhost:5001 - -### Advanced -If you want to store your stacks in another directory, you can generate your compose.yaml file by using the following URL with custom query strings. - -``` -# Download your compose.yaml -curl "https://dockge.kuma.pet/compose.yaml?port=5001&stacksPath=/opt/stacks" --output compose.yaml -``` +- Docker Engine 20+ with Docker Compose V2, or a compatible Podman installation +- A Linux host capable of mounting the Docker socket +- `amd64`, `arm64`, or `arm/v7` -- port=`5001` -- stacksPath=`/opt/stacks` +Create the directories and save the following as `/opt/dockge/compose.yaml`: -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 +```bash +mkdir -p /opt/dockge /opt/stacks +cd /opt/dockge ``` -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: -``` +```yaml services: dockge: - image: louislam/dockge:1 + image: docker.io/moailaozi/dockge:1.5.0 restart: unless-stopped ports: - # Host Port:Container Port - - 5001:5001 + - "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) + # The host and container paths must be identical. - /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 + # Set both values when stack files should be owned by a non-root user. + - PUID=1000 + - PGID=1000 ``` -## How to Update +Start Dockge: ```bash -cd /opt/dockge -docker compose pull && docker compose up -d +docker compose up -d ``` -## Screenshots - -![](https://github.com/louislam/dockge/assets/1336778/e7ff0222-af2e-405c-b533-4eab04791b40) - - -![](https://github.com/louislam/dockge/assets/1336778/7139e88c-77ed-4d45-96e3-00b66d36d871) - -![](https://github.com/louislam/dockge/assets/1336778/f019944c-0e87-405b-a1b8-625b35de1eeb) - -![](https://github.com/louislam/dockge/assets/1336778/a4478d23-b1c4-4991-8768-1a7cad3472e3) - +Open . For production, pin a full version such as `1.5.0`; the `latest` tag follows the +newest stable release of this fork. -## Motivations +## Stack directory and imports -- 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 - -If you love this project, please consider giving it a ⭐. +The default stacks directory in the example is `/opt/stacks`. Each stack has its own directory and Compose file: +```text +/opt/stacks/ +└── example-stack/ + └── compose.yaml +``` -## 🗣️ Community and Contribution +Existing Compose projects can also be represented with an include-only stack file: -### Bug Report -https://github.com/louislam/dockge/issues +```yaml +name: nginx-proxy-manager +include: + - path: /data/nginx-proxy-manager/docker-compose.yaml + project_directory: /data/nginx-proxy-manager +services: {} +``` -### Ask for Help / Discussions -https://github.com/louislam/dockge/discussions +After adding files outside the UI, use **Scan Stacks Folder** in Dockge. Paths referenced by Compose must be +available to the Dockge container at the same absolute path. -### Translation -If you want to translate Dockge into your language, please read [Translation Guide](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md) +## Upgrade -### Create a Pull Request +Back up `/opt/dockge/data` and the stack directory, update the pinned image version, then run: -Be sure to read the [guide](https://github.com/louislam/dockge/blob/master/CONTRIBUTING.md), as we don't accept all types of pull requests and don't want to waste your time. +```bash +cd /opt/dockge +docker compose pull +docker compose up -d +``` -## FAQ +Database migrations run automatically at startup. Restore a pre-upgrade backup if a downgrade is required. -#### "Dockge"? +## Local development -"Dockge" is a coinage word which is created by myself. I originally hoped it sounds like `Dodge`, but apparently many people called it `Dockage`, it is also acceptable. +Dockge requires Node.js 22.14 or newer. Install dependencies and start both development servers: -The naming idea came from Twitch emotes like `sadge`, `bedge` or `wokege`. They all end in `-ge`. +```bash +npm install +npm run dev +``` -#### Can I manage a single container without `compose.yaml`? +- Frontend: +- Backend: +- `npm run dev:frontend` and `npm run dev:backend` can be run separately. -The main objective of Dockge is to try to use the docker `compose.yaml` for everything. If you want to manage a single container, you can just use Portainer or Docker CLI. +Before submitting changes, run: -#### Can I manage existing stacks? +```bash +npm run lint +npm run check-ts +npm run build:frontend +``` -Yes, you can. However, you need to move your compose file into the stacks directory: +## Docker image publishing -1. Stop your stack -2. Move your compose file into `/opt/stacks//compose.yaml` -3. In Dockge, click the " Scan Stacks Folder" button in the top-right corner's dropdown menu -4. Now you should see your stack in the list +The `Build and push Docker image` GitHub Actions workflow publishes multi-platform images for `amd64`, `arm64`, +and `arm/v7`. It can only publish stable images from `master`, and derives the release version from `package.json`. +For version `1.5.0`, it publishes `1.5.0`, `1.5`, `1`, and `latest` tags. -#### Is Dockge a Portainer replacement? +Configure these GitHub Actions secrets before running the workflow: -Yes or no. Portainer provides a lot of Docker features. While Dockge is currently only focusing on docker-compose with a better user interface and better user experience. +- `DOCKER_HUB_USERNAME`: Docker Hub account name +- `DOCKER_HUB_TOKEN`: Docker Hub access token with permission to push `moailaozi/dockge` -If you want to manage your container with docker-compose only, the answer may be yes. +Then open **Actions → Build and push Docker image → Run workflow** on the `master` branch. -If you still need to manage something like docker networks, single containers, the answer may be no. +## Support and contribution -#### Can I install both Dockge and Portainer? +- [Issues](https://github.com/Lorwell/dockge/issues) +- [Actions](https://github.com/Lorwell/dockge/actions) +- [Docker image tags](https://hub.docker.com/r/moailaozi/dockge/tags) +- [Development guidelines](./CONTRIBUTING.md) -Yes, you can. +This fork is maintained independently. Do not report fork-specific problems or request its features in the +upstream Dockge repository. -## Others +## Attribution -Dockge is built on top of [Compose V2](https://docs.docker.com/compose/migrate/). `compose.yaml` also known as `docker-compose.yml`. +Dockge is licensed under the MIT License and builds on the work of Louis Lam and the upstream Dockge contributors. diff --git a/SECURITY.md b/SECURITY.md index a0a632a85..be3991116 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,11 +2,12 @@ ## Reporting a Vulnerability -1. Please report security issues to https://github.com/louislam/dockge/security/advisories/new. -1. Please also create an empty security issue to alert me, as GitHub Advisories do not send a notification, I probably will miss it without this. https://github.com/louislam/dockge/issues/new?assignees=&labels=help&template=security.md +Please report vulnerabilities privately through +[GitHub Security Advisories](https://github.com/Lorwell/dockge/security/advisories/new). Do not use the public issue tracker or discuss it in public as it will cause more damage. -## Do you accept other 3rd-party bug bounty platforms? +## Third-party bug bounty platforms -At this moment, I DO NOT accept other bug bounty platforms, because I am not familiar with these platforms and someone has tried to send a phishing link to me by doing this already. To minimize my own risk, please report through GitHub Advisories only. I will ignore all 3rd-party bug bounty platforms emails. +This project does not currently operate a bug bounty program. Reports sent through third-party bounty platforms +are not accepted; use GitHub Security Advisories instead. diff --git a/backend/agent-socket-handlers/docker-socket-handler.ts b/backend/agent-socket-handlers/docker-socket-handler.ts index 28f0c31fd..6105415c2 100644 --- a/backend/agent-socket-handlers/docker-socket-handler.ts +++ b/backend/agent-socket-handlers/docker-socket-handler.ts @@ -19,7 +19,6 @@ export class DockerSocketHandler extends AgentSocketHandler { msg: "Deployed", msgi18n: true, }, callback); - stack.joinCombinedTerminal(socket); } catch (e) { callbackError(e, callback); } @@ -77,10 +76,6 @@ export class DockerSocketHandler extends AgentSocketHandler { const stack = await Stack.getStack(server, stackName); - if (stack.isManagedByDockge) { - stack.joinCombinedTerminal(socket); - } - callbackResult({ ok: true, stack: await stack.toJSON(socket.endpoint), @@ -123,8 +118,6 @@ export class DockerSocketHandler extends AgentSocketHandler { }, callback); server.sendStackList(); - stack.joinCombinedTerminal(socket); - } catch (e) { callbackError(e, callback); } @@ -147,8 +140,6 @@ export class DockerSocketHandler extends AgentSocketHandler { msgi18n: true, }, callback); server.sendStackList(); - - stack.leaveCombinedTerminal(socket); } catch (e) { callbackError(e, callback); } @@ -267,7 +258,6 @@ export class DockerSocketHandler extends AgentSocketHandler { 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" @@ -318,6 +308,57 @@ export class DockerSocketHandler extends AgentSocketHandler { } }); + agentSocket.on("startContainer", async (stackName: unknown, containerName: unknown, callback) => { + try { + checkLogin(socket); + if (typeof stackName !== "string" || typeof containerName !== "string") { + throw new ValidationError("Stack name and container name must be strings"); + } + const stack = await Stack.getStack(server, stackName, true); + await stack.runContainerAction(containerName, "start"); + callbackResult({ ok: true, + msg: "Started", + msgi18n: true }, callback); + server.sendStackList(); + } catch (e) { + callbackError(e, callback); + } + }); + + agentSocket.on("stopContainer", async (stackName: unknown, containerName: unknown, callback) => { + try { + checkLogin(socket); + if (typeof stackName !== "string" || typeof containerName !== "string") { + throw new ValidationError("Stack name and container name must be strings"); + } + const stack = await Stack.getStack(server, stackName, true); + await stack.runContainerAction(containerName, "stop"); + callbackResult({ ok: true, + msg: "Stopped", + msgi18n: true }, callback); + server.sendStackList(); + } catch (e) { + callbackError(e, callback); + } + }); + + agentSocket.on("restartContainer", async (stackName: unknown, containerName: unknown, callback) => { + try { + checkLogin(socket); + if (typeof stackName !== "string" || typeof containerName !== "string") { + throw new ValidationError("Stack name and container name must be strings"); + } + const stack = await Stack.getStack(server, stackName, true); + await stack.runContainerAction(containerName, "restart"); + callbackResult({ ok: true, + msg: "Restarted", + msgi18n: true }, callback); + server.sendStackList(); + } catch (e) { + callbackError(e, callback); + } + }); + // getExternalNetworkList agentSocket.on("getDockerNetworkList", async (callback) => { try { @@ -354,4 +395,3 @@ export class DockerSocketHandler extends AgentSocketHandler { } } - diff --git a/backend/agent-socket-handlers/terminal-socket-handler.ts b/backend/agent-socket-handlers/terminal-socket-handler.ts index ab8fb5cf7..7dc987ec9 100644 --- a/backend/agent-socket-handlers/terminal-socket-handler.ts +++ b/backend/agent-socket-handlers/terminal-socket-handler.ts @@ -25,6 +25,7 @@ export class TerminalSocketHandler extends AgentSocketHandler { if (terminal instanceof InteractiveTerminal) { //log.debug("terminalInput", "Terminal found, writing to terminal."); terminal.write(cmd); + callbackResult({ ok: true }, callback); } else { throw new Error("Terminal not found or it is not a Interactive Terminal."); } @@ -99,6 +100,9 @@ export class TerminalSocketHandler extends AgentSocketHandler { if (typeof(shell) !== "string") { throw new ValidationError("Shell must be a string."); } + if (shell !== "bash" && shell !== "sh") { + throw new ValidationError("Shell must be bash or sh."); + } log.debug("interactiveTerminal", "Stack name: " + stackName); log.debug("interactiveTerminal", "Service name: " + serviceName); @@ -115,6 +119,85 @@ export class TerminalSocketHandler extends AgentSocketHandler { } }); + agentSocket.on("leaveInteractiveTerminal", async (stackName : unknown, serviceName : unknown, shell : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(stackName) !== "string" || typeof(serviceName) !== "string" || typeof(shell) !== "string") { + throw new ValidationError("Stack name, service name and shell must be strings."); + } + if (shell !== "bash" && shell !== "sh") { + throw new ValidationError("Shell must be bash or sh."); + } + const stack = await Stack.getStack(server, stackName, true); + stack.leaveContainerTerminal(socket, serviceName, shell); + callbackResult({ ok: true }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + + agentSocket.on("joinContainerLogs", async (stackName : unknown, containerName : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(stackName) !== "string" || typeof(containerName) !== "string") { + throw new ValidationError("Stack name and container name must be strings."); + } + const stack = await Stack.getStack(server, stackName, true); + const terminalName = await stack.joinContainerLogs(socket, containerName); + callbackResult({ + ok: true, + terminalName, + }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + + agentSocket.on("leaveContainerLogs", async (stackName : unknown, containerName : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(stackName) !== "string" || typeof(containerName) !== "string") { + throw new ValidationError("Stack name and container name must be strings."); + } + const stack = await Stack.getStack(server, stackName, true); + await stack.leaveContainerLogs(socket, containerName); + callbackResult({ ok: true }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + + agentSocket.on("interactiveContainerTerminal", async (stackName : unknown, containerName : unknown, shell : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(stackName) !== "string" || typeof(containerName) !== "string" || typeof(shell) !== "string") { + throw new ValidationError("Stack name, container name and shell must be strings."); + } + if (shell !== "bash" && shell !== "sh") { + throw new ValidationError("Shell must be bash or sh."); + } + const stack = await Stack.getStack(server, stackName, true); + await stack.joinContainerInstanceTerminal(socket, containerName, shell); + callbackResult({ ok: true }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + + agentSocket.on("leaveInteractiveContainerTerminal", async (stackName : unknown, containerName : unknown, shell : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(stackName) !== "string" || typeof(containerName) !== "string" || typeof(shell) !== "string") { + throw new ValidationError("Stack name, container name and shell must be strings."); + } + const stack = await Stack.getStack(server, stackName, true); + stack.leaveContainerInstanceTerminal(socket, containerName, shell); + callbackResult({ ok: true }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + // Join Output Terminal agentSocket.on("terminalJoin", async (terminalName : unknown, callback) => { if (typeof(callback) !== "function") { diff --git a/backend/stack.ts b/backend/stack.ts index 5fe8b99d2..0f91b2ff3 100644 --- a/backend/stack.ts +++ b/backend/stack.ts @@ -11,9 +11,9 @@ import { CREATED_FILE, CREATED_STACK, EXITED, getCombinedTerminalName, - getComposeTerminalName, getContainerExecTerminalName, - PROGRESS_TERMINAL_ROWS, - RUNNING, TERMINAL_ROWS, + getComposeTerminalName, getContainerExecTerminalName, getContainerInstanceExecTerminalName, + getContainerLogTerminalName, + RUNNING, TERMINAL_COLS, TERMINAL_ROWS, UNKNOWN } from "../common/util-common"; import { InteractiveTerminal, Terminal } from "./terminal"; @@ -494,11 +494,11 @@ export class Stack { } async joinContainerTerminal(socket: DockgeSocket, serviceName: string, shell : string = "sh", index: number = 0) { - const terminalName = getContainerExecTerminalName(socket.endpoint, this.name, serviceName, index); + const terminalName = getContainerExecTerminalName(socket.endpoint, this.name, serviceName, index, shell); let terminal = Terminal.getTerminal(terminalName); if (!terminal) { - terminal = new InteractiveTerminal(this.server, terminalName, "docker", this.getComposeOptions("exec", serviceName, shell), this.path); + terminal = new InteractiveTerminal(this.server, terminalName, "docker", this.getComposeOptions("exec", "-e", "TERM=xterm-256color", serviceName, shell), this.path); terminal.rows = TERMINAL_ROWS; log.debug("joinContainerTerminal", "Terminal created"); } @@ -507,11 +507,30 @@ export class Stack { terminal.start(); } + /** + * Leave a Compose service terminal and close its PTY when unused. + * + * @param socket Socket leaving the terminal + * @param serviceName Compose service name + * @param shell Shell executable + * @param index Compose replica index + */ + leaveContainerTerminal(socket: DockgeSocket, serviceName: string, shell: string, index: number = 0) { + const terminalName = getContainerExecTerminalName(socket.endpoint, this.name, serviceName, index, shell); + const terminal = Terminal.getTerminal(terminalName); + if (terminal) { + terminal.leave(socket); + if (!terminal.hasClients) { + terminal.close(); + } + } + } + async getServiceStatusList() { let statusList = new Map>(); try { - let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), { + let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--all", "--format", "json"), { cwd: this.path, encoding: "utf-8", }); @@ -522,13 +541,35 @@ export class Stack { let lines = res.stdout?.toString().split("\n"); - const addLine = (obj: { Service: string, State: string, Name: string, Health: string }) => { + const addLine = (obj: { + ID: string, + Service: string, + State: string, + Name: string, + Health: string, + Image: string, + Command: string, + CreatedAt: string, + RunningFor: string, + Ports: string, + Publishers?: Array + }) => { if (!statusList.has(obj.Service)) { statusList.set(obj.Service, []); } statusList.get(obj.Service)?.push({ + id: obj.ID, + service: obj.Service, status: obj.Health || obj.State, - name: obj.Name + state: obj.State, + health: obj.Health, + name: obj.Name, + image: obj.Image, + command: obj.Command, + createdAt: obj.CreatedAt, + runningFor: obj.RunningFor, + ports: obj.Ports, + publishers: obj.Publishers || [], }); }; @@ -551,6 +592,120 @@ export class Stack { } } + /** + * Resolve a container by name and verify that it belongs to this stack. + * + * @param containerName Docker container name + * @returns Container status returned by Docker Compose + */ + async getContainer(containerName: string) : Promise> { + const serviceStatusList = await this.getServiceStatusList(); + for (const containers of serviceStatusList.values()) { + const container = containers.find((item) => (item as { name?: string }).name === containerName); + if (container) { + return container as Record; + } + } + throw new ValidationError(`Container ${containerName} does not belong to stack ${this.name}.`); + } + + /** + * Join a read-only log stream for one container instance. + * + * @param socket Socket joining the terminal + * @param containerName Docker container name + * @returns Terminal name used by the frontend + */ + async joinContainerLogs(socket: DockgeSocket, containerName: string) : Promise { + const container = await this.getContainer(containerName); + const terminalName = getContainerLogTerminalName(socket.endpoint, this.name, containerName); + const terminal = Terminal.getOrCreateTerminal( + this.server, + terminalName, + "docker", + [ "logs", "--follow", "--tail", "200", String(container.id) ], + this.path + ); + terminal.enableKeepAlive = true; + terminal.rows = COMBINED_TERMINAL_ROWS; + terminal.cols = TERMINAL_COLS; + terminal.join(socket); + terminal.start(); + return terminalName; + } + + /** + * Leave a container log stream. + * + * @param socket Socket leaving the terminal + * @param containerName Docker container name + */ + async leaveContainerLogs(socket: DockgeSocket, containerName: string) { + const terminalName = getContainerLogTerminalName(socket.endpoint, this.name, containerName); + const terminal = Terminal.getTerminal(terminalName); + terminal?.leave(socket); + } + + /** + * Open an interactive shell for one concrete container instance. + * + * @param socket Socket joining the terminal + * @param containerName Docker container name + * @param shell Shell executable + */ + async joinContainerInstanceTerminal(socket: DockgeSocket, containerName: string, shell: string) { + const container = await this.getContainer(containerName); + const terminalName = getContainerInstanceExecTerminalName(socket.endpoint, this.name, containerName, shell); + let terminal = Terminal.getTerminal(terminalName); + if (!terminal) { + terminal = new InteractiveTerminal( + this.server, + terminalName, + "docker", + [ "exec", "-it", "-e", "TERM=xterm-256color", String(container.id), shell ], + this.path + ); + terminal.rows = TERMINAL_ROWS; + } + terminal.join(socket); + terminal.start(); + } + + /** + * Leave an interactive container terminal and close it when unused. + * + * @param socket Socket leaving the terminal + * @param containerName Docker container name + * @param shell Shell executable + */ + leaveContainerInstanceTerminal(socket: DockgeSocket, containerName: string, shell: string) { + const terminalName = getContainerInstanceExecTerminalName(socket.endpoint, this.name, containerName, shell); + const terminal = Terminal.getTerminal(terminalName); + if (terminal) { + terminal.leave(socket); + if (!terminal.hasClients) { + terminal.close(); + } + } + } + + /** + * Run a lifecycle action against one verified container instance. + * + * @param containerName Docker container name + * @param action Docker lifecycle action + */ + async runContainerAction(containerName: string, action: "start" | "stop" | "restart") { + const container = await this.getContainer(containerName); + const result = await childProcessAsync.spawn("docker", [ action, String(container.id) ], { + cwd: this.path, + encoding: "utf-8", + }); + if (result.code !== 0) { + throw new Error(`Failed to ${action} container ${containerName}.`); + } + } + 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); diff --git a/backend/terminal.ts b/backend/terminal.ts index 36d26b37e..2a63a12d3 100644 --- a/backend/terminal.ts +++ b/backend/terminal.ts @@ -113,9 +113,9 @@ export class Terminal { try { this._ptyProcess = pty.spawn(this.file, this.args, { - name: this.name, + name: "xterm-256color", cwd: this.cwd, - cols: TERMINAL_COLS, + cols: this.cols, rows: this.rows, }); @@ -180,6 +180,10 @@ export class Terminal { delete this.socketList[socket.id]; } + public get hasClients() { + return Object.keys(this.socketList).length > 0; + } + public get ptyProcess() { return this._ptyProcess; } @@ -200,8 +204,7 @@ export class Terminal { close() { clearInterval(this.keepAliveInterval); - // Send Ctrl+C to the terminal - this.ptyProcess?.write("\x03"); + this.ptyProcess?.kill(); } /** diff --git a/common/util-common.ts b/common/util-common.ts index 008b476b2..eacc3b15d 100644 --- a/common/util-common.ts +++ b/common/util-common.ts @@ -209,8 +209,16 @@ export function getContainerTerminalName(endpoint : string, container : string) return "container-" + endpoint + "-" + container; } -export function getContainerExecTerminalName(endpoint : string, stackName : string, container : string, index : number) { - return "container-exec-" + endpoint + "-" + stackName + "-" + container + "-" + index; +export function getContainerExecTerminalName(endpoint : string, stackName : string, container : string, index : number, shell : string = "") { + return "container-exec-" + endpoint + "-" + stackName + "-" + container + "-" + index + (shell ? "-" + shell : ""); +} + +export function getContainerLogTerminalName(endpoint : string, stackName : string, container : string) { + return "container-log-" + endpoint + "-" + stackName + "-" + container; +} + +export function getContainerInstanceExecTerminalName(endpoint : string, stackName : string, container : string, shell : string) { + return "container-instance-exec-" + endpoint + "-" + stackName + "-" + container + "-" + shell; } export function copyYAMLComments(doc : Document, src : Document) { @@ -427,4 +435,3 @@ function traverseYAML(pair : Pair, env : DotenvParseOutput) : void { pair.value.value = envsubst(pair.value.value, env); } } - diff --git a/compose.yaml b/compose.yaml index b2b7bdb02..03ff44a88 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: dockge: - image: louislam/dockge:1 + image: docker.io/moailaozi/dockge:1.5.0 restart: unless-stopped ports: # Host Port : Container Port diff --git a/frontend/components.d.ts b/frontend/components.d.ts index e1dea73e2..d5c38ac48 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -11,11 +11,8 @@ 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'] @@ -34,7 +31,4 @@ 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'] - } } diff --git a/frontend/src/components/Container.vue b/frontend/src/components/Container.vue index a4a6e2641..fda5a9b56 100644 --- a/frontend/src/components/Container.vue +++ b/frontend/src/components/Container.vue @@ -4,12 +4,12 @@

{{ name }}

- {{ imageName }}:{{ imageTag }} + {{ imageDisplay }}
@@ -64,31 +64,29 @@ {{ $t("deleteContainer") }}
-
-
- -
-
-
- -
- +
+ + {{ $t("details") }} + + + {{ $t("logs") }} +
- +
@@ -193,12 +191,10 @@ 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 + FontAwesomeIcon }, props: { name: { @@ -214,12 +210,16 @@ export default defineComponent({ default: false, }, serviceStatus: { - type: Object, - default: null, + type: Array, + default: () => [], }, dockerStats: { type: Object, - default: null + default: () => ({}), + }, + serviceCount: { + type: Number, + default: 1, } }, emits: [ @@ -230,14 +230,13 @@ export default defineComponent({ data() { return { showConfig: false, - expandedStats: false, }; }, computed: { networkList() { let list = []; - for (const networkName in this.jsonObject.networks) { + for (const networkName in (this.jsonObject.networks || {})) { list.push(networkName); } return list; @@ -289,16 +288,12 @@ export default defineComponent({ }, service() { - if (!this.jsonObject.services[this.name]) { + if (!this.jsonObject.services || !this.jsonObject.services[this.name]) { return {}; } return this.jsonObject.services[this.name]; }, - serviceCount() { - return Object.keys(this.jsonObject.services).length; - }, - jsonObject() { return this.$parent.$parent.jsonConfig; }, @@ -308,45 +303,17 @@ export default defineComponent({ }, envsubstService() { - if (!this.envsubstJSONConfig.services[this.name]) { + if (!this.envsubstJSONConfig.services || !this.envsubstJSONConfig.services[this.name]) { return {}; } return this.envsubstJSONConfig.services[this.name]; }, - imageName() { - if (this.envsubstService.image) { - return this.envsubstService.image.split(":")[0]; - } else { - return ""; - } - }, - - imageTag() { - if (this.envsubstService.image) { - let tag = this.envsubstService.image.split(":")[1]; - - if (tag) { - return tag; - } else { - return "latest"; - } - } else { - 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)); + imageDisplay() { + return this.envsubstService.image || this.serviceStatus[0]?.image || ""; }, status() { - if (!this.serviceStatus) { + if (this.serviceStatus.length === 0) { return "N/A"; } return this.serviceStatus[0].status; @@ -377,6 +344,28 @@ export default defineComponent({ }, restartService() { this.$emit("restart-service", this.name); + }, + containerDetailsRoute(instance, tab) { + const route = { + name: this.endpoint ? "containerDetailsEndpoint" : "containerDetails", + params: { + stackName: this.stackName, + containerName: instance.name, + }, + }; + if (this.endpoint) { + route.params.endpoint = this.endpoint; + } + if (tab) { + route.query = { tab }; + } + return route; + }, + instanceStatusClass(instance) { + if (instance.health === "unhealthy") { + return "bg-danger"; + } + return instance.state === "running" ? "bg-primary" : "bg-secondary"; } } @@ -391,7 +380,7 @@ export default defineComponent({ font-size: 0.8rem; color: #6c757d; .tag { - color: #33383b; + color: var(--bs-heading-color); } } @@ -408,5 +397,38 @@ export default defineComponent({ font-size: 0.8rem; color: #6c757d; } + + .container-instances { + border-top: 1px solid $dark-border-color; + } + + .instance-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 0.85rem; + } + + .instance-name { + overflow-wrap: anywhere; + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; + } + + @media (max-width: 575.98px) { + .instance-row { + align-items: stretch; + flex-direction: column; + } + + .instance-actions { + display: flex; + } + + .instance-actions .btn { + flex: 1; + } + } } diff --git a/frontend/src/components/Terminal.vue b/frontend/src/components/Terminal.vue index 8135d833d..0bffbb5c3 100644 --- a/frontend/src/components/Terminal.vue +++ b/frontend/src/components/Terminal.vue @@ -46,6 +46,16 @@ export default { default: "bash", }, + containerName: { + type: String, + default: "", + }, + + autoFollow: { + type: Boolean, + default: false, + }, + rows: { type: Number, default: TERMINAL_ROWS, @@ -59,18 +69,21 @@ export default { // Mode // displayOnly: Only display terminal output // mainTerminal: Allow input limited commands and output - // interactive: Free input and output + // interactive: Free input and output through Docker Compose + // interactiveContainer: Free input and output for one container instance mode: { type: String, default: "displayOnly", } }, - emits: [ "has-data" ], + emits: [ "has-data", "ready", "follow-change", "selection-change" ], data() { return { first: true, terminalInputBuffer: "", cursorPosition: 0, + followOutput: this.autoFollow, + hasSelection: false, }; }, created() { @@ -93,7 +106,7 @@ export default { if (this.mode === "mainTerminal") { this.mainTerminalConfig(); - } else if (this.mode === "interactive") { + } else if (this.mode === "interactive" || this.mode === "interactiveContainer") { this.interactiveTerminalConfig(); } @@ -111,6 +124,18 @@ export default { this.handleSelection(); }); + this.terminal.onScroll((position) => { + if (this.followOutput && position < this.terminal.buffer.active.baseY) { + this.setFollow(false); + } + }); + + this.terminal.onWriteParsed(() => { + if (this.followOutput) { + this.terminal.scrollToBottom(); + } + }); + // Notify parent component when data is received this.terminal.onCursorMove(() => { console.debug("onData triggered"); @@ -120,44 +145,61 @@ export default { } }); - this.bind(); + this.bind(undefined, undefined, () => { + this.$emit("ready"); - // Create a new Terminal - if (this.mode === "mainTerminal") { - this.$root.emitAgent(this.endpoint, "mainTerminal", this.name, (res) => { - if (!res.ok) { - this.$root.toastRes(res); - } - }); - } else if (this.mode === "interactive") { - console.debug("Create Interactive terminal:", this.name); - this.$root.emitAgent(this.endpoint, "interactiveTerminal", this.stackName, this.serviceName, this.shell, (res) => { - if (!res.ok) { - this.$root.toastRes(res); - } - }); - } + // Create a new Terminal + if (this.mode === "mainTerminal") { + this.$root.emitAgent(this.endpoint, "mainTerminal", this.name, (res) => { + if (!res.ok) { + this.$root.toastRes(res); + } + }); + } else if (this.mode === "interactive") { + console.debug("Create Interactive terminal:", this.name); + this.$root.emitAgent(this.endpoint, "interactiveTerminal", this.stackName, this.serviceName, this.shell, (res) => { + if (!res.ok) { + this.$root.toastRes(res); + } else { + this.onResizeEvent(); + } + }); + } else if (this.mode === "interactiveContainer") { + this.$root.emitAgent(this.endpoint, "interactiveContainerTerminal", this.stackName, this.containerName, this.shell, (res) => { + if (!res.ok) { + this.$root.toastRes(res); + } else { + this.onResizeEvent(); + } + }); + } + }); // Fit the terminal width to the div container size after terminal is created. this.updateTerminalSize(); }, unmounted() { window.removeEventListener("resize", this.onResizeEvent); // Remove the resize event listener from the window object. + if (this.mode === "interactive") { + this.$root.emitAgent(this.endpoint, "leaveInteractiveTerminal", this.stackName, this.serviceName, this.shell, () => {}); + } else if (this.mode === "interactiveContainer") { + this.$root.emitAgent(this.endpoint, "leaveInteractiveContainerTerminal", this.stackName, this.containerName, this.shell, () => {}); + } this.$root.unbindTerminal(this.name); this.terminal.dispose(); this.$refs.terminal?.removeEventListener("contextmenu", this.handleContextMenu); }, methods: { - bind(endpoint, name) { + bind(endpoint, name, callback) { // Workaround: normally this.name should be set, but it is not sometimes, so we use the parameter, but eventually this.name and name must be the same name if (name) { this.$root.unbindTerminal(name); - this.$root.bindTerminal(endpoint, name, this.terminal); + this.$root.bindTerminal(endpoint, name, this.terminal, callback); console.debug("Terminal bound via parameter: " + name); } else if (this.name) { this.$root.unbindTerminal(this.name); - this.$root.bindTerminal(this.endpoint, this.name, this.terminal); + this.$root.bindTerminal(this.endpoint, this.name, this.terminal, callback); console.debug("Terminal bound: " + this.name); } else { console.debug("Terminal name not set"); @@ -198,9 +240,7 @@ export default { // Remove the input from the terminal this.removeInput(); - this.$root.emitAgent(this.endpoint, "terminalInput", this.name, buffer + e.key, (err) => { - this.$root.toastError(err.msg); - }); + this.sendTerminalInput(buffer + e.key); } else if (e.key === "\u007F") { // Backspace if (this.cursorPosition > 0) { // Remove character to the left of cursor @@ -236,7 +276,7 @@ export default { } } else if (e.key === "\u0003") { // Ctrl + C console.debug("Ctrl + C"); - this.$root.emitAgent(this.endpoint, "terminalInput", this.name, e.key); + this.sendTerminalInput(e.key); this.removeInput(); } else if (e.key === "\u0016" || (e.domEvent?.ctrlKey && e.key.toLowerCase() === "v")) { // Ctrl + V this.handlePaste(); @@ -253,18 +293,29 @@ export default { }, interactiveTerminalConfig() { - this.terminal.onKey(e => { - // Handle Ctrl+V for paste - if (e.key === "\u0016" || (e.domEvent?.ctrlKey && e.key.toLowerCase() === "v")) { + this.terminal.attachCustomKeyEventHandler(event => { + if (event.type === "keydown" && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { this.handlePaste(); - return; + return false; } + return true; + }); - this.$root.emitAgent(this.endpoint, "terminalInput", this.name, e.key, (res) => { - if (!res.ok) { - this.$root.toastRes(res); - } - }); + this.terminal.onData(data => { + this.sendTerminalInput(data); + }); + }, + + /** + * Forward raw terminal input to the backing PTY. + * + * @param {string} input Raw data emitted by xterm + */ + sendTerminalInput(input) { + this.$root.emitAgent(this.endpoint, "terminalInput", this.name, input, (res) => { + if (!res.ok) { + this.$root.toastRes(res); + } }); }, @@ -292,6 +343,31 @@ export default { this.$root.emitAgent(this.endpoint, "terminalResize", this.name, rows, cols); }, + fit() { + this.updateTerminalSize(); + }, + + clear() { + this.terminal.clear(); + }, + + setFollow(enabled) { + this.followOutput = enabled; + if (enabled) { + this.terminal.scrollToBottom(); + } + this.$emit("follow-change", enabled); + }, + + async copySelection() { + const selectedText = this.terminal.getSelection(); + if (!selectedText) { + return false; + } + await this.copyToClipboard(selectedText); + return true; + }, + /** * Handle clipboard paste operation */ @@ -327,13 +403,9 @@ export default { 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); - } - }); + } else if (this.mode === "interactive" || this.mode === "interactiveContainer") { + // Let xterm encode bracketed paste mode before forwarding the raw data. + this.terminal.paste(text); } }, @@ -345,7 +417,7 @@ export default { event.preventDefault(); // Only handle paste for modes that support input - if (this.mode === "mainTerminal" || this.mode === "interactive") { + if (this.mode === "mainTerminal" || this.mode === "interactive" || this.mode === "interactiveContainer") { this.handlePaste(); } }, @@ -355,9 +427,8 @@ export default { */ handleSelection() { const selectedText = this.terminal.getSelection(); - if (selectedText && selectedText.length > 0) { - this.copyToClipboard(selectedText); - } + this.hasSelection = selectedText.length > 0; + this.$emit("selection-change", this.hasSelection); }, /** diff --git a/frontend/src/icon.ts b/frontend/src/icon.ts index 396f402bd..994f1f939 100644 --- a/frontend/src/icon.ts +++ b/frontend/src/icon.ts @@ -57,6 +57,8 @@ import { faCloudArrowDown, faArrowsRotate, faChevronCircleRight, faChevronCircleDown, + faExpand, + faCompress, } from "@fortawesome/free-solid-svg-icons"; library.add( @@ -115,6 +117,8 @@ library.add( faArrowsRotate, faChevronCircleRight, faChevronCircleDown, + faExpand, + faCompress, ); export { FontAwesomeIcon }; diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index 2a731a411..701376944 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -148,5 +148,25 @@ "rmCode": "rm", "rmRfCode": "rm -rf", "envVarCode": "DOCKGE_ENABLE_CONSOLE=true", - "confirmLeaveStack": "You are currently editing a stack. Are you sure you want to leave?" + "confirmLeaveStack": "You are currently editing a stack. Are you sure you want to leave?", + "service": "Service", + "overview": "Overview", + "logs": "Logs", + "status": "Status", + "health": "Health", + "createdAt": "Created", + "runningFor": "Running For", + "loadingContainer": "Loading container details…", + "containerNotFound": "Container not found", + "containerNotFoundDescription": "This container no longer exists or does not belong to this stack.", + "backToStack": "Back to Stack", + "pauseFollow": "Pause Follow", + "resumeFollow": "Resume Follow", + "clearDisplay": "Clear Display", + "copySelection": "Copy Selection", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit Fullscreen", + "terminalRequiresRunningContainer": "Start the container before opening an interactive terminal.", + "shell": "Shell", + "details": "Details" } diff --git a/frontend/src/lang/zh-CN.json b/frontend/src/lang/zh-CN.json index bc4d35966..2f4e14855 100644 --- a/frontend/src/lang/zh-CN.json +++ b/frontend/src/lang/zh-CN.json @@ -128,5 +128,25 @@ "Network name...": "网络名称...", "Select a network...": "选择网络...", "NoNetworksAvailable": "网络不可用.你需要在正确的方向先添加内部网络或者启用外部网络.", - "Downed": "已宕机" + "Downed": "已宕机", + "service": "服务", + "overview": "概览", + "logs": "日志", + "status": "状态", + "health": "健康状态", + "createdAt": "创建时间", + "runningFor": "运行时长", + "loadingContainer": "正在加载容器详情…", + "containerNotFound": "未找到容器", + "containerNotFoundDescription": "此容器已不存在,或已不属于当前堆栈。", + "backToStack": "返回堆栈", + "pauseFollow": "暂停跟随", + "resumeFollow": "恢复跟随", + "clearDisplay": "清空显示", + "copySelection": "复制选中内容", + "fullscreen": "全屏", + "exitFullscreen": "退出全屏", + "terminalRequiresRunningContainer": "请先启动容器,再打开交互式终端。", + "shell": "Shell", + "details": "详情" } diff --git a/frontend/src/mixins/socket.ts b/frontend/src/mixins/socket.ts index 3e7eed853..6f186ec12 100644 --- a/frontend/src/mixins/socket.ts +++ b/frontend/src/mixins/socket.ts @@ -400,12 +400,13 @@ export default defineComponent({ }, - bindTerminal(endpoint : string, terminalName : string, terminal : Terminal) { + bindTerminal(endpoint : string, terminalName : string, terminal : Terminal, callback? : () => void) { // Load terminal, get terminal screen this.emitAgent(endpoint, "terminalJoin", terminalName, (res) => { if (res.ok) { terminal.write(res.buffer); terminalMap.set(terminalName, terminal); + callback?.(); } else { this.toastRes(res); } diff --git a/frontend/src/pages/Compose.vue b/frontend/src/pages/Compose.vue index 20cf9ecb3..f3e61a309 100644 --- a/frontend/src/pages/Compose.vue +++ b/frontend/src/pages/Compose.vue @@ -1,6 +1,10 @@