Full Code of louislam/dockge for AI

master cc180562fcd5 cached
145 files
557.7 KB
143.3k tokens
263 symbols
1 requests
Download .txt
Showing preview only (595K chars total). Download the full file or copy to clipboard to get everything.
Repository: louislam/dockge
Branch: master
Commit: cc180562fcd5
Files: 145
Total size: 557.7 KB

Directory structure:
gitextract_xvxd0m7m/

├── .dockerignore
├── .editorconfig
├── .eslintrc.cjs
├── .github/
│   ├── DISCUSSION_TEMPLATE/
│   │   ├── ask-for-help.yml
│   │   └── feature-request.yml
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── ask-for-help.yaml
│   │   ├── bug_report.yaml
│   │   ├── feature_request.yaml
│   │   └── security.md
│   ├── PULL_REQUEST_TEMPLATE.md
│   ├── config/
│   │   └── exclude.txt
│   └── workflows/
│       ├── ci.yml
│       ├── close-incorrect-issue.yml
│       ├── json-yaml-validate.yml
│       ├── nightly-release.yml
│       └── prevent-file-change.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── backend/
│   ├── agent-manager.ts
│   ├── agent-socket-handler.ts
│   ├── agent-socket-handlers/
│   │   ├── docker-socket-handler.ts
│   │   └── terminal-socket-handler.ts
│   ├── check-version.ts
│   ├── database.ts
│   ├── dockge-server.ts
│   ├── index.ts
│   ├── log.ts
│   ├── migrations/
│   │   ├── 2023-10-20-0829-setting-table.ts
│   │   ├── 2023-10-20-0829-user-table.ts
│   │   └── 2023-12-20-2117-agent-table.ts
│   ├── models/
│   │   ├── agent.ts
│   │   └── user.ts
│   ├── password-hash.ts
│   ├── rate-limiter.ts
│   ├── router.ts
│   ├── routers/
│   │   └── main-router.ts
│   ├── settings.ts
│   ├── socket-handler.ts
│   ├── socket-handlers/
│   │   ├── agent-proxy-socket-handler.ts
│   │   ├── main-socket-handler.ts
│   │   └── manage-agent-socket-handler.ts
│   ├── stack.ts
│   ├── terminal.ts
│   ├── util-server.ts
│   └── utils/
│       └── limit-queue.ts
├── common/
│   ├── agent-socket.ts
│   └── util-common.ts
├── compose.yaml
├── docker/
│   ├── Base.Dockerfile
│   ├── BuildHealthCheck.Dockerfile
│   └── Dockerfile
├── extra/
│   ├── close-incorrect-issue.js
│   ├── env2arg.js
│   ├── healthcheck.go
│   ├── mark-as-nightly.ts
│   ├── reformat-changelog.ts
│   ├── reset-password.ts
│   ├── templates/
│   │   ├── mariadb/
│   │   │   └── compose.yaml
│   │   ├── nginx-proxy-manager/
│   │   │   └── compose.yaml
│   │   └── uptime-kuma/
│   │       └── compose.yaml
│   ├── test-docker.ts
│   └── update-version.ts
├── frontend/
│   ├── components.d.ts
│   ├── index.html
│   ├── public/
│   │   └── manifest.json
│   ├── src/
│   │   ├── App.vue
│   │   ├── components/
│   │   │   ├── ArrayInput.vue
│   │   │   ├── ArraySelect.vue
│   │   │   ├── Confirm.vue
│   │   │   ├── Container.vue
│   │   │   ├── HiddenInput.vue
│   │   │   ├── Login.vue
│   │   │   ├── NetworkInput.vue
│   │   │   ├── StackList.vue
│   │   │   ├── StackListItem.vue
│   │   │   ├── Terminal.vue
│   │   │   ├── TwoFADialog.vue
│   │   │   ├── Uptime.vue
│   │   │   └── settings/
│   │   │       ├── About.vue
│   │   │       ├── Appearance.vue
│   │   │       ├── General.vue
│   │   │       ├── GlobalEnv.vue
│   │   │       └── Security.vue
│   │   ├── i18n.ts
│   │   ├── icon.ts
│   │   ├── lang/
│   │   │   ├── README.md
│   │   │   ├── ar.json
│   │   │   ├── be.json
│   │   │   ├── bg-BG.json
│   │   │   ├── ca.json
│   │   │   ├── cs-CZ.json
│   │   │   ├── da.json
│   │   │   ├── de-CH.json
│   │   │   ├── de.json
│   │   │   ├── en.json
│   │   │   ├── es.json
│   │   │   ├── fr.json
│   │   │   ├── ga.json
│   │   │   ├── hu.json
│   │   │   ├── id.json
│   │   │   ├── it-IT.json
│   │   │   ├── ja.json
│   │   │   ├── ko-KR.json
│   │   │   ├── nb_NO.json
│   │   │   ├── nl.json
│   │   │   ├── pl-PL.json
│   │   │   ├── pt-BR.json
│   │   │   ├── pt.json
│   │   │   ├── ro.json
│   │   │   ├── ru.json
│   │   │   ├── sl.json
│   │   │   ├── sv-SE.json
│   │   │   ├── th.json
│   │   │   ├── tr.json
│   │   │   ├── uk-UA.json
│   │   │   ├── ur.json
│   │   │   ├── vi.json
│   │   │   ├── zh-CN.json
│   │   │   └── zh-TW.json
│   │   ├── layouts/
│   │   │   ├── EmptyLayout.vue
│   │   │   └── Layout.vue
│   │   ├── main.ts
│   │   ├── mixins/
│   │   │   ├── lang.ts
│   │   │   ├── socket.ts
│   │   │   └── theme.ts
│   │   ├── pages/
│   │   │   ├── Compose.vue
│   │   │   ├── Console.vue
│   │   │   ├── ContainerTerminal.vue
│   │   │   ├── Dashboard.vue
│   │   │   ├── DashboardHome.vue
│   │   │   ├── Settings.vue
│   │   │   └── Setup.vue
│   │   ├── router.ts
│   │   ├── styles/
│   │   │   ├── localization.scss
│   │   │   ├── main.scss
│   │   │   └── vars.scss
│   │   ├── util-frontend.ts
│   │   └── vite-env.d.ts
│   └── vite.config.ts
├── package.json
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
# Should be identical to .gitignore
.env
node_modules
.idea
data
stacks
tmp
/private

# Docker extra
docker
frontend
.editorconfig
.eslintrc.cjs
.git
.gitignore
README.md


================================================
FILE: .editorconfig
================================================
root = true

[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

[*.yaml]
indent_size = 2

[*.yml]
indent_size = 2

[*.vue]
trim_trailing_whitespace = false

[*.go]
indent_style = tab


================================================
FILE: .eslintrc.cjs
================================================
module.exports = {
    root: true,
    env: {
        browser: true,
        node: true,
    },
    extends: [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:vue/vue3-recommended",
    ],
    parser: "vue-eslint-parser",
    parserOptions: {
        "parser": "@typescript-eslint/parser",
    },
    plugins: [
        "@typescript-eslint",
        "jsdoc"
    ],
    rules: {
        "yoda": "error",
        "linebreak-style": [ "error", "unix" ],
        "camelcase": [ "warn", {
            "properties": "never",
            "ignoreImports": true
        }],
        "no-unused-vars": [ "warn", {
            "args": "none"
        }],
        indent: [
            "error",
            4,
            {
                ignoredNodes: [ "TemplateLiteral" ],
                SwitchCase: 1,
            },
        ],
        quotes: [ "error", "double" ],
        semi: "error",
        "vue/html-indent": [ "error", 4 ], // default: 2
        "vue/max-attributes-per-line": "off",
        "vue/singleline-html-element-content-newline": "off",
        "vue/html-self-closing": "off",
        "vue/require-component-is": "off",      // not allow is="style" https://github.com/vuejs/eslint-plugin-vue/issues/462#issuecomment-430234675
        "vue/attribute-hyphenation": "off",     // This change noNL to "no-n-l" unexpectedly
        "vue/multi-word-component-names": "off",
        "no-multi-spaces": [ "error", {
            ignoreEOLComments: true,
        }],
        "array-bracket-spacing": [ "warn", "always", {
            "singleValue": true,
            "objectsInArrays": false,
            "arraysInArrays": false
        }],
        "space-before-function-paren": [ "error", {
            "anonymous": "always",
            "named": "never",
            "asyncArrow": "always"
        }],
        "curly": "error",
        "object-curly-spacing": [ "error", "always" ],
        "object-curly-newline": "off",
        "object-property-newline": "error",
        "comma-spacing": "error",
        "brace-style": "error",
        "no-var": "error",
        "key-spacing": "warn",
        "keyword-spacing": "warn",
        "space-infix-ops": "error",
        "arrow-spacing": "warn",
        "no-trailing-spaces": "error",
        "no-constant-condition": [ "error", {
            "checkLoops": false,
        }],
        "space-before-blocks": "warn",
        "no-extra-boolean-cast": "off",
        "no-multiple-empty-lines": [ "warn", {
            "max": 1,
            "maxBOF": 0,
        }],
        "lines-between-class-members": [ "warn", "always", {
            exceptAfterSingleLine: true,
        }],
        "no-unneeded-ternary": "error",
        "array-bracket-newline": [ "error", "consistent" ],
        "eol-last": [ "error", "always" ],
        "comma-dangle": [ "warn", "only-multiline" ],
        "no-empty": [ "error", {
            "allowEmptyCatch": true
        }],
        "no-control-regex": "off",
        "one-var": [ "error", "never" ],
        "max-statements-per-line": [ "error", { "max": 1 }],
        "@typescript-eslint/ban-ts-comment": "off",
        "@typescript-eslint/no-unused-vars": [ "warn", {
            "args": "none"
        }],
        "prefer-const" : "off",
    },
};


================================================
FILE: .github/DISCUSSION_TEMPLATE/ask-for-help.yml
================================================
labels: [help]
body:
  - type: checkboxes
    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)"
      options:
        - label: "I checked and didn't find similar issue"
          required: true
  - type: checkboxes
    attributes:
      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)
          required: true
  - type: textarea
    id: steps-to-reproduce
    validations:
      required: true
    attributes:
      label: "📝 Describe your problem"
      description: "Please walk us through it step by step."
      placeholder: "Describe what are you asking for..."
  - type: textarea
    id: error-msg
    validations:
      required: false
    attributes:
      label: "📝 Error Message(s) or Log"
  - type: input
    id: dockge-version
    attributes:
      label: "🐻 Dockge Version"
      description: "Which version of Dockge are you running? Please do NOT provide the docker tag such as latest or 1"
      placeholder: "Ex. 1.10.0"
    validations:
      required: true
  - type: input
    id: operating-system
    attributes:
      label: "💻 Operating System and Arch"
      description: "Which OS is your server/device running on? (For Replit, please do not report this bug)"
      placeholder: "Ex. Ubuntu 20.04 x86"
    validations:
      required: true
  - type: input
    id: browser-vendor
    attributes:
      label: "🌐 Browser"
      description: "Which browser are you running on? (For Replit, please do not report this bug)"
      placeholder: "Ex. Google Chrome 95.0.4638.69"
    validations:
      required: true
  - type: input
    id: docker-version
    attributes:
      label: "🐋 Docker Version"
      description: "If running with Docker, which version are you running?"
      placeholder: "Ex. Docker 20.10.9 / K8S / Podman"
    validations:
      required: false
  - type: input
    id: nodejs-version
    attributes:
      label: "🟩 NodeJS Version"
      description: "If running with Node.js? which version are you running?"
      placeholder: "Ex. 14.18.0"
    validations:
      required: false


================================================
FILE: .github/DISCUSSION_TEMPLATE/feature-request.yml
================================================
labels: [feature-request]
body:
  - type: checkboxes
    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)"
      options:
        - label: "I checked and didn't find similar feature request"
          required: true
  - type: dropdown
    id: feature-area
    attributes:
      label: "🏷️ Feature Request Type"
      description: "What kind of feature request is this?"
      multiple: true
      options:
        - API
        - UI Feature
        - Other
    validations:
      required: true
  - type: textarea
    id: feature-description
    validations:
      required: true
    attributes:
      label: "🔖 Feature description"
      description: "A clear and concise description of what the feature request is."
      placeholder: "You should add ..."
  - type: textarea
    id: solution
    validations:
      required: true
    attributes:
      label: "✔️ Solution"
      description: "A clear and concise description of what you want to happen."
      placeholder: "In my use-case, ..."
  - type: textarea
    id: alternatives
    validations:
      required: false
    attributes:
      label: "❓ Alternatives"
      description: "A clear and concise description of any alternative solutions or features you've considered."
      placeholder: "I have considered ..."
  - type: textarea
    id: additional-context
    validations:
      required: false
    attributes:
      label: "📝 Additional Context"
      description: "Add any other context or screenshots about the feature request here."
      placeholder: "..."


================================================
FILE: .github/FUNDING.yml
================================================
# 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']


================================================
FILE: .github/ISSUE_TEMPLATE/ask-for-help.yaml
================================================
name: "⚠️ Ask for help (Please go to the \"Discussions\" tab to submit a Help Request)"
description: "⚠️ Please go to the \"Discussions\" tab to submit a Help Request"
body:
  - type: markdown
    attributes:
      value: |
        ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️  Please go to https://github.com/louislam/dockge/discussions/new?category=ask-for-help
  - type: checkboxes
    id: no-duplicate-issues
    attributes:
      label: "Issues are for bug reports only, please go to the \"Discussions\" tab to submit a Feature Request"
      options:
        - label: "I understand"
          required: true


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yaml
================================================
name: "🐛 Bug Report"
description: "Submit a bug report to help us improve"
#title: "[Bug] "
labels: [bug]
body:
  - type: checkboxes
    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=)"
      options:
        - label: "I checked and didn't find similar issue"
          required: true
  - type: checkboxes
    attributes:
      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)
          required: true
  - type: textarea
    id: description
    validations:
      required: false
    attributes:
      label: "Description"
      description: "You could also upload screenshots"
  - type: textarea
    id: steps-to-reproduce
    validations:
      required: true
    attributes:
      label: "👟 Reproduction steps"
      description: "How do you trigger this bug? Please walk us through it step by step."
      placeholder: "..."
  - type: textarea
    id: expected-behavior
    validations:
      required: true
    attributes:
      label: "👀 Expected behavior"
      description: "What did you think would happen?"
      placeholder: "..."
  - type: textarea
    id: actual-behavior
    validations:
      required: true
    attributes:
      label: "😓 Actual Behavior"
      description: "What actually happen?"
      placeholder: "..."
  - type: input
    id: dockge-version
    attributes:
      label: "Dockge Version"
      description: "Which version of Dockge are you running? Please do NOT provide the docker tag such as latest or 1"
      placeholder: "Ex. 1.1.1"
    validations:
      required: true
  - type: input
    id: operating-system
    attributes:
      label: "💻 Operating System and Arch"
      description: "Which OS is your server/device running on?"
      placeholder: "Ex. Ubuntu 20.04 x64 "
    validations:
      required: true
  - type: input
    id: browser-vendor
    attributes:
      label: "🌐 Browser"
      description: "Which browser are you running on?"
      placeholder: "Ex. Google Chrome 95.0.4638.69"
    validations:
      required: true
  - type: input
    id: docker-version
    attributes:
      label: "🐋 Docker Version"
      description: "If running with Docker, which version are you running?"
      placeholder: "Ex. Docker 20.10.9 / K8S / Podman"
    validations:
      required: false
  - type: input
    id: nodejs-version
    attributes:
      label: "🟩 NodeJS Version"
      description: "If running with Node.js? which version are you running?"
      placeholder: "Ex. 14.18.0"
    validations:
      required: false
  - type: textarea
    id: logs
    attributes:
      label: "📝 Relevant log output"
      description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
      render: shell
    validations:
      required: false


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.yaml
================================================
name: 🚀 Feature Request (Please go to the "Discussions" tab to submit a Feature Request)
description: "⚠️ Please go to the \"Discussions\" tab to submit a Feature Request"
body:
  - type: markdown
    attributes:
      value: |
        ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️  Please go to https://github.com/louislam/dockge/discussions/new?category=ask-for-help
  - type: checkboxes
    id: no-duplicate-issues
    attributes:
      label: "Issues are for bug reports only, please go to the \"Discussions\" tab to submit a Feature Request"
      options:
        - label: "I understand"
          required: true


================================================
FILE: .github/ISSUE_TEMPLATE/security.md
================================================
---

name: "Security Issue"
about: "Just for alerting @louislam, do not provide any details here"
title: "Security Issue"
ref: "main"
labels:

- security

---

DO NOT PROVIDE ANY DETAILS HERE. Please privately report to https://github.com/louislam/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:



================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
⚠️⚠️⚠️ 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

Tick the checkbox if you understand [x]: 
- [ ] I have read and understand the pull request rules.

# Description

Fixes #(issue)

## Type of change

Please delete any options that are not relevant.

- Bug fix (non-breaking change which fixes an issue)
- User interface (UI)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
- Other
- This change requires a documentation update

## Checklist

- [ ] My code follows the style guidelines of this project
- [ ] I ran ESLint and other linters for modified files
- [ ] I have performed a self-review of my own code and tested it
- [ ] I have commented my code, particularly in hard-to-understand areas
  (including JSDoc for methods)
- [ ] My changes generate no new warnings
- [ ] My code needed automated testing. I have added them (this is optional task)

## Screenshots (if any)

Please do not use any external image service. Instead, just paste in or drag and drop the image here, and it will be uploaded automatically.


================================================
FILE: .github/config/exclude.txt
================================================
# This is a .gitignore style file for 'GrantBirki/json-yaml-validate' Action workflow


================================================
FILE: .github/workflows/ci.yml
================================================
name: Node.js CI - Dockge

on:
  push:
    branches: [master]
    paths-ignore:
      - '*.md'
  pull_request:
    branches: [master]
    paths-ignore:
      - '*.md'

jobs:
  ci:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest, ARM, ARM64]
        node: [22] # Can be changed
    runs-on: ${{ matrix.os }}
    steps:
      - name: Checkout Code
        run: |  # Mainly for Windows
          git config --global core.autocrlf false
          git config --global core.eol lf
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{matrix.node}}

      - name: Install dependencies
        run: npm install

      - name: Lint
        run: npm run lint

      - name: Check Typescript
        run: npm run check-ts

      - name: Build
        run: npm run build:frontend
      # more things can be add later like tests etc..



================================================
FILE: .github/workflows/close-incorrect-issue.yml
================================================
name: Close Incorrect Issue

on:
  issues:
    types: [opened]

jobs:
  close-incorrect-issue:
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [ubuntu-latest]
        node-version: [16]

    steps:
    - uses: actions/checkout@v3

    - name: Close Incorrect Issue
      run: node extra/close-incorrect-issue.js ${{ secrets.GITHUB_TOKEN }} ${{ github.event.issue.number }} ${{ github.event.issue.user.login }}


================================================
FILE: .github/workflows/json-yaml-validate.yml
================================================
name: json-yaml-validate
on:
  push:
    branches:
      - master
  pull_request:
    branches:
      - master
      - 2.0.X
  workflow_dispatch:

permissions:
  contents: read
  pull-requests: write # enable write permissions for pull request comments

jobs:
  json-yaml-validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: json-yaml-validate
        id: json-yaml-validate
        uses: GrantBirki/json-yaml-validate@v2.6.1
        with:
          comment: "false" # enable comment mode
          exclude_file: ".github/config/exclude.txt" # gitignore style file for exclusions


================================================
FILE: .github/workflows/nightly-release.yml
================================================
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


================================================
FILE: .github/workflows/prevent-file-change.yml
================================================
name: Prevent File Change

on:
  pull_request:

jobs:
  check-file-changes:
    runs-on: ubuntu-latest
    steps:
      - name: Prevent file change
        uses: xalvarez/prevent-file-change-action@v1
        with:
          githubToken: ${{ secrets.GITHUB_TOKEN }}
          # Regex, /src/lang/*.json is not allowed to be changed, except for /src/lang/en.json
          pattern: '^(?!frontend/src/lang/en\.json$)frontend/src/lang/.*\.json$'
          trustedAuthors: UptimeKumaBot


================================================
FILE: .gitignore
================================================
# Should update .dockerignore as well
.env
node_modules
.idea
data
stacks
tmp
/private

# Git only
frontend-dist



================================================
FILE: CONTRIBUTING.md
================================================
## Can I create a pull request for Dockge?

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.

Here are some references:

### ✅ 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("...")`

### ⚠️ Discussion required:
- Large pull requests
- New features

### ❌ 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.

The above cases may not cover all possible situations.

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

```bash
npm install
```

## Dev Server

```
npm run dev:frontend
npm run dev:backend
```

## Backend Dev Server

It binds to `0.0.0.0:5001` by default.

It is mainly a socket.io app + express.js.

## Frontend Dev Server

It binds to `0.0.0.0:5000` by default. The frontend dev server is used for development only.

For production, it is not used. It will be compiled to `frontend-dist` directory instead.

You can use Vue.js devtools Chrome extension for debugging.

### Build the frontend

```bash
npm run build
```

## 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).

## Spelling & Grammar

Feel free to correct the grammar in the documentation or code.
My mother language is not English and my grammar is not that great.


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2023 Louis Lam

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<div align="center" width="100%">
    <img src="./frontend/public/icon.svg" width="128" alt="" />
</div>

# Dockge

A fancy, easy-to-use and reactive self-hosted docker compose.yaml stack-oriented manager.

[![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/)

<img src="https://github.com/louislam/dockge/assets/1336778/26a583e1-ecb1-4a8d-aedf-76157d714ad7" width="900" alt="" />

View Video: https://youtu.be/AWAlOQeNpgU?t=48

## ⭐ Features

- 🧑‍💼 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

<img src="https://github.com/louislam/dockge/assets/1336778/cc071864-592e-4909-b73a-343a57494002" width=300 />

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

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

## 🔧 How to Install

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
```

- port=`5001`
- stacksPath=`/opt/stacks`

Interactive compose.yaml generator is available on: 
https://dockge.kuma.pet

## How to Update

```bash
cd /opt/dockge
docker compose pull && 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)


## 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

If you love this project, please consider giving it a ⭐.


## 🗣️ Community and Contribution

### Bug Report
https://github.com/louislam/dockge/issues

### Ask for Help / Discussions
https://github.com/louislam/dockge/discussions

### 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)

### Create a Pull Request

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.

## FAQ

#### "Dockge"?

"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.

The naming idea came from Twitch emotes like `sadge`, `bedge` or `wokege`. They all end in `-ge`.

#### Can I manage a single container without `compose.yaml`?

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.

#### Can I manage existing stacks?

Yes, you can. However, you need to move your compose file into the stacks directory:

1. Stop your stack
2. Move your compose file into `/opt/stacks/<stackName>/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

#### Is Dockge a Portainer replacement?

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.

If you want to manage your container with docker-compose only, the answer may be yes.

If you still need to manage something like docker networks, single containers, the answer may be no.

#### Can I install both Dockge and Portainer?

Yes, you can.

## Others

Dockge is built on top of [Compose V2](https://docs.docker.com/compose/migrate/). `compose.yaml`  also known as `docker-compose.yml`.


================================================
FILE: SECURITY.md
================================================
# Security Policy

## 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

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?

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.


================================================
FILE: backend/agent-manager.ts
================================================
import { DockgeSocket } from "./util-server";
import { io, Socket as SocketClient } from "socket.io-client";
import { log } from "./log";
import { Agent } from "./models/agent";
import { isDev, LooseObject, sleep } from "../common/util-common";
import semver from "semver";
import { R } from "redbean-node";
import dayjs, { Dayjs } from "dayjs";

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        this.agentSocketList[endpoint] = client;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        this.socket.emit("agentList", {
            ok: true,
            agentList: result,
        });
    }
}


================================================
FILE: backend/agent-socket-handler.ts
================================================
import { DockgeServer } from "./dockge-server";
import { AgentSocket } from "../common/agent-socket";
import { DockgeSocket } from "./util-server";

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


================================================
FILE: backend/agent-socket-handlers/docker-socket-handler.ts
================================================
import { AgentSocketHandler } from "../agent-socket-handler";
import { DockgeServer } from "../dockge-server";
import { callbackError, callbackResult, checkLogin, DockgeSocket, ValidationError } from "../util-server";
import { Stack } from "../stack";
import { AgentSocket } from "../../common/agent-socket";

export class DockerSocketHandler extends AgentSocketHandler {
    create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) {
        // Do not call super.create()

        agentSocket.on("deployStack", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => {
            try {
                checkLogin(socket);
                const stack = await this.saveStack(server, name, composeYAML, composeENV, isAdd);
                await stack.deploy(socket);
                server.sendStackList();
                callbackResult({
                    ok: true,
                    msg: "Deployed",
                    msgi18n: true,
                }, callback);
                stack.joinCombinedTerminal(socket);
            } catch (e) {
                callbackError(e, callback);
            }
        });

        agentSocket.on("saveStack", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => {
            try {
                checkLogin(socket);
                await this.saveStack(server, name, composeYAML, composeENV, isAdd);
                callbackResult({
                    ok: true,
                    msg: "Saved",
                    msgi18n: true,
                }, callback);
                server.sendStackList();
            } catch (e) {
                callbackError(e, callback);
            }
        });

        agentSocket.on("deleteStack", async (name : unknown, callback) => {
            try {
                checkLogin(socket);
                if (typeof(name) !== "string") {
                    throw new ValidationError("Name must be a string");
                }
                const stack = await Stack.getStack(server, name);

                try {
                    await stack.delete(socket);
                } catch (e) {
                    server.sendStackList();
                    throw e;
                }

                server.sendStackList();
                callbackResult({
                    ok: true,
                    msg: "Deleted",
                    msgi18n: true,
                }, callback);

            } catch (e) {
                callbackError(e, callback);
            }
        });

        agentSocket.on("getStack", async (stackName : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(stackName) !== "string") {
                    throw new ValidationError("Stack name must be a string");
                }

                const stack = await Stack.getStack(server, stackName);

                if (stack.isManagedByDockge) {
                    stack.joinCombinedTerminal(socket);
                }

                callbackResult({
                    ok: true,
                    stack: await stack.toJSON(socket.endpoint),
                }, callback);
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // requestStackList
        agentSocket.on("requestStackList", async (callback) => {
            try {
                checkLogin(socket);
                server.sendStackList();
                callbackResult({
                    ok: true,
                    msg: "Updated",
                    msgi18n: true,
                }, callback);
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // startStack
        agentSocket.on("startStack", async (stackName : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(stackName) !== "string") {
                    throw new ValidationError("Stack name must be a string");
                }

                const stack = await Stack.getStack(server, stackName);
                await stack.start(socket);
                callbackResult({
                    ok: true,
                    msg: "Started",
                    msgi18n: true,
                }, callback);
                server.sendStackList();

                stack.joinCombinedTerminal(socket);

            } catch (e) {
                callbackError(e, callback);
            }
        });

        // stopStack
        agentSocket.on("stopStack", async (stackName : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(stackName) !== "string") {
                    throw new ValidationError("Stack name must be a string");
                }

                const stack = await Stack.getStack(server, stackName);
                await stack.stop(socket);
                callbackResult({
                    ok: true,
                    msg: "Stopped",
                    msgi18n: true,
                }, callback);
                server.sendStackList();
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // restartStack
        agentSocket.on("restartStack", async (stackName : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(stackName) !== "string") {
                    throw new ValidationError("Stack name must be a string");
                }

                const stack = await Stack.getStack(server, stackName);
                await stack.restart(socket);
                callbackResult({
                    ok: true,
                    msg: "Restarted",
                    msgi18n: true,
                }, callback);
                server.sendStackList();
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // updateStack
        agentSocket.on("updateStack", async (stackName : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(stackName) !== "string") {
                    throw new ValidationError("Stack name must be a string");
                }

                const stack = await Stack.getStack(server, stackName);
                await stack.update(socket);
                callbackResult({
                    ok: true,
                    msg: "Updated",
                    msgi18n: true,
                }, callback);
                server.sendStackList();
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // down stack
        agentSocket.on("downStack", async (stackName : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(stackName) !== "string") {
                    throw new ValidationError("Stack name must be a string");
                }

                const stack = await Stack.getStack(server, stackName);
                await stack.down(socket);
                callbackResult({
                    ok: true,
                    msg: "Downed",
                    msgi18n: true,
                }, callback);
                server.sendStackList();
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // Services status
        agentSocket.on("serviceStatusList", async (stackName : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(stackName) !== "string") {
                    throw new ValidationError("Stack name must be a string");
                }

                const stack = await Stack.getStack(server, stackName, true);
                const serviceStatusList = Object.fromEntries(await stack.getServiceStatusList());
                callbackResult({
                    ok: true,
                    serviceStatusList,
                }, callback);
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // getExternalNetworkList
        agentSocket.on("getDockerNetworkList", async (callback) => {
            try {
                checkLogin(socket);
                const dockerNetworkList = await server.getDockerNetworkList();
                callbackResult({
                    ok: true,
                    dockerNetworkList,
                }, callback);
            } catch (e) {
                callbackError(e, callback);
            }
        });
    }

    async saveStack(server : DockgeServer, name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown) : Promise<Stack> {
        // Check types
        if (typeof(name) !== "string") {
            throw new ValidationError("Name must be a string");
        }
        if (typeof(composeYAML) !== "string") {
            throw new ValidationError("Compose YAML must be a string");
        }
        if (typeof(composeENV) !== "string") {
            throw new ValidationError("Compose ENV must be a string");
        }
        if (typeof(isAdd) !== "boolean") {
            throw new ValidationError("isAdd must be a boolean");
        }

        const stack = new Stack(server, name, composeYAML, composeENV, false);
        await stack.save(isAdd);
        return stack;
    }

}



================================================
FILE: backend/agent-socket-handlers/terminal-socket-handler.ts
================================================
import { DockgeServer } from "../dockge-server";
import { callbackError, callbackResult, checkLogin, DockgeSocket, ValidationError } from "../util-server";
import { log } from "../log";
import { InteractiveTerminal, MainTerminal, Terminal } from "../terminal";
import { Stack } from "../stack";
import { AgentSocketHandler } from "../agent-socket-handler";
import { AgentSocket } from "../../common/agent-socket";

export class TerminalSocketHandler extends AgentSocketHandler {
    create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) {

        agentSocket.on("terminalInput", async (terminalName : unknown, cmd : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(terminalName) !== "string") {
                    throw new Error("Terminal name must be a string.");
                }

                if (typeof(cmd) !== "string") {
                    throw new Error("Command must be a string.");
                }

                let terminal = Terminal.getTerminal(terminalName);
                if (terminal instanceof InteractiveTerminal) {
                    //log.debug("terminalInput", "Terminal found, writing to terminal.");
                    terminal.write(cmd);
                } else {
                    throw new Error("Terminal not found or it is not a Interactive Terminal.");
                }
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // Main Terminal
        agentSocket.on("mainTerminal", async (terminalName : unknown, callback) => {
            try {
                checkLogin(socket);

                // Throw an error if console is not enabled
                if (!server.config.enableConsole) {
                    throw new ValidationError("Console is not enabled.");
                }

                // TODO: Reset the name here, force one main terminal for now
                terminalName = "console";

                if (typeof(terminalName) !== "string") {
                    throw new ValidationError("Terminal name must be a string.");
                }

                log.debug("mainTerminal", "Terminal name: " + terminalName);

                let terminal = Terminal.getTerminal(terminalName);

                if (!terminal) {
                    terminal = new MainTerminal(server, terminalName);
                    terminal.rows = 50;
                    log.debug("mainTerminal", "Terminal created");
                }

                terminal.join(socket);
                terminal.start();

                callbackResult({
                    ok: true,
                }, callback);
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // Check if MainTerminal is enabled
        agentSocket.on("checkMainTerminal", async (callback) => {
            try {
                checkLogin(socket);
                callbackResult({
                    ok: server.config.enableConsole,
                }, callback);
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // Interactive Terminal for containers
        agentSocket.on("interactiveTerminal", async (stackName : unknown, serviceName : unknown, shell : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(stackName) !== "string") {
                    throw new ValidationError("Stack name must be a string.");
                }

                if (typeof(serviceName) !== "string") {
                    throw new ValidationError("Service name must be a string.");
                }

                if (typeof(shell) !== "string") {
                    throw new ValidationError("Shell must be a string.");
                }

                log.debug("interactiveTerminal", "Stack name: " + stackName);
                log.debug("interactiveTerminal", "Service name: " + serviceName);

                // Get stack
                const stack = await Stack.getStack(server, stackName);
                stack.joinContainerTerminal(socket, serviceName, shell);

                callbackResult({
                    ok: true,
                }, callback);
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // Join Output Terminal
        agentSocket.on("terminalJoin", async (terminalName : unknown, callback) => {
            if (typeof(callback) !== "function") {
                log.debug("console", "Callback is not a function.");
                return;
            }

            try {
                checkLogin(socket);
                if (typeof(terminalName) !== "string") {
                    throw new ValidationError("Terminal name must be a string.");
                }

                let buffer : string = Terminal.getTerminal(terminalName)?.getBuffer() ?? "";

                if (!buffer) {
                    log.debug("console", "No buffer found.");
                }

                callback({
                    ok: true,
                    buffer,
                });
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // Leave Combined Terminal
        agentSocket.on("leaveCombinedTerminal", async (stackName : unknown, callback) => {
            try {
                checkLogin(socket);

                log.debug("leaveCombinedTerminal", "Stack name: " + stackName);

                if (typeof(stackName) !== "string") {
                    throw new ValidationError("Stack name must be a string.");
                }

                const stack = await Stack.getStack(server, stackName);
                await stack.leaveCombinedTerminal(socket);

                callbackResult({
                    ok: true,
                }, callback);
            } catch (e) {
                callbackError(e, callback);
            }
        });

        // Resize Terminal
        agentSocket.on("terminalResize", async (terminalName: unknown, rows: unknown, cols: unknown) => {
            log.info("terminalResize", `Terminal: ${terminalName}`);
            try {
                checkLogin(socket);
                if (typeof terminalName !== "string") {
                    throw new Error("Terminal name must be a string.");
                }

                if (typeof rows !== "number") {
                    throw new Error("Command must be a number.");
                }
                if (typeof cols !== "number") {
                    throw new Error("Command must be a number.");
                }

                let terminal = Terminal.getTerminal(terminalName);

                // log.info("terminal", terminal);
                if (terminal instanceof Terminal) {
                    //log.debug("terminalInput", "Terminal found, writing to terminal.");
                    terminal.rows = rows;
                    terminal.cols = cols;
                } else {
                    throw new Error(`${terminalName} Terminal not found.`);
                }
            } catch (e) {
                log.debug("terminalResize",
                        // Added to prevent the lint error when adding the type
                        // and ts type checker saying type is unknown.
                        // @ts-ignore
                        `Error on ${terminalName}: ${e.message}`
                );
            }
        });
    }
}


================================================
FILE: backend/check-version.ts
================================================
import { log } from "./log";
import compareVersions from "compare-versions";
import packageJSON from "../package.json";
import { Settings } from "./settings";

// How much time in ms to wait between update checks
const UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 60 * 48;
const CHECK_URL = "https://dockge.kuma.pet/version";

class CheckVersion {
    version = packageJSON.version;
    latestVersion? : string;
    interval? : NodeJS.Timeout;

    async startInterval() {
        const check = async () => {
            if (await Settings.get("checkUpdate") === false) {
                return;
            }

            log.debug("update-checker", "Retrieving latest versions");

            try {
                const res = await fetch(CHECK_URL);
                const data = await res.json();

                // For debug
                if (process.env.TEST_CHECK_VERSION === "1") {
                    data.slow = "1000.0.0";
                }

                const checkBeta = await Settings.get("checkBeta");

                if (checkBeta && data.beta) {
                    if (compareVersions.compare(data.beta, data.slow, ">")) {
                        this.latestVersion = data.beta;
                        return;
                    }
                }

                if (data.slow) {
                    this.latestVersion = data.slow;
                }

            } catch (_) {
                log.info("update-checker", "Failed to check for new versions");
            }

        };

        await check();
        this.interval = setInterval(check, UPDATE_CHECKER_INTERVAL_MS);
    }
}

const checkVersion = new CheckVersion();
export default checkVersion;


================================================
FILE: backend/database.ts
================================================
import { log } from "./log";
import { R } from "redbean-node";
import { DockgeServer } from "./dockge-server";
import fs from "fs";
import path from "path";
import knex from "knex";

// @ts-ignore
import Dialect from "knex/lib/dialects/sqlite3/index.js";

import sqlite from "@louislam/sqlite3";
import { sleep } from "../common/util-common";

interface DBConfig {
    type?: "sqlite" | "mysql";
    hostname?: string;
    port?: string;
    database?: string;
    username?: string;
    password?: string;
}

export class Database {
    /**
     * SQLite file path (Default: ./data/dockge.db)
     * @type {string}
     */
    static sqlitePath : string;

    static noReject = true;

    static dbConfig: DBConfig = {};

    static knexMigrationsPath = "./backend/migrations";

    private static server : DockgeServer;

    /**
     * Use for decode the auth object
     */
    jwtSecret? : string;

    static async init(server : DockgeServer) {
        this.server = server;

        log.debug("server", "Connecting to the database");
        await Database.connect();
        log.info("server", "Connected to the database");

        // Patch the database
        await Database.patch();
    }

    /**
     * Read the database config
     * @throws {Error} If the config is invalid
     * @typedef {string|undefined} envString
     * @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config
     */
    static readDBConfig() : DBConfig {
        const dbConfigString = fs.readFileSync(path.join(this.server.config.dataDir, "db-config.json")).toString("utf-8");
        const dbConfig = JSON.parse(dbConfigString);

        if (typeof dbConfig !== "object") {
            throw new Error("Invalid db-config.json, it must be an object");
        }

        if (typeof dbConfig.type !== "string") {
            throw new Error("Invalid db-config.json, type must be a string");
        }
        return dbConfig;
    }

    /**
     * @typedef {string|undefined} envString
     * @param dbConfig the database configuration that should be written
     * @returns {void}
     */
    static writeDBConfig(dbConfig : DBConfig) {
        fs.writeFileSync(path.join(this.server.config.dataDir, "db-config.json"), JSON.stringify(dbConfig, null, 4));
    }

    /**
     * Connect to the database
     * @param {boolean} autoloadModels Should models be automatically loaded?
     * @param {boolean} noLog Should logs not be output?
     * @returns {Promise<void>}
     */
    static async connect(autoloadModels = true) {
        const acquireConnectionTimeout = 120 * 1000;
        let dbConfig : DBConfig;
        try {
            dbConfig = this.readDBConfig();
            Database.dbConfig = dbConfig;
        } catch (err) {
            if (err instanceof Error) {
                log.warn("db", err.message);
            }

            dbConfig = {
                type: "sqlite",
            };
            this.writeDBConfig(dbConfig);
        }

        let config = {};

        log.info("db", `Database Type: ${dbConfig.type}`);

        if (dbConfig.type === "sqlite") {
            this.sqlitePath = path.join(this.server.config.dataDir, "dockge.db");
            Dialect.prototype._driver = () => sqlite;

            config = {
                client: Dialect,
                connection: {
                    filename: Database.sqlitePath,
                    acquireConnectionTimeout: acquireConnectionTimeout,
                },
                useNullAsDefault: true,
                pool: {
                    min: 1,
                    max: 1,
                    idleTimeoutMillis: 120 * 1000,
                    propagateCreateError: false,
                    acquireTimeoutMillis: acquireConnectionTimeout,
                }
            };
        } else {
            throw new Error("Unknown Database type: " + dbConfig.type);
        }

        const knexInstance = knex(config);

        // @ts-ignore
        R.setup(knexInstance);

        if (process.env.SQL_LOG === "1") {
            R.debug(true);
        }

        // Auto map the model to a bean object
        R.freeze(true);

        if (autoloadModels) {
            R.autoloadModels("./backend/models", "ts");
        }

        if (dbConfig.type === "sqlite") {
            await this.initSQLite();
        }
    }

    /**
     @returns {Promise<void>}
     */
    static async initSQLite() {
        await R.exec("PRAGMA foreign_keys = ON");
        // Change to WAL
        await R.exec("PRAGMA journal_mode = WAL");
        await R.exec("PRAGMA cache_size = -12000");
        await R.exec("PRAGMA auto_vacuum = INCREMENTAL");

        // This ensures that an operating system crash or power failure will not corrupt the database.
        // FULL synchronous is very safe, but it is also slower.
        // Read more: https://sqlite.org/pragma.html#pragma_synchronous
        await R.exec("PRAGMA synchronous = NORMAL");

        log.debug("db", "SQLite config:");
        log.debug("db", await R.getAll("PRAGMA journal_mode"));
        log.debug("db", await R.getAll("PRAGMA cache_size"));
        log.debug("db", "SQLite Version: " + await R.getCell("SELECT sqlite_version()"));
    }

    /**
     * Patch the database
     * @returns {void}
     */
    static async patch() {
        // Using knex migrations
        // https://knexjs.org/guide/migrations.html
        // https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261
        try {
            await R.knex.migrate.latest({
                directory: Database.knexMigrationsPath,
            });
        } catch (e) {
            if (e instanceof Error) {
                // Allow missing patch files for downgrade or testing pr.
                if (e.message.includes("the following files are missing:")) {
                    log.warn("db", e.message);
                    log.warn("db", "Database migration failed, you may be downgrading Dockge.");
                } else {
                    log.error("db", "Database migration failed");
                    throw e;
                }
            }
        }
    }

    /**
     * Special handle, because tarn.js throw a promise reject that cannot be caught
     * @returns {Promise<void>}
     */
    static async close() {
        const listener = () => {
            Database.noReject = false;
        };
        process.addListener("unhandledRejection", listener);

        log.info("db", "Closing the database");

        // Flush WAL to main database
        if (Database.dbConfig.type === "sqlite") {
            await R.exec("PRAGMA wal_checkpoint(TRUNCATE)");
        }

        while (true) {
            Database.noReject = true;
            await R.close();
            await sleep(2000);

            if (Database.noReject) {
                break;
            } else {
                log.info("db", "Waiting to close the database");
            }
        }
        log.info("db", "Database closed");

        process.removeListener("unhandledRejection", listener);
    }

    /**
     * Get the size of the database (SQLite only)
     * @returns {number} Size of database
     */
    static getSize() {
        if (Database.dbConfig.type === "sqlite") {
            log.debug("db", "Database.getSize()");
            const stats = fs.statSync(Database.sqlitePath);
            log.debug("db", stats);
            return stats.size;
        }
        return 0;
    }

    /**
     * Shrink the database
     * @returns {Promise<void>}
     */
    static async shrink() {
        if (Database.dbConfig.type === "sqlite") {
            await R.exec("VACUUM");
        }
    }

}


================================================
FILE: backend/dockge-server.ts
================================================
import "dotenv/config";
import { MainRouter } from "./routers/main-router";
import * as fs from "node:fs";
import { PackageJson } from "type-fest";
import { Database } from "./database";
import packageJSON from "../package.json";
import { log } from "./log";
import * as socketIO from "socket.io";
import express, { Express } from "express";
import { parse } from "ts-command-line-args";
import https from "https";
import http from "http";
import { Router } from "./router";
import { Socket } from "socket.io";
import { MainSocketHandler } from "./socket-handlers/main-socket-handler";
import { SocketHandler } from "./socket-handler";
import { Settings } from "./settings";
import checkVersion from "./check-version";
import dayjs from "dayjs";
import { R } from "redbean-node";
import { genSecret, isDev, LooseObject } from "../common/util-common";
import { generatePasswordHash } from "./password-hash";
import { Bean } from "redbean-node/dist/bean";
import { Arguments, Config, DockgeSocket } from "./util-server";
import { DockerSocketHandler } from "./agent-socket-handlers/docker-socket-handler";
import expressStaticGzip from "express-static-gzip";
import path from "path";
import { TerminalSocketHandler } from "./agent-socket-handlers/terminal-socket-handler";
import { Stack } from "./stack";
import { Cron } from "croner";
import gracefulShutdown from "http-graceful-shutdown";
import User from "./models/user";
import childProcessAsync from "promisify-child-process";
import { AgentManager } from "./agent-manager";
import { AgentProxySocketHandler } from "./socket-handlers/agent-proxy-socket-handler";
import { AgentSocketHandler } from "./agent-socket-handler";
import { AgentSocket } from "../common/agent-socket";
import { ManageAgentSocketHandler } from "./socket-handlers/manage-agent-socket-handler";
import { Terminal } from "./terminal";

export class DockgeServer {
    app : Express;
    httpServer : http.Server;
    packageJSON : PackageJson;
    io : socketIO.Server;
    config : Config;
    indexHTML : string = "";

    /**
     * List of express routers
     */
    routerList : Router[] = [
        new MainRouter(),
    ];

    /**
     * List of socket handlers (no agent support)
     */
    socketHandlerList : SocketHandler[] = [
        new MainSocketHandler(),
        new ManageAgentSocketHandler(),
    ];

    agentProxySocketHandler = new AgentProxySocketHandler();

    /**
     * List of socket handlers (support agent)
     */
    agentSocketHandlerList : AgentSocketHandler[] = [
        new DockerSocketHandler(),
        new TerminalSocketHandler(),
    ];

    /**
     * Show Setup Page
     */
    needSetup = false;

    jwtSecret : string = "";

    stacksDir : string = "";

    /**
     *
     */
    constructor() {
        // Catch unexpected errors here
        let unexpectedErrorHandler = (error : unknown) => {
            console.trace(error);
            console.error("If you keep encountering errors, please report to https://github.com/louislam/dockge");
        };
        process.addListener("unhandledRejection", unexpectedErrorHandler);
        process.addListener("uncaughtException", unexpectedErrorHandler);

        if (!process.env.NODE_ENV) {
            process.env.NODE_ENV = "production";
        }

        // Log NODE ENV
        log.info("server", "NODE_ENV: " + process.env.NODE_ENV);

        // Default stacks directory
        let defaultStacksDir;
        if (process.platform === "win32") {
            defaultStacksDir = "./stacks";
        } else {
            defaultStacksDir = "/opt/stacks";
        }

        // Define all possible arguments
        let args = parse<Arguments>({
            sslKey: {
                type: String,
                optional: true,
            },
            sslCert: {
                type: String,
                optional: true,
            },
            sslKeyPassphrase: {
                type: String,
                optional: true,
            },
            port: {
                type: Number,
                optional: true,
            },
            hostname: {
                type: String,
                optional: true,
            },
            dataDir: {
                type: String,
                optional: true,
            },
            stacksDir: {
                type: String,
                optional: true,
            },
            enableConsole: {
                type: Boolean,
                optional: true,
                defaultValue: false,
            }
        });

        this.config = args as Config;

        // Load from environment variables or default values if args are not set
        this.config.sslKey = args.sslKey || process.env.DOCKGE_SSL_KEY || undefined;
        this.config.sslCert = args.sslCert || process.env.DOCKGE_SSL_CERT || undefined;
        this.config.sslKeyPassphrase = args.sslKeyPassphrase || process.env.DOCKGE_SSL_KEY_PASSPHRASE || undefined;
        this.config.port = args.port || Number(process.env.DOCKGE_PORT) || 5001;
        this.config.hostname = args.hostname || process.env.DOCKGE_HOSTNAME || undefined;
        this.config.dataDir = args.dataDir || process.env.DOCKGE_DATA_DIR || "./data/";
        this.config.stacksDir = args.stacksDir || process.env.DOCKGE_STACKS_DIR || defaultStacksDir;
        this.config.enableConsole = args.enableConsole || process.env.DOCKGE_ENABLE_CONSOLE === "true" || false;
        this.stacksDir = this.config.stacksDir;

        log.debug("server", this.config);

        this.packageJSON = packageJSON as PackageJson;

        try {
            this.indexHTML = fs.readFileSync("./frontend-dist/index.html").toString();
        } catch (e) {
            // "dist/index.html" is not necessary for development
            if (process.env.NODE_ENV !== "development") {
                log.error("server", "Error: Cannot find 'frontend-dist/index.html', did you install correctly?");
                process.exit(1);
            }
        }

        // Create express
        this.app = express();

        // Create HTTP server
        if (this.config.sslKey && this.config.sslCert) {
            log.info("server", "Server Type: HTTPS");
            this.httpServer = https.createServer({
                key: fs.readFileSync(this.config.sslKey),
                cert: fs.readFileSync(this.config.sslCert),
                passphrase: this.config.sslKeyPassphrase,
            }, this.app);
        } else {
            log.info("server", "Server Type: HTTP");
            this.httpServer = http.createServer(this.app);
        }

        // Binding Routers
        for (const router of this.routerList) {
            this.app.use(router.create(this.app, this));
        }

        // Static files
        this.app.use("/", expressStaticGzip("frontend-dist", {
            enableBrotli: true,
        }));

        // Universal Route Handler, must be at the end of all express routes.
        this.app.get("*", async (_request, response) => {
            response.send(this.indexHTML);
        });

        // Allow all CORS origins in development
        let cors = undefined;
        if (isDev) {
            cors = {
                origin: "*",
            };
        }

        // Create Socket.io
        this.io = new socketIO.Server(this.httpServer, {
            cors,
            allowRequest: (req, callback) => {
                let isOriginValid = true;
                const bypass = isDev || process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass";

                if (!bypass) {
                    let host = req.headers.host;

                    // If this is set, it means the request is from the browser
                    let origin = req.headers.origin;

                    // If this is from the browser, check if the origin is allowed
                    if (origin) {
                        try {
                            let originURL = new URL(origin);

                            if (host !== originURL.host) {
                                isOriginValid = false;
                                log.error("auth", `Origin (${origin}) does not match host (${host}), IP: ${req.socket.remoteAddress}`);
                            }
                        } catch (e) {
                            // Invalid origin url, probably not from browser
                            isOriginValid = false;
                            log.error("auth", `Invalid origin url (${origin}), IP: ${req.socket.remoteAddress}`);
                        }
                    } else {
                        log.info("auth", `Origin is not set, IP: ${req.socket.remoteAddress}`);
                    }
                } else {
                    log.debug("auth", "Origin check is bypassed");
                }

                callback(null, isOriginValid);
            }
        });

        this.io.on("connection", async (socket: Socket) => {
            let dockgeSocket = socket as DockgeSocket;
            dockgeSocket.instanceManager = new AgentManager(dockgeSocket);
            dockgeSocket.emitAgent = (event : string, ...args : unknown[]) => {
                let obj = args[0];
                if (typeof(obj) === "object") {
                    let obj2 = obj as LooseObject;
                    obj2.endpoint = dockgeSocket.endpoint;
                }
                dockgeSocket.emit("agent", event, ...args);
            };

            if (typeof(socket.request.headers.endpoint) === "string") {
                dockgeSocket.endpoint = socket.request.headers.endpoint;
            } else {
                dockgeSocket.endpoint = "";
            }

            if (dockgeSocket.endpoint) {
                log.info("server", "Socket connected (agent), as endpoint " + dockgeSocket.endpoint);
            } else {
                log.info("server", "Socket connected (direct)");
            }

            this.sendInfo(dockgeSocket, true);

            if (this.needSetup) {
                log.info("server", "Redirect to setup page");
                dockgeSocket.emit("setup");
            }

            // Create socket handlers (original, no agent support)
            for (const socketHandler of this.socketHandlerList) {
                socketHandler.create(dockgeSocket, this);
            }

            // Create Agent Socket
            let agentSocket = new AgentSocket();

            // Create agent socket handlers
            for (const socketHandler of this.agentSocketHandlerList) {
                socketHandler.create(dockgeSocket, this, agentSocket);
            }

            // Create agent proxy socket handlers
            this.agentProxySocketHandler.create2(dockgeSocket, this, agentSocket);

            // ***************************
            // Better do anything after added all socket handlers here
            // ***************************

            log.debug("auth", "check auto login");
            if (await Settings.get("disableAuth")) {
                log.info("auth", "Disabled Auth: auto login to admin");
                this.afterLogin(dockgeSocket, await R.findOne("user") as User);
                dockgeSocket.emit("autoLogin");
            } else {
                log.debug("auth", "need auth");
            }

            // Socket disconnect
            dockgeSocket.on("disconnect", () => {
                log.info("server", "Socket disconnected!");
                dockgeSocket.instanceManager.disconnectAll();
            });

        });

        this.io.on("disconnect", () => {

        });

        if (isDev) {
            setInterval(() => {
                log.debug("terminal", "Terminal count: " + Terminal.getTerminalCount());
            }, 5000);
        }
    }

    async afterLogin(socket : DockgeSocket, user : User) {
        socket.userID = user.id;
        socket.join(user.id.toString());

        this.sendInfo(socket);

        try {
            this.sendStackList();
        } catch (e) {
            log.error("server", e);
        }

        socket.instanceManager.sendAgentList();

        // Also connect to other dockge instances
        socket.instanceManager.connectAll();
    }

    /**
     *
     */
    async serve() {
        // Create all the necessary directories
        this.initDataDir();

        // Connect to database
        try {
            await Database.init(this);
        } catch (e) {
            if (e instanceof Error) {
                log.error("server", "Failed to prepare your database: " + e.message);
            }
            process.exit(1);
        }

        // First time setup if needed
        let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
            "jwtSecret",
        ]);

        if (! jwtSecretBean) {
            log.info("server", "JWT secret is not found, generate one.");
            jwtSecretBean = await this.initJWTSecret();
            log.info("server", "Stored JWT secret into database");
        } else {
            log.debug("server", "Load JWT secret from database.");
        }

        this.jwtSecret = jwtSecretBean.value;

        const userCount = (await R.knex("user").count("id as count").first()).count;

        log.debug("server", "User count: " + userCount);

        // If there is no record in user table, it is a new Dockge instance, need to setup
        if (userCount == 0) {
            log.info("server", "No user, need setup");
            this.needSetup = true;
        }

        // Listen
        this.httpServer.listen(this.config.port, this.config.hostname, () => {
            if (this.config.hostname) {
                log.info( "server", `Listening on ${this.config.hostname}:${this.config.port}`);
            } else {
                log.info("server", `Listening on ${this.config.port}`);
            }

            // Run every 10 seconds
            Cron("*/10 * * * * *", {
                protect: true,  // Enabled over-run protection.
            }, () => {
                //log.debug("server", "Cron job running");
                this.sendStackList(true);
            });

            checkVersion.startInterval();
        });

        gracefulShutdown(this.httpServer, {
            signals: "SIGINT SIGTERM",
            timeout: 30000,                   // timeout: 30 secs
            development: false,               // not in dev mode
            forceExit: true,                  // triggers process.exit() at the end of shutdown process
            onShutdown: this.shutdownFunction,     // shutdown function (async) - e.g. for cleanup DB, ...
            finally: this.finalFunction,            // finally function (sync) - e.g. for logging
        });

    }

    /**
     * Emits the version information to the client.
     * @param socket Socket.io socket instance
     * @param hideVersion Should we hide the version information in the response?
     * @returns
     */
    async sendInfo(socket : Socket, hideVersion = false) {
        let versionProperty;
        let latestVersionProperty;
        let isContainer;

        if (!hideVersion) {
            versionProperty = packageJSON.version;
            latestVersionProperty = checkVersion.latestVersion;
            isContainer = (process.env.DOCKGE_IS_CONTAINER === "1");
        }

        socket.emit("info", {
            version: versionProperty,
            latestVersion: latestVersionProperty,
            isContainer,
            primaryHostname: await Settings.get("primaryHostname"),
            //serverTimezone: await this.getTimezone(),
            //serverTimezoneOffset: this.getTimezoneOffset(),
        });
    }

    /**
     * Get the IP of the client connected to the socket
     * @param {Socket} socket Socket to query
     * @returns IP of client
     */
    async getClientIP(socket : Socket) : Promise<string> {
        let clientIP = socket.client.conn.remoteAddress;

        if (clientIP === undefined) {
            clientIP = "";
        }

        if (await Settings.get("trustProxy")) {
            const forwardedFor = socket.client.conn.request.headers["x-forwarded-for"];

            if (typeof forwardedFor === "string") {
                return forwardedFor.split(",")[0].trim();
            } else if (typeof socket.client.conn.request.headers["x-real-ip"] === "string") {
                return socket.client.conn.request.headers["x-real-ip"];
            }
        }
        return clientIP.replace(/^::ffff:/, "");
    }

    /**
     * Attempt to get the current server timezone
     * If this fails, fall back to environment variables and then make a
     * guess.
     * @returns {Promise<string>} Current timezone
     */
    async getTimezone() {
        // From process.env.TZ
        try {
            if (process.env.TZ) {
                this.checkTimezone(process.env.TZ);
                return process.env.TZ;
            }
        } catch (e) {
            if (e instanceof Error) {
                log.warn("timezone", e.message + " in process.env.TZ");
            }
        }

        const timezone = await Settings.get("serverTimezone");

        // From Settings
        try {
            log.debug("timezone", "Using timezone from settings: " + timezone);
            if (timezone) {
                this.checkTimezone(timezone);
                return timezone;
            }
        } catch (e) {
            if (e instanceof Error) {
                log.warn("timezone", e.message + " in settings");
            }
        }

        // Guess
        try {
            const guess = dayjs.tz.guess();
            log.debug("timezone", "Guessing timezone: " + guess);
            if (guess) {
                this.checkTimezone(guess);
                return guess;
            } else {
                return "UTC";
            }
        } catch (e) {
            // Guess failed, fall back to UTC
            log.debug("timezone", "Guessed an invalid timezone. Use UTC as fallback");
            return "UTC";
        }
    }

    /**
     * Get the current offset
     * @returns {string} Time offset
     */
    getTimezoneOffset() {
        return dayjs().format("Z");
    }

    /**
     * Throw an error if the timezone is invalid
     * @param {string} timezone Timezone to test
     * @returns {void}
     * @throws The timezone is invalid
     */
    checkTimezone(timezone : string) {
        try {
            dayjs.utc("2013-11-18 11:55").tz(timezone).format();
        } catch (e) {
            throw new Error("Invalid timezone:" + timezone);
        }
    }

    /**
     * Initialize the data directory
     */
    initDataDir() {
        if (! fs.existsSync(this.config.dataDir)) {
            fs.mkdirSync(this.config.dataDir, { recursive: true });
        }

        // Check if a directory
        if (!fs.lstatSync(this.config.dataDir).isDirectory()) {
            throw new Error(`Fatal error: ${this.config.dataDir} is not a directory`);
        }

        // Create data/stacks directory
        if (!fs.existsSync(this.stacksDir)) {
            fs.mkdirSync(this.stacksDir, { recursive: true });
        }

        log.info("server", `Data Dir: ${this.config.dataDir}`);
    }

    /**
     * Init or reset JWT secret
     * @returns  JWT secret
     */
    async initJWTSecret() : Promise<Bean> {
        let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
            "jwtSecret",
        ]);

        if (!jwtSecretBean) {
            jwtSecretBean = R.dispense("setting");
            jwtSecretBean.key = "jwtSecret";
        }

        jwtSecretBean.value = generatePasswordHash(genSecret());
        await R.store(jwtSecretBean);
        return jwtSecretBean;
    }

    /**
     * Send stack list to all connected sockets
     * @param useCache
     */
    async sendStackList(useCache = false) {
        let socketList = this.io.sockets.sockets.values();

        let stackList;

        for (let socket of socketList) {
            let dockgeSocket = socket as DockgeSocket;

            // Check if the room is a number (user id)
            if (dockgeSocket.userID) {

                // Get the list only if there is a logged in user
                if (!stackList) {
                    stackList = await Stack.getStackList(this, useCache);
                }

                let map : Map<string, object> = new Map();

                for (let [ stackName, stack ] of stackList) {
                    map.set(stackName, stack.toSimpleJSON(dockgeSocket.endpoint));
                }

                log.debug("server", "Send stack list to user: " + dockgeSocket.id + " (" + dockgeSocket.endpoint + ")");
                dockgeSocket.emitAgent("stackList", {
                    ok: true,
                    stackList: Object.fromEntries(map),
                });
            }
        }
    }

    async getDockerNetworkList() : Promise<string[]> {
        let res = await childProcessAsync.spawn("docker", [ "network", "ls", "--format", "{{.Name}}" ], {
            encoding: "utf-8",
        });

        if (!res.stdout) {
            return [];
        }

        let list = res.stdout.toString().split("\n");

        // Remove empty string item
        list = list.filter((item) => {
            return item !== "";
        }).sort((a, b) => {
            return a.localeCompare(b);
        });

        return list;
    }

    get stackDirFullPath() {
        return path.resolve(this.stacksDir);
    }

    /**
     * Shutdown the application
     * Stops all monitors and closes the database connection.
     * @param signal The signal that triggered this function to be called.
     */
    async shutdownFunction(signal : string | undefined) {
        log.info("server", "Shutdown requested");
        log.info("server", "Called signal: " + signal);

        // TODO: Close all terminals?

        await Database.close();
        Settings.stopCacheCleaner();
    }

    /**
     * Final function called before application exits
     */
    finalFunction() {
        log.info("server", "Graceful shutdown successful!");
    }

    /**
     * Force connected sockets of a user to refresh and disconnect.
     * Used for resetting password.
     * @param {string} userID
     * @param {string?} currentSocketID
     */
    disconnectAllSocketClients(userID: number | undefined, currentSocketID? : string) {
        for (const rawSocket of this.io.sockets.sockets.values()) {
            let socket = rawSocket as DockgeSocket;
            if ((!userID || socket.userID === userID) && socket.id !== currentSocketID) {
                try {
                    socket.emit("refresh");
                    socket.disconnect();
                } catch (e) {

                }
            }
        }
    }

    isSSL() {
        return this.config.sslKey && this.config.sslCert;
    }

    getLocalWebSocketURL() {
        const protocol = this.isSSL() ? "wss" : "ws";
        const host = this.config.hostname || "localhost";
        return `${protocol}://${host}:${this.config.port}`;
    }

}


================================================
FILE: backend/index.ts
================================================
import { DockgeServer } from "./dockge-server";
import { log } from "./log";

log.info("server", "Welcome to dockge!");
const server = new DockgeServer();
await server.serve();


================================================
FILE: backend/log.ts
================================================
// Console colors
// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color
import { intHash, isDev } from "../common/util-common";
import dayjs from "dayjs";

export const CONSOLE_STYLE_Reset = "\x1b[0m";
export const CONSOLE_STYLE_Bright = "\x1b[1m";
export const CONSOLE_STYLE_Dim = "\x1b[2m";
export const CONSOLE_STYLE_Underscore = "\x1b[4m";
export const CONSOLE_STYLE_Blink = "\x1b[5m";
export const CONSOLE_STYLE_Reverse = "\x1b[7m";
export const CONSOLE_STYLE_Hidden = "\x1b[8m";

export const CONSOLE_STYLE_FgBlack = "\x1b[30m";
export const CONSOLE_STYLE_FgRed = "\x1b[31m";
export const CONSOLE_STYLE_FgGreen = "\x1b[32m";
export const CONSOLE_STYLE_FgYellow = "\x1b[33m";
export const CONSOLE_STYLE_FgBlue = "\x1b[34m";
export const CONSOLE_STYLE_FgMagenta = "\x1b[35m";
export const CONSOLE_STYLE_FgCyan = "\x1b[36m";
export const CONSOLE_STYLE_FgWhite = "\x1b[37m";
export const CONSOLE_STYLE_FgGray = "\x1b[90m";
export const CONSOLE_STYLE_FgOrange = "\x1b[38;5;208m";
export const CONSOLE_STYLE_FgLightGreen = "\x1b[38;5;119m";
export const CONSOLE_STYLE_FgLightBlue = "\x1b[38;5;117m";
export const CONSOLE_STYLE_FgViolet = "\x1b[38;5;141m";
export const CONSOLE_STYLE_FgBrown = "\x1b[38;5;130m";
export const CONSOLE_STYLE_FgPink = "\x1b[38;5;219m";

export const CONSOLE_STYLE_BgBlack = "\x1b[40m";
export const CONSOLE_STYLE_BgRed = "\x1b[41m";
export const CONSOLE_STYLE_BgGreen = "\x1b[42m";
export const CONSOLE_STYLE_BgYellow = "\x1b[43m";
export const CONSOLE_STYLE_BgBlue = "\x1b[44m";
export const CONSOLE_STYLE_BgMagenta = "\x1b[45m";
export const CONSOLE_STYLE_BgCyan = "\x1b[46m";
export const CONSOLE_STYLE_BgWhite = "\x1b[47m";
export const CONSOLE_STYLE_BgGray = "\x1b[100m";

const consoleModuleColors = [
    CONSOLE_STYLE_FgCyan,
    CONSOLE_STYLE_FgGreen,
    CONSOLE_STYLE_FgLightGreen,
    CONSOLE_STYLE_FgBlue,
    CONSOLE_STYLE_FgLightBlue,
    CONSOLE_STYLE_FgMagenta,
    CONSOLE_STYLE_FgOrange,
    CONSOLE_STYLE_FgViolet,
    CONSOLE_STYLE_FgBrown,
    CONSOLE_STYLE_FgPink,
];

const consoleLevelColors : Record<string, string> = {
    "INFO": CONSOLE_STYLE_FgCyan,
    "WARN": CONSOLE_STYLE_FgYellow,
    "ERROR": CONSOLE_STYLE_FgRed,
    "DEBUG": CONSOLE_STYLE_FgGray,
};

class Logger {

    /**
     * DOCKGE_HIDE_LOG=debug_monitor,info_monitor
     *
     * Example:
     *  [
     *     "debug_monitor",          // Hide all logs that level is debug and the module is monitor
     *     "info_monitor",
     *  ]
     */
    hideLog : Record<string, string[]> = {
        info: [],
        warn: [],
        error: [],
        debug: [],
    };

    /**
     *
     */
    constructor() {
        if (typeof process !== "undefined" && process.env.DOCKGE_HIDE_LOG) {
            const list = process.env.DOCKGE_HIDE_LOG.split(",").map(v => v.toLowerCase());

            for (const pair of list) {
                // split first "_" only
                const values = pair.split(/_(.*)/s);

                if (values.length >= 2) {
                    this.hideLog[values[0]].push(values[1]);
                }
            }

            this.debug("server", "DOCKGE_HIDE_LOG is set");
            this.debug("server", this.hideLog);
        }
    }

    /**
     * Write a message to the log
     * @param module The module the log comes from
     * @param msg Message to write
     * @param level Log level. One of INFO, WARN, ERROR, DEBUG or can be customized.
     */
    log(module: string, msg: unknown, level: string) {
        if (level === "DEBUG" && !isDev) {
            return;
        }

        if (this.hideLog[level] && this.hideLog[level].includes(module.toLowerCase())) {
            return;
        }

        module = module.toUpperCase();
        level = level.toUpperCase();

        let now;
        if (dayjs.tz) {
            now = dayjs.tz(new Date()).format();
        } else {
            now = dayjs().format();
        }

        const levelColor = consoleLevelColors[level];
        const moduleColor = consoleModuleColors[intHash(module, consoleModuleColors.length)];

        let timePart = CONSOLE_STYLE_FgCyan + now + CONSOLE_STYLE_Reset;
        const modulePart = "[" + moduleColor + module + CONSOLE_STYLE_Reset + "]";
        const levelPart = levelColor + `${level}:` + CONSOLE_STYLE_Reset;

        if (level === "INFO") {
            console.info(timePart, modulePart, levelPart, msg);
        } else if (level === "WARN") {
            console.warn(timePart, modulePart, levelPart, msg);
        } else if (level === "ERROR") {
            let msgPart : unknown;
            if (typeof msg === "string") {
                msgPart = CONSOLE_STYLE_FgRed + msg + CONSOLE_STYLE_Reset;
            } else {
                msgPart = msg;
            }
            console.error(timePart, modulePart, levelPart, msgPart);
        } else if (level === "DEBUG") {
            if (isDev) {
                timePart = CONSOLE_STYLE_FgGray + now + CONSOLE_STYLE_Reset;
                let msgPart : unknown;
                if (typeof msg === "string") {
                    msgPart = CONSOLE_STYLE_FgGray + msg + CONSOLE_STYLE_Reset;
                } else {
                    msgPart = msg;
                }
                console.debug(timePart, modulePart, levelPart, msgPart);
            }
        } else {
            console.log(timePart, modulePart, msg);
        }
    }

    /**
     * Log an INFO message
     * @param module Module log comes from
     * @param msg Message to write
     */
    info(module: string, msg: unknown) {
        this.log(module, msg, "info");
    }

    /**
     * Log a WARN message
     * @param module Module log comes from
     * @param msg Message to write
     */
    warn(module: string, msg: unknown) {
        this.log(module, msg, "warn");
    }

    /**
     * Log an ERROR message
     * @param module Module log comes from
     * @param msg Message to write
     */
    error(module: string, msg: unknown) {
        this.log(module, msg, "error");
    }

    /**
     * Log a DEBUG message
     * @param module Module log comes from
     * @param msg Message to write
     */
    debug(module: string, msg: unknown) {
        this.log(module, msg, "debug");
    }

    /**
     * Log an exception as an ERROR
     * @param module Module log comes from
     * @param exception The exception to include
     * @param msg The message to write
     */
    exception(module: string, exception: unknown, msg: unknown) {
        let finalMessage = exception;

        if (msg) {
            finalMessage = `${msg}: ${exception}`;
        }

        this.log(module, finalMessage, "error");
    }
}

export const log = new Logger();


================================================
FILE: backend/migrations/2023-10-20-0829-setting-table.ts
================================================
import { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
    return knex.schema.createTable("setting", (table) => {
        table.increments("id");
        table.string("key", 200).notNullable().unique().collate("utf8_general_ci");
        table.text("value");
        table.string("type", 20);
    });
}

export async function down(knex: Knex): Promise<void> {
    return knex.schema.dropTable("setting");
}


================================================
FILE: backend/migrations/2023-10-20-0829-user-table.ts
================================================
import { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
    // Create the user table
    return knex.schema.createTable("user", (table) => {
        table.increments("id");
        table.string("username", 255).notNullable().unique().collate("utf8_general_ci");
        table.string("password", 255);
        table.boolean("active").notNullable().defaultTo(true);
        table.string("timezone", 150);
        table.string("twofa_secret", 64);
        table.boolean("twofa_status").notNullable().defaultTo(false);
        table.string("twofa_last_token", 6);
    });
}

export async function down(knex: Knex): Promise<void> {
    return knex.schema.dropTable("user");
}


================================================
FILE: backend/migrations/2023-12-20-2117-agent-table.ts
================================================
import { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
    // Create the user table
    return knex.schema.createTable("agent", (table) => {
        table.increments("id");
        table.string("url", 255).notNullable().unique();
        table.string("username", 255).notNullable();
        table.string("password", 255).notNullable();
        table.boolean("active").notNullable().defaultTo(true);
    });
}

export async function down(knex: Knex): Promise<void> {
    return knex.schema.dropTable("agent");
}


================================================
FILE: backend/models/agent.ts
================================================
import { BeanModel } from "redbean-node/dist/bean-model";
import { R } from "redbean-node";
import { LooseObject } from "../../common/util-common";

export class Agent extends BeanModel {

    static async getAgentList() : Promise<Record<string, Agent>> {
        let list = await R.findAll("agent") as Agent[];
        let result : Record<string, Agent> = {};
        for (let agent of list) {
            result[agent.endpoint] = agent;
        }
        return result;
    }

    get endpoint() : string {
        let obj = new URL(this.url);
        return obj.host;
    }

    toJSON() : LooseObject {
        return {
            url: this.url,
            username: this.username,
            endpoint: this.endpoint,
        };
    }

}

export default Agent;


================================================
FILE: backend/models/user.ts
================================================
import jwt from "jsonwebtoken";
import { R } from "redbean-node";
import { BeanModel } from "redbean-node/dist/bean-model";
import { generatePasswordHash, shake256, SHAKE256_LENGTH } from "../password-hash";

export class User extends BeanModel {
    /**
     * Reset user password
     * Fix #1510, as in the context reset-password.js, there is no auto model mapping. Call this static function instead.
     * @param {number} userID ID of user to update
     * @param {string} newPassword Users new password
     * @returns {Promise<void>}
     */
    static async resetPassword(userID : number, newPassword : string) {
        await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
            generatePasswordHash(newPassword),
            userID
        ]);
    }

    /**
     * Reset this users password
     * @param {string} newPassword
     * @returns {Promise<void>}
     */
    async resetPassword(newPassword : string) {
        await User.resetPassword(this.id, newPassword);
        this.password = newPassword;
    }

    /**
     * Create a new JWT for a user
     * @param {User} user The User to create a JsonWebToken for
     * @param {string} jwtSecret The key used to sign the JsonWebToken
     * @returns {string} the JsonWebToken as a string
     */
    static createJWT(user : User, jwtSecret : string) {
        return jwt.sign({
            username: user.username,
            h: shake256(user.password, SHAKE256_LENGTH),
        }, jwtSecret);
    }

}

export default User;


================================================
FILE: backend/password-hash.ts
================================================
import bcrypt from "bcryptjs";
import crypto from "crypto";
const saltRounds = 10;

/**
 * Hash a password
 * @param {string} password Password to hash
 * @returns {string} Hash
 */
export function generatePasswordHash(password : string) {
    return bcrypt.hashSync(password, saltRounds);
}

/**
 * Verify a password against a hash
 * @param {string} password Password to verify
 * @param {string} hash Hash to verify against
 * @returns {boolean} Does the password match the hash?
 */
export function verifyPassword(password : string, hash : string) {
    return bcrypt.compareSync(password, hash);
}

/**
 * Does the hash need to be rehashed?
 * @param {string} hash Hash to check
 * @returns {boolean} Needs to be rehashed?
 */
export function needRehashPassword(hash : string) : boolean {
    return false;
}

export const SHAKE256_LENGTH = 16;

/**
 * @param {string} data The data to be hashed
 * @param {number} len Output length of the hash
 * @returns {string} The hashed data in hex format
 */
export function shake256(data : string, len : number) {
    if (!data) {
        return "";
    }
    return crypto.createHash("shake256", { outputLength: len })
        .update(data)
        .digest("hex");
}


================================================
FILE: backend/rate-limiter.ts
================================================
// "limit" is bugged in Typescript, use "limiter-es6-compat" instead
// See https://github.com/jhurliman/node-rate-limiter/issues/80
import { RateLimiter, RateLimiterOpts } from "limiter-es6-compat";
import { log } from "./log";

export interface KumaRateLimiterOpts extends RateLimiterOpts {
    errorMessage : string;
}

export type KumaRateLimiterCallback = (err : object) => void;

class KumaRateLimiter {

    errorMessage : string;
    rateLimiter : RateLimiter;

    /**
     * @param {object} config Rate limiter configuration object
     */
    constructor(config : KumaRateLimiterOpts) {
        this.errorMessage = config.errorMessage;
        this.rateLimiter = new RateLimiter(config);
    }

    /**
     * Callback for pass
     * @callback passCB
     * @param {object} err Too many requests
     */

    /**
     * Should the request be passed through
     * @param callback Callback function to call with decision
     * @param {number} num Number of tokens to remove
     * @returns {Promise<boolean>} Should the request be allowed?
     */
    async pass(callback : KumaRateLimiterCallback, num = 1) {
        const remainingRequests = await this.removeTokens(num);
        log.info("rate-limit", "remaining requests: " + remainingRequests);
        if (remainingRequests < 0) {
            if (callback) {
                callback({
                    ok: false,
                    msg: this.errorMessage,
                });
            }
            return false;
        }
        return true;
    }

    /**
     * Remove a given number of tokens
     * @param {number} num Number of tokens to remove
     * @returns {Promise<number>} Number of remaining tokens
     */
    async removeTokens(num = 1) {
        return await this.rateLimiter.removeTokens(num);
    }
}

export const loginRateLimiter = new KumaRateLimiter({
    tokensPerInterval: 20,
    interval: "minute",
    fireImmediately: true,
    errorMessage: "Too frequently, try again later."
});

export const apiRateLimiter = new KumaRateLimiter({
    tokensPerInterval: 60,
    interval: "minute",
    fireImmediately: true,
    errorMessage: "Too frequently, try again later."
});

export const twoFaRateLimiter = new KumaRateLimiter({
    tokensPerInterval: 30,
    interval: "minute",
    fireImmediately: true,
    errorMessage: "Too frequently, try again later."
});


================================================
FILE: backend/router.ts
================================================
import { DockgeServer } from "./dockge-server";
import { Express, Router as ExpressRouter } from "express";

export abstract class Router {
    abstract create(app : Express, server : DockgeServer): ExpressRouter;
}


================================================
FILE: backend/routers/main-router.ts
================================================
import { DockgeServer } from "../dockge-server";
import { Router } from "../router";
import express, { Express, Router as ExpressRouter } from "express";

export class MainRouter extends Router {
    create(app: Express, server: DockgeServer): ExpressRouter {
        const router = express.Router();

        router.get("/", (req, res) => {
            res.send(server.indexHTML);
        });

        // Robots.txt
        router.get("/robots.txt", async (_request, response) => {
            let txt = "User-agent: *\nDisallow: /";
            response.setHeader("Content-Type", "text/plain");
            response.send(txt);
        });

        return router;
    }

}


================================================
FILE: backend/settings.ts
================================================
import { R } from "redbean-node";
import { log } from "./log";
import { LooseObject } from "../common/util-common";

export class Settings {

    /**
     *  Example:
     *      {
     *         key1: {
     *             value: "value2",
     *             timestamp: 12345678
     *         },
     *         key2: {
     *             value: 2,
     *             timestamp: 12345678
     *         },
     *     }
     */
    static cacheList : LooseObject = {

    };

    static cacheCleaner? : NodeJS.Timeout;

    /**
     * Retrieve value of setting based on key
     * @param key Key of setting to retrieve
     * @returns Value
     */
    static async get(key : string) {

        // Start cache clear if not started yet
        if (!Settings.cacheCleaner) {
            Settings.cacheCleaner = setInterval(() => {
                log.debug("settings", "Cache Cleaner is just started.");
                for (key in Settings.cacheList) {
                    if (Date.now() - Settings.cacheList[key].timestamp > 60 * 1000) {
                        log.debug("settings", "Cache Cleaner deleted: " + key);
                        delete Settings.cacheList[key];
                    }
                }

            }, 60 * 1000);
        }

        // Query from cache
        if (key in Settings.cacheList) {
            const v = Settings.cacheList[key].value;
            log.debug("settings", `Get Setting (cache): ${key}: ${v}`);
            return v;
        }

        const value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [
            key,
        ]);

        try {
            const v = JSON.parse(value);
            log.debug("settings", `Get Setting: ${key}: ${v}`);

            Settings.cacheList[key] = {
                value: v,
                timestamp: Date.now()
            };

            return v;
        } catch (e) {
            return value;
        }
    }

    /**
     * Sets the specified setting to specified value
     * @param key Key of setting to set
     * @param value Value to set to
     * @param {?string} type Type of setting
     * @returns {Promise<void>}
     */
    static async set(key : string, value : object | string | number | boolean, type : string | null = null) {

        let bean = await R.findOne("setting", " `key` = ? ", [
            key,
        ]);
        if (!bean) {
            bean = R.dispense("setting");
            bean.key = key;
        }
        bean.type = type;
        bean.value = JSON.stringify(value);
        await R.store(bean);

        Settings.deleteCache([ key ]);
    }

    /**
     * Get settings based on type
     * @param type The type of setting
     * @returns Settings
     */
    static async getSettings(type : string) {
        const list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [
            type,
        ]);

        const result : LooseObject = {};

        for (const row of list) {
            try {
                result[row.key] = JSON.parse(row.value);
            } catch (e) {
                result[row.key] = row.value;
            }
        }

        return result;
    }

    /**
     * Set settings based on type
     * @param type Type of settings to set
     * @param data Values of settings
     * @returns {Promise<void>}
     */
    static async setSettings(type : string, data : LooseObject) {
        const keyList = Object.keys(data);

        const promiseList = [];

        for (const key of keyList) {
            let bean = await R.findOne("setting", " `key` = ? ", [
                key
            ]);

            if (bean == null) {
                bean = R.dispense("setting");
                bean.type = type;
                bean.key = key;
            }

            if (bean.type === type) {
                bean.value = JSON.stringify(data[key]);
                promiseList.push(R.store(bean));
            }
        }

        await Promise.all(promiseList);

        Settings.deleteCache(keyList);
    }

    /**
     * Delete selected keys from settings cache
     * @param {string[]} keyList Keys to remove
     * @returns {void}
     */
    static deleteCache(keyList : string[]) {
        for (const key of keyList) {
            delete Settings.cacheList[key];
        }
    }

    /**
     * Stop the cache cleaner if running
     * @returns {void}
     */
    static stopCacheCleaner() {
        if (Settings.cacheCleaner) {
            clearInterval(Settings.cacheCleaner);
            Settings.cacheCleaner = undefined;
        }
    }
}



================================================
FILE: backend/socket-handler.ts
================================================
import { DockgeServer } from "./dockge-server";
import { DockgeSocket } from "./util-server";

export abstract class SocketHandler {
    abstract create(socket : DockgeSocket, server : DockgeServer): void;
}


================================================
FILE: backend/socket-handlers/agent-proxy-socket-handler.ts
================================================
import { SocketHandler } from "../socket-handler.js";
import { DockgeServer } from "../dockge-server";
import { log } from "../log";
import { checkLogin, DockgeSocket } from "../util-server";
import { AgentSocket } from "../../common/agent-socket";
import { ALL_ENDPOINTS } from "../../common/util-common";

export class AgentProxySocketHandler extends SocketHandler {

    create2(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) {
        // Agent - proxying requests if needed
        socket.on("agent", async (endpoint : unknown, eventName : unknown, ...args : unknown[]) => {
            try {
                checkLogin(socket);

                // Check Type
                if (typeof(endpoint) !== "string") {
                    throw new Error("Endpoint must be a string: " + endpoint);
                }
                if (typeof(eventName) !== "string") {
                    throw new Error("Event name must be a string");
                }

                if (endpoint === ALL_ENDPOINTS) {      // Send to all endpoints
                    log.debug("agent", "Sending to all endpoints: " + eventName);
                    socket.instanceManager.emitToAllEndpoints(eventName, ...args);

                } else if (!endpoint || endpoint === socket.endpoint) {      // Direct connection or matching endpoint
                    log.debug("agent", "Matched endpoint: " + eventName);
                    agentSocket.call(eventName, ...args);

                } else {
                    log.debug("agent", "Proxying request to " + endpoint + " for " + eventName);
                    await socket.instanceManager.emitToEndpoint(endpoint, eventName, ...args);
                }
            } catch (e) {
                if (e instanceof Error) {
                    log.warn("agent", e.message);
                }
            }
        });
    }

    create(socket : DockgeSocket, server : DockgeServer) {
        throw new Error("Method not implemented. Please use create2 instead.");
    }
}


================================================
FILE: backend/socket-handlers/main-socket-handler.ts
================================================
// @ts-ignore
import composerize from "composerize";
import { SocketHandler } from "../socket-handler.js";
import { DockgeServer } from "../dockge-server";
import { log } from "../log";
import { R } from "redbean-node";
import { loginRateLimiter, twoFaRateLimiter } from "../rate-limiter";
import { generatePasswordHash, needRehashPassword, shake256, SHAKE256_LENGTH, verifyPassword } from "../password-hash";
import { User } from "../models/user";
import {
    callbackError,
    checkLogin,
    DockgeSocket,
    doubleCheckPassword,
    JWTDecoded,
    ValidationError
} from "../util-server";
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) {

        // ***************************
        // Public Socket API
        // ***************************

        // Setup
        socket.on("setup", async (username, password, callback) => {
            try {
                if (passwordStrength(password).value === "Too weak") {
                    throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
                }

                if ((await R.knex("user").count("id as count").first()).count !== 0) {
                    throw new Error("Dockge has been initialized. If you want to run setup again, please delete the database.");
                }

                const user = R.dispense("user");
                user.username = username;
                user.password = generatePasswordHash(password);
                await R.store(user);

                server.needSetup = false;

                callback({
                    ok: true,
                    msg: "successAdded",
                    msgi18n: true,
                });

            } catch (e) {
                if (e instanceof Error) {
                    callback({
                        ok: false,
                        msg: e.message,
                    });
                }
            }
        });

        // Login by token
        socket.on("loginByToken", async (token, callback) => {
            const clientIP = await server.getClientIP(socket);

            log.info("auth", `Login by token. IP=${clientIP}`);

            try {
                const decoded = jwt.verify(token, server.jwtSecret) as JWTDecoded;

                log.info("auth", "Username from JWT: " + decoded.username);

                const user = await R.findOne("user", " username = ? AND active = 1 ", [
                    decoded.username,
                ]) as User;

                if (user) {
                    // Check if the password changed
                    if (decoded.h !== shake256(user.password, SHAKE256_LENGTH)) {
                        throw new Error("The token is invalid due to password change or old token");
                    }

                    log.debug("auth", "afterLogin");
                    await server.afterLogin(socket, user);
                    log.debug("auth", "afterLogin ok");

                    log.info("auth", `Successfully logged in user ${decoded.username}. IP=${clientIP}`);

                    callback({
                        ok: true,
                    });
                } else {

                    log.info("auth", `Inactive or deleted user ${decoded.username}. IP=${clientIP}`);

                    callback({
                        ok: false,
                        msg: "authUserInactiveOrDeleted",
                        msgi18n: true,
                    });
                }
            } catch (error) {
                if (!(error instanceof Error)) {
                    console.error("Unknown error:", error);
                    return;
                }
                log.error("auth", `Invalid token. IP=${clientIP}`);
                if (error.message) {
                    log.error("auth", error.message + ` IP=${clientIP}`);
                }
                callback({
                    ok: false,
                    msg: "authInvalidToken",
                    msgi18n: true,
                });
            }

        });

        // Login
        socket.on("login", async (data, callback) => {
            const clientIP = await server.getClientIP(socket);

            log.info("auth", `Login by username + password. IP=${clientIP}`);

            // Checking
            if (typeof callback !== "function") {
                return;
            }

            if (!data) {
                return;
            }

            // Login Rate Limit
            if (!await loginRateLimiter.pass(callback)) {
                log.info("auth", `Too many failed requests for user ${data.username}. IP=${clientIP}`);
                return;
            }

            const user = await this.login(data.username, data.password);

            if (user) {
                if (user.twofa_status === 0) {
                    server.afterLogin(socket, user);

                    log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);

                    callback({
                        ok: true,
                        token: User.createJWT(user, server.jwtSecret),
                    });
                }

                if (user.twofa_status === 1 && !data.token) {

                    log.info("auth", `2FA token required for user ${data.username}. IP=${clientIP}`);

                    callback({
                        tokenRequired: true,
                    });
                }

                if (data.token) {
                    // @ts-ignore
                    const verify = notp.totp.verify(data.token, user.twofa_secret, twoFAVerifyOptions);

                    if (user.twofa_last_token !== data.token && verify) {
                        server.afterLogin(socket, user);

                        await R.exec("UPDATE `user` SET twofa_last_token = ? WHERE id = ? ", [
                            data.token,
                            socket.userID,
                        ]);

                        log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);

                        callback({
                            ok: true,
                            token: User.createJWT(user, server.jwtSecret),
                        });
                    } else {

                        log.warn("auth", `Invalid token provided for user ${data.username}. IP=${clientIP}`);

                        callback({
                            ok: false,
                            msg: "authInvalidToken",
                            msgi18n: true,
                        });
                    }
                }
            } else {

                log.warn("auth", `Incorrect username or password for user ${data.username}. IP=${clientIP}`);

                callback({
                    ok: false,
                    msg: "authIncorrectCreds",
                    msgi18n: true,
                });
            }

        });

        // Change Password
        socket.on("changePassword", async (password, callback) => {
            try {
                checkLogin(socket);

                if (! password.newPassword) {
                    throw new Error("Invalid new password");
                }

                if (passwordStrength(password.newPassword).value === "Too weak") {
                    throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
                }

                let user = await doubleCheckPassword(socket, password.currentPassword);
                await user.resetPassword(password.newPassword);

                server.disconnectAllSocketClients(user.id, socket.id);

                callback({
                    ok: true,
                    msg: "Password has been updated successfully.",
                });

            } catch (e) {
                if (e instanceof Error) {
                    callback({
                        ok: false,
                        msg: e.message,
                    });
                }
            }
        });

        socket.on("getSettings", async (callback) => {
            try {
                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,
                });

            } catch (e) {
                if (e instanceof Error) {
                    callback({
                        ok: false,
                        msg: e.message,
                    });
                }
            }
        });

        socket.on("setSettings", async (data, currentPassword, callback) => {
            try {
                checkLogin(socket);

                // If currently is disabled auth, don't need to check
                // Disabled Auth + Want to Disable Auth => No Check
                // Disabled Auth + Want to Enable Auth => No Check
                // Enabled Auth + Want to Disable Auth => Check!!
                // Enabled Auth + Want to Enable Auth => No Check
                const currentDisabledAuth = await Settings.get("disableAuth");
                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);

                callback({
                    ok: true,
                    msg: "Saved"
                });

                server.sendInfo(socket);

            } catch (e) {
                if (e instanceof Error) {
                    callback({
                        ok: false,
                        msg: e.message,
                    });
                }
            }
        });

        // Disconnect all other socket clients of the user
        socket.on("disconnectOtherSocketClients", async () => {
            try {
                checkLogin(socket);
                server.disconnectAllSocketClients(socket.userID, socket.id);
            } catch (e) {
                if (e instanceof Error) {
                    log.warn("disconnectOtherSocketClients", e.message);
                }
            }
        });

        // composerize
        socket.on("composerize", async (dockerRunCommand : unknown, callback) => {
            try {
                checkLogin(socket);

                if (typeof(dockerRunCommand) !== "string") {
                    throw new ValidationError("dockerRunCommand must be a string");
                }

                // 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,
                });
            } catch (e) {
                callbackError(e, callback);
            }
        });
    }

    async login(username : string, password : string) : Promise<User | null> {
        if (typeof username !== "string" || typeof password !== "string") {
            return null;
        }

        const user = await R.findOne("user", " username = ? AND active = 1 ", [
            username,
        ]) as User;

        if (user && verifyPassword(password, user.password)) {
            // Upgrade the hash to bcrypt
            if (needRehashPassword(user.password)) {
                await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
                    generatePasswordHash(password),
                    user.id,
                ]);
            }
            return user;
        }

        return null;
    }
}


================================================
FILE: backend/socket-handlers/manage-agent-socket-handler.ts
================================================
import { SocketHandler } from "../socket-handler.js";
import { DockgeServer } from "../dockge-server";
import { log } from "../log";
import { callbackError, callbackResult, checkLogin, DockgeSocket } from "../util-server";
import { LooseObject } from "../../common/util-common";

export class ManageAgentSocketHandler extends SocketHandler {

    create(socket : DockgeSocket, server : DockgeServer) {
        // addAgent
        socket.on("addAgent", async (requestData : unknown, callback : unknown) => {
            try {
                log.debug("manage-agent-socket-handler", "addAgent");
                checkLogin(socket);

                if (typeof(requestData) !== "object") {
                    throw new Error("Data must be an object");
                }

                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);

                // connect to the agent
                manager.connect(data.url, data.username, data.password);

                // Refresh another sockets
                // It is a bit difficult to control another browser sessions to connect/disconnect agents, so force them to refresh the page will be easier.
                server.disconnectAllSocketClients(undefined, socket.id);
                manager.sendAgentList();

                callbackResult({
                    ok: true,
                    msg: "agentAddedSuccessfully",
                    msgi18n: true,
                }, callback);

            } catch (e) {
                callbackError(e, callback);
            }
        });

        // removeAgent
        socket.on("removeAgent", async (url : unknown, callback : unknown) => {
            try {
                log.debug("manage-agent-socket-handler", "removeAgent");
                checkLogin(socket);

                if (typeof(url) !== "string") {
                    throw new Error("URL must be a string");
                }

                let manager = socket.instanceManager;
                await manager.remove(url);

                server.disconnectAllSocketClients(undefined, socket.id);
                manager.sendAgentList();

                callbackResult({
                    ok: true,
                    msg: "agentRemovedSuccessfully",
                    msgi18n: true,
                }, callback);
            } catch (e) {
                callbackError(e, callback);
            }
        });
    }
}


================================================
FILE: backend/stack.ts
================================================
import { DockgeServer } from "./dockge-server";
import fs, { promises as fsAsync } from "fs";
import { log } from "./log";
import yaml from "yaml";
import { DockgeSocket, fileExists, ValidationError } from "./util-server";
import path from "path";
import {
    acceptedComposeFileNames,
    COMBINED_TERMINAL_COLS,
    COMBINED_TERMINAL_ROWS,
    CREATED_FILE,
    CREATED_STACK,
    EXITED, getCombinedTerminalName,
    getComposeTerminalName, getContainerExecTerminalName,
    PROGRESS_TERMINAL_ROWS,
    RUNNING, TERMINAL_ROWS,
    UNKNOWN
} from "../common/util-common";
import { InteractiveTerminal, Terminal } from "./terminal";
import childProcessAsync from "promisify-child-process";
import { Settings } from "./settings";

export class Stack {

    name: string;
    protected _status: number = UNKNOWN;
    protected _composeYAML?: string;
    protected _composeENV?: string;
    protected _configFilePath?: string;
    protected _composeFileName: string = "compose.yaml";
    protected server: DockgeServer;

    protected combinedTerminal? : Terminal;

    protected static managedStackList: Map<string, Stack> = new Map();

    constructor(server : DockgeServer, name : string, composeYAML? : string, composeENV? : string, skipFSOperations = false) {
        this.name = name;
        this.server = server;
        this._composeYAML = composeYAML;
        this._composeENV = composeENV;

        if (!skipFSOperations) {
            // Check if compose file name is different from compose.yaml
            for (const filename of acceptedComposeFileNames) {
                if (fs.existsSync(path.join(this.path, filename))) {
                    this._composeFileName = filename;
                    break;
                }
            }
        }
    }

    async toJSON(endpoint : string) : Promise<object> {

        // Since we have multiple agents now, embed primary hostname in the stack object too.
        let primaryHostname = await Settings.get("primaryHostname");
        if (!primaryHostname) {
            if (!endpoint) {
                primaryHostname = "localhost";
            } else {
                // Use the endpoint as the primary hostname
                try {
                    primaryHostname = (new URL("https://" + endpoint).hostname);
                } catch (e) {
                    // Just in case if the endpoint is in a incorrect format
                    primaryHostname = "localhost";
                }
            }
        }

        let obj = this.toSimpleJSON(endpoint);
        return {
            ...obj,
            composeYAML: this.composeYAML,
            composeENV: this.composeENV,
            primaryHostname,
        };
    }

    toSimpleJSON(endpoint : string) : object {
        return {
            name: this.name,
            status: this._status,
            tags: [],
            isManagedByDockge: this.isManagedByDockge,
            composeFileName: this._composeFileName,
            endpoint,
        };
    }

    /**
     * Get the status of the stack from `docker compose ps --format json`
     */
    async ps() : Promise<object> {
        let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), {
            cwd: this.path,
            encoding: "utf-8",
        });
        if (!res.stdout) {
            return {};
        }
        return JSON.parse(res.stdout.toString());
    }

    get isManagedByDockge() : boolean {
        return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();
    }

    get status() : number {
        return this._status;
    }

    validate() {
        // Check name, allows [a-z][0-9] _ - only
        if (!this.name.match(/^[a-z0-9_-]+$/)) {
            throw new ValidationError("Stack name can only contain [a-z][0-9] _ - only");
        }

        // Check YAML format
        yaml.parse(this.composeYAML);

        let lines = this.composeENV.split("\n");

        // Check if the .env is able to pass docker-compose
        // Prevent "setenv: The parameter is incorrect"
        // It only happens when there is one line and it doesn't contain "="
        if (lines.length === 1 && !lines[0].includes("=") && lines[0].length > 0) {
            throw new ValidationError("Invalid .env format");
        }
    }

    get composeYAML() : string {
        if (this._composeYAML === undefined) {
            try {
                this._composeYAML = fs.readFileSync(path.join(this.path, this._composeFileName), "utf-8");
            } catch (e) {
                this._composeYAML = "";
            }
        }
        return this._composeYAML;
    }

    get composeENV() : string {
        if (this._composeENV === undefined) {
            try {
                this._composeENV = fs.readFileSync(path.join(this.path, ".env"), "utf-8");
            } catch (e) {
                this._composeENV = "";
            }
        }
        return this._composeENV;
    }

    get path() : string {
        return path.join(this.server.stacksDir, this.name);
    }

    get fullPath() : string {
        let dir = this.path;

        // Compose up via node-pty
        let fullPathDir;

        // if dir is relative, make it absolute
        if (!path.isAbsolute(dir)) {
            fullPathDir = path.join(process.cwd(), dir);
        } else {
            fullPathDir = dir;
        }
        return fullPathDir;
    }

    /**
     * Save the stack to the disk
     * @param isAdd
     */
    async save(isAdd : boolean) {
        this.validate();

        let dir = this.path;

        // Check if the name is used if isAdd
        if (isAdd) {
            if (await fileExists(dir)) {
                throw new ValidationError("Stack name already exists");
            }

            // Create the stack folder
            await fsAsync.mkdir(dir);
        } else {
            if (!await fileExists(dir)) {
                throw new ValidationError("Stack not found");
            }
        }

        // 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);
        }
    }

    async deploy(socket : DockgeSocket) : Promise<number> {
        const terminalName = getComposeTerminalName(socket.endpoint, this.name);
        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.");
        }
        return exitCode;
    }

    async delete(socket: DockgeSocket) : Promise<number> {
        const terminalName = getComposeTerminalName(socket.endpoint, this.name);
        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.");
        }

        // Remove the stack folder
        await fsAsync.rm(this.path, {
            recursive: true,
            force: true
        });

        return exitCode;
    }

    async updateStatus() {
        let statusList = await Stack.getStatusList();
        let status = statusList.get(this.name);

        if (status) {
            this._status = status;
        } else {
            this._status = UNKNOWN;
        }
    }

    /**
     * Checks if a compose file exists in the specified directory.
     * @async
     * @static
     * @param {string} stacksDir - The directory of the stack.
     * @param {string} filename - The name of the directory to check for the compose file.
     * @returns {Promise<boolean>} A promise that resolves to a boolean indicating whether any compose file exists.
     */
    static async composeFileExists(stacksDir : string, filename : string) : Promise<boolean> {
        let filenamePath = path.join(stacksDir, filename);
        // Check if any compose file exists
        for (const filename of acceptedComposeFileNames) {
            let composeFile = path.join(filenamePath, filename);
            if (await fileExists(composeFile)) {
                return true;
            }
        }
        return false;
    }

    static async getStackList(server : DockgeServer, useCacheForManaged = false) : Promise<Map<string, Stack>> {
        let stacksDir = server.stacksDir;
        let stackList : Map<string, Stack>;

        // Use cached stack list?
        if (useCacheForManaged && this.managedStackList.size > 0) {
            stackList = this.managedStackList;
        } else {
            stackList = new Map<string, Stack>();

            // Scan the stacks directory, and get the stack list
            let filenameList = await fsAsync.readdir(stacksDir);

            for (let filename of filenameList) {
                try {
                    // Check if it is a directory
                    let stat = await fsAsync.stat(path.join(stacksDir, filename));
                    if (!stat.isDirectory()) {
                        continue;
                    }
                    // If no compose file exists, skip it
                    if (!await Stack.composeFileExists(stacksDir, filename)) {
                        continue;
                    }
                    let stack = await this.getStack(server, filename);
                    stack._status = CREATED_FILE;
                    stackList.set(filename, stack);
                } catch (e) {
                    if (e instanceof Error) {
                        log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`);
                    }
                }
            }

            // Cache by copying
            this.managedStackList = new Map(stackList);
        }

        // Get status from docker compose ls
        let res = await childProcessAsync.spawn("docker", [ "compose", "ls", "--all", "--format", "json" ], {
            encoding: "utf-8",
        });

        if (!res.stdout) {
            return stackList;
        }

        let composeList = JSON.parse(res.stdout.toString());

        for (let composeStack of composeList) {
            let stack = stackList.get(composeStack.Name);

            // This stack probably is not managed by Dockge, but we still want to show it
            if (!stack) {
                // Skip the dockge stack if it is not managed by Dockge
                if (composeStack.Name === "dockge") {
                    continue;
                }
                stack = new Stack(server, composeStack.Name);
                stackList.set(composeStack.Name, stack);
            }

            stack._status = this.statusConvert(composeStack.Status);
            stack._configFilePath = composeStack.ConfigFiles;
        }

        return stackList;
    }

    /**
     * Get the status list, it will be used to update the status of the stacks
     * Not all status will be returned, only the stack that is deployed or created to `docker compose` will be returned
     */
    static async getStatusList() : Promise<Map<string, number>> {
        let statusList = new Map<string, number>();

        let res = await childProcessAsync.spawn("docker", [ "compose", "ls", "--all", "--format", "json" ], {
            encoding: "utf-8",
        });

        if (!res.stdout) {
            return statusList;
        }

        let composeList = JSON.parse(res.stdout.toString());

        for (let composeStack of composeList) {
            statusList.set(composeStack.Name, this.statusConvert(composeStack.Status));
        }

        return statusList;
    }

    /**
     * Convert the status string from `docker compose ls` to the status number
     * Input Example: "exited(1), running(1)"
     * @param status
     */
    static statusConvert(status : string) : number {
        if (status.startsWith("created")) {
            return CREATED_STACK;
        } else if (status.includes("exited")) {
            // If one of the service is exited, we consider the stack is exited
            return EXITED;
        } else if (status.startsWith("running")) {
            // If there is no exited services, there should be only running services
            return RUNNING;
        } else {
            return UNKNOWN;
        }
    }

    static async getStack(server: DockgeServer, stackName: string, skipFSOperations = false) : Promise<Stack> {
        let dir = path.join(server.stacksDir, stackName);

        if (!skipFSOperations) {
            if (!await fileExists(dir) || !(await fsAsync.stat(dir)).isDirectory()) {
                // Maybe it is a stack managed by docker compose directly
                let stackList = await this.getStackList(server, true);
                let stack = stackList.get(stackName);

                if (stack) {
                    return stack;
                } else {
                    // Really not found
                    throw new ValidationError("Stack not found");
                }
            }
        } else {
            //log.debug("getStack", "Skip FS operations");
        }

        let stack : Stack;

        if (!skipFSOperations) {
            stack = new Stack(server, stackName);
        } else {
            stack = new Stack(server, stackName, undefined, undefined, true);
        }

        stack._status = UNKNOWN;
        stack._configFilePath = path.resolve(dir);
        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", 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.");
        }
        return exitCode;
    }

    async stop(socket: DockgeSocket) : Promise<number> {
        const terminalName = getComposeTerminalName(socket.endpoint, this.name);
        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.");
        }
        return exitCode;
    }

    async restart(socket: DockgeSocket) : Promise<number> {
        const terminalName = getComposeTerminalName(socket.endpoint, this.name);
        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.");
        }
        return exitCode;
    }

    async down(socket: DockgeSocket) : Promise<number> {
        const terminalName = getComposeTerminalName(socket.endpoint, this.name);
        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.");
        }
        return exitCode;
    }

    async update(socket: DockgeSocket) {
        const terminalName = getComposeTerminalName(socket.endpoint, this.name);
        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.");
        }

        // If the stack is not running, we don't need to restart it
        await this.updateStatus();
        log.debug("update", "Status: " + this.status);
        if (this.status !== RUNNING) {
            return exitCode;
        }

        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.");
        }
        return exitCode;
    }

    async joinCombinedTerminal(socket: DockgeSocket) {
        const terminalName = getCombinedTerminalName(socket.endpoint, this.name);
        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;
        terminal.join(socket);
        terminal.start();
    }

    async leaveCombinedTerminal(socket: DockgeSocket) {
        const terminalName = getCombinedTerminalName(socket.endpoint, this.name);
        const terminal = Terminal.getTerminal(terminalName);
        if (terminal) {
            terminal.leave(socket);
        }
    }

    async joinContainerTerminal(socket: DockgeSocket, serviceName: string, shell : string = "sh", index: number = 0) {
        const terminalName = getContainerExecTerminalName(socket.endpoint, this.name, serviceName, index);
        let terminal = Terminal.getTerminal(terminalName);

        if (!terminal) {
            terminal = new InteractiveTerminal(this.server, terminalName, "docker", this.getComposeOptions("exec", serviceName, shell), this.path);
            terminal.rows = TERMINAL_ROWS;
            log.debug("joinContainerTerminal", "Terminal created");
        }

        terminal.join(socket);
        terminal.start();
    }

    async getServiceStatusList() {
        let statusList = new Map<string, { state: string, ports: string[] }>();

        try {
            let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), {
                cwd: this.path,
                encoding: "utf-8",
            });

            if (!res.stdout) {
                return statusList;
            }

            let lines = res.stdout?.toString().split("\n");

            for (let line of lines) {
                try {
                    let obj = JSON.parse(line);
                    let ports = (obj.Ports as string).split(/,\s*/).filter((s) => {
                        return s.indexOf("->") >= 0;
                    });
                    if (obj.Health === "") {
                        statusList.set(obj.Service, {
                            state: obj.State,
                            ports: ports
                        });
                    } else {
                        statusList.set(obj.Service, {
                            state: obj.Health,
                            ports: ports
                        });
                    }
                } catch (e) {
                }
            }

            return statusList;
        } catch (e) {
            log.error("getServiceStatusList", e);
            return statusList;
        }

    }
}


================================================
FILE: backend/terminal.ts
================================================
import { DockgeServer } from "./dockge-server";
import * as os from "node:os";
import * as pty from "@homebridge/node-pty-prebuilt-multiarch";
import { LimitQueue } from "./utils/limit-queue";
import { DockgeSocket } from "./util-server";
import {
    PROGRESS_TERMINAL_ROWS,
    TERMINAL_COLS,
    TERMINAL_ROWS
} from "../common/util-common";
import { sync as commandExistsSync } from "command-exists";
import { log } from "./log";

/**
 * Terminal for running commands, no user interaction
 */
export class Terminal {
    protected static terminalMap : Map<string, Terminal> = new Map();

    protected _ptyProcess? : pty.IPty;
    protected server : DockgeServer;
    protected buffer : LimitQueue<string> = new LimitQueue(100);
    protected _name : string;

    protected file : string;
    protected args : string | string[];
    protected cwd : string;
    protected callback? : (exitCode : number) => void;

    protected _rows : number = TERMINAL_ROWS;
    protected _cols : number = TERMINAL_COLS;

    public enableKeepAlive : boolean = false;
    protected keepAliveInterval? : NodeJS.Timeout;
    protected kickDisconnectedClientsInterval? : NodeJS.Timeout;

    protected socketList : Record<string, DockgeSocket> = {};

    constructor(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) {
        this.server = server;
        this._name = name;
        //this._name = "terminal-" + Date.now() + "-" + getCryptoRandomInt(0, 1000000);
        this.file = file;
        this.args = args;
        this.cwd = cwd;

        Terminal.terminalMap.set(this.name, this);
    }

    get rows() {
        return this._rows;
    }

    set rows(rows : number) {
        this._rows = rows;
        try {
            this.ptyProcess?.resize(this.cols, this.rows);
        } catch (e) {
            if (e instanceof Error) {
                log.debug("Terminal", "Failed to resize terminal: " + e.message);
            }
        }
    }

    get cols() {
        return this._cols;
    }

    set cols(cols : number) {
        this._cols = cols;
        log.debug("Terminal", `Terminal cols: ${this._cols}`); // Added to check if cols is being set when changing terminal size.
        try {
            this.ptyProcess?.resize(this.cols, this.rows);
        } catch (e) {
            if (e instanceof Error) {
                log.debug("Terminal", "Failed to resize terminal: " + e.message);
            }
        }
    }

    public start() {
        if (this._ptyProcess) {
            return;
        }

        this.kickDisconnectedClientsInterval = setInterval(() => {
            for (const socketID in this.socketList) {
                const socket = this.socketList[socketID];
                if (!socket.connected) {
                    log.debug("Terminal", "Kicking disconnected client " + socket.id + " from terminal " + this.name);
                    this.leave(socket);
                }
            }
        }, 60 * 1000);

        if (this.enableKeepAlive) {
            log.debug("Terminal", "Keep alive enabled for terminal " + this.name);

            // Close if there is no clients
            this.keepAliveInterval = setInterval(() => {
                const numClients = Object.keys(this.socketList).length;

                if (numClients === 0) {
                    log.debug("Terminal", "Terminal " + this.name + " has no client, closing...");
                    this.close();
                } else {
                    log.debug("Terminal", "Terminal " + this.name + " has " + numClients + " client(s)");
                }
            }, 60 * 1000);
        } else {
            log.debug("Terminal", "Keep alive disabled for terminal " + this.name);
        }

        try {
            this._ptyProcess = pty.spawn(this.file, this.args, {
                name: this.name,
                cwd: this.cwd,
                cols: TERMINAL_COLS,
                rows: this.rows,
            });

            // On Data
            this._ptyProcess.onData((data) => {
                this.buffer.pushItem(data);

                for (const socketID in this.socketList) {
                    const socket = this.socketList[socketID];
                    socket.emitAgent("terminalWrite", this.name, data);
                }
            });

            // On Exit
            this._ptyProcess.onExit(this.exit);
        } catch (error) {
            if (error instanceof Error) {
                clearInterval(this.keepAliveInterval);

                log.error("Terminal", "Failed to start terminal: " + error.message);
                const exitCode = Number(error.message.split(" ").pop());
                this.exit({
                    exitCode,
                });
            }
        }
    }

    /**
     * Exit event handler
     * @param res
     */
    protected exit = (res : {exitCode: number, signal?: number | undefined}) => {
        for (const socketID in this.socketList) {
            const socket = this.socketList[socketID];
            socket.emitAgent("terminalExit", this.name, res.exitCode);
        }

        // Remove all clients
        this.socketList = {};

        Terminal.terminalMap.delete(this.name);
        log.debug("Terminal", "Terminal " + this.name + " exited with code " + res.exitCode);

        clearInterval(this.keepAliveInterval);
        clearInterval(this.kickDisconnectedClientsInterval);

        if (this.callback) {
            this.callback(res.exitCode);
        }
    };

    public onExit(callback : (exitCode : number) => void) {
        this.callback = callback;
    }

    public join(socket : DockgeSocket) {
        this.socketList[socket.id] = socket;
    }

    public leave(socket : DockgeSocket) {
        delete this.socketList[socket.id];
    }

    public get ptyProcess() {
        return this._ptyProcess;
    }

    public get name() {
        return this._name;
    }

    /**
     * Get the terminal output string
     */
    getBuffer() : string {
        if (this.buffer.length === 0) {
            return "";
        }
        return this.buffer.join("");
    }

    close() {
        clearInterval(this.keepAliveInterval);
        // Send Ctrl+C to the terminal
        this.ptyProcess?.write("\x03");
    }

    /**
     * Get a running and non-exited terminal
     * @param name
     */
    public static getTerminal(name : string) : Terminal | undefined {
        return Terminal.terminalMap.get(name);
    }

    public static getOrCreateTerminal(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) : Terminal {
        // Since exited terminal will be removed from the map, it is safe to get the terminal from the map
        let terminal = Terminal.getTerminal(name);
        if (!terminal) {
            terminal = new Terminal(server, name, file, args, cwd);
        }
        return terminal;
    }

    public static exec(server : DockgeServer, socket : DockgeSocket | undefined, terminalName : string, file : string, args : string | string[], cwd : string) : Promise<number> {
        return new Promise((resolve, reject) => {
            // check if terminal exists
            if (Terminal.terminalMap.has(terminalName)) {
                reject("Another operation is already running, please try again later.");
                return;
            }

            let terminal = new Terminal(server, terminalName, file, args, cwd);
            terminal.rows = PROGRESS_TERMINAL_ROWS;

            if (socket) {
                terminal.join(socket);
            }

            terminal.onExit((exitCode : number) => {
                resolve(exitCode);
            });
            terminal.start();
        });
    }

    public static getTerminalCount() {
        return Terminal.terminalMap.size;
    }
}

/**
 * Interactive terminal
 * Mainly used for container exec
 */
export class InteractiveTerminal extends Terminal {
    public write(input : string) {
        this.ptyProcess?.write(input);
    }

    resetCWD() {
        const cwd = process.cwd();
        this.ptyProcess?.write(`cd "${cwd}"\r`);
    }
}

/**
 * User interactive terminal that use bash or powershell with limited commands such as docker, ls, cd, dir
 */
export class MainTerminal extends InteractiveTerminal {
    constructor(server : DockgeServer, name : string) {
        let shell;

        // Throw an error if console is not enabled
        if (!server.config.enableConsole) {
            throw new Error("Console is not enabled.");
        }

        if (os.platform() === "win32") {
            if (commandExistsSync("pwsh.exe")) {
                shell = "pwsh.exe";
            } else {
                shell = "powershell.exe";
            }
        } else {
            shell = "bash";
        }
        super(server, name, shell, [], server.stacksDir);
    }

    public write(input : string) {
        super.write(input);
    }
}


================================================
FILE: backend/util-server.ts
================================================
import { Socket } from "socket.io";
import { Terminal } from "./terminal";
import { randomBytes } from "crypto";
import { log } from "./log";
import { ERROR_TYPE_VALIDATION } from "../common/util-common";
import { R } from "redbean-node";
import { verifyPassword } from "./password-hash";
import fs from "fs";
import { AgentManager } from "./agent-manager";

export interface JWTDecoded {
    username : string;
    h? : string;
}

export interface DockgeSocket extends Socket {
    userID: number;
    consoleTerminal? : Terminal;
    instanceManager : AgentManager;
    endpoint : string;
    emitAgent : (eventName : string, ...args : unknown[]) => void;
}

// For command line arguments, so they are nullable
export interface Arguments {
    sslKey? : string;
    sslCert? : string;
    sslKeyPassphrase? : string;
    port? : number;
    hostname? : string;
    dataDir? : string;
    stacksDir? : string;
    enableConsole? : boolean;
}

// Some config values are required
export interface Config extends Arguments {
    dataDir : string;
    stacksDir : string;
}

export function checkLogin(socket : DockgeSocket) {
    if (!socket.userID) {
        throw new Error("You are not logged in.");
    }
}

export class ValidationError extends Error {
    constructor(message : string) {
        super(message);
    }
}

export function callbackError(error : unknown, callback : unknown) {
    if (typeof(callback) !== "function") {
        log.error("console", "Callback is not a function");
        return;
    }

    if (error instanceof Error) {
        callback({
            ok: false,
            msg: error.message,
            msgi18n: true,
        });
    } else if (error instanceof ValidationError) {
        callback({
            ok: false,
            type: ERROR_TYPE_VALIDATION,
            msg: error.message,
            msgi18n: true,
        });
    } else {
        log.debug("console", "Unknown error: " + error);
    }
}

export function callbackResult(result : unknown, callback : unknown) {
    if (typeof(callback) !== "function") {
        log.error("console", "Callback is not a function");
        return;
    }
    callback(result);
}

export async function doubleCheckPassword(socket : DockgeSocket, currentPassword : unknown) {
    if (typeof currentPassword !== "string") {
        throw new Error("Wrong data type?");
    }

    let user = await R.findOne("user", " id = ? AND active = 1 ", [
        socket.userID,
    ]);

    if (!user || !verifyPassword(currentPassword, user.password)) {
        throw new Error("Incorrect current password");
    }

    return user;
}

export function fileExists(file : string) {
    return fs.promises.access(file, fs.constants.F_OK)
        .then(() => true)
        .catch(() => false);
}


================================================
FILE: backend/utils/limit-queue.ts
================================================
/**
 * Limit Queue
 * The first element will be removed when the length exceeds the limit
 */
export class LimitQueue<T> extends Array<T> {
    __limit;
    __onExceed? : (item : T | undefined) => void;

    constructor(limit: number) {
        super();
        this.__limit = limit;
    }

    pushItem(value : T) {
        super.push(value);
        if (this.length > this.__limit) {
            const item = this.shift();
            if (this.__onExceed) {
                this.__onExceed(item);
            }
        }
    }

}


================================================
FILE: common/agent-socket.ts
================================================
export class AgentSocket {

    eventList : Map<string, (...args : unknown[]) => void> = new Map();

    on(event : string, callback : (...args : unknown[]) => void) {
        this.eventList.set(event, callback);
    }

    call(eventName : string, ...args : unknown[]) {
        const callback = this.eventList.get(eventName);
        if (callback) {
            callback(...args);
        }
    }
}


================================================
FILE: common/util-common.ts
================================================
/*
 * Common utilities for backend and frontend
 */
import yaml, { Document, Pair, Scalar } from "yaml";
import { DotenvParseOutput } from "dotenv";

// Init dayjs
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import relativeTime from "dayjs/plugin/relativeTime";
// @ts-ignore
import { replaceVariablesSync } from "@inventage/envsubst";

dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);

export interface LooseObject {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    [key: string]: any
}

export interface BaseRes {
    ok: boolean;
    msg?: string;
}

let randomBytes : (numBytes: number) => Uint8Array;
initRandomBytes();

async function initRandomBytes() {
    if (typeof window !== "undefined" && window.crypto) {
        randomBytes = function randomBytes(numBytes: number) {
            const bytes = new Uint8Array(numBytes);
            for (let i = 0; i < numBytes; i += 65536) {
                window.crypto.getRandomValues(bytes.subarray(i, i + Math.min(numBytes - i, 65536)));
            }
            return bytes;
        };
    } else {
        randomBytes = (await import("node:crypto")).randomBytes;
    }
}

export const ALL_ENDPOINTS = "##ALL_DOCKGE_ENDPOINTS##";

// Stack Status
export const UNKNOWN = 0;
export const CREATED_FILE = 1;
export const CREATED_STACK = 2;
export const RUNNING = 3;
export const EXITED = 4;

export function statusName(status : number) : string {
    switch (status) {
        case CREATED_FILE:
            return "draft";
        case CREATED_STACK:
            return "created_stack";
        case RUNNING:
            return "running";
        case EXITED:
            return "exited";
        default:
            return "unknown";
    }
}

export function statusNameShort(status : number) : string {
    switch (status) {
        case CREATED_FILE:
            return "inactive";
        case CREATED_STACK:
            return "inactive";
        case RUNNING:
            return "active";
        case EXITED:
            return "exited";
        default:
            return "?";
    }
}

export function statusColor(status : number) : string {
    switch (status) {
        case CREATED_FILE:
            return "dark";
        case CREATED_STACK:
            return "dark";
        case RUNNING:
            return "primary";
        case EXITED:
            return "danger";
        default:
            return "secondary";
    }
}

export const isDev = process.env.NODE_ENV === "development";
export const TERMINAL_COLS = 105;
export const TERMINAL_ROWS = 10;
export const PROGRESS_TERMINAL_ROWS = 8;

export const COMBINED_TERMINAL_COLS = 58;
export const COMBINED_TERMINAL_ROWS = 20;

export const ERROR_TYPE_VALIDATION = 1;

export const acceptedComposeFileNames = [
    "compose.yaml",
    "docker-compose.yaml",
    "docker-compose.yml",
    "compose.yml",
];

/**
 * Generate a decimal integer number from a string
 * @param str Input
 * @param length Default is 10 which means 0 - 9
 */
export function intHash(str : string, length = 10) : number {
    // A simple hashing function (you can use more complex hash functions if needed)
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
        hash += str.charCodeAt(i);
    }
    // Normalize the hash to the range [0, 10]
    return (hash % length + length) % length; // Ensure the result is non-negative
}

/**
 * Delays for specified number of seconds
 * @param ms Number of milliseconds to sleep for
 */
export function sleep(ms: number) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

/**
 * Generate a random alphanumeric string of fixed length
 * @param length Length of string to generate
 * @returns string
 */
export function genSecret(length = 64) {
    let secret = "";
    const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    const charsLength = chars.length;
    for ( let i = 0; i < length; i++ ) {
        secret += chars.charAt(getCryptoRandomInt(0, charsLength - 1));
    }
    return secret;
}

/**
 * Get a random integer suitable for use in cryptography between upper
 * and lower bounds.
 * @param min Minimum value of integer
 * @param max Maximum value of integer
 * @returns Cryptographically suitable random integer
 */
export function getCryptoRandomInt(min: number, max: number):number {
    // synchronous version of: https://github.com/joepie91/node-random-number-csprng

    const range = max - min;
    if (range >= Math.pow(2, 32)) {
        console.log("Warning! Range is too large.");
    }

    let tmpRange = range;
    let bitsNeeded = 0;
    let bytesNeeded = 0;
    let mask = 1;

    while (tmpRange > 0) {
        if (bitsNeeded % 8 === 0) {
            bytesNeeded += 1;
        }
        bitsNeeded += 1;
        mask = mask << 1 | 1;
        tmpRange = tmpRange >>> 1;
    }

    const bytes = randomBytes(bytesNeeded);
    let randomValue = 0;

    for (let i = 0; i < bytesNeeded; i++) {
        randomValue |= bytes[i] << 8 * i;
    }

    randomValue = randomValue & mask;

    if (randomValue <= range) {
        return min + randomValue;
    } else {
        return getCryptoRandomInt(min, max);
    }
}

export function getComposeTerminalName(endpoint : string, stack : string) {
    return "compose-" + endpoint + "-" + stack;
}

export function getCombinedTerminalName(endpoint : string, stack : string) {
    return "combined-" + endpoint + "-" + stack;
}

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 copyYAMLComments(doc : Document, src : Document) {
    doc.comment = src.comment;
    doc.commentBefore = src.commentBefore;

    if (doc && doc.contents && src && src.contents) {
        // @ts-ignore
        copyYAMLCommentsItems(doc.contents.items, src.contents.items);
    }
}

/**
 * Copy yaml comments from srcItems to items
 * Attempts to preserve comments by matching content rather than just array indices
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function copyYAMLCommentsItems(items: any, srcItems: any) {
    if (!items || !srcItems) {
        return;
    }

    // First pass - try to match items by their content
    for (let i = 0; i < items.length; i++) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const item: any = items[i];

        // Try to find matching source item by content
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const srcIndex = srcItems.findIndex((srcItem: any) =>
            JSON.stringify(srcItem.value) === JSON.stringify(item.value) &&
            JSON.stringify(srcItem.key) === JSON.stringify(item.key)
        );

        if (srcIndex !== -1) {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            const srcItem: any = srcItems[srcIndex];
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            const nextSrcItem: any = srcItems[srcIndex + 1];

            if (item.key && srcItem.key) {
                item.key.comment = srcItem.key.comment;
                item.key.commentBefore = srcItem.key.commentBefore;
            }

            if (srcItem.comment) {
                item.comment = srcItem.comment;
            }

            // Handle comments between array items
            if (nextSrcItem && nextSrcItem.commentBefore) {
                if (items[i + 1]) {
                    items[i + 1].commentBefore = nextSrcItem.commentBefore;
                }
            }

            // Handle trailing comments after array items
            if (srcItem.value && srcItem.value.comment) {
                if (item.value) {
                    item.value.comment = srcItem.value.comment;
                }
            }

            if (item.value && srcItem.value) {
                if (typeof item.value === "object" && typeof srcItem.value === "object") {
                    item.value.comment = srcItem.value.comment;
                    item.value.commentBefore = srcItem.value.commentBefore;

                    if (item.value.items && srcItem.value.items) {
                        copyYAMLCommentsItems(item.value.items, srcItem.value.items);
                    }
                }
            }
        }
    }
}

/**
 * Possible Inputs:
 * ports:
 *   - "3000"
 *   - "3000-3005"
 *   - "8000:8000"
 *   - "9090-9091:8080-8081"
 *   - "49100:22"
 *   - "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
 */
export function parseDockerPort(input : string, hostname : string) {
    let port;
    let display;

    const parts = input.split("/");
    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(":");

    if (lastColon === -1) {
        // No colon, so it's just a port or port range
        // Check if it's a port range
        const dash = part1.indexOf("-");
        if (dash === -1) {
            // No dash, so it's just a port
            port = part1;
        } else {
            // Has dash, so it's a port range, use the first port
            port = part1.substring(0, dash);
        }

        display = part1;

    } else {
        // Has colon, so it's a port mapping
        let hostPart = part1.substring(0, lastColon);
        display = hostPart;

        // Check if it's a port range
        const dash = part1.indexOf("-");

        if (dash !== -1) {
            // Has dash, so it's a port range, use the first port
            hostPart = part1.substring(0, dash);
        }

        // Check if it has a ip (ip:port)
        const colon = hostPart.indexOf(":");

        if (colon !== -1) {
            // Has colon, so it's a ip:port
            hostname = hostPart.substring(0, colon);
            port = hostPart.substring(colon + 1);
        } else {
            // No colon, so it's just a port
            port = hostPart;
        }
    }

    let portInt = parseInt(port);

    if (portInt == 443) {
        protocol = "https";
    } else if (protocol === "tcp") {
        protocol = "http";
    }

    return {
        url: protocol + "://" + hostname + ":" + portInt,
        display: display,
    };
}

export function envsubst(string : string, variables : LooseObject) : string {
    return replaceVariablesSync(string, variables)[0];
}

/**
 * Traverse all values in the yaml and for each value, if there are template variables, replace it environment variables
 * Emulates the behavior of how docker-compose handles environment variables in yaml files
 * @param content Yaml string
 * @param env Environment variables
 * @returns string Yaml string with environment variables replaced
 */
export function envsubstYAML(content : string, env : DotenvParseOutput) : string {
    const doc = yaml.parseDocument(content);
    if (doc.contents) {
        // @ts-ignore
        for (const item of doc.contents.items) {
            traverseYAML(item, env);
        }
    }
    return doc.toString();
}

/**
 * Used for envsubstYAML(...)
 * @param pair
 * @param env
 */
function traverseYAML(pair : Pair, env : DotenvParseOutput) : void {
    // @ts-ignore
    if (pair.value && pair.value.items) {
        // @ts-ignore
        for (const item of pair.value.items) {
            if (item instanceof Pair) {
                traverseYAML(item, env);
            } else if (item instanceof Scalar) {
                let value = item.value as unknown;

                if (typeof(value) === "string") {
                    item.value = envsubst(value, env);
                }
            }
        }
    // @ts-ignore
    } else if (pair.value && typeof(pair.value.value) === "string") {
        // @ts-ignore
        pair.value.value = envsubst(pair.value.value, env);
    }
}



================================================
FILE: compose.yaml
================================================
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
      # ⚠️ READ IT CAREFULLY. If you did it wrong, your data could end up writing into a WRONG PATH.
      # ⚠️ 1. FULL path only. No relative path (MUST)
      # ⚠️ 2. Left Stacks Path === Right Stacks Path (MUST)
      - /opt/stacks:/opt/stacks
    environment:
      # Tell Dockge where is your stacks directory
      - DOCKGE_STACKS_DIR=/opt/stacks


================================================
FILE: docker/Base.Dockerfile
================================================
FROM node:22-bookworm-slim
RUN apt update && apt install --yes --no-install-recommends \
    curl \
    ca-certificates \
    gnupg \
    unzip \
    dumb-init \
    && install -m 0755 -d /etc/apt/keyrings \
    && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
    && chmod a+r /etc/apt/keyrings/docker.gpg \
    && echo \
         "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
         "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
         tee /etc/apt/sources.list.d/docker.list > /dev/null \
    && apt update \
    && apt --yes --no-install-recommends install \
         docker-ce-cli \
         docker-compose-plugin \
    && rm -rf /var/lib/apt/lists/* \
    && npm install -g tsx


================================================
FILE: docker/BuildHealthCheck.Dockerfile
================================================
############################################
# Build in Golang
############################################
FROM golang:1.21.4-bookworm
WORKDIR /app
ARG TARGETPLATFORM
COPY ./extra/healthcheck.go ./extra/healthcheck.go

# Compile healthcheck.go
RUN go build -x -o ./extra/healthcheck ./extra/healthcheck.go


================================================
FILE: docker/Dockerfile
================================================
############################################
# Healthcheck Binary
############################################
FROM louislam/dockge:build-healthcheck AS build_healthcheck

############################################
# Build
############################################
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 --omit=dev

############################################
# ⭐ Main Image
############################################
FROM louislam/dockge:base AS release
WORKDIR /app
COPY --chown=node:node --from=build_healthcheck /app/extra/healthcheck /app/extra/healthcheck
COPY --from=build /app/node_modules /app/node_modules
COPY --chown=node:node  . .
RUN mkdir ./data


# It is just for safe, as by default, it is disabled in the latest Node.js now.
# Read more:
# - https://github.com/sagemathinc/cocalc/issues/6963
# - https://github.com/microsoft/node-pty/issues/630#issuecomment-1987212447
ENV UV_USE_IO_URING=0

VOLUME /app/data
EXPOSE 5001
HEALTHCHECK --interval=60s --timeout=30s --start-period=60s --retries=5 CMD extra/healthcheck
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["tsx", "./backend/index.ts"]

############################################
# Mark as Nightly
############################################
FROM release AS nightly
RUN npm run mark-as-nightly


================================================
FILE: extra/close-incorrect-issue.js
================================================
import github from "@actions/github";

(async () => {
    try {
        const token = process.argv[2];
        const issueNumber = process.argv[3];
        const username = process.argv[4];

        const client = github.getOctokit(token).rest;

        const issue = {
            owner: "louislam",
            repo: "dockge",
            number: issueNumber,
        };

        const labels = (
            await client.issues.listLabelsOnIssue({
                owner: issue.owner,
                repo: issue.repo,
                issue_number: issue.number
            })
        ).data.map(({ name }) => name);

        if (labels.length === 0) {
            console.log("Bad format here");

            await client.issues.addLabels({
                owner: issue.owner,
                repo: issue.repo,
                issue_number: issue.number,
                labels: [ "invalid-format" ]
            });

            // Add the issue closing comment
            await client.issues.createComment({
                owner: issue.owner,
                repo: issue.repo,
                issue_number: issue.number,
                body: `@${username}: Hello! :wave:\n\nThis issue is being automatically closed because it does not follow the issue template. Please DO NOT open a blank issue.`
            });

            // Close the issue
            await client.issues.update({
                owner: issue.owner,
                repo: issue.repo,
                issue_number: issue.number,
                state: "closed"
            });
        } else {
            console.log("Pass!");
        }
    } catch (e) {
        console.log(e);
    }

})();


================================================
FILE: extra/env2arg.js
================================================
#!/usr/bin/env node

import childProcess from "child_process";

let env = process.env;

let cmd = process.argv[2];
let args = process.argv.slice(3);
let replacedArgs = [];

for (let arg of args) {
    for (let key in env) {
        arg = arg.replaceAll(`$${key}`, env[key]);
    }
    replacedArgs.push(arg);
}

let child = childProcess.spawn(cmd, replacedArgs);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);


================================================
FILE: extra/healthcheck.go
================================================
/*
 * If changed, have to run `npm run build-docker-builder-go`.
 * This script should be run after a period of time (180s), because the server may need some time to prepare.
 */
package main

import (
	"crypto/tls"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

func main() {
	// Is K8S + "dockge" as the container name
	// See https://github.com/louislam/uptime-kuma/pull/2083
	isK8s := strings.HasPrefix(os.Getenv("DOCKGE_PORT"), "tcp://")

	// process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
	http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
		InsecureSkipVerify: true,
	}

	client := http.Client{
		Timeout: 28 * time.Second,
	}

	sslKey := os.Getenv("DOCKGE_SSL_KEY")
	sslCert := os.Getenv("DOCKGE_SSL_CERT")

	hostname := os.Getenv("DOCKGE_HOST")
	if len(hostname) == 0 {
		hostname = "127.0.0.1"
	}

	port := ""
	// DOCKGE_PORT is override by K8S unexpectedly,
	if !isK8s {
		port = os.Getenv("DOCKGE_PORT")
	}
	if len(port) == 0 {
		port = "5001"
	}

	protocol := ""
	if len(sslKey) != 0 && len(sslCert) != 0 {
		protocol = "https"
	} else {
		protocol = "http"
	}

	url := protocol + "://" + hostname + ":" + port

	log.Println("Checking " + url)
	resp, err := client.Get(url)

	if err != nil {
		log.Fatalln(err)
	}

	defer resp.Body.Close()

	_, err = ioutil.ReadAll(resp.Body)

	if err != nil {
		log.Fatalln(err)
	}

	log.Printf("Health Check OK [Res Code: %d]\n", resp.StatusCode)

}


================================================
FILE: extra/mark-as-nightly.ts
================================================
import pkg from "../package.json";
import fs from "fs";
import dayjs from "dayjs";

const oldVersion = pkg.version;
const newVersion = oldVersion + "-nightly-" + dayjs().format("YYYYMMDDHHmmss");

console.log("Old Version: " + oldVersion);
console.log("New Version: " + newVersion);

if (newVersion) {
    // Process package.json
    pkg.version = newVersion;
    //pkg.scripts.setup = pkg.scripts.setup.replaceAll(oldVersion, newVersion);
    //pkg.scripts["build-docker"] = pkg.scripts["build-docker"].replaceAll(oldVersion, newVersion);
    fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n");

    // Process README.md
    if (fs.existsSync("README.md")) {
        fs.writeFileSync("README.md", fs.readFileSync("README.md", "utf8").replaceAll(oldVersion, newVersion));
    }
}


================================================
FILE: extra/reformat-changelog.ts
================================================
// Generate on GitHub
const input = `
* Fixed envsubst issue by @louislam in https://github.com/louislam/dockge/pull/301
* Fix: Only adding folders to stack with a compose file. by @Ozy-Viking in https://github.com/louislam/dockge/pull/299
* Terminal text cols adjusts to terminal container.  by @Ozy-Viking in https://github.com/louislam/dockge/pull/285
* Update Docker Dompose plugin to 2.23.3 by @louislam in https://github.com/louislam/dockge/pull/303
* Translations update from Kuma Weblate by @UptimeKumaBot in https://github.com/louislam/dockge/pull/302
`;

const template = `

> [!WARNING]
>

### 🆕 New Features
-

### ⬆️ Improvements
-

### 🐛 Bug Fixes
-

### 🦎 Translation Contributions
-

### ⬆️ Security Fixes
-

### Others
- Other small changes, code refactoring and comment/doc updates in this repo:
- 

Please let me know if your username is missing, if your pull request has been merged in this version, or your commit has been included in one of the pull requests.
`;

const lines = input.split("\n").filter((line) => line.trim() !== "");

for (const line of lines) {
    // Split the last " by "
    const usernamePullRequesURL = line.split(" by ").pop();

    if (!usernamePullRequesURL) {
        console.log("Unable to parse", line);
        continue;
    }

    const [ username, pullRequestURL ] = usernamePullRequesURL.split(" in ");
    const pullRequestID = "#" + pullRequestURL.split("/").pop();
    let message = line.split(" by ").shift();

    if (!message) {
        console.log("Unable to parse", line);
        continue;
    }

    message = message.split("* ").pop();

    let thanks = "";
    if (username != "@louislam") {
        thanks = `(Thanks ${username})`;
    }

    console.log(pullRequestID, message, thanks);
}
console.log(template);


================================================
FILE: extra/reset-password.ts
================================================
import { Database } from "../backend/database";
import { R } from "redbean-node";
import readline from "readline";
import { User } from "../backend/models/user";
import { DockgeServer } from "../backend/dockge-server";
import { log } from "../backend/log";
import { io } from "socket.io-client";
import { BaseRes } from "../common/util-common";

console.log("== Dockge Reset Password Tool ==");

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
});

const server = new DockgeServer();

export const main = async () => {
    // Check if
    console.log("Connecting the database");
    try {
        await Database.init(server);
    } catch (e) {
        if (e instanceof Error) {
            log.error("server", "Failed to connect to your database: " + e.message);
        }
        process.exit(1);
    }

    try {
        // No need to actually reset the password for testing, just make sure no connection problem. It is ok for now.
        if (!process.env.TEST_BACKEND) {
            const user = await R.findOne("user");
            if (! user) {
                throw new Error("user not found, have you installed?");
            }

            console.log("Found user: " + user.username);

            while (true) {
                let password = await question("New Password: ");
                let confirmPassword = await question("Confirm New Password: ");

                if (password === confirmPassword) {
                    await User.resetPassword(user.id, password);

                    // Reset all sessions by reset jwt secret
                    await server.initJWTSecret();

                    console.log("Password reset successfully.");

                    // Disconnect all other socket clients of the user
                    await disconnectAllSocketClients(user.username, password);

                    break;
                } else {
                    console.log("Passwords do not match, please try again.");
                }
            }
        }
    } catch (e) {
        if (e instanceof Error) {
            console.error("Error: " + e.message);
        }
    }

    await Database.close();
    rl.close();

    console.log("Finished.");
};

/**
 * Ask question of user
 * @param question Question to ask
 * @returns Users response
 */
function question(question : string) : Promise<string> {
    return new Promise((resolve) => {
        rl.question(question, (answer) => {
            resolve(answer);
        });
    });
}

function disconnectAllSocketClients(username : string, password : string) : Promise<void> {
    return new Promise((resolve) => {
        const url = server.getLocalWebSocketURL();

        console.log("Connecting to " + url + " to disconnect all other socket clients");

        // Disconnect all socket connections
        const socket = io(url, {
            reconnection: false,
            timeout: 5000,
        });
        socket.on("connect", () => {
            socket.emit("login", {
                username,
                password,
            }, (res : BaseRes) => {
                if (res.ok) {
                    console.log("Logged in.");
                    socket.emit("disconnectOtherSocketClients");
                } else {
                    console.warn("Login failed.");
                    console.warn("Please restart the server to disconnect all sessions.");
                }
                socket.close();
            });
        });

        socket.on("connect_error", function () {
            // The localWebSocketURL is not guaranteed to be working for some complicated Uptime Kuma setup
            // Ask the user to restart the server manually
            console.warn("Failed to connect to " + url);
            console.warn("Please restart the server to disconnect all sessions manually.");
            resolve();
        });
        socket.on("disconnect", () => {
            resolve();
        });
    });
}

if (!process.env.TEST_BACKEND) {
    main();
}


================================================
FILE: extra/templates/mariadb/compose.yaml
================================================
services:
  mariadb:
    image: mariadb:latest
    restart: unless-stopped
    ports:
      - 3306:3306
    environment:
      - MARIADB_ROOT_PASSWORD=123456


================================================
FILE: extra/templates/nginx-proxy-manager/compose.yaml
================================================
services:
  nginx-proxy-manager:
    image: 'jc21/nginx-proxy-manager:latest'
    restart: unless-stopped
    ports:
      - '80:80'
      - '81:81'
      - '443:443'
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt


================================================
FILE: extra/templates/uptime-kuma/compose.yaml
================================================
services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    volumes:
      - ./data:/app/data
    ports:
      - "3001:3001"
    restart: always


================================================
FILE: extra/test-docker.ts
================================================
// Check if docker is running
import { exec } from "child_process";

exec("docker ps", (err, stdout, stderr) => {
    if (err) {
        console.error("Docker is not running. Please start docker and try again.");
        process.exit(1);
    }
});


================================================
FILE: extra/update-version.ts
================================================
import pkg from "../package.json";
import childProcess from "child_process";
import fs from "fs";

const newVersion = process.env.VERSION;

console.log("New Version: " + newVersion);

if (! newVersion) {
    console.error("invalid version");
    process.exit(1);
}

const exists = tagExists(newVersion);

if (! exists) {
    // Process package.json
    pkg.version = newVersion;
    fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n");
    commit(newVersion);
    tag(newVersion);
} else {
    console.log("version exists");
}

/**
 * Commit updated files
 * @param {string} version Version to update to
 */
function commit(version) {
    let msg = "Update to " + version;

    let res = childProcess.spawnSync("git", [ "commit", "-m", msg, "-a" ]);
    let stdout = res.stdout.toString().trim();
    console.log(stdout);

    if (stdout.includes("no changes added to commit")) {
        throw new Error("commit error");
    }
}

/**
 * Create a tag with the specified version
 * @param {string} version Tag to create
 */
function tag(version) {
    let res = childProcess.spawnSync("git", [ "tag", version ]);
    console.log(res.stdout.toString().trim());
}

/**
 * Check if a tag exists for the specified version
 * @param {string} version Version to check
 * @returns {boolean} Does the tag already exist
 */
function tagExists(version) {
    if (! version) {
        throw new Error("invalid version");
    }

    let res = childProcess.spawnSync("git", [ "tag", "-l", version ]);

    return res.stdout.toString().trim() === version;
}


================================================
FILE: frontend/components.d.ts
================================================
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}

declare module 'vue' {
  export interface GlobalComponents {
    About: typeof import('./src/components/settings/About.vue')['default']
    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']
    BDropdown: typeof import('bootstrap-vue-next')['BDropdown']
    BDropdownItem: typeof import('bootstrap-vue-next')['BDropdownItem']
    BModal: typeof import('bootstrap-vue-next')['BModal']
    Confirm: typeof import('./src/components/Confirm.vue')['default']
    Container: typeof import('./src/components/Container.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']
    RouterLink: typeof import('vue-router')['RouterLink']
    RouterView: typeof import('vue-router')['RouterView']
    Security: typeof import('./src/components/settings/Security.vue')['default']
    StackList: typeof import('./src/components/StackList.vue')['default']
    StackListItem: typeof import('./src/components/StackListItem.vue')['default']
    Terminal: typeof import('./sr
Download .txt
gitextract_xvxd0m7m/

├── .dockerignore
├── .editorconfig
├── .eslintrc.cjs
├── .github/
│   ├── DISCUSSION_TEMPLATE/
│   │   ├── ask-for-help.yml
│   │   └── feature-request.yml
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE/
│   │   ├── ask-for-help.yaml
│   │   ├── bug_report.yaml
│   │   ├── feature_request.yaml
│   │   └── security.md
│   ├── PULL_REQUEST_TEMPLATE.md
│   ├── config/
│   │   └── exclude.txt
│   └── workflows/
│       ├── ci.yml
│       ├── close-incorrect-issue.yml
│       ├── json-yaml-validate.yml
│       ├── nightly-release.yml
│       └── prevent-file-change.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── backend/
│   ├── agent-manager.ts
│   ├── agent-socket-handler.ts
│   ├── agent-socket-handlers/
│   │   ├── docker-socket-handler.ts
│   │   └── terminal-socket-handler.ts
│   ├── check-version.ts
│   ├── database.ts
│   ├── dockge-server.ts
│   ├── index.ts
│   ├── log.ts
│   ├── migrations/
│   │   ├── 2023-10-20-0829-setting-table.ts
│   │   ├── 2023-10-20-0829-user-table.ts
│   │   └── 2023-12-20-2117-agent-table.ts
│   ├── models/
│   │   ├── agent.ts
│   │   └── user.ts
│   ├── password-hash.ts
│   ├── rate-limiter.ts
│   ├── router.ts
│   ├── routers/
│   │   └── main-router.ts
│   ├── settings.ts
│   ├── socket-handler.ts
│   ├── socket-handlers/
│   │   ├── agent-proxy-socket-handler.ts
│   │   ├── main-socket-handler.ts
│   │   └── manage-agent-socket-handler.ts
│   ├── stack.ts
│   ├── terminal.ts
│   ├── util-server.ts
│   └── utils/
│       └── limit-queue.ts
├── common/
│   ├── agent-socket.ts
│   └── util-common.ts
├── compose.yaml
├── docker/
│   ├── Base.Dockerfile
│   ├── BuildHealthCheck.Dockerfile
│   └── Dockerfile
├── extra/
│   ├── close-incorrect-issue.js
│   ├── env2arg.js
│   ├── healthcheck.go
│   ├── mark-as-nightly.ts
│   ├── reformat-changelog.ts
│   ├── reset-password.ts
│   ├── templates/
│   │   ├── mariadb/
│   │   │   └── compose.yaml
│   │   ├── nginx-proxy-manager/
│   │   │   └── compose.yaml
│   │   └── uptime-kuma/
│   │       └── compose.yaml
│   ├── test-docker.ts
│   └── update-version.ts
├── frontend/
│   ├── components.d.ts
│   ├── index.html
│   ├── public/
│   │   └── manifest.json
│   ├── src/
│   │   ├── App.vue
│   │   ├── components/
│   │   │   ├── ArrayInput.vue
│   │   │   ├── ArraySelect.vue
│   │   │   ├── Confirm.vue
│   │   │   ├── Container.vue
│   │   │   ├── HiddenInput.vue
│   │   │   ├── Login.vue
│   │   │   ├── NetworkInput.vue
│   │   │   ├── StackList.vue
│   │   │   ├── StackListItem.vue
│   │   │   ├── Terminal.vue
│   │   │   ├── TwoFADialog.vue
│   │   │   ├── Uptime.vue
│   │   │   └── settings/
│   │   │       ├── About.vue
│   │   │       ├── Appearance.vue
│   │   │       ├── General.vue
│   │   │       ├── GlobalEnv.vue
│   │   │       └── Security.vue
│   │   ├── i18n.ts
│   │   ├── icon.ts
│   │   ├── lang/
│   │   │   ├── README.md
│   │   │   ├── ar.json
│   │   │   ├── be.json
│   │   │   ├── bg-BG.json
│   │   │   ├── ca.json
│   │   │   ├── cs-CZ.json
│   │   │   ├── da.json
│   │   │   ├── de-CH.json
│   │   │   ├── de.json
│   │   │   ├── en.json
│   │   │   ├── es.json
│   │   │   ├── fr.json
│   │   │   ├── ga.json
│   │   │   ├── hu.json
│   │   │   ├── id.json
│   │   │   ├── it-IT.json
│   │   │   ├── ja.json
│   │   │   ├── ko-KR.json
│   │   │   ├── nb_NO.json
│   │   │   ├── nl.json
│   │   │   ├── pl-PL.json
│   │   │   ├── pt-BR.json
│   │   │   ├── pt.json
│   │   │   ├── ro.json
│   │   │   ├── ru.json
│   │   │   ├── sl.json
│   │   │   ├── sv-SE.json
│   │   │   ├── th.json
│   │   │   ├── tr.json
│   │   │   ├── uk-UA.json
│   │   │   ├── ur.json
│   │   │   ├── vi.json
│   │   │   ├── zh-CN.json
│   │   │   └── zh-TW.json
│   │   ├── layouts/
│   │   │   ├── EmptyLayout.vue
│   │   │   └── Layout.vue
│   │   ├── main.ts
│   │   ├── mixins/
│   │   │   ├── lang.ts
│   │   │   ├── socket.ts
│   │   │   └── theme.ts
│   │   ├── pages/
│   │   │   ├── Compose.vue
│   │   │   ├── Console.vue
│   │   │   ├── ContainerTerminal.vue
│   │   │   ├── Dashboard.vue
│   │   │   ├── DashboardHome.vue
│   │   │   ├── Settings.vue
│   │   │   └── Setup.vue
│   │   ├── router.ts
│   │   ├── styles/
│   │   │   ├── localization.scss
│   │   │   ├── main.scss
│   │   │   └── vars.scss
│   │   ├── util-frontend.ts
│   │   └── vite-env.d.ts
│   └── vite.config.ts
├── package.json
└── tsconfig.json
Download .txt
SYMBOL INDEX (263 symbols across 34 files)

FILE: backend/agent-manager.ts
  class AgentManager (line 14) | class AgentManager {
    method constructor (line 21) | constructor(socket: DockgeSocket) {
    method firstConnectTime (line 25) | get firstConnectTime() : Dayjs {
    method test (line 29) | test(url : string, username : string, password : string) : Promise<voi...
    method add (line 80) | async add(url : string, username : string, password : string) : Promis...
    method remove (line 93) | async remove(url : string) {
    method connect (line 109) | connect(url : string, username : string, password : string) {
    method disconnect (line 197) | disconnect(endpoint : string) {
    method connectAll (line 202) | async connectAll() {
    method disconnectAll (line 222) | disconnectAll() {
    method emitToEndpoint (line 228) | async emitToEndpoint(endpoint: string, eventName: string, ...args : un...
    method emitToAllEndpoints (line 263) | emitToAllEndpoints(eventName: string, ...args : unknown[]) {
    method sendAgentList (line 272) | async sendAgentList() {

FILE: backend/agent-socket-handlers/docker-socket-handler.ts
  class DockerSocketHandler (line 7) | class DockerSocketHandler extends AgentSocketHandler {
    method create (line 8) | create(socket : DockgeSocket, server : DockgeServer, agentSocket : Age...
    method saveStack (line 256) | async saveStack(server : DockgeServer, name : unknown, composeYAML : u...

FILE: backend/agent-socket-handlers/terminal-socket-handler.ts
  class TerminalSocketHandler (line 9) | class TerminalSocketHandler extends AgentSocketHandler {
    method create (line 10) | create(socket : DockgeSocket, server : DockgeServer, agentSocket : Age...

FILE: backend/check-version.ts
  constant UPDATE_CHECKER_INTERVAL_MS (line 7) | const UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 60 * 48;
  constant CHECK_URL (line 8) | const CHECK_URL = "https://dockge.kuma.pet/version";
  class CheckVersion (line 10) | class CheckVersion {
    method startInterval (line 15) | async startInterval() {

FILE: backend/database.ts
  type DBConfig (line 14) | interface DBConfig {
  class Database (line 23) | class Database {
    method init (line 43) | static async init(server : DockgeServer) {
    method readDBConfig (line 60) | static readDBConfig() : DBConfig {
    method writeDBConfig (line 79) | static writeDBConfig(dbConfig : DBConfig) {
    method connect (line 89) | static async connect(autoloadModels = true) {
    method initSQLite (line 157) | static async initSQLite() {
    method patch (line 179) | static async patch() {
    method close (line 205) | static async close() {
    method getSize (line 238) | static getSize() {
    method shrink (line 252) | static async shrink() {

FILE: backend/dockge-server.ts
  class DockgeServer (line 41) | class DockgeServer {
    method constructor (line 86) | constructor() {
    method afterLogin (line 330) | async afterLogin(socket : DockgeSocket, user : User) {
    method serve (line 351) | async serve() {
    method sendInfo (line 426) | async sendInfo(socket : Socket, hideVersion = false) {
    method getClientIP (line 452) | async getClientIP(socket : Socket) : Promise<string> {
    method getTimezone (line 477) | async getTimezone() {
    method getTimezoneOffset (line 526) | getTimezoneOffset() {
    method checkTimezone (line 536) | checkTimezone(timezone : string) {
    method initDataDir (line 547) | initDataDir() {
    method initJWTSecret (line 569) | async initJWTSecret() : Promise<Bean> {
    method sendStackList (line 588) | async sendStackList(useCache = false) {
    method getDockerNetworkList (line 619) | async getDockerNetworkList() : Promise<string[]> {
    method stackDirFullPath (line 640) | get stackDirFullPath() {
    method shutdownFunction (line 649) | async shutdownFunction(signal : string | undefined) {
    method finalFunction (line 662) | finalFunction() {
    method disconnectAllSocketClients (line 672) | disconnectAllSocketClients(userID: number | undefined, currentSocketID...
    method isSSL (line 686) | isSSL() {
    method getLocalWebSocketURL (line 690) | getLocalWebSocketURL() {

FILE: backend/log.ts
  class Logger (line 60) | class Logger {
    method constructor (line 81) | constructor() {
    method log (line 105) | log(module: string, msg: unknown, level: string) {
    method info (line 164) | info(module: string, msg: unknown) {
    method warn (line 173) | warn(module: string, msg: unknown) {
    method error (line 182) | error(module: string, msg: unknown) {
    method debug (line 191) | debug(module: string, msg: unknown) {
    method exception (line 201) | exception(module: string, exception: unknown, msg: unknown) {

FILE: backend/migrations/2023-10-20-0829-setting-table.ts
  function up (line 3) | async function up(knex: Knex): Promise<void> {
  function down (line 12) | async function down(knex: Knex): Promise<void> {

FILE: backend/migrations/2023-10-20-0829-user-table.ts
  function up (line 3) | async function up(knex: Knex): Promise<void> {
  function down (line 17) | async function down(knex: Knex): Promise<void> {

FILE: backend/migrations/2023-12-20-2117-agent-table.ts
  function up (line 3) | async function up(knex: Knex): Promise<void> {
  function down (line 14) | async function down(knex: Knex): Promise<void> {

FILE: backend/models/agent.ts
  class Agent (line 5) | class Agent extends BeanModel {
    method getAgentList (line 7) | static async getAgentList() : Promise<Record<string, Agent>> {
    method endpoint (line 16) | get endpoint() : string {
    method toJSON (line 21) | toJSON() : LooseObject {

FILE: backend/models/user.ts
  class User (line 6) | class User extends BeanModel {
    method resetPassword (line 14) | static async resetPassword(userID : number, newPassword : string) {
    method resetPassword (line 26) | async resetPassword(newPassword : string) {
    method createJWT (line 37) | static createJWT(user : User, jwtSecret : string) {

FILE: backend/password-hash.ts
  function generatePasswordHash (line 10) | function generatePasswordHash(password : string) {
  function verifyPassword (line 20) | function verifyPassword(password : string, hash : string) {
  function needRehashPassword (line 29) | function needRehashPassword(hash : string) : boolean {
  constant SHAKE256_LENGTH (line 33) | const SHAKE256_LENGTH = 16;
  function shake256 (line 40) | function shake256(data : string, len : number) {

FILE: backend/rate-limiter.ts
  type KumaRateLimiterOpts (line 6) | interface KumaRateLimiterOpts extends RateLimiterOpts {
  type KumaRateLimiterCallback (line 10) | type KumaRateLimiterCallback = (err : object) => void;
  class KumaRateLimiter (line 12) | class KumaRateLimiter {
    method constructor (line 20) | constructor(config : KumaRateLimiterOpts) {
    method pass (line 37) | async pass(callback : KumaRateLimiterCallback, num = 1) {
    method removeTokens (line 57) | async removeTokens(num = 1) {

FILE: backend/routers/main-router.ts
  class MainRouter (line 5) | class MainRouter extends Router {
    method create (line 6) | create(app: Express, server: DockgeServer): ExpressRouter {

FILE: backend/settings.ts
  class Settings (line 5) | class Settings {
    method get (line 31) | static async get(key : string) {
    method set (line 80) | static async set(key : string, value : object | string | number | bool...
    method getSettings (line 101) | static async getSettings(type : string) {
    method setSettings (line 125) | static async setSettings(type : string, data : LooseObject) {
    method deleteCache (line 157) | static deleteCache(keyList : string[]) {
    method stopCacheCleaner (line 167) | static stopCacheCleaner() {

FILE: backend/socket-handlers/agent-proxy-socket-handler.ts
  class AgentProxySocketHandler (line 8) | class AgentProxySocketHandler extends SocketHandler {
    method create2 (line 10) | create2(socket : DockgeSocket, server : DockgeServer, agentSocket : Ag...
    method create (line 44) | create(socket : DockgeSocket, server : DockgeServer) {

FILE: backend/socket-handlers/main-socket-handler.ts
  class MainSocketHandler (line 24) | class MainSocketHandler extends SocketHandler {
    method create (line 25) | create(socket : DockgeSocket, server : DockgeServer) {
    method login (line 348) | async login(username : string, password : string) : Promise<User | nul...

FILE: backend/socket-handlers/manage-agent-socket-handler.ts
  class ManageAgentSocketHandler (line 7) | class ManageAgentSocketHandler extends SocketHandler {
    method create (line 9) | create(socket : DockgeSocket, server : DockgeServer) {

FILE: backend/stack.ts
  class Stack (line 23) | class Stack {
    method constructor (line 37) | constructor(server : DockgeServer, name : string, composeYAML? : strin...
    method toJSON (line 54) | async toJSON(endpoint : string) : Promise<object> {
    method toSimpleJSON (line 81) | toSimpleJSON(endpoint : string) : object {
    method ps (line 95) | async ps() : Promise<object> {
    method isManagedByDockge (line 106) | get isManagedByDockge() : boolean {
    method status (line 110) | get status() : number {
    method validate (line 114) | validate() {
    method composeYAML (line 133) | get composeYAML() : string {
    method composeENV (line 144) | get composeENV() : string {
    method path (line 155) | get path() : string {
    method fullPath (line 159) | get fullPath() : string {
    method save (line 178) | async save(isAdd : boolean) {
    method deploy (line 209) | async deploy(socket : DockgeSocket) : Promise<number> {
    method delete (line 218) | async delete(socket: DockgeSocket) : Promise<number> {
    method updateStatus (line 234) | async updateStatus() {
    method composeFileExists (line 253) | static async composeFileExists(stacksDir : string, filename : string) ...
    method getStackList (line 265) | static async getStackList(server : DockgeServer, useCacheForManaged = ...
    method getStatusList (line 338) | static async getStatusList() : Promise<Map<string, number>> {
    method statusConvert (line 363) | static statusConvert(status : string) : number {
    method getStack (line 377) | static async getStack(server: DockgeServer, stackName: string, skipFSO...
    method getComposeOptions (line 410) | getComposeOptions(command : string, ...extraOptions : string[]) {
    method start (line 423) | async start(socket: DockgeSocket) {
    method stop (line 432) | async stop(socket: DockgeSocket) : Promise<number> {
    method restart (line 441) | async restart(socket: DockgeSocket) : Promise<number> {
    method down (line 450) | async down(socket: DockgeSocket) : Promise<number> {
    method update (line 459) | async update(socket: DockgeSocket) {
    method joinCombinedTerminal (line 480) | async joinCombinedTerminal(socket: DockgeSocket) {
    method leaveCombinedTerminal (line 490) | async leaveCombinedTerminal(socket: DockgeSocket) {
    method joinContainerTerminal (line 498) | async joinContainerTerminal(socket: DockgeSocket, serviceName: string,...
    method getServiceStatusList (line 512) | async getServiceStatusList() {

FILE: backend/terminal.ts
  class Terminal (line 17) | class Terminal {
    method constructor (line 39) | constructor(server : DockgeServer, name : string, file : string, args ...
    method rows (line 50) | get rows() {
    method rows (line 54) | set rows(rows : number) {
    method cols (line 65) | get cols() {
    method cols (line 69) | set cols(cols : number) {
    method start (line 81) | public start() {
    method onExit (line 171) | public onExit(callback : (exitCode : number) => void) {
    method join (line 175) | public join(socket : DockgeSocket) {
    method leave (line 179) | public leave(socket : DockgeSocket) {
    method ptyProcess (line 183) | public get ptyProcess() {
    method name (line 187) | public get name() {
    method getBuffer (line 194) | getBuffer() : string {
    method close (line 201) | close() {
    method getTerminal (line 211) | public static getTerminal(name : string) : Terminal | undefined {
    method getOrCreateTerminal (line 215) | public static getOrCreateTerminal(server : DockgeServer, name : string...
    method exec (line 224) | public static exec(server : DockgeServer, socket : DockgeSocket | unde...
    method getTerminalCount (line 246) | public static getTerminalCount() {
  class InteractiveTerminal (line 255) | class InteractiveTerminal extends Terminal {
    method write (line 256) | public write(input : string) {
    method resetCWD (line 260) | resetCWD() {
  class MainTerminal (line 269) | class MainTerminal extends InteractiveTerminal {
    method constructor (line 270) | constructor(server : DockgeServer, name : string) {
    method write (line 290) | public write(input : string) {

FILE: backend/util-server.ts
  type JWTDecoded (line 11) | interface JWTDecoded {
  type DockgeSocket (line 16) | interface DockgeSocket extends Socket {
  type Arguments (line 25) | interface Arguments {
  type Config (line 37) | interface Config extends Arguments {
  function checkLogin (line 42) | function checkLogin(socket : DockgeSocket) {
  class ValidationError (line 48) | class ValidationError extends Error {
    method constructor (line 49) | constructor(message : string) {
  function callbackError (line 54) | function callbackError(error : unknown, callback : unknown) {
  function callbackResult (line 78) | function callbackResult(result : unknown, callback : unknown) {
  function doubleCheckPassword (line 86) | async function doubleCheckPassword(socket : DockgeSocket, currentPasswor...
  function fileExists (line 102) | function fileExists(file : string) {

FILE: backend/utils/limit-queue.ts
  class LimitQueue (line 5) | class LimitQueue<T> extends Array<T> {
    method constructor (line 9) | constructor(limit: number) {
    method pushItem (line 14) | pushItem(value : T) {

FILE: common/agent-socket.ts
  class AgentSocket (line 1) | class AgentSocket {
    method on (line 5) | on(event : string, callback : (...args : unknown[]) => void) {
    method call (line 9) | call(eventName : string, ...args : unknown[]) {

FILE: common/util-common.ts
  type LooseObject (line 19) | interface LooseObject {
  type BaseRes (line 24) | interface BaseRes {
  function initRandomBytes (line 32) | async function initRandomBytes() {
  constant ALL_ENDPOINTS (line 46) | const ALL_ENDPOINTS = "##ALL_DOCKGE_ENDPOINTS##";
  constant UNKNOWN (line 49) | const UNKNOWN = 0;
  constant CREATED_FILE (line 50) | const CREATED_FILE = 1;
  constant CREATED_STACK (line 51) | const CREATED_STACK = 2;
  constant RUNNING (line 52) | const RUNNING = 3;
  constant EXITED (line 53) | const EXITED = 4;
  function statusName (line 55) | function statusName(status : number) : string {
  function statusNameShort (line 70) | function statusNameShort(status : number) : string {
  function statusColor (line 85) | function statusColor(status : number) : string {
  constant TERMINAL_COLS (line 101) | const TERMINAL_COLS = 105;
  constant TERMINAL_ROWS (line 102) | const TERMINAL_ROWS = 10;
  constant PROGRESS_TERMINAL_ROWS (line 103) | const PROGRESS_TERMINAL_ROWS = 8;
  constant COMBINED_TERMINAL_COLS (line 105) | const COMBINED_TERMINAL_COLS = 58;
  constant COMBINED_TERMINAL_ROWS (line 106) | const COMBINED_TERMINAL_ROWS = 20;
  constant ERROR_TYPE_VALIDATION (line 108) | const ERROR_TYPE_VALIDATION = 1;
  function intHash (line 122) | function intHash(str : string, length = 10) : number {
  function sleep (line 136) | function sleep(ms: number) {
  function genSecret (line 145) | function genSecret(length = 64) {
  function getCryptoRandomInt (line 162) | function getCryptoRandomInt(min: number, max: number):number {
  function getComposeTerminalName (line 200) | function getComposeTerminalName(endpoint : string, stack : string) {
  function getCombinedTerminalName (line 204) | function getCombinedTerminalName(endpoint : string, stack : string) {
  function getContainerTerminalName (line 208) | function getContainerTerminalName(endpoint : string, container : string) {
  function getContainerExecTerminalName (line 212) | function getContainerExecTerminalName(endpoint : string, stackName : str...
  function copyYAMLComments (line 216) | function copyYAMLComments(doc : Document, src : Document) {
  function copyYAMLCommentsItems (line 231) | function copyYAMLCommentsItems(items: any, srcItems: any) {
  function parseDockerPort (line 307) | function parseDockerPort(input : string, hostname : string) {
  function envsubst (line 382) | function envsubst(string : string, variables : LooseObject) : string {
  function envsubstYAML (line 393) | function envsubstYAML(content : string, env : DotenvParseOutput) : string {
  function traverseYAML (line 409) | function traverseYAML(pair : Pair, env : DotenvParseOutput) : void {

FILE: extra/healthcheck.go
  function main (line 17) | func main() {

FILE: extra/reset-password.ts
  function question (line 79) | function question(question : string) : Promise<string> {
  function disconnectAllSocketClients (line 87) | function disconnectAllSocketClients(username : string, password : string...

FILE: extra/update-version.ts
  function commit (line 30) | function commit(version) {
  function tag (line 46) | function tag(version) {
  function tagExists (line 56) | function tagExists(version) {

FILE: frontend/components.d.ts
  type GlobalComponents (line 9) | interface GlobalComponents {

FILE: frontend/src/main.ts
  function rootApp (line 43) | function rootApp() {

FILE: frontend/src/mixins/lang.ts
  method data (line 7) | data() {
  method language (line 14) | async language(lang) {
  method created (line 19) | async created() {
  method changeLang (line 31) | async changeLang(lang : string) {

FILE: frontend/src/mixins/socket.ts
  method data (line 13) | data() {
  method agentCount (line 52) | agentCount() {
  method completeStackList (line 56) | completeStackList() {
  method usernameFirstChar (line 72) | usernameFirstChar() {
  method frontendVersion (line 86) | frontendVersion() {
  method isFrontendBackendVersionMatched (line 95) | isFrontendBackendVersionMatched() {
  method "socketIO.connected" (line 105) | "socketIO.connected"() {
  method remember (line 113) | remember() {
  method "info.version" (line 118) | "info.version"(to, from) {
  method created (line 124) | created() {
  method mounted (line 127) | mounted() {
  method endpointDisplayFunction (line 133) | endpointDisplayFunction(endpoint : string) {
  method initSocketIO (line 146) | initSocketIO(bypass = false) {
  method storage (line 296) | storage() : Storage {
  method getSocket (line 300) | getSocket() : Socket {
  method emitAgent (line 304) | emitAgent(endpoint : string, eventName : string, ...args : unknown[]) {
  method getJWTPayload (line 312) | getJWTPayload() {
  method login (line 329) | login(username : string, password : string, token : string, callback) {
  method loginByToken (line 360) | loginByToken(token : string) {
  method logout (line 378) | logout() {
  method clearData (line 390) | clearData() {
  method afterLogin (line 394) | afterLogin() {
  method bindTerminal (line 398) | bindTerminal(endpoint : string, terminalName : string, terminal : Termin...
  method unbindTerminal (line 410) | unbindTerminal(terminalName : string) {

FILE: frontend/src/mixins/theme.ts
  method data (line 4) | data() {
  method theme (line 15) | theme() {
  method isDark (line 22) | isDark() {
  method "$route.fullPath" (line 28) | "$route.fullPath"(path) {
  method userTheme (line 32) | userTheme(to, from) {
  method styleElapsedTime (line 36) | styleElapsedTime(to, from) {
  method theme (line 40) | theme(to, from) {
  method userHeartbeatBar (line 46) | userHeartbeatBar(to, from) {
  method heartbeatBarTheme (line 50) | heartbeatBarTheme(to, from) {
  method mounted (line 56) | mounted() {
  method updateThemeColorMeta (line 71) | updateThemeColorMeta() {

FILE: frontend/src/util-frontend.ts
  function getTimezoneOffset (line 13) | function getTimezoneOffset(timeZone : string) {
  function timezoneList (line 30) | function timezoneList() {
  function setPageLocale (line 66) | function setPageLocale() {
  function getResBaseURL (line 77) | function getResBaseURL() {
  function isDevContainer (line 92) | function isDevContainer() {
  function getDevContainerServerHostname (line 101) | function getDevContainerServerHostname() {
  function hostNameRegexPattern (line 116) | function hostNameRegexPattern(mqtt = false) {
  function loadToastSettings (line 131) | function loadToastSettings() {
  function getToastSuccessTimeout (line 151) | function getToastSuccessTimeout() {
  function getToastErrorTimeout (line 172) | function getToastErrorTimeout() {
Condensed preview — 145 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (620K chars).
[
  {
    "path": ".dockerignore",
    "chars": 171,
    "preview": "# Should be identical to .gitignore\n.env\nnode_modules\n.idea\ndata\nstacks\ntmp\n/private\n\n# Docker extra\ndocker\nfrontend\n.ed"
  },
  {
    "path": ".editorconfig",
    "chars": 308,
    "preview": "root = true\n\n[*]\nindent_style = space\nindent_size = 4\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ni"
  },
  {
    "path": ".eslintrc.cjs",
    "chars": 3278,
    "preview": "module.exports = {\n    root: true,\n    env: {\n        browser: true,\n        node: true,\n    },\n    extends: [\n        \""
  },
  {
    "path": ".github/DISCUSSION_TEMPLATE/ask-for-help.yml",
    "chars": 2449,
    "preview": "labels: [help]\nbody:\n  - type: checkboxes\n    id: no-duplicate-issues\n    attributes:\n      label: \"⚠️ Please verify tha"
  },
  {
    "path": ".github/DISCUSSION_TEMPLATE/feature-request.yml",
    "chars": 1754,
    "preview": "labels: [feature-request]\nbody:\n  - type: checkboxes\n    id: no-duplicate-issues\n    attributes:\n      label: \"⚠️ Please"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 740,
    "preview": "# These are supported funding model platforms\n\ngithub: louislam # Replace with up to 4 GitHub Sponsors-enabled usernames"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/ask-for-help.yaml",
    "chars": 590,
    "preview": "name: \"⚠️ Ask for help (Please go to the \\\"Discussions\\\" tab to submit a Help Request)\"\ndescription: \"⚠️ Please go to th"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yaml",
    "chars": 3158,
    "preview": "name: \"🐛 Bug Report\"\ndescription: \"Submit a bug report to help us improve\"\n#title: \"[Bug] \"\nlabels: [bug]\nbody:\n  - type"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yaml",
    "chars": 594,
    "preview": "name: 🚀 Feature Request (Please go to the \"Discussions\" tab to submit a Feature Request)\ndescription: \"⚠️ Please go to t"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/security.md",
    "chars": 431,
    "preview": "---\n\nname: \"Security Issue\"\nabout: \"Just for alerting @louislam, do not provide any details here\"\ntitle: \"Security Issue"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "chars": 1292,
    "preview": "⚠️⚠️⚠️ Since we do not accept all types of pull requests and do not want to waste your time. Please be sure that you hav"
  },
  {
    "path": ".github/config/exclude.txt",
    "chars": 86,
    "preview": "# This is a .gitignore style file for 'GrantBirki/json-yaml-validate' Action workflow\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 947,
    "preview": "name: Node.js CI - Dockge\n\non:\n  push:\n    branches: [master]\n    paths-ignore:\n      - '*.md'\n  pull_request:\n    branc"
  },
  {
    "path": ".github/workflows/close-incorrect-issue.yml",
    "chars": 433,
    "preview": "name: Close Incorrect Issue\n\non:\n  issues:\n    types: [opened]\n\njobs:\n  close-incorrect-issue:\n    runs-on: ${{ matrix.o"
  },
  {
    "path": ".github/workflows/json-yaml-validate.yml",
    "chars": 624,
    "preview": "name: json-yaml-validate\non:\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\n      - 2"
  },
  {
    "path": ".github/workflows/nightly-release.yml",
    "chars": 1218,
    "preview": "name: Nightly Release\n\non:\n  schedule:\n    # Runs at 2:00 AM UTC every day\n    - cron: \"0 2 * * *\"\n  workflow_dispatch: "
  },
  {
    "path": ".github/workflows/prevent-file-change.yml",
    "chars": 482,
    "preview": "name: Prevent File Change\n\non:\n  pull_request:\n\njobs:\n  check-file-changes:\n    runs-on: ubuntu-latest\n    steps:\n      "
  },
  {
    "path": ".gitignore",
    "chars": 114,
    "preview": "# Should update .dockerignore as well\n.env\nnode_modules\n.idea\ndata\nstacks\ntmp\n/private\n\n# Git only\nfrontend-dist\n\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 4986,
    "preview": "## Can I create a pull request for Dockge?\n\nYes or no, it depends on what you will try to do. Since I don't want to wast"
  },
  {
    "path": "LICENSE",
    "chars": 1066,
    "preview": "MIT License\n\nCopyright (c) 2023 Louis Lam\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n"
  },
  {
    "path": "README.md",
    "chars": 6267,
    "preview": "<div align=\"center\" width=\"100%\">\n    <img src=\"./frontend/public/icon.svg\" width=\"128\" alt=\"\" />\n</div>\n\n# Dockge\n\nA fa"
  },
  {
    "path": "SECURITY.md",
    "chars": 839,
    "preview": "# Security Policy\n\n## Reporting a Vulnerability\n\n1. Please report security issues to https://github.com/louislam/dockge/"
  },
  {
    "path": "backend/agent-manager.ts",
    "chars": 9438,
    "preview": "import { DockgeSocket } from \"./util-server\";\nimport { io, Socket as SocketClient } from \"socket.io-client\";\nimport { lo"
  },
  {
    "path": "backend/agent-socket-handler.ts",
    "chars": 294,
    "preview": "import { DockgeServer } from \"./dockge-server\";\nimport { AgentSocket } from \"../common/agent-socket\";\nimport { DockgeSoc"
  },
  {
    "path": "backend/agent-socket-handlers/docker-socket-handler.ts",
    "chars": 9437,
    "preview": "import { AgentSocketHandler } from \"../agent-socket-handler\";\nimport { DockgeServer } from \"../dockge-server\";\nimport { "
  },
  {
    "path": "backend/agent-socket-handlers/terminal-socket-handler.ts",
    "chars": 7513,
    "preview": "import { DockgeServer } from \"../dockge-server\";\nimport { callbackError, callbackResult, checkLogin, DockgeSocket, Valid"
  },
  {
    "path": "backend/check-version.ts",
    "chars": 1683,
    "preview": "import { log } from \"./log\";\nimport compareVersions from \"compare-versions\";\nimport packageJSON from \"../package.json\";\n"
  },
  {
    "path": "backend/database.ts",
    "chars": 7721,
    "preview": "import { log } from \"./log\";\nimport { R } from \"redbean-node\";\nimport { DockgeServer } from \"./dockge-server\";\nimport fs"
  },
  {
    "path": "backend/dockge-server.ts",
    "chars": 22985,
    "preview": "import \"dotenv/config\";\nimport { MainRouter } from \"./routers/main-router\";\nimport * as fs from \"node:fs\";\nimport { Pack"
  },
  {
    "path": "backend/index.ts",
    "chars": 177,
    "preview": "import { DockgeServer } from \"./dockge-server\";\nimport { log } from \"./log\";\n\nlog.info(\"server\", \"Welcome to dockge!\");\n"
  },
  {
    "path": "backend/log.ts",
    "chars": 6699,
    "preview": "// Console colors\n// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color\nimport { intH"
  },
  {
    "path": "backend/migrations/2023-10-20-0829-setting-table.ts",
    "chars": 436,
    "preview": "import { Knex } from \"knex\";\n\nexport async function up(knex: Knex): Promise<void> {\n    return knex.schema.createTable(\""
  },
  {
    "path": "backend/migrations/2023-10-20-0829-user-table.ts",
    "chars": 699,
    "preview": "import { Knex } from \"knex\";\n\nexport async function up(knex: Knex): Promise<void> {\n    // Create the user table\n    ret"
  },
  {
    "path": "backend/migrations/2023-12-20-2117-agent-table.ts",
    "chars": 540,
    "preview": "import { Knex } from \"knex\";\n\nexport async function up(knex: Knex): Promise<void> {\n    // Create the user table\n    ret"
  },
  {
    "path": "backend/models/agent.ts",
    "chars": 768,
    "preview": "import { BeanModel } from \"redbean-node/dist/bean-model\";\nimport { R } from \"redbean-node\";\nimport { LooseObject } from "
  },
  {
    "path": "backend/models/user.ts",
    "chars": 1511,
    "preview": "import jwt from \"jsonwebtoken\";\nimport { R } from \"redbean-node\";\nimport { BeanModel } from \"redbean-node/dist/bean-mode"
  },
  {
    "path": "backend/password-hash.ts",
    "chars": 1215,
    "preview": "import bcrypt from \"bcryptjs\";\nimport crypto from \"crypto\";\nconst saltRounds = 10;\n\n/**\n * Hash a password\n * @param {st"
  },
  {
    "path": "backend/rate-limiter.ts",
    "chars": 2364,
    "preview": "// \"limit\" is bugged in Typescript, use \"limiter-es6-compat\" instead\n// See https://github.com/jhurliman/node-rate-limit"
  },
  {
    "path": "backend/router.ts",
    "chars": 216,
    "preview": "import { DockgeServer } from \"./dockge-server\";\nimport { Express, Router as ExpressRouter } from \"express\";\n\nexport abst"
  },
  {
    "path": "backend/routers/main-router.ts",
    "chars": 674,
    "preview": "import { DockgeServer } from \"../dockge-server\";\nimport { Router } from \"../router\";\nimport express, { Express, Router a"
  },
  {
    "path": "backend/settings.ts",
    "chars": 4562,
    "preview": "import { R } from \"redbean-node\";\nimport { log } from \"./log\";\nimport { LooseObject } from \"../common/util-common\";\n\nexp"
  },
  {
    "path": "backend/socket-handler.ts",
    "chars": 208,
    "preview": "import { DockgeServer } from \"./dockge-server\";\nimport { DockgeSocket } from \"./util-server\";\n\nexport abstract class Soc"
  },
  {
    "path": "backend/socket-handlers/agent-proxy-socket-handler.ts",
    "chars": 2030,
    "preview": "import { SocketHandler } from \"../socket-handler.js\";\nimport { DockgeServer } from \"../dockge-server\";\nimport { log } fr"
  },
  {
    "path": "backend/socket-handlers/main-socket-handler.ts",
    "chars": 12871,
    "preview": "// @ts-ignore\nimport composerize from \"composerize\";\nimport { SocketHandler } from \"../socket-handler.js\";\nimport { Dock"
  },
  {
    "path": "backend/socket-handlers/manage-agent-socket-handler.ts",
    "chars": 2594,
    "preview": "import { SocketHandler } from \"../socket-handler.js\";\nimport { DockgeServer } from \"../dockge-server\";\nimport { log } fr"
  },
  {
    "path": "backend/stack.ts",
    "chars": 19929,
    "preview": "import { DockgeServer } from \"./dockge-server\";\nimport fs, { promises as fsAsync } from \"fs\";\nimport { log } from \"./log"
  },
  {
    "path": "backend/terminal.ts",
    "chars": 8928,
    "preview": "import { DockgeServer } from \"./dockge-server\";\nimport * as os from \"node:os\";\nimport * as pty from \"@homebridge/node-pt"
  },
  {
    "path": "backend/util-server.ts",
    "chars": 2770,
    "preview": "import { Socket } from \"socket.io\";\nimport { Terminal } from \"./terminal\";\nimport { randomBytes } from \"crypto\";\nimport "
  },
  {
    "path": "backend/utils/limit-queue.ts",
    "chars": 532,
    "preview": "/**\n * Limit Queue\n * The first element will be removed when the length exceeds the limit\n */\nexport class LimitQueue<T>"
  },
  {
    "path": "common/agent-socket.ts",
    "chars": 401,
    "preview": "export class AgentSocket {\n\n    eventList : Map<string, (...args : unknown[]) => void> = new Map();\n\n    on(event : stri"
  },
  {
    "path": "common/util-common.ts",
    "chars": 12471,
    "preview": "/*\n * Common utilities for backend and frontend\n */\nimport yaml, { Document, Pair, Scalar } from \"yaml\";\nimport { Dotenv"
  },
  {
    "path": "compose.yaml",
    "chars": 748,
    "preview": "services:\n  dockge:\n    image: louislam/dockge:1\n    restart: unless-stopped\n    ports:\n      # Host Port : Container Po"
  },
  {
    "path": "docker/Base.Dockerfile",
    "chars": 844,
    "preview": "FROM node:22-bookworm-slim\nRUN apt update && apt install --yes --no-install-recommends \\\n    curl \\\n    ca-certificates "
  },
  {
    "path": "docker/BuildHealthCheck.Dockerfile",
    "chars": 307,
    "preview": "############################################\n# Build in Golang\n############################################\nFROM golang:"
  },
  {
    "path": "docker/Dockerfile",
    "chars": 1414,
    "preview": "############################################\n# Healthcheck Binary\n############################################\nFROM loui"
  },
  {
    "path": "extra/close-incorrect-issue.js",
    "chars": 1671,
    "preview": "import github from \"@actions/github\";\n\n(async () => {\n    try {\n        const token = process.argv[2];\n        const iss"
  },
  {
    "path": "extra/env2arg.js",
    "chars": 433,
    "preview": "#!/usr/bin/env node\n\nimport childProcess from \"child_process\";\n\nlet env = process.env;\n\nlet cmd = process.argv[2];\nlet a"
  },
  {
    "path": "extra/healthcheck.go",
    "chars": 1434,
    "preview": "/*\n * If changed, have to run `npm run build-docker-builder-go`.\n * This script should be run after a period of time (18"
  },
  {
    "path": "extra/mark-as-nightly.ts",
    "chars": 799,
    "preview": "import pkg from \"../package.json\";\nimport fs from \"fs\";\nimport dayjs from \"dayjs\";\n\nconst oldVersion = pkg.version;\ncons"
  },
  {
    "path": "extra/reformat-changelog.ts",
    "chars": 1781,
    "preview": "// Generate on GitHub\nconst input = `\n* Fixed envsubst issue by @louislam in https://github.com/louislam/dockge/pull/301"
  },
  {
    "path": "extra/reset-password.ts",
    "chars": 4018,
    "preview": "import { Database } from \"../backend/database\";\nimport { R } from \"redbean-node\";\nimport readline from \"readline\";\nimpor"
  },
  {
    "path": "extra/templates/mariadb/compose.yaml",
    "chars": 158,
    "preview": "services:\n  mariadb:\n    image: mariadb:latest\n    restart: unless-stopped\n    ports:\n      - 3306:3306\n    environment:"
  },
  {
    "path": "extra/templates/nginx-proxy-manager/compose.yaml",
    "chars": 240,
    "preview": "services:\n  nginx-proxy-manager:\n    image: 'jc21/nginx-proxy-manager:latest'\n    restart: unless-stopped\n    ports:\n   "
  },
  {
    "path": "extra/templates/uptime-kuma/compose.yaml",
    "chars": 148,
    "preview": "services:\n  uptime-kuma:\n    image: louislam/uptime-kuma:1\n    volumes:\n      - ./data:/app/data\n    ports:\n      - \"300"
  },
  {
    "path": "extra/test-docker.ts",
    "chars": 248,
    "preview": "// Check if docker is running\nimport { exec } from \"child_process\";\n\nexec(\"docker ps\", (err, stdout, stderr) => {\n    if"
  },
  {
    "path": "extra/update-version.ts",
    "chars": 1560,
    "preview": "import pkg from \"../package.json\";\nimport childProcess from \"child_process\";\nimport fs from \"fs\";\n\nconst newVersion = pr"
  },
  {
    "path": "frontend/components.d.ts",
    "chars": 1839,
    "preview": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// Generated by unplugin-vue-components\n// Read more: https://"
  },
  {
    "path": "frontend/index.html",
    "chars": 1090,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-wid"
  },
  {
    "path": "frontend/public/manifest.json",
    "chars": 405,
    "preview": "{\n    \"name\": \"Dockge\",\n    \"short_name\": \"Dockge\",\n    \"start_url\": \"/\",\n    \"background_color\": \"#fff\",\n    \"display\":"
  },
  {
    "path": "frontend/src/App.vue",
    "chars": 84,
    "preview": "<template>\n    <router-view />\n</template>\n\n<script>\nexport default {\n\n};\n</script>\n"
  },
  {
    "path": "frontend/src/components/ArrayInput.vue",
    "chars": 3811,
    "preview": "<template>\n    <div>\n        <div v-if=\"valid\">\n            <ul v-if=\"isArrayInited\" class=\"list-group\">\n               "
  },
  {
    "path": "frontend/src/components/ArraySelect.vue",
    "chars": 3219,
    "preview": "<template>\n    <div>\n        <div v-if=\"valid\">\n            <ul v-if=\"isArrayInited\" class=\"list-group\">\n               "
  },
  {
    "path": "frontend/src/components/Confirm.vue",
    "chars": 2352,
    "preview": "<template>\n    <div ref=\"modal\" class=\"modal fade\" tabindex=\"-1\">\n        <div class=\"modal-dialog\">\n            <div cl"
  },
  {
    "path": "frontend/src/components/Container.vue",
    "chars": 10166,
    "preview": "<template>\n    <div class=\"shadow-box big-padding mb-3 container\">\n        <div class=\"row\">\n            <div class=\"col"
  },
  {
    "path": "frontend/src/components/HiddenInput.vue",
    "chars": 2116,
    "preview": "<template>\n    <div class=\"input-group mb-3\">\n        <input\n            ref=\"input\"\n            v-model=\"model\"\n       "
  },
  {
    "path": "frontend/src/components/Login.vue",
    "chars": 3343,
    "preview": "<template>\n    <div class=\"form-container\">\n        <div class=\"form\">\n            <form @submit.prevent=\"submit\">\n     "
  },
  {
    "path": "frontend/src/components/NetworkInput.vue",
    "chars": 6740,
    "preview": "<template>\n    <div>\n        <h5>{{ $t(\"Internal Networks\") }}</h5>\n        <ul class=\"list-group\">\n            <li v-fo"
  },
  {
    "path": "frontend/src/components/StackList.vue",
    "chars": 13250,
    "preview": "<template>\n    <div class=\"shadow-box mb-3\" :style=\"boxStyle\">\n        <div class=\"list-header\">\n            <div class="
  },
  {
    "path": "frontend/src/components/StackListItem.vue",
    "chars": 4205,
    "preview": "<template>\n    <router-link :to=\"url\" :class=\"{ 'dim' : !stack.isManagedByDockge }\" class=\"item\">\n        <Uptime :stack"
  },
  {
    "path": "frontend/src/components/Terminal.vue",
    "chars": 14226,
    "preview": "<template>\n    <div class=\"shadow-box\">\n        <div v-pre ref=\"terminal\" class=\"main-terminal\"></div>\n    </div>\n</temp"
  },
  {
    "path": "frontend/src/components/TwoFADialog.vue",
    "chars": 7745,
    "preview": "<template>\n    <form @submit.prevent=\"submit\">\n        <div ref=\"modal\" class=\"modal fade\" tabindex=\"-1\" data-bs-backdro"
  },
  {
    "path": "frontend/src/components/Uptime.vue",
    "chars": 1037,
    "preview": "<template>\n    <span :class=\"className\">{{ statusName }}</span>\n</template>\n\n<script>\nimport { statusColor, statusNameSh"
  },
  {
    "path": "frontend/src/components/settings/About.vue",
    "chars": 2001,
    "preview": "<template>\n    <div class=\"d-flex justify-content-center align-items-center\">\n        <div class=\"logo d-flex flex-colum"
  },
  {
    "path": "frontend/src/components/settings/Appearance.vue",
    "chars": 2795,
    "preview": "<template>\n    <div>\n        <div class=\"my-4\">\n            <label for=\"language\" class=\"form-label\">\n                {{"
  },
  {
    "path": "frontend/src/components/settings/General.vue",
    "chars": 3484,
    "preview": "<template>\n    <div>\n        <form class=\"my-4\" autocomplete=\"off\" @submit.prevent=\"saveGeneral\">\n            <!-- Clien"
  },
  {
    "path": "frontend/src/components/settings/GlobalEnv.vue",
    "chars": 2569,
    "preview": "<template>\n    <div>\n        <div v-if=\"settingsLoaded\" class=\"my-4\">\n            <form class=\"my-4\" autocomplete=\"off\" "
  },
  {
    "path": "frontend/src/components/settings/Security.vue",
    "chars": 7499,
    "preview": "<template>\n    <div>\n        <div v-if=\"settingsLoaded\" class=\"my-4\">\n            <!-- Change Password -->\n            <"
  },
  {
    "path": "frontend/src/i18n.ts",
    "chars": 1711,
    "preview": "// @ts-ignore Performance issue when using \"vue-i18n\", so we use \"vue-i18n/dist/vue-i18n.esm-browser.prod.js\", but types"
  },
  {
    "path": "frontend/src/icon.ts",
    "chars": 2064,
    "preview": "import { library } from \"@fortawesome/fontawesome-svg-core\";\nimport { FontAwesomeIcon } from \"@fortawesome/vue-fontaweso"
  },
  {
    "path": "frontend/src/lang/README.md",
    "chars": 764,
    "preview": "# Translations\r\n\r\nA simple guide on how to translate `Dockge` in your native language.\r\n\r\n## How to Translate\r\n\r\n(11-26-"
  },
  {
    "path": "frontend/src/lang/ar.json",
    "chars": 4505,
    "preview": "{\n    \"languageName\": \"العربية\",\n    \"Create your admin account\": \"إنشاء حساب المشرف\",\n    \"authIncorrectCreds\": \"اسم ال"
  },
  {
    "path": "frontend/src/lang/be.json",
    "chars": 5018,
    "preview": "{\n    \"active\": \"акт.\",\n    \"LongSyntaxNotSupported\": \"Доўгі сінтаксіс тут не падтрымліваецца. Выкарыстоўвайце рэдактар "
  },
  {
    "path": "frontend/src/lang/bg-BG.json",
    "chars": 5804,
    "preview": "{\n    \"languageName\": \"Български\",\n    \"Create your admin account\": \"Създайте администраторски профил\",\n    \"authIncorre"
  },
  {
    "path": "frontend/src/lang/ca.json",
    "chars": 5173,
    "preview": "{\n    \"Create your admin account\": \"Crea el teu compte d'administrador\",\n    \"Repeat Password\": \"Repeteix la contrasenya"
  },
  {
    "path": "frontend/src/lang/cs-CZ.json",
    "chars": 5551,
    "preview": "{\n    \"languageName\": \"Čeština\",\n    \"Create your admin account\": \"Vytvořit účet administrátora\",\n    \"authIncorrectCred"
  },
  {
    "path": "frontend/src/lang/da.json",
    "chars": 5263,
    "preview": "{\n    \"languageName\": \"Dansk\",\n    \"authIncorrectCreds\": \"Forkert brugernavn eller adgangskode.\",\n    \"PasswordsDoNotMat"
  },
  {
    "path": "frontend/src/lang/de-CH.json",
    "chars": 5824,
    "preview": "{\n    \"languageName\": \"Schwiizerdütsch\",\n    \"Create your admin account\": \"Erstell dis Admin-Konto\",\n    \"authIncorrectC"
  },
  {
    "path": "frontend/src/lang/de.json",
    "chars": 5989,
    "preview": "{\n    \"languageName\": \"Deutsch\",\n    \"Create your admin account\": \"Erstelle dein Admin-Konto\",\n    \"authIncorrectCreds\":"
  },
  {
    "path": "frontend/src/lang/en.json",
    "chars": 6260,
    "preview": "{\n    \"languageName\": \"English\",\n    \"Create your admin account\": \"Create your admin account\",\n    \"authIncorrectCreds\":"
  },
  {
    "path": "frontend/src/lang/es.json",
    "chars": 5958,
    "preview": "{\n    \"languageName\": \"Inglés\",\n    \"Create your admin account\": \"Crea tu cuenta de administrador\",\n    \"authIncorrectCr"
  },
  {
    "path": "frontend/src/lang/fr.json",
    "chars": 6074,
    "preview": "{\n    \"languageName\": \"Français\",\n    \"Create your admin account\": \"Créez votre compte administrateur\",\n    \"authIncorre"
  },
  {
    "path": "frontend/src/lang/ga.json",
    "chars": 5999,
    "preview": "{\n    \"Create your admin account\": \"Cruthaigh do chuntas riaracháin\",\n    \"authIncorrectCreds\": \"Ainm úsáideora nó pasfh"
  },
  {
    "path": "frontend/src/lang/hu.json",
    "chars": 5820,
    "preview": "{\n    \"languageName\": \"Angol\",\n    \"Repeat Password\": \"Jelszó Ismétlése\",\n    \"Create\": \"Létrehozás\",\n    \"signedInDisp\""
  },
  {
    "path": "frontend/src/lang/id.json",
    "chars": 5145,
    "preview": "{\n    \"Create your admin account\": \"Buat akun admin Anda\",\n    \"PasswordsDoNotMatch\": \"Kata sandi tidak sama.\",\n    \"Rep"
  },
  {
    "path": "frontend/src/lang/it-IT.json",
    "chars": 5108,
    "preview": "{\n    \"languageName\": \"Italiano\",\n    \"Create your admin account\": \"Crea il tuo account amministratore\",\n    \"authIncorr"
  },
  {
    "path": "frontend/src/lang/ja.json",
    "chars": 4447,
    "preview": "{\n    \"authIncorrectCreds\": \"ユーザーネームまたはパスワードが正しくありません。\",\n    \"PasswordsDoNotMatch\": \"パスワードが一致しません。\",\n    \"Repeat Passwor"
  },
  {
    "path": "frontend/src/lang/ko-KR.json",
    "chars": 3856,
    "preview": "{\n    \"languageName\": \"한국어\",\n    \"Create your admin account\": \"관리자 계정 만들기\",\n    \"authIncorrectCreds\": \"사용자명 또는 비밀번호가 일치하"
  },
  {
    "path": "frontend/src/lang/nb_NO.json",
    "chars": 1212,
    "preview": "{\n    \"Create your admin account\": \"Lag din administrator konto\",\n    \"authIncorrectCreds\": \"Brukernavn eller passord st"
  },
  {
    "path": "frontend/src/lang/nl.json",
    "chars": 4913,
    "preview": "{\n    \"languageName\": \"Nederlands\",\n    \"authIncorrectCreds\": \"Onjuiste gebruikersnaam of wachtwoord.\",\n    \"PasswordsDo"
  },
  {
    "path": "frontend/src/lang/pl-PL.json",
    "chars": 5751,
    "preview": "{\n    \"languageName\": \"Polski\",\n    \"Create your admin account\": \"Utwórz konto administratora\",\n    \"authIncorrectCreds\""
  },
  {
    "path": "frontend/src/lang/pt-BR.json",
    "chars": 5782,
    "preview": "{\n    \"languageName\": \"Português-Brasil\",\n    \"Create your admin account\": \"Crie sua conta de administrador\",\n    \"authI"
  },
  {
    "path": "frontend/src/lang/pt.json",
    "chars": 5026,
    "preview": "{\n    \"languageName\": \"Português\",\n    \"Create your admin account\": \"Crie sua conta de administrador\",\n    \"authIncorrec"
  },
  {
    "path": "frontend/src/lang/ro.json",
    "chars": 5832,
    "preview": "{\n    \"Create your admin account\": \"Creați-vă contul de administrator\",\n    \"PasswordsDoNotMatch\": \"Parolele nu se potri"
  },
  {
    "path": "frontend/src/lang/ru.json",
    "chars": 5064,
    "preview": "{\n    \"languageName\": \"Русский\",\n    \"Create your admin account\": \"Создайте учетную запись администратора\",\n    \"authInc"
  },
  {
    "path": "frontend/src/lang/sl.json",
    "chars": 4978,
    "preview": "{\n    \"languageName\": \"Slovenščina\",\n    \"Create your admin account\": \"Ustvarite svoj skrbniški račun\",\n    \"authIncorre"
  },
  {
    "path": "frontend/src/lang/sv-SE.json",
    "chars": 5611,
    "preview": "{\n    \"languageName\": \"Svenska\",\n    \"Create your admin account\": \"Skapa ditt Admin-konto\",\n    \"authIncorrectCreds\": \"F"
  },
  {
    "path": "frontend/src/lang/th.json",
    "chars": 4780,
    "preview": "{\n    \"languageName\": \"อังกฤษ\",\n    \"Create your admin account\": \"สร้างบัญชีผู้ดูแลระบบของคุณ\",\n    \"authIncorrectCreds\""
  },
  {
    "path": "frontend/src/lang/tr.json",
    "chars": 5807,
    "preview": "{\n    \"languageName\": \"Türkçe\",\n    \"Create your admin account\": \"Yönetici hesabınızı oluşturun\",\n    \"authIncorrectCred"
  },
  {
    "path": "frontend/src/lang/uk-UA.json",
    "chars": 5796,
    "preview": "{\n    \"languageName\": \"Українська\",\n    \"Create your admin account\": \"Створити акаунт адміністратора\",\n    \"authIncorrec"
  },
  {
    "path": "frontend/src/lang/ur.json",
    "chars": 4951,
    "preview": "{\n    \"languageName\": \"اردو\",\n    \"Create your admin account\": \"اپنا ایڈمن اکاؤنٹ بنائیں\",\n    \"authIncorrectCreds\": \"غل"
  },
  {
    "path": "frontend/src/lang/vi.json",
    "chars": 4813,
    "preview": "{\n    \"authIncorrectCreds\": \"Sai tên người dùng hoặc mật khẩu.\",\n    \"PasswordsDoNotMatch\": \"Mật khẩu không khớp.\",\n    "
  },
  {
    "path": "frontend/src/lang/zh-CN.json",
    "chars": 4171,
    "preview": "{\n    \"languageName\": \"简体中文\",\n    \"Create your admin account\": \"创建你的管理员账号\",\n    \"authIncorrectCreds\": \"用户名或密码错误。\",\n    \""
  },
  {
    "path": "frontend/src/lang/zh-TW.json",
    "chars": 4202,
    "preview": "{\n    \"languageName\": \"繁體中文 (台灣)\",\n    \"Create your admin account\": \"建立您的管理員帳號\",\n    \"authIncorrectCreds\": \"使用者名稱或密碼錯誤。\""
  },
  {
    "path": "frontend/src/layouts/EmptyLayout.vue",
    "chars": 83,
    "preview": "<template>\n    <router-view />\n</template>\n\n<script>\nexport default {};\n</script>\n\n"
  },
  {
    "path": "frontend/src/layouts/Layout.vue",
    "chars": 8902,
    "preview": "<template>\n    <div :class=\"classes\">\n        <div v-if=\"! $root.socketIO.connected && ! $root.socketIO.firstConnect\" cl"
  },
  {
    "path": "frontend/src/main.ts",
    "chars": 2666,
    "preview": "// Dayjs init inside this, so it has to be the first import\nimport \"../../common/util-common\";\n\nimport { createApp, defi"
  },
  {
    "path": "frontend/src/mixins/lang.ts",
    "chars": 1030,
    "preview": "import { currentLocale } from \"../i18n\";\nimport { setPageLocale } from \"../util-frontend\";\nimport { defineComponent } fr"
  },
  {
    "path": "frontend/src/mixins/socket.ts",
    "chars": 12834,
    "preview": "import { io } from \"socket.io-client\";\nimport { Socket } from \"socket.io-client\";\nimport { defineComponent } from \"vue\";"
  },
  {
    "path": "frontend/src/mixins/theme.ts",
    "chars": 1972,
    "preview": "import { defineComponent } from \"vue\";\n\nexport default defineComponent({\n    data() {\n        return {\n            syste"
  },
  {
    "path": "frontend/src/pages/Compose.vue",
    "chars": 27497,
    "preview": "<template>\n    <transition name=\"slide-fade\" appear>\n        <div>\n            <h1 v-if=\"isAdd\" class=\"mb-3\">{{ $t(\"comp"
  },
  {
    "path": "frontend/src/pages/Console.vue",
    "chars": 1262,
    "preview": "<template>\n    <transition name=\"slide-fade\" appear>\n        <div v-if=\"!processing\">\n            <h1 class=\"mb-3\">{{ $t"
  },
  {
    "path": "frontend/src/pages/ContainerTerminal.vue",
    "chars": 1860,
    "preview": "<template>\n    <transition name=\"slide-fade\" appear>\n        <div>\n            <h1 class=\"mb-3\">{{$t(\"terminal\")}} - {{ "
  },
  {
    "path": "frontend/src/pages/Dashboard.vue",
    "chars": 1058,
    "preview": "<template>\n    <div class=\"container-fluid\">\n        <div class=\"row\">\n            <div v-if=\"!$root.isMobile\" class=\"co"
  },
  {
    "path": "frontend/src/pages/DashboardHome.vue",
    "chars": 11894,
    "preview": "<template>\n    <transition ref=\"tableContainer\" name=\"slide-fade\" appear>\n        <div v-if=\"$route.name === 'DashboardH"
  },
  {
    "path": "frontend/src/pages/Settings.vue",
    "chars": 6657,
    "preview": "<template>\n    <div>\n        <h1 v-show=\"show\" class=\"mb-3\">\n            {{ $t(\"Settings\") }}\n        </h1>\n\n        <di"
  },
  {
    "path": "frontend/src/pages/Setup.vue",
    "chars": 4104,
    "preview": "<template>\n    <div class=\"form-container\" data-cy=\"setup-form\">\n        <div class=\"form\">\n            <form @submit.pr"
  },
  {
    "path": "frontend/src/router.ts",
    "chars": 3813,
    "preview": "import { createRouter, createWebHistory } from \"vue-router\";\n\nimport Layout from \"./layouts/Layout.vue\";\nimport Setup fr"
  },
  {
    "path": "frontend/src/styles/localization.scss",
    "chars": 364,
    "preview": "html[lang='fa'] {\n    #app {\n        font-family: 'IRANSans', 'Iranian Sans','B Nazanin', 'Tahoma', ui-sans-serif, syste"
  },
  {
    "path": "frontend/src/styles/main.scss",
    "chars": 12836,
    "preview": "@import \"vars.scss\";\n@import \"bootstrap/scss/bootstrap\";\n@import \"bootstrap-vue-next/dist/bootstrap-vue-next.css\";\n\n#app"
  },
  {
    "path": "frontend/src/styles/vars.scss",
    "chars": 702,
    "preview": "$primary: #74c2ff;\n$danger: #dc3545;\n$warning: #f8a306;\n$maintenance: #1747f5;\n$link-color: #111;\n$border-radius: 50rem;"
  },
  {
    "path": "frontend/src/util-frontend.ts",
    "chars": 6760,
    "preview": "import dayjs from \"dayjs\";\nimport timezones from \"timezones-list\";\nimport { localeDirection, currentLocale } from \"./i18"
  },
  {
    "path": "frontend/src/vite-env.d.ts",
    "chars": 216,
    "preview": "/* eslint-disable */\n/// <reference types=\"vite/client\" />\n\ndeclare module \"*.vue\" {\n    import type { DefineComponent }"
  },
  {
    "path": "frontend/vite.config.ts",
    "chars": 985,
    "preview": "import { defineConfig } from \"vite\";\nimport vue from \"@vitejs/plugin-vue\";\nimport Components from \"unplugin-vue-componen"
  },
  {
    "path": "package.json",
    "chars": 5075,
    "preview": "{\n    \"name\": \"dockge\",\n    \"version\": \"1.5.0\",\n    \"type\": \"module\",\n    \"engines\": {\n        \"node\": \">= 22.14.0\"\n    "
  },
  {
    "path": "tsconfig.json",
    "chars": 253,
    "preview": "{\n    \"compilerOptions\": {\n        \"module\": \"ESNext\",\n        \"target\": \"ESNext\",\n        \"strict\": true,\n        \"modu"
  }
]

About this extraction

This page contains the full source code of the louislam/dockge GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 145 files (557.7 KB), approximately 143.3k tokens, and a symbol index with 263 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!