[
  {
    "path": ".dockerignore",
    "content": "# Should be identical to .gitignore\n.env\nnode_modules\n.idea\ndata\nstacks\ntmp\n/private\n\n# Docker extra\ndocker\nfrontend\n.editorconfig\n.eslintrc.cjs\n.git\n.gitignore\nREADME.md\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 4\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.md]\ntrim_trailing_whitespace = false\n\n[*.yaml]\nindent_size = 2\n\n[*.yml]\nindent_size = 2\n\n[*.vue]\ntrim_trailing_whitespace = false\n\n[*.go]\nindent_style = tab\n"
  },
  {
    "path": ".eslintrc.cjs",
    "content": "module.exports = {\n    root: true,\n    env: {\n        browser: true,\n        node: true,\n    },\n    extends: [\n        \"eslint:recommended\",\n        \"plugin:@typescript-eslint/recommended\",\n        \"plugin:vue/vue3-recommended\",\n    ],\n    parser: \"vue-eslint-parser\",\n    parserOptions: {\n        \"parser\": \"@typescript-eslint/parser\",\n    },\n    plugins: [\n        \"@typescript-eslint\",\n        \"jsdoc\"\n    ],\n    rules: {\n        \"yoda\": \"error\",\n        \"linebreak-style\": [ \"error\", \"unix\" ],\n        \"camelcase\": [ \"warn\", {\n            \"properties\": \"never\",\n            \"ignoreImports\": true\n        }],\n        \"no-unused-vars\": [ \"warn\", {\n            \"args\": \"none\"\n        }],\n        indent: [\n            \"error\",\n            4,\n            {\n                ignoredNodes: [ \"TemplateLiteral\" ],\n                SwitchCase: 1,\n            },\n        ],\n        quotes: [ \"error\", \"double\" ],\n        semi: \"error\",\n        \"vue/html-indent\": [ \"error\", 4 ], // default: 2\n        \"vue/max-attributes-per-line\": \"off\",\n        \"vue/singleline-html-element-content-newline\": \"off\",\n        \"vue/html-self-closing\": \"off\",\n        \"vue/require-component-is\": \"off\",      // not allow is=\"style\" https://github.com/vuejs/eslint-plugin-vue/issues/462#issuecomment-430234675\n        \"vue/attribute-hyphenation\": \"off\",     // This change noNL to \"no-n-l\" unexpectedly\n        \"vue/multi-word-component-names\": \"off\",\n        \"no-multi-spaces\": [ \"error\", {\n            ignoreEOLComments: true,\n        }],\n        \"array-bracket-spacing\": [ \"warn\", \"always\", {\n            \"singleValue\": true,\n            \"objectsInArrays\": false,\n            \"arraysInArrays\": false\n        }],\n        \"space-before-function-paren\": [ \"error\", {\n            \"anonymous\": \"always\",\n            \"named\": \"never\",\n            \"asyncArrow\": \"always\"\n        }],\n        \"curly\": \"error\",\n        \"object-curly-spacing\": [ \"error\", \"always\" ],\n        \"object-curly-newline\": \"off\",\n        \"object-property-newline\": \"error\",\n        \"comma-spacing\": \"error\",\n        \"brace-style\": \"error\",\n        \"no-var\": \"error\",\n        \"key-spacing\": \"warn\",\n        \"keyword-spacing\": \"warn\",\n        \"space-infix-ops\": \"error\",\n        \"arrow-spacing\": \"warn\",\n        \"no-trailing-spaces\": \"error\",\n        \"no-constant-condition\": [ \"error\", {\n            \"checkLoops\": false,\n        }],\n        \"space-before-blocks\": \"warn\",\n        \"no-extra-boolean-cast\": \"off\",\n        \"no-multiple-empty-lines\": [ \"warn\", {\n            \"max\": 1,\n            \"maxBOF\": 0,\n        }],\n        \"lines-between-class-members\": [ \"warn\", \"always\", {\n            exceptAfterSingleLine: true,\n        }],\n        \"no-unneeded-ternary\": \"error\",\n        \"array-bracket-newline\": [ \"error\", \"consistent\" ],\n        \"eol-last\": [ \"error\", \"always\" ],\n        \"comma-dangle\": [ \"warn\", \"only-multiline\" ],\n        \"no-empty\": [ \"error\", {\n            \"allowEmptyCatch\": true\n        }],\n        \"no-control-regex\": \"off\",\n        \"one-var\": [ \"error\", \"never\" ],\n        \"max-statements-per-line\": [ \"error\", { \"max\": 1 }],\n        \"@typescript-eslint/ban-ts-comment\": \"off\",\n        \"@typescript-eslint/no-unused-vars\": [ \"warn\", {\n            \"args\": \"none\"\n        }],\n        \"prefer-const\" : \"off\",\n    },\n};\n"
  },
  {
    "path": ".github/DISCUSSION_TEMPLATE/ask-for-help.yml",
    "content": "labels: [help]\nbody:\n  - type: checkboxes\n    id: no-duplicate-issues\n    attributes:\n      label: \"⚠️ Please verify that this bug has NOT been raised before.\"\n      description: \"Search in the issues sections by clicking [HERE](https://github.com/louislam/dockge/discussions/categories/ask-for-help)\"\n      options:\n        - label: \"I checked and didn't find similar issue\"\n          required: true\n  - type: checkboxes\n    attributes:\n      label: \"🛡️ Security Policy\"\n      description: Please review the security policy before reporting security related issues/bugs.\n      options:\n        - label: I agree to have read this project [Security Policy](https://github.com/louislam/dockge/security/policy)\n          required: true\n  - type: textarea\n    id: steps-to-reproduce\n    validations:\n      required: true\n    attributes:\n      label: \"📝 Describe your problem\"\n      description: \"Please walk us through it step by step.\"\n      placeholder: \"Describe what are you asking for...\"\n  - type: textarea\n    id: error-msg\n    validations:\n      required: false\n    attributes:\n      label: \"📝 Error Message(s) or Log\"\n  - type: input\n    id: dockge-version\n    attributes:\n      label: \"🐻 Dockge Version\"\n      description: \"Which version of Dockge are you running? Please do NOT provide the docker tag such as latest or 1\"\n      placeholder: \"Ex. 1.10.0\"\n    validations:\n      required: true\n  - type: input\n    id: operating-system\n    attributes:\n      label: \"💻 Operating System and Arch\"\n      description: \"Which OS is your server/device running on? (For Replit, please do not report this bug)\"\n      placeholder: \"Ex. Ubuntu 20.04 x86\"\n    validations:\n      required: true\n  - type: input\n    id: browser-vendor\n    attributes:\n      label: \"🌐 Browser\"\n      description: \"Which browser are you running on? (For Replit, please do not report this bug)\"\n      placeholder: \"Ex. Google Chrome 95.0.4638.69\"\n    validations:\n      required: true\n  - type: input\n    id: docker-version\n    attributes:\n      label: \"🐋 Docker Version\"\n      description: \"If running with Docker, which version are you running?\"\n      placeholder: \"Ex. Docker 20.10.9 / K8S / Podman\"\n    validations:\n      required: false\n  - type: input\n    id: nodejs-version\n    attributes:\n      label: \"🟩 NodeJS Version\"\n      description: \"If running with Node.js? which version are you running?\"\n      placeholder: \"Ex. 14.18.0\"\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/DISCUSSION_TEMPLATE/feature-request.yml",
    "content": "labels: [feature-request]\nbody:\n  - type: checkboxes\n    id: no-duplicate-issues\n    attributes:\n      label: \"⚠️ Please verify that this feature request has NOT been suggested before.\"\n      description: \"Search in the issues sections by clicking [HERE](https://github.com/louislam/dockge/discussions/categories/feature-request)\"\n      options:\n        - label: \"I checked and didn't find similar feature request\"\n          required: true\n  - type: dropdown\n    id: feature-area\n    attributes:\n      label: \"🏷️ Feature Request Type\"\n      description: \"What kind of feature request is this?\"\n      multiple: true\n      options:\n        - API\n        - UI Feature\n        - Other\n    validations:\n      required: true\n  - type: textarea\n    id: feature-description\n    validations:\n      required: true\n    attributes:\n      label: \"🔖 Feature description\"\n      description: \"A clear and concise description of what the feature request is.\"\n      placeholder: \"You should add ...\"\n  - type: textarea\n    id: solution\n    validations:\n      required: true\n    attributes:\n      label: \"✔️ Solution\"\n      description: \"A clear and concise description of what you want to happen.\"\n      placeholder: \"In my use-case, ...\"\n  - type: textarea\n    id: alternatives\n    validations:\n      required: false\n    attributes:\n      label: \"❓ Alternatives\"\n      description: \"A clear and concise description of any alternative solutions or features you've considered.\"\n      placeholder: \"I have considered ...\"\n  - type: textarea\n    id: additional-context\n    validations:\n      required: false\n    attributes:\n      label: \"📝 Additional Context\"\n      description: \"Add any other context or screenshots about the feature request here.\"\n      placeholder: \"...\"\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: louislam # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]\n#patreon: # Replace with a single Patreon username\nopen_collective: uptime-kuma # Replace with a single Open Collective username\n#ko_fi: # Replace with a single Ko-fi username\n#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\n#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\n#liberapay: # Replace with a single Liberapay username\n#issuehunt: # Replace with a single IssueHunt username\n#otechie: # Replace with a single Otechie username\n#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/ask-for-help.yaml",
    "content": "name: \"⚠️ Ask for help (Please go to the \\\"Discussions\\\" tab to submit a Help Request)\"\ndescription: \"⚠️ Please go to the \\\"Discussions\\\" tab to submit a Help Request\"\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️  Please go to https://github.com/louislam/dockge/discussions/new?category=ask-for-help\n  - type: checkboxes\n    id: no-duplicate-issues\n    attributes:\n      label: \"Issues are for bug reports only, please go to the \\\"Discussions\\\" tab to submit a Feature Request\"\n      options:\n        - label: \"I understand\"\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yaml",
    "content": "name: \"🐛 Bug Report\"\ndescription: \"Submit a bug report to help us improve\"\n#title: \"[Bug] \"\nlabels: [bug]\nbody:\n  - type: checkboxes\n    id: no-duplicate-issues\n    attributes:\n      label: \"⚠️ Please verify that this bug has NOT been reported before.\"\n      description: \"Search in the issues sections by clicking [HERE](https://github.com/louislam/dockge/issues?q=)\"\n      options:\n        - label: \"I checked and didn't find similar issue\"\n          required: true\n  - type: checkboxes\n    attributes:\n      label: \"🛡️ Security Policy\"\n      description: Please review the security policy before reporting security related issues/bugs.\n      options:\n        - label: I agree to have read this project [Security Policy](https://github.com/louislam/dockge/security/policy)\n          required: true\n  - type: textarea\n    id: description\n    validations:\n      required: false\n    attributes:\n      label: \"Description\"\n      description: \"You could also upload screenshots\"\n  - type: textarea\n    id: steps-to-reproduce\n    validations:\n      required: true\n    attributes:\n      label: \"👟 Reproduction steps\"\n      description: \"How do you trigger this bug? Please walk us through it step by step.\"\n      placeholder: \"...\"\n  - type: textarea\n    id: expected-behavior\n    validations:\n      required: true\n    attributes:\n      label: \"👀 Expected behavior\"\n      description: \"What did you think would happen?\"\n      placeholder: \"...\"\n  - type: textarea\n    id: actual-behavior\n    validations:\n      required: true\n    attributes:\n      label: \"😓 Actual Behavior\"\n      description: \"What actually happen?\"\n      placeholder: \"...\"\n  - type: input\n    id: dockge-version\n    attributes:\n      label: \"Dockge Version\"\n      description: \"Which version of Dockge are you running? Please do NOT provide the docker tag such as latest or 1\"\n      placeholder: \"Ex. 1.1.1\"\n    validations:\n      required: true\n  - type: input\n    id: operating-system\n    attributes:\n      label: \"💻 Operating System and Arch\"\n      description: \"Which OS is your server/device running on?\"\n      placeholder: \"Ex. Ubuntu 20.04 x64 \"\n    validations:\n      required: true\n  - type: input\n    id: browser-vendor\n    attributes:\n      label: \"🌐 Browser\"\n      description: \"Which browser are you running on?\"\n      placeholder: \"Ex. Google Chrome 95.0.4638.69\"\n    validations:\n      required: true\n  - type: input\n    id: docker-version\n    attributes:\n      label: \"🐋 Docker Version\"\n      description: \"If running with Docker, which version are you running?\"\n      placeholder: \"Ex. Docker 20.10.9 / K8S / Podman\"\n    validations:\n      required: false\n  - type: input\n    id: nodejs-version\n    attributes:\n      label: \"🟩 NodeJS Version\"\n      description: \"If running with Node.js? which version are you running?\"\n      placeholder: \"Ex. 14.18.0\"\n    validations:\n      required: false\n  - type: textarea\n    id: logs\n    attributes:\n      label: \"📝 Relevant log output\"\n      description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.\n      render: shell\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yaml",
    "content": "name: 🚀 Feature Request (Please go to the \"Discussions\" tab to submit a Feature Request)\ndescription: \"⚠️ Please go to the \\\"Discussions\\\" tab to submit a Feature Request\"\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️  Please go to https://github.com/louislam/dockge/discussions/new?category=ask-for-help\n  - type: checkboxes\n    id: no-duplicate-issues\n    attributes:\n      label: \"Issues are for bug reports only, please go to the \\\"Discussions\\\" tab to submit a Feature Request\"\n      options:\n        - label: \"I understand\"\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/security.md",
    "content": "---\n\nname: \"Security Issue\"\nabout: \"Just for alerting @louislam, do not provide any details here\"\ntitle: \"Security Issue\"\nref: \"main\"\nlabels:\n\n- security\n\n---\n\nDO NOT PROVIDE ANY DETAILS HERE. Please privately report to https://github.com/louislam/dockge/security/advisories/new.\n\n\nWhy need this issue? It is because GitHub Advisory do not send a notification to @louislam, it is a workaround to do so.\n\nYour GitHub Advisory URL:\n\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "⚠️⚠️⚠️ 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:\nhttps://github.com/louislam/dockge/blob/master/CONTRIBUTING.md\n\nTick the checkbox if you understand [x]: \n- [ ] I have read and understand the pull request rules.\n\n# Description\n\nFixes #(issue)\n\n## Type of change\n\nPlease delete any options that are not relevant.\n\n- Bug fix (non-breaking change which fixes an issue)\n- User interface (UI)\n- New feature (non-breaking change which adds functionality)\n- Breaking change (fix or feature that would cause existing functionality to not work as expected)\n- Other\n- This change requires a documentation update\n\n## Checklist\n\n- [ ] My code follows the style guidelines of this project\n- [ ] I ran ESLint and other linters for modified files\n- [ ] I have performed a self-review of my own code and tested it\n- [ ] I have commented my code, particularly in hard-to-understand areas\n  (including JSDoc for methods)\n- [ ] My changes generate no new warnings\n- [ ] My code needed automated testing. I have added them (this is optional task)\n\n## Screenshots (if any)\n\nPlease do not use any external image service. Instead, just paste in or drag and drop the image here, and it will be uploaded automatically.\n"
  },
  {
    "path": ".github/config/exclude.txt",
    "content": "# This is a .gitignore style file for 'GrantBirki/json-yaml-validate' Action workflow\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: Node.js CI - Dockge\n\non:\n  push:\n    branches: [master]\n    paths-ignore:\n      - '*.md'\n  pull_request:\n    branches: [master]\n    paths-ignore:\n      - '*.md'\n\njobs:\n  ci:\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest, ARM, ARM64]\n        node: [22] # Can be changed\n    runs-on: ${{ matrix.os }}\n    steps:\n      - name: Checkout Code\n        run: |  # Mainly for Windows\n          git config --global core.autocrlf false\n          git config --global core.eol lf\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: ${{matrix.node}}\n\n      - name: Install dependencies\n        run: npm install\n\n      - name: Lint\n        run: npm run lint\n\n      - name: Check Typescript\n        run: npm run check-ts\n\n      - name: Build\n        run: npm run build:frontend\n      # more things can be add later like tests etc..\n\n"
  },
  {
    "path": ".github/workflows/close-incorrect-issue.yml",
    "content": "name: Close Incorrect Issue\n\non:\n  issues:\n    types: [opened]\n\njobs:\n  close-incorrect-issue:\n    runs-on: ${{ matrix.os }}\n\n    strategy:\n      matrix:\n        os: [ubuntu-latest]\n        node-version: [16]\n\n    steps:\n    - uses: actions/checkout@v3\n\n    - name: Close Incorrect Issue\n      run: node extra/close-incorrect-issue.js ${{ secrets.GITHUB_TOKEN }} ${{ github.event.issue.number }} ${{ github.event.issue.user.login }}\n"
  },
  {
    "path": ".github/workflows/json-yaml-validate.yml",
    "content": "name: json-yaml-validate\non:\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\n      - 2.0.X\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pull-requests: write # enable write permissions for pull request comments\n\njobs:\n  json-yaml-validate:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: json-yaml-validate\n        id: json-yaml-validate\n        uses: GrantBirki/json-yaml-validate@v2.6.1\n        with:\n          comment: \"false\" # enable comment mode\n          exclude_file: \".github/config/exclude.txt\" # gitignore style file for exclusions\n"
  },
  {
    "path": ".github/workflows/nightly-release.yml",
    "content": "name: Nightly Release\n\non:\n  schedule:\n    # Runs at 2:00 AM UTC every day\n    - cron: \"0 2 * * *\"\n  workflow_dispatch:  # Allow manual trigger\n\npermissions: {}\n\njobs:\n  release-nightly:\n    runs-on: ubuntu-latest\n    timeout-minutes: 120\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          persist-credentials: false\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GHCR_TOKEN }}\n\n      - name: Use Node.js 22\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n\n      - name: Install dependencies\n        run: npm clean-install --no-fund\n\n      - name: Run release-nightly\n        run: npm run release-nightly\n"
  },
  {
    "path": ".github/workflows/prevent-file-change.yml",
    "content": "name: Prevent File Change\n\non:\n  pull_request:\n\njobs:\n  check-file-changes:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Prevent file change\n        uses: xalvarez/prevent-file-change-action@v1\n        with:\n          githubToken: ${{ secrets.GITHUB_TOKEN }}\n          # Regex, /src/lang/*.json is not allowed to be changed, except for /src/lang/en.json\n          pattern: '^(?!frontend/src/lang/en\\.json$)frontend/src/lang/.*\\.json$'\n          trustedAuthors: UptimeKumaBot\n"
  },
  {
    "path": ".gitignore",
    "content": "# 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",
    "content": "## 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 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.\n\nHere are some references:\n\n### ✅ Usually accepted:\n- Bug fix\n- Security fix\n- Adding new language files (see [these instructions](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md))\n- Adding new language keys: `$t(\"...\")`\n\n### ⚠️ Discussion required:\n- Large pull requests\n- New features\n\n### ❌ Won't be merged:\n- A dedicated PR for translating existing languages (see [these instructions](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md))\n- Do not pass the auto-test\n- Any breaking changes\n- Duplicated pull requests\n- Buggy\n- UI/UX is not close to Dockge\n- Modifications or deletions of existing logic without a valid reason.\n- Adding functions that is completely out of scope\n- Converting existing code into other programming languages\n- Unnecessarily large code changes that are hard to review and cause conflicts with other PRs.\n\nThe above cases may not cover all possible situations.\n\nI (@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.\n\nI will assign your pull request to a [milestone](https://github.com/louislam/dockge/milestones), if I plan to review and merge it.\n\nAlso, 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.\n\n## Project Styles\n\nI personally do not like something that requires so many configurations before you can finally start the app.\n\n- Settings should be configurable in the frontend. Environment variables are discouraged, unless it is related to startup such as `DOCKGE_STACKS_DIR`\n- Easy to use\n- The web UI styling should be consistent and nice\n- No native build dependency\n\n## Coding Styles\n\n- 4 spaces indentation\n- Follow `.editorconfig`\n- Follow ESLint\n- Methods and functions should be documented with JSDoc\n\n## Name Conventions\n\n- Javascript/Typescript: camelCaseType\n- SQLite: snake_case (Underscore)\n- CSS/SCSS: kebab-case (Dash)\n\n## Tools\n\n- [`Node.js`](https://nodejs.org/) >= 22.14.0\n- [`git`](https://git-scm.com/)\n- IDE that supports [`ESLint`](https://eslint.org/) and EditorConfig (I am using [`IntelliJ IDEA`](https://www.jetbrains.com/idea/))\n- A SQLite GUI tool (f.ex. [`SQLite Expert Personal`](https://www.sqliteexpert.com/download.html) or [`DBeaver Community`](https://dbeaver.io/download/))\n\n## Install Dependencies for Development\n\n```bash\nnpm install\n```\n\n## Dev Server\n\n```\nnpm run dev:frontend\nnpm run dev:backend\n```\n\n## Backend Dev Server\n\nIt binds to `0.0.0.0:5001` by default.\n\nIt is mainly a socket.io app + express.js.\n\n## Frontend Dev Server\n\nIt binds to `0.0.0.0:5000` by default. The frontend dev server is used for development only.\n\nFor production, it is not used. It will be compiled to `frontend-dist` directory instead.\n\nYou can use Vue.js devtools Chrome extension for debugging.\n\n### Build the frontend\n\n```bash\nnpm run build\n```\n\n## Database Migration\n\nTODO\n\n## Dependencies\n\nBoth 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:\n\n- Frontend dependencies = \"devDependencies\"\n    - Examples: vue, chart.js\n- Backend dependencies = \"dependencies\"\n    - Examples: socket.io, sqlite3\n- Development dependencies = \"devDependencies\"\n    - Examples: eslint, sass\n\n### Update Dependencies\n\nShould only be done by the maintainer.\n\n```bash\nnpm update\n````\n\nIt should update the patch release version only.\n\nPatch release = the third digit ([Semantic Versioning](https://semver.org/))\n\nIf for security / bug / other reasons, a library must be updated, breaking changes need to be checked by the person proposing the change.\n\n## Translations\n\nPlease add **all** the strings which are translatable to `src/lang/en.json` (If translation keys are omitted, they can not be translated).\n\n**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`.  \nThe translations can then (after merging a PR into `master`) be translated by awesome people donating their language skills.\n\nIf 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).\n\n## Spelling & Grammar\n\nFeel free to correct the grammar in the documentation or code.\nMy mother language is not English and my grammar is not that great.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 Louis Lam\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\" width=\"100%\">\n    <img src=\"./frontend/public/icon.svg\" width=\"128\" alt=\"\" />\n</div>\n\n# Dockge\n\nA fancy, easy-to-use and reactive self-hosted docker compose.yaml stack-oriented manager.\n\n[![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/)\n\n<img src=\"https://github.com/louislam/dockge/assets/1336778/26a583e1-ecb1-4a8d-aedf-76157d714ad7\" width=\"900\" alt=\"\" />\n\nView Video: https://youtu.be/AWAlOQeNpgU?t=48\n\n## ⭐ Features\n\n- 🧑‍💼 Manage your `compose.yaml` files\n  - Create/Edit/Start/Stop/Restart/Delete\n  - Update Docker Images\n- ⌨️ Interactive Editor for `compose.yaml`\n- 🦦 Interactive Web Terminal\n- 🕷️ (1.4.0 🆕) Multiple agents support - You can manage multiple stacks from different Docker hosts in one single interface\n- 🏪 Convert `docker run ...` commands into `compose.yaml`\n- 📙 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\n\n<img src=\"https://github.com/louislam/dockge/assets/1336778/cc071864-592e-4909-b73a-343a57494002\" width=300 />\n\n- 🚄 Reactive - Everything is just responsive. Progress (Pull/Up/Down) and terminal output are in real-time\n- 🐣 Easy-to-use & fancy UI - If you love Uptime Kuma's UI/UX, you will love this one too\n\n![](https://github.com/louislam/dockge/assets/1336778/89fc1023-b069-42c0-a01c-918c495f1a6a)\n\n## 🔧 How to Install\n\nRequirements:\n- [Docker](https://docs.docker.com/engine/install/) 20+ / Podman\n- (Podman only) podman-docker (Debian: `apt install podman-docker`)\n- OS:\n  - Major Linux distros that can run Docker/Podman such as:\n     - ✅ Ubuntu\n     - ✅ Debian (Bullseye or newer)\n     - ✅ Raspbian (Bullseye or newer)\n     - ✅ CentOS\n     - ✅ Fedora\n     - ✅ ArchLinux\n  - ❌ Debian/Raspbian Buster or lower is not supported\n  - ❌ Windows (Will be supported later)\n- Arch: armv7, arm64, amd64 (a.k.a x86_64)\n\n### Basic\n\n- Default Stacks Directory: `/opt/stacks`\n- Default Port: 5001\n\n```\n# Create directories that store your stacks and stores Dockge's stack\nmkdir -p /opt/stacks /opt/dockge\ncd /opt/dockge\n\n# Download the compose.yaml\ncurl https://raw.githubusercontent.com/louislam/dockge/master/compose.yaml --output compose.yaml\n\n# Start the server\ndocker compose up -d\n\n# If you are using docker-compose V1 or Podman\n# docker-compose up -d\n```\n\nDockge is now running on http://localhost:5001\n\n### Advanced\n\nIf 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.\n\n```\n# Download your compose.yaml\ncurl \"https://dockge.kuma.pet/compose.yaml?port=5001&stacksPath=/opt/stacks\" --output compose.yaml\n```\n\n- port=`5001`\n- stacksPath=`/opt/stacks`\n\nInteractive compose.yaml generator is available on: \nhttps://dockge.kuma.pet\n\n## How to Update\n\n```bash\ncd /opt/dockge\ndocker compose pull && docker compose up -d\n```\n\n## Screenshots\n\n![](https://github.com/louislam/dockge/assets/1336778/e7ff0222-af2e-405c-b533-4eab04791b40)\n\n\n![](https://github.com/louislam/dockge/assets/1336778/7139e88c-77ed-4d45-96e3-00b66d36d871)\n\n![](https://github.com/louislam/dockge/assets/1336778/f019944c-0e87-405b-a1b8-625b35de1eeb)\n\n![](https://github.com/louislam/dockge/assets/1336778/a4478d23-b1c4-4991-8768-1a7cad3472e3)\n\n\n## Motivations\n\n- 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.\n- Try to develop with ES Module + TypeScript\n\nIf you love this project, please consider giving it a ⭐.\n\n\n## 🗣️ Community and Contribution\n\n### Bug Report\nhttps://github.com/louislam/dockge/issues\n\n### Ask for Help / Discussions\nhttps://github.com/louislam/dockge/discussions\n\n### Translation\nIf you want to translate Dockge into your language, please read [Translation Guide](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md)\n\n### Create a Pull Request\n\nBe 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.\n\n## FAQ\n\n#### \"Dockge\"?\n\n\"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.\n\nThe naming idea came from Twitch emotes like `sadge`, `bedge` or `wokege`. They all end in `-ge`.\n\n#### Can I manage a single container without `compose.yaml`?\n\nThe 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.\n\n#### Can I manage existing stacks?\n\nYes, you can. However, you need to move your compose file into the stacks directory:\n\n1. Stop your stack\n2. Move your compose file into `/opt/stacks/<stackName>/compose.yaml`\n3. In Dockge, click the \" Scan Stacks Folder\" button in the top-right corner's dropdown menu\n4. Now you should see your stack in the list\n\n#### Is Dockge a Portainer replacement?\n\nYes 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.\n\nIf you want to manage your container with docker-compose only, the answer may be yes.\n\nIf you still need to manage something like docker networks, single containers, the answer may be no.\n\n#### Can I install both Dockge and Portainer?\n\nYes, you can.\n\n## Others\n\nDockge is built on top of [Compose V2](https://docs.docker.com/compose/migrate/). `compose.yaml`  also known as `docker-compose.yml`.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\n1. Please report security issues to https://github.com/louislam/dockge/security/advisories/new.\n1. 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\n\nDo not use the public issue tracker or discuss it in public as it will cause more damage.\n\n## Do you accept other 3rd-party bug bounty platforms?\n\nAt 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.\n"
  },
  {
    "path": "backend/agent-manager.ts",
    "content": "import { DockgeSocket } from \"./util-server\";\nimport { io, Socket as SocketClient } from \"socket.io-client\";\nimport { log } from \"./log\";\nimport { Agent } from \"./models/agent\";\nimport { isDev, LooseObject, sleep } from \"../common/util-common\";\nimport semver from \"semver\";\nimport { R } from \"redbean-node\";\nimport dayjs, { Dayjs } from \"dayjs\";\n\n/**\n * Dockge Instance Manager\n * One AgentManager per Socket connection\n */\nexport class AgentManager {\n\n    protected socket : DockgeSocket;\n    protected agentSocketList : Record<string, SocketClient> = {};\n    protected agentLoggedInList : Record<string, boolean> = {};\n    protected _firstConnectTime : Dayjs = dayjs();\n\n    constructor(socket: DockgeSocket) {\n        this.socket = socket;\n    }\n\n    get firstConnectTime() : Dayjs {\n        return this._firstConnectTime;\n    }\n\n    test(url : string, username : string, password : string) : Promise<void> {\n        return new Promise((resolve, reject) => {\n            let obj = new URL(url);\n            let endpoint = obj.host;\n\n            if (!endpoint) {\n                reject(new Error(\"Invalid Dockge URL\"));\n            }\n\n            if (this.agentSocketList[endpoint]) {\n                reject(new Error(\"The Dockge URL already exists\"));\n            }\n\n            let client = io(url, {\n                reconnection: false,\n                extraHeaders: {\n                    endpoint,\n                }\n            });\n\n            client.on(\"connect\", () => {\n                client.emit(\"login\", {\n                    username: username,\n                    password: password,\n                }, (res : LooseObject) => {\n                    if (res.ok) {\n                        resolve();\n                    } else {\n                        reject(new Error(res.msg));\n                    }\n                    client.disconnect();\n                });\n            });\n\n            client.on(\"connect_error\", (err) => {\n                if (err.message === \"xhr poll error\") {\n                    reject(new Error(\"Unable to connect to the Dockge instance\"));\n                } else {\n                    reject(err);\n                }\n                client.disconnect();\n            });\n        });\n    }\n\n    /**\n     *\n     * @param url\n     * @param username\n     * @param password\n     */\n    async add(url : string, username : string, password : string) : Promise<Agent> {\n        let bean = R.dispense(\"agent\") as Agent;\n        bean.url = url;\n        bean.username = username;\n        bean.password = password;\n        await R.store(bean);\n        return bean;\n    }\n\n    /**\n     *\n     * @param url\n     */\n    async remove(url : string) {\n        let bean = await R.findOne(\"agent\", \" url = ? \", [\n            url,\n        ]);\n\n        if (bean) {\n            await R.trash(bean);\n            let endpoint = bean.endpoint;\n            this.disconnect(endpoint);\n            this.sendAgentList();\n            delete this.agentSocketList[endpoint];\n        } else {\n            throw new Error(\"Agent not found\");\n        }\n    }\n\n    connect(url : string, username : string, password : string) {\n        let obj = new URL(url);\n        let endpoint = obj.host;\n\n        this.socket.emit(\"agentStatus\", {\n            endpoint: endpoint,\n            status: \"connecting\",\n        });\n\n        if (!endpoint) {\n            log.error(\"agent-manager\", \"Invalid endpoint: \" + endpoint + \" URL: \" + url);\n            return;\n        }\n\n        if (this.agentSocketList[endpoint]) {\n            log.debug(\"agent-manager\", \"Already connected to the socket server: \" + endpoint);\n            return;\n        }\n\n        log.info(\"agent-manager\", \"Connecting to the socket server: \" + endpoint);\n        let client = io(url, {\n            extraHeaders: {\n                endpoint,\n            }\n        });\n\n        client.on(\"connect\", () => {\n            log.info(\"agent-manager\", \"Connected to the socket server: \" + endpoint);\n\n            client.emit(\"login\", {\n                username: username,\n                password: password,\n            }, (res : LooseObject) => {\n                if (res.ok) {\n                    log.info(\"agent-manager\", \"Logged in to the socket server: \" + endpoint);\n                    this.agentLoggedInList[endpoint] = true;\n                    this.socket.emit(\"agentStatus\", {\n                        endpoint: endpoint,\n                        status: \"online\",\n                    });\n                } else {\n                    log.error(\"agent-manager\", \"Failed to login to the socket server: \" + endpoint);\n                    this.agentLoggedInList[endpoint] = false;\n                    this.socket.emit(\"agentStatus\", {\n                        endpoint: endpoint,\n                        status: \"offline\",\n                    });\n                }\n            });\n        });\n\n        client.on(\"connect_error\", (err) => {\n            log.error(\"agent-manager\", \"Error from the socket server: \" + endpoint);\n            this.socket.emit(\"agentStatus\", {\n                endpoint: endpoint,\n                status: \"offline\",\n            });\n        });\n\n        client.on(\"disconnect\", () => {\n            log.info(\"agent-manager\", \"Disconnected from the socket server: \" + endpoint);\n            this.socket.emit(\"agentStatus\", {\n                endpoint: endpoint,\n                status: \"offline\",\n            });\n        });\n\n        client.on(\"agent\", (...args : unknown[]) => {\n            this.socket.emit(\"agent\", ...args);\n        });\n\n        client.on(\"info\", (res) => {\n            log.debug(\"agent-manager\", res);\n\n            // Disconnect if the version is lower than 1.4.0\n            if (!isDev && semver.satisfies(res.version, \"< 1.4.0\")) {\n                this.socket.emit(\"agentStatus\", {\n                    endpoint: endpoint,\n                    status: \"offline\",\n                    msg: `${endpoint}: Unsupported version: ` + res.version,\n                });\n                client.disconnect();\n            }\n        });\n\n        this.agentSocketList[endpoint] = client;\n    }\n\n    disconnect(endpoint : string) {\n        let client = this.agentSocketList[endpoint];\n        client?.disconnect();\n    }\n\n    async connectAll() {\n        this._firstConnectTime = dayjs();\n\n        if (this.socket.endpoint) {\n            log.info(\"agent-manager\", \"This connection is connected as an agent, skip connectAll()\");\n            return;\n        }\n\n        let list : Record<string, Agent> = await Agent.getAgentList();\n\n        if (Object.keys(list).length !== 0) {\n            log.info(\"agent-manager\", \"Connecting to all instance socket server(s)...\");\n        }\n\n        for (let endpoint in list) {\n            let agent = list[endpoint];\n            this.connect(agent.url, agent.username, agent.password);\n        }\n    }\n\n    disconnectAll() {\n        for (let endpoint in this.agentSocketList) {\n            this.disconnect(endpoint);\n        }\n    }\n\n    async emitToEndpoint(endpoint: string, eventName: string, ...args : unknown[]) {\n        log.debug(\"agent-manager\", \"Emitting event to endpoint: \" + endpoint);\n        let client = this.agentSocketList[endpoint];\n\n        if (!client) {\n            log.error(\"agent-manager\", \"Socket client not found for endpoint: \" + endpoint);\n            throw new Error(\"Socket client not found for endpoint: \" + endpoint);\n        }\n\n        if (!client.connected || !this.agentLoggedInList[endpoint]) {\n            // Maybe the request is too quick, the socket is not connected yet, check firstConnectTime\n            // If it is within 10 seconds, we should apply retry logic here\n            let diff = dayjs().diff(this.firstConnectTime, \"second\");\n            log.debug(\"agent-manager\", endpoint + \": diff: \" + diff);\n            let ok = false;\n            while (diff < 10) {\n                if (client.connected && this.agentLoggedInList[endpoint]) {\n                    log.debug(\"agent-manager\", `${endpoint}: Connected & Logged in`);\n                    ok = true;\n                    break;\n                }\n                log.debug(\"agent-manager\", endpoint + \": not ready yet, retrying in 1 second...\");\n                await sleep(1000);\n                diff = dayjs().diff(this.firstConnectTime, \"second\");\n            }\n\n            if (!ok) {\n                log.error(\"agent-manager\", `${endpoint}: Socket client not connected`);\n                throw new Error(\"Socket client not connected for endpoint: \" + endpoint);\n            }\n        }\n\n        client.emit(\"agent\", endpoint, eventName, ...args);\n    }\n\n    emitToAllEndpoints(eventName: string, ...args : unknown[]) {\n        log.debug(\"agent-manager\", \"Emitting event to all endpoints\");\n        for (let endpoint in this.agentSocketList) {\n            this.emitToEndpoint(endpoint, eventName, ...args).catch((e) => {\n                log.warn(\"agent-manager\", e.message);\n            });\n        }\n    }\n\n    async sendAgentList() {\n        let list = await Agent.getAgentList();\n        let result : Record<string, LooseObject> = {};\n\n        // Myself\n        result[\"\"] = {\n            url: \"\",\n            username: \"\",\n            endpoint: \"\",\n        };\n\n        for (let endpoint in list) {\n            let agent = list[endpoint];\n            result[endpoint] = agent.toJSON();\n        }\n\n        this.socket.emit(\"agentList\", {\n            ok: true,\n            agentList: result,\n        });\n    }\n}\n"
  },
  {
    "path": "backend/agent-socket-handler.ts",
    "content": "import { DockgeServer } from \"./dockge-server\";\nimport { AgentSocket } from \"../common/agent-socket\";\nimport { DockgeSocket } from \"./util-server\";\n\nexport abstract class AgentSocketHandler {\n    abstract create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket): void;\n}\n"
  },
  {
    "path": "backend/agent-socket-handlers/docker-socket-handler.ts",
    "content": "import { AgentSocketHandler } from \"../agent-socket-handler\";\nimport { DockgeServer } from \"../dockge-server\";\nimport { callbackError, callbackResult, checkLogin, DockgeSocket, ValidationError } from \"../util-server\";\nimport { Stack } from \"../stack\";\nimport { AgentSocket } from \"../../common/agent-socket\";\n\nexport class DockerSocketHandler extends AgentSocketHandler {\n    create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) {\n        // Do not call super.create()\n\n        agentSocket.on(\"deployStack\", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => {\n            try {\n                checkLogin(socket);\n                const stack = await this.saveStack(server, name, composeYAML, composeENV, isAdd);\n                await stack.deploy(socket);\n                server.sendStackList();\n                callbackResult({\n                    ok: true,\n                    msg: \"Deployed\",\n                    msgi18n: true,\n                }, callback);\n                stack.joinCombinedTerminal(socket);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        agentSocket.on(\"saveStack\", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => {\n            try {\n                checkLogin(socket);\n                await this.saveStack(server, name, composeYAML, composeENV, isAdd);\n                callbackResult({\n                    ok: true,\n                    msg: \"Saved\",\n                    msgi18n: true,\n                }, callback);\n                server.sendStackList();\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        agentSocket.on(\"deleteStack\", async (name : unknown, callback) => {\n            try {\n                checkLogin(socket);\n                if (typeof(name) !== \"string\") {\n                    throw new ValidationError(\"Name must be a string\");\n                }\n                const stack = await Stack.getStack(server, name);\n\n                try {\n                    await stack.delete(socket);\n                } catch (e) {\n                    server.sendStackList();\n                    throw e;\n                }\n\n                server.sendStackList();\n                callbackResult({\n                    ok: true,\n                    msg: \"Deleted\",\n                    msgi18n: true,\n                }, callback);\n\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        agentSocket.on(\"getStack\", async (stackName : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(stackName) !== \"string\") {\n                    throw new ValidationError(\"Stack name must be a string\");\n                }\n\n                const stack = await Stack.getStack(server, stackName);\n\n                if (stack.isManagedByDockge) {\n                    stack.joinCombinedTerminal(socket);\n                }\n\n                callbackResult({\n                    ok: true,\n                    stack: await stack.toJSON(socket.endpoint),\n                }, callback);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // requestStackList\n        agentSocket.on(\"requestStackList\", async (callback) => {\n            try {\n                checkLogin(socket);\n                server.sendStackList();\n                callbackResult({\n                    ok: true,\n                    msg: \"Updated\",\n                    msgi18n: true,\n                }, callback);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // startStack\n        agentSocket.on(\"startStack\", async (stackName : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(stackName) !== \"string\") {\n                    throw new ValidationError(\"Stack name must be a string\");\n                }\n\n                const stack = await Stack.getStack(server, stackName);\n                await stack.start(socket);\n                callbackResult({\n                    ok: true,\n                    msg: \"Started\",\n                    msgi18n: true,\n                }, callback);\n                server.sendStackList();\n\n                stack.joinCombinedTerminal(socket);\n\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // stopStack\n        agentSocket.on(\"stopStack\", async (stackName : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(stackName) !== \"string\") {\n                    throw new ValidationError(\"Stack name must be a string\");\n                }\n\n                const stack = await Stack.getStack(server, stackName);\n                await stack.stop(socket);\n                callbackResult({\n                    ok: true,\n                    msg: \"Stopped\",\n                    msgi18n: true,\n                }, callback);\n                server.sendStackList();\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // restartStack\n        agentSocket.on(\"restartStack\", async (stackName : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(stackName) !== \"string\") {\n                    throw new ValidationError(\"Stack name must be a string\");\n                }\n\n                const stack = await Stack.getStack(server, stackName);\n                await stack.restart(socket);\n                callbackResult({\n                    ok: true,\n                    msg: \"Restarted\",\n                    msgi18n: true,\n                }, callback);\n                server.sendStackList();\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // updateStack\n        agentSocket.on(\"updateStack\", async (stackName : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(stackName) !== \"string\") {\n                    throw new ValidationError(\"Stack name must be a string\");\n                }\n\n                const stack = await Stack.getStack(server, stackName);\n                await stack.update(socket);\n                callbackResult({\n                    ok: true,\n                    msg: \"Updated\",\n                    msgi18n: true,\n                }, callback);\n                server.sendStackList();\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // down stack\n        agentSocket.on(\"downStack\", async (stackName : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(stackName) !== \"string\") {\n                    throw new ValidationError(\"Stack name must be a string\");\n                }\n\n                const stack = await Stack.getStack(server, stackName);\n                await stack.down(socket);\n                callbackResult({\n                    ok: true,\n                    msg: \"Downed\",\n                    msgi18n: true,\n                }, callback);\n                server.sendStackList();\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // Services status\n        agentSocket.on(\"serviceStatusList\", async (stackName : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(stackName) !== \"string\") {\n                    throw new ValidationError(\"Stack name must be a string\");\n                }\n\n                const stack = await Stack.getStack(server, stackName, true);\n                const serviceStatusList = Object.fromEntries(await stack.getServiceStatusList());\n                callbackResult({\n                    ok: true,\n                    serviceStatusList,\n                }, callback);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // getExternalNetworkList\n        agentSocket.on(\"getDockerNetworkList\", async (callback) => {\n            try {\n                checkLogin(socket);\n                const dockerNetworkList = await server.getDockerNetworkList();\n                callbackResult({\n                    ok: true,\n                    dockerNetworkList,\n                }, callback);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n    }\n\n    async saveStack(server : DockgeServer, name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown) : Promise<Stack> {\n        // Check types\n        if (typeof(name) !== \"string\") {\n            throw new ValidationError(\"Name must be a string\");\n        }\n        if (typeof(composeYAML) !== \"string\") {\n            throw new ValidationError(\"Compose YAML must be a string\");\n        }\n        if (typeof(composeENV) !== \"string\") {\n            throw new ValidationError(\"Compose ENV must be a string\");\n        }\n        if (typeof(isAdd) !== \"boolean\") {\n            throw new ValidationError(\"isAdd must be a boolean\");\n        }\n\n        const stack = new Stack(server, name, composeYAML, composeENV, false);\n        await stack.save(isAdd);\n        return stack;\n    }\n\n}\n\n"
  },
  {
    "path": "backend/agent-socket-handlers/terminal-socket-handler.ts",
    "content": "import { DockgeServer } from \"../dockge-server\";\nimport { callbackError, callbackResult, checkLogin, DockgeSocket, ValidationError } from \"../util-server\";\nimport { log } from \"../log\";\nimport { InteractiveTerminal, MainTerminal, Terminal } from \"../terminal\";\nimport { Stack } from \"../stack\";\nimport { AgentSocketHandler } from \"../agent-socket-handler\";\nimport { AgentSocket } from \"../../common/agent-socket\";\n\nexport class TerminalSocketHandler extends AgentSocketHandler {\n    create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) {\n\n        agentSocket.on(\"terminalInput\", async (terminalName : unknown, cmd : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(terminalName) !== \"string\") {\n                    throw new Error(\"Terminal name must be a string.\");\n                }\n\n                if (typeof(cmd) !== \"string\") {\n                    throw new Error(\"Command must be a string.\");\n                }\n\n                let terminal = Terminal.getTerminal(terminalName);\n                if (terminal instanceof InteractiveTerminal) {\n                    //log.debug(\"terminalInput\", \"Terminal found, writing to terminal.\");\n                    terminal.write(cmd);\n                } else {\n                    throw new Error(\"Terminal not found or it is not a Interactive Terminal.\");\n                }\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // Main Terminal\n        agentSocket.on(\"mainTerminal\", async (terminalName : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                // Throw an error if console is not enabled\n                if (!server.config.enableConsole) {\n                    throw new ValidationError(\"Console is not enabled.\");\n                }\n\n                // TODO: Reset the name here, force one main terminal for now\n                terminalName = \"console\";\n\n                if (typeof(terminalName) !== \"string\") {\n                    throw new ValidationError(\"Terminal name must be a string.\");\n                }\n\n                log.debug(\"mainTerminal\", \"Terminal name: \" + terminalName);\n\n                let terminal = Terminal.getTerminal(terminalName);\n\n                if (!terminal) {\n                    terminal = new MainTerminal(server, terminalName);\n                    terminal.rows = 50;\n                    log.debug(\"mainTerminal\", \"Terminal created\");\n                }\n\n                terminal.join(socket);\n                terminal.start();\n\n                callbackResult({\n                    ok: true,\n                }, callback);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // Check if MainTerminal is enabled\n        agentSocket.on(\"checkMainTerminal\", async (callback) => {\n            try {\n                checkLogin(socket);\n                callbackResult({\n                    ok: server.config.enableConsole,\n                }, callback);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // Interactive Terminal for containers\n        agentSocket.on(\"interactiveTerminal\", async (stackName : unknown, serviceName : unknown, shell : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(stackName) !== \"string\") {\n                    throw new ValidationError(\"Stack name must be a string.\");\n                }\n\n                if (typeof(serviceName) !== \"string\") {\n                    throw new ValidationError(\"Service name must be a string.\");\n                }\n\n                if (typeof(shell) !== \"string\") {\n                    throw new ValidationError(\"Shell must be a string.\");\n                }\n\n                log.debug(\"interactiveTerminal\", \"Stack name: \" + stackName);\n                log.debug(\"interactiveTerminal\", \"Service name: \" + serviceName);\n\n                // Get stack\n                const stack = await Stack.getStack(server, stackName);\n                stack.joinContainerTerminal(socket, serviceName, shell);\n\n                callbackResult({\n                    ok: true,\n                }, callback);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // Join Output Terminal\n        agentSocket.on(\"terminalJoin\", async (terminalName : unknown, callback) => {\n            if (typeof(callback) !== \"function\") {\n                log.debug(\"console\", \"Callback is not a function.\");\n                return;\n            }\n\n            try {\n                checkLogin(socket);\n                if (typeof(terminalName) !== \"string\") {\n                    throw new ValidationError(\"Terminal name must be a string.\");\n                }\n\n                let buffer : string = Terminal.getTerminal(terminalName)?.getBuffer() ?? \"\";\n\n                if (!buffer) {\n                    log.debug(\"console\", \"No buffer found.\");\n                }\n\n                callback({\n                    ok: true,\n                    buffer,\n                });\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // Leave Combined Terminal\n        agentSocket.on(\"leaveCombinedTerminal\", async (stackName : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                log.debug(\"leaveCombinedTerminal\", \"Stack name: \" + stackName);\n\n                if (typeof(stackName) !== \"string\") {\n                    throw new ValidationError(\"Stack name must be a string.\");\n                }\n\n                const stack = await Stack.getStack(server, stackName);\n                await stack.leaveCombinedTerminal(socket);\n\n                callbackResult({\n                    ok: true,\n                }, callback);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // Resize Terminal\n        agentSocket.on(\"terminalResize\", async (terminalName: unknown, rows: unknown, cols: unknown) => {\n            log.info(\"terminalResize\", `Terminal: ${terminalName}`);\n            try {\n                checkLogin(socket);\n                if (typeof terminalName !== \"string\") {\n                    throw new Error(\"Terminal name must be a string.\");\n                }\n\n                if (typeof rows !== \"number\") {\n                    throw new Error(\"Command must be a number.\");\n                }\n                if (typeof cols !== \"number\") {\n                    throw new Error(\"Command must be a number.\");\n                }\n\n                let terminal = Terminal.getTerminal(terminalName);\n\n                // log.info(\"terminal\", terminal);\n                if (terminal instanceof Terminal) {\n                    //log.debug(\"terminalInput\", \"Terminal found, writing to terminal.\");\n                    terminal.rows = rows;\n                    terminal.cols = cols;\n                } else {\n                    throw new Error(`${terminalName} Terminal not found.`);\n                }\n            } catch (e) {\n                log.debug(\"terminalResize\",\n                        // Added to prevent the lint error when adding the type\n                        // and ts type checker saying type is unknown.\n                        // @ts-ignore\n                        `Error on ${terminalName}: ${e.message}`\n                );\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "backend/check-version.ts",
    "content": "import { log } from \"./log\";\nimport compareVersions from \"compare-versions\";\nimport packageJSON from \"../package.json\";\nimport { Settings } from \"./settings\";\n\n// How much time in ms to wait between update checks\nconst UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 60 * 48;\nconst CHECK_URL = \"https://dockge.kuma.pet/version\";\n\nclass CheckVersion {\n    version = packageJSON.version;\n    latestVersion? : string;\n    interval? : NodeJS.Timeout;\n\n    async startInterval() {\n        const check = async () => {\n            if (await Settings.get(\"checkUpdate\") === false) {\n                return;\n            }\n\n            log.debug(\"update-checker\", \"Retrieving latest versions\");\n\n            try {\n                const res = await fetch(CHECK_URL);\n                const data = await res.json();\n\n                // For debug\n                if (process.env.TEST_CHECK_VERSION === \"1\") {\n                    data.slow = \"1000.0.0\";\n                }\n\n                const checkBeta = await Settings.get(\"checkBeta\");\n\n                if (checkBeta && data.beta) {\n                    if (compareVersions.compare(data.beta, data.slow, \">\")) {\n                        this.latestVersion = data.beta;\n                        return;\n                    }\n                }\n\n                if (data.slow) {\n                    this.latestVersion = data.slow;\n                }\n\n            } catch (_) {\n                log.info(\"update-checker\", \"Failed to check for new versions\");\n            }\n\n        };\n\n        await check();\n        this.interval = setInterval(check, UPDATE_CHECKER_INTERVAL_MS);\n    }\n}\n\nconst checkVersion = new CheckVersion();\nexport default checkVersion;\n"
  },
  {
    "path": "backend/database.ts",
    "content": "import { log } from \"./log\";\nimport { R } from \"redbean-node\";\nimport { DockgeServer } from \"./dockge-server\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport knex from \"knex\";\n\n// @ts-ignore\nimport Dialect from \"knex/lib/dialects/sqlite3/index.js\";\n\nimport sqlite from \"@louislam/sqlite3\";\nimport { sleep } from \"../common/util-common\";\n\ninterface DBConfig {\n    type?: \"sqlite\" | \"mysql\";\n    hostname?: string;\n    port?: string;\n    database?: string;\n    username?: string;\n    password?: string;\n}\n\nexport class Database {\n    /**\n     * SQLite file path (Default: ./data/dockge.db)\n     * @type {string}\n     */\n    static sqlitePath : string;\n\n    static noReject = true;\n\n    static dbConfig: DBConfig = {};\n\n    static knexMigrationsPath = \"./backend/migrations\";\n\n    private static server : DockgeServer;\n\n    /**\n     * Use for decode the auth object\n     */\n    jwtSecret? : string;\n\n    static async init(server : DockgeServer) {\n        this.server = server;\n\n        log.debug(\"server\", \"Connecting to the database\");\n        await Database.connect();\n        log.info(\"server\", \"Connected to the database\");\n\n        // Patch the database\n        await Database.patch();\n    }\n\n    /**\n     * Read the database config\n     * @throws {Error} If the config is invalid\n     * @typedef {string|undefined} envString\n     * @returns {{type: \"sqlite\"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config\n     */\n    static readDBConfig() : DBConfig {\n        const dbConfigString = fs.readFileSync(path.join(this.server.config.dataDir, \"db-config.json\")).toString(\"utf-8\");\n        const dbConfig = JSON.parse(dbConfigString);\n\n        if (typeof dbConfig !== \"object\") {\n            throw new Error(\"Invalid db-config.json, it must be an object\");\n        }\n\n        if (typeof dbConfig.type !== \"string\") {\n            throw new Error(\"Invalid db-config.json, type must be a string\");\n        }\n        return dbConfig;\n    }\n\n    /**\n     * @typedef {string|undefined} envString\n     * @param dbConfig the database configuration that should be written\n     * @returns {void}\n     */\n    static writeDBConfig(dbConfig : DBConfig) {\n        fs.writeFileSync(path.join(this.server.config.dataDir, \"db-config.json\"), JSON.stringify(dbConfig, null, 4));\n    }\n\n    /**\n     * Connect to the database\n     * @param {boolean} autoloadModels Should models be automatically loaded?\n     * @param {boolean} noLog Should logs not be output?\n     * @returns {Promise<void>}\n     */\n    static async connect(autoloadModels = true) {\n        const acquireConnectionTimeout = 120 * 1000;\n        let dbConfig : DBConfig;\n        try {\n            dbConfig = this.readDBConfig();\n            Database.dbConfig = dbConfig;\n        } catch (err) {\n            if (err instanceof Error) {\n                log.warn(\"db\", err.message);\n            }\n\n            dbConfig = {\n                type: \"sqlite\",\n            };\n            this.writeDBConfig(dbConfig);\n        }\n\n        let config = {};\n\n        log.info(\"db\", `Database Type: ${dbConfig.type}`);\n\n        if (dbConfig.type === \"sqlite\") {\n            this.sqlitePath = path.join(this.server.config.dataDir, \"dockge.db\");\n            Dialect.prototype._driver = () => sqlite;\n\n            config = {\n                client: Dialect,\n                connection: {\n                    filename: Database.sqlitePath,\n                    acquireConnectionTimeout: acquireConnectionTimeout,\n                },\n                useNullAsDefault: true,\n                pool: {\n                    min: 1,\n                    max: 1,\n                    idleTimeoutMillis: 120 * 1000,\n                    propagateCreateError: false,\n                    acquireTimeoutMillis: acquireConnectionTimeout,\n                }\n            };\n        } else {\n            throw new Error(\"Unknown Database type: \" + dbConfig.type);\n        }\n\n        const knexInstance = knex(config);\n\n        // @ts-ignore\n        R.setup(knexInstance);\n\n        if (process.env.SQL_LOG === \"1\") {\n            R.debug(true);\n        }\n\n        // Auto map the model to a bean object\n        R.freeze(true);\n\n        if (autoloadModels) {\n            R.autoloadModels(\"./backend/models\", \"ts\");\n        }\n\n        if (dbConfig.type === \"sqlite\") {\n            await this.initSQLite();\n        }\n    }\n\n    /**\n     @returns {Promise<void>}\n     */\n    static async initSQLite() {\n        await R.exec(\"PRAGMA foreign_keys = ON\");\n        // Change to WAL\n        await R.exec(\"PRAGMA journal_mode = WAL\");\n        await R.exec(\"PRAGMA cache_size = -12000\");\n        await R.exec(\"PRAGMA auto_vacuum = INCREMENTAL\");\n\n        // This ensures that an operating system crash or power failure will not corrupt the database.\n        // FULL synchronous is very safe, but it is also slower.\n        // Read more: https://sqlite.org/pragma.html#pragma_synchronous\n        await R.exec(\"PRAGMA synchronous = NORMAL\");\n\n        log.debug(\"db\", \"SQLite config:\");\n        log.debug(\"db\", await R.getAll(\"PRAGMA journal_mode\"));\n        log.debug(\"db\", await R.getAll(\"PRAGMA cache_size\"));\n        log.debug(\"db\", \"SQLite Version: \" + await R.getCell(\"SELECT sqlite_version()\"));\n    }\n\n    /**\n     * Patch the database\n     * @returns {void}\n     */\n    static async patch() {\n        // Using knex migrations\n        // https://knexjs.org/guide/migrations.html\n        // https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261\n        try {\n            await R.knex.migrate.latest({\n                directory: Database.knexMigrationsPath,\n            });\n        } catch (e) {\n            if (e instanceof Error) {\n                // Allow missing patch files for downgrade or testing pr.\n                if (e.message.includes(\"the following files are missing:\")) {\n                    log.warn(\"db\", e.message);\n                    log.warn(\"db\", \"Database migration failed, you may be downgrading Dockge.\");\n                } else {\n                    log.error(\"db\", \"Database migration failed\");\n                    throw e;\n                }\n            }\n        }\n    }\n\n    /**\n     * Special handle, because tarn.js throw a promise reject that cannot be caught\n     * @returns {Promise<void>}\n     */\n    static async close() {\n        const listener = () => {\n            Database.noReject = false;\n        };\n        process.addListener(\"unhandledRejection\", listener);\n\n        log.info(\"db\", \"Closing the database\");\n\n        // Flush WAL to main database\n        if (Database.dbConfig.type === \"sqlite\") {\n            await R.exec(\"PRAGMA wal_checkpoint(TRUNCATE)\");\n        }\n\n        while (true) {\n            Database.noReject = true;\n            await R.close();\n            await sleep(2000);\n\n            if (Database.noReject) {\n                break;\n            } else {\n                log.info(\"db\", \"Waiting to close the database\");\n            }\n        }\n        log.info(\"db\", \"Database closed\");\n\n        process.removeListener(\"unhandledRejection\", listener);\n    }\n\n    /**\n     * Get the size of the database (SQLite only)\n     * @returns {number} Size of database\n     */\n    static getSize() {\n        if (Database.dbConfig.type === \"sqlite\") {\n            log.debug(\"db\", \"Database.getSize()\");\n            const stats = fs.statSync(Database.sqlitePath);\n            log.debug(\"db\", stats);\n            return stats.size;\n        }\n        return 0;\n    }\n\n    /**\n     * Shrink the database\n     * @returns {Promise<void>}\n     */\n    static async shrink() {\n        if (Database.dbConfig.type === \"sqlite\") {\n            await R.exec(\"VACUUM\");\n        }\n    }\n\n}\n"
  },
  {
    "path": "backend/dockge-server.ts",
    "content": "import \"dotenv/config\";\nimport { MainRouter } from \"./routers/main-router\";\nimport * as fs from \"node:fs\";\nimport { PackageJson } from \"type-fest\";\nimport { Database } from \"./database\";\nimport packageJSON from \"../package.json\";\nimport { log } from \"./log\";\nimport * as socketIO from \"socket.io\";\nimport express, { Express } from \"express\";\nimport { parse } from \"ts-command-line-args\";\nimport https from \"https\";\nimport http from \"http\";\nimport { Router } from \"./router\";\nimport { Socket } from \"socket.io\";\nimport { MainSocketHandler } from \"./socket-handlers/main-socket-handler\";\nimport { SocketHandler } from \"./socket-handler\";\nimport { Settings } from \"./settings\";\nimport checkVersion from \"./check-version\";\nimport dayjs from \"dayjs\";\nimport { R } from \"redbean-node\";\nimport { genSecret, isDev, LooseObject } from \"../common/util-common\";\nimport { generatePasswordHash } from \"./password-hash\";\nimport { Bean } from \"redbean-node/dist/bean\";\nimport { Arguments, Config, DockgeSocket } from \"./util-server\";\nimport { DockerSocketHandler } from \"./agent-socket-handlers/docker-socket-handler\";\nimport expressStaticGzip from \"express-static-gzip\";\nimport path from \"path\";\nimport { TerminalSocketHandler } from \"./agent-socket-handlers/terminal-socket-handler\";\nimport { Stack } from \"./stack\";\nimport { Cron } from \"croner\";\nimport gracefulShutdown from \"http-graceful-shutdown\";\nimport User from \"./models/user\";\nimport childProcessAsync from \"promisify-child-process\";\nimport { AgentManager } from \"./agent-manager\";\nimport { AgentProxySocketHandler } from \"./socket-handlers/agent-proxy-socket-handler\";\nimport { AgentSocketHandler } from \"./agent-socket-handler\";\nimport { AgentSocket } from \"../common/agent-socket\";\nimport { ManageAgentSocketHandler } from \"./socket-handlers/manage-agent-socket-handler\";\nimport { Terminal } from \"./terminal\";\n\nexport class DockgeServer {\n    app : Express;\n    httpServer : http.Server;\n    packageJSON : PackageJson;\n    io : socketIO.Server;\n    config : Config;\n    indexHTML : string = \"\";\n\n    /**\n     * List of express routers\n     */\n    routerList : Router[] = [\n        new MainRouter(),\n    ];\n\n    /**\n     * List of socket handlers (no agent support)\n     */\n    socketHandlerList : SocketHandler[] = [\n        new MainSocketHandler(),\n        new ManageAgentSocketHandler(),\n    ];\n\n    agentProxySocketHandler = new AgentProxySocketHandler();\n\n    /**\n     * List of socket handlers (support agent)\n     */\n    agentSocketHandlerList : AgentSocketHandler[] = [\n        new DockerSocketHandler(),\n        new TerminalSocketHandler(),\n    ];\n\n    /**\n     * Show Setup Page\n     */\n    needSetup = false;\n\n    jwtSecret : string = \"\";\n\n    stacksDir : string = \"\";\n\n    /**\n     *\n     */\n    constructor() {\n        // Catch unexpected errors here\n        let unexpectedErrorHandler = (error : unknown) => {\n            console.trace(error);\n            console.error(\"If you keep encountering errors, please report to https://github.com/louislam/dockge\");\n        };\n        process.addListener(\"unhandledRejection\", unexpectedErrorHandler);\n        process.addListener(\"uncaughtException\", unexpectedErrorHandler);\n\n        if (!process.env.NODE_ENV) {\n            process.env.NODE_ENV = \"production\";\n        }\n\n        // Log NODE ENV\n        log.info(\"server\", \"NODE_ENV: \" + process.env.NODE_ENV);\n\n        // Default stacks directory\n        let defaultStacksDir;\n        if (process.platform === \"win32\") {\n            defaultStacksDir = \"./stacks\";\n        } else {\n            defaultStacksDir = \"/opt/stacks\";\n        }\n\n        // Define all possible arguments\n        let args = parse<Arguments>({\n            sslKey: {\n                type: String,\n                optional: true,\n            },\n            sslCert: {\n                type: String,\n                optional: true,\n            },\n            sslKeyPassphrase: {\n                type: String,\n                optional: true,\n            },\n            port: {\n                type: Number,\n                optional: true,\n            },\n            hostname: {\n                type: String,\n                optional: true,\n            },\n            dataDir: {\n                type: String,\n                optional: true,\n            },\n            stacksDir: {\n                type: String,\n                optional: true,\n            },\n            enableConsole: {\n                type: Boolean,\n                optional: true,\n                defaultValue: false,\n            }\n        });\n\n        this.config = args as Config;\n\n        // Load from environment variables or default values if args are not set\n        this.config.sslKey = args.sslKey || process.env.DOCKGE_SSL_KEY || undefined;\n        this.config.sslCert = args.sslCert || process.env.DOCKGE_SSL_CERT || undefined;\n        this.config.sslKeyPassphrase = args.sslKeyPassphrase || process.env.DOCKGE_SSL_KEY_PASSPHRASE || undefined;\n        this.config.port = args.port || Number(process.env.DOCKGE_PORT) || 5001;\n        this.config.hostname = args.hostname || process.env.DOCKGE_HOSTNAME || undefined;\n        this.config.dataDir = args.dataDir || process.env.DOCKGE_DATA_DIR || \"./data/\";\n        this.config.stacksDir = args.stacksDir || process.env.DOCKGE_STACKS_DIR || defaultStacksDir;\n        this.config.enableConsole = args.enableConsole || process.env.DOCKGE_ENABLE_CONSOLE === \"true\" || false;\n        this.stacksDir = this.config.stacksDir;\n\n        log.debug(\"server\", this.config);\n\n        this.packageJSON = packageJSON as PackageJson;\n\n        try {\n            this.indexHTML = fs.readFileSync(\"./frontend-dist/index.html\").toString();\n        } catch (e) {\n            // \"dist/index.html\" is not necessary for development\n            if (process.env.NODE_ENV !== \"development\") {\n                log.error(\"server\", \"Error: Cannot find 'frontend-dist/index.html', did you install correctly?\");\n                process.exit(1);\n            }\n        }\n\n        // Create express\n        this.app = express();\n\n        // Create HTTP server\n        if (this.config.sslKey && this.config.sslCert) {\n            log.info(\"server\", \"Server Type: HTTPS\");\n            this.httpServer = https.createServer({\n                key: fs.readFileSync(this.config.sslKey),\n                cert: fs.readFileSync(this.config.sslCert),\n                passphrase: this.config.sslKeyPassphrase,\n            }, this.app);\n        } else {\n            log.info(\"server\", \"Server Type: HTTP\");\n            this.httpServer = http.createServer(this.app);\n        }\n\n        // Binding Routers\n        for (const router of this.routerList) {\n            this.app.use(router.create(this.app, this));\n        }\n\n        // Static files\n        this.app.use(\"/\", expressStaticGzip(\"frontend-dist\", {\n            enableBrotli: true,\n        }));\n\n        // Universal Route Handler, must be at the end of all express routes.\n        this.app.get(\"*\", async (_request, response) => {\n            response.send(this.indexHTML);\n        });\n\n        // Allow all CORS origins in development\n        let cors = undefined;\n        if (isDev) {\n            cors = {\n                origin: \"*\",\n            };\n        }\n\n        // Create Socket.io\n        this.io = new socketIO.Server(this.httpServer, {\n            cors,\n            allowRequest: (req, callback) => {\n                let isOriginValid = true;\n                const bypass = isDev || process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === \"bypass\";\n\n                if (!bypass) {\n                    let host = req.headers.host;\n\n                    // If this is set, it means the request is from the browser\n                    let origin = req.headers.origin;\n\n                    // If this is from the browser, check if the origin is allowed\n                    if (origin) {\n                        try {\n                            let originURL = new URL(origin);\n\n                            if (host !== originURL.host) {\n                                isOriginValid = false;\n                                log.error(\"auth\", `Origin (${origin}) does not match host (${host}), IP: ${req.socket.remoteAddress}`);\n                            }\n                        } catch (e) {\n                            // Invalid origin url, probably not from browser\n                            isOriginValid = false;\n                            log.error(\"auth\", `Invalid origin url (${origin}), IP: ${req.socket.remoteAddress}`);\n                        }\n                    } else {\n                        log.info(\"auth\", `Origin is not set, IP: ${req.socket.remoteAddress}`);\n                    }\n                } else {\n                    log.debug(\"auth\", \"Origin check is bypassed\");\n                }\n\n                callback(null, isOriginValid);\n            }\n        });\n\n        this.io.on(\"connection\", async (socket: Socket) => {\n            let dockgeSocket = socket as DockgeSocket;\n            dockgeSocket.instanceManager = new AgentManager(dockgeSocket);\n            dockgeSocket.emitAgent = (event : string, ...args : unknown[]) => {\n                let obj = args[0];\n                if (typeof(obj) === \"object\") {\n                    let obj2 = obj as LooseObject;\n                    obj2.endpoint = dockgeSocket.endpoint;\n                }\n                dockgeSocket.emit(\"agent\", event, ...args);\n            };\n\n            if (typeof(socket.request.headers.endpoint) === \"string\") {\n                dockgeSocket.endpoint = socket.request.headers.endpoint;\n            } else {\n                dockgeSocket.endpoint = \"\";\n            }\n\n            if (dockgeSocket.endpoint) {\n                log.info(\"server\", \"Socket connected (agent), as endpoint \" + dockgeSocket.endpoint);\n            } else {\n                log.info(\"server\", \"Socket connected (direct)\");\n            }\n\n            this.sendInfo(dockgeSocket, true);\n\n            if (this.needSetup) {\n                log.info(\"server\", \"Redirect to setup page\");\n                dockgeSocket.emit(\"setup\");\n            }\n\n            // Create socket handlers (original, no agent support)\n            for (const socketHandler of this.socketHandlerList) {\n                socketHandler.create(dockgeSocket, this);\n            }\n\n            // Create Agent Socket\n            let agentSocket = new AgentSocket();\n\n            // Create agent socket handlers\n            for (const socketHandler of this.agentSocketHandlerList) {\n                socketHandler.create(dockgeSocket, this, agentSocket);\n            }\n\n            // Create agent proxy socket handlers\n            this.agentProxySocketHandler.create2(dockgeSocket, this, agentSocket);\n\n            // ***************************\n            // Better do anything after added all socket handlers here\n            // ***************************\n\n            log.debug(\"auth\", \"check auto login\");\n            if (await Settings.get(\"disableAuth\")) {\n                log.info(\"auth\", \"Disabled Auth: auto login to admin\");\n                this.afterLogin(dockgeSocket, await R.findOne(\"user\") as User);\n                dockgeSocket.emit(\"autoLogin\");\n            } else {\n                log.debug(\"auth\", \"need auth\");\n            }\n\n            // Socket disconnect\n            dockgeSocket.on(\"disconnect\", () => {\n                log.info(\"server\", \"Socket disconnected!\");\n                dockgeSocket.instanceManager.disconnectAll();\n            });\n\n        });\n\n        this.io.on(\"disconnect\", () => {\n\n        });\n\n        if (isDev) {\n            setInterval(() => {\n                log.debug(\"terminal\", \"Terminal count: \" + Terminal.getTerminalCount());\n            }, 5000);\n        }\n    }\n\n    async afterLogin(socket : DockgeSocket, user : User) {\n        socket.userID = user.id;\n        socket.join(user.id.toString());\n\n        this.sendInfo(socket);\n\n        try {\n            this.sendStackList();\n        } catch (e) {\n            log.error(\"server\", e);\n        }\n\n        socket.instanceManager.sendAgentList();\n\n        // Also connect to other dockge instances\n        socket.instanceManager.connectAll();\n    }\n\n    /**\n     *\n     */\n    async serve() {\n        // Create all the necessary directories\n        this.initDataDir();\n\n        // Connect to database\n        try {\n            await Database.init(this);\n        } catch (e) {\n            if (e instanceof Error) {\n                log.error(\"server\", \"Failed to prepare your database: \" + e.message);\n            }\n            process.exit(1);\n        }\n\n        // First time setup if needed\n        let jwtSecretBean = await R.findOne(\"setting\", \" `key` = ? \", [\n            \"jwtSecret\",\n        ]);\n\n        if (! jwtSecretBean) {\n            log.info(\"server\", \"JWT secret is not found, generate one.\");\n            jwtSecretBean = await this.initJWTSecret();\n            log.info(\"server\", \"Stored JWT secret into database\");\n        } else {\n            log.debug(\"server\", \"Load JWT secret from database.\");\n        }\n\n        this.jwtSecret = jwtSecretBean.value;\n\n        const userCount = (await R.knex(\"user\").count(\"id as count\").first()).count;\n\n        log.debug(\"server\", \"User count: \" + userCount);\n\n        // If there is no record in user table, it is a new Dockge instance, need to setup\n        if (userCount == 0) {\n            log.info(\"server\", \"No user, need setup\");\n            this.needSetup = true;\n        }\n\n        // Listen\n        this.httpServer.listen(this.config.port, this.config.hostname, () => {\n            if (this.config.hostname) {\n                log.info( \"server\", `Listening on ${this.config.hostname}:${this.config.port}`);\n            } else {\n                log.info(\"server\", `Listening on ${this.config.port}`);\n            }\n\n            // Run every 10 seconds\n            Cron(\"*/10 * * * * *\", {\n                protect: true,  // Enabled over-run protection.\n            }, () => {\n                //log.debug(\"server\", \"Cron job running\");\n                this.sendStackList(true);\n            });\n\n            checkVersion.startInterval();\n        });\n\n        gracefulShutdown(this.httpServer, {\n            signals: \"SIGINT SIGTERM\",\n            timeout: 30000,                   // timeout: 30 secs\n            development: false,               // not in dev mode\n            forceExit: true,                  // triggers process.exit() at the end of shutdown process\n            onShutdown: this.shutdownFunction,     // shutdown function (async) - e.g. for cleanup DB, ...\n            finally: this.finalFunction,            // finally function (sync) - e.g. for logging\n        });\n\n    }\n\n    /**\n     * Emits the version information to the client.\n     * @param socket Socket.io socket instance\n     * @param hideVersion Should we hide the version information in the response?\n     * @returns\n     */\n    async sendInfo(socket : Socket, hideVersion = false) {\n        let versionProperty;\n        let latestVersionProperty;\n        let isContainer;\n\n        if (!hideVersion) {\n            versionProperty = packageJSON.version;\n            latestVersionProperty = checkVersion.latestVersion;\n            isContainer = (process.env.DOCKGE_IS_CONTAINER === \"1\");\n        }\n\n        socket.emit(\"info\", {\n            version: versionProperty,\n            latestVersion: latestVersionProperty,\n            isContainer,\n            primaryHostname: await Settings.get(\"primaryHostname\"),\n            //serverTimezone: await this.getTimezone(),\n            //serverTimezoneOffset: this.getTimezoneOffset(),\n        });\n    }\n\n    /**\n     * Get the IP of the client connected to the socket\n     * @param {Socket} socket Socket to query\n     * @returns IP of client\n     */\n    async getClientIP(socket : Socket) : Promise<string> {\n        let clientIP = socket.client.conn.remoteAddress;\n\n        if (clientIP === undefined) {\n            clientIP = \"\";\n        }\n\n        if (await Settings.get(\"trustProxy\")) {\n            const forwardedFor = socket.client.conn.request.headers[\"x-forwarded-for\"];\n\n            if (typeof forwardedFor === \"string\") {\n                return forwardedFor.split(\",\")[0].trim();\n            } else if (typeof socket.client.conn.request.headers[\"x-real-ip\"] === \"string\") {\n                return socket.client.conn.request.headers[\"x-real-ip\"];\n            }\n        }\n        return clientIP.replace(/^::ffff:/, \"\");\n    }\n\n    /**\n     * Attempt to get the current server timezone\n     * If this fails, fall back to environment variables and then make a\n     * guess.\n     * @returns {Promise<string>} Current timezone\n     */\n    async getTimezone() {\n        // From process.env.TZ\n        try {\n            if (process.env.TZ) {\n                this.checkTimezone(process.env.TZ);\n                return process.env.TZ;\n            }\n        } catch (e) {\n            if (e instanceof Error) {\n                log.warn(\"timezone\", e.message + \" in process.env.TZ\");\n            }\n        }\n\n        const timezone = await Settings.get(\"serverTimezone\");\n\n        // From Settings\n        try {\n            log.debug(\"timezone\", \"Using timezone from settings: \" + timezone);\n            if (timezone) {\n                this.checkTimezone(timezone);\n                return timezone;\n            }\n        } catch (e) {\n            if (e instanceof Error) {\n                log.warn(\"timezone\", e.message + \" in settings\");\n            }\n        }\n\n        // Guess\n        try {\n            const guess = dayjs.tz.guess();\n            log.debug(\"timezone\", \"Guessing timezone: \" + guess);\n            if (guess) {\n                this.checkTimezone(guess);\n                return guess;\n            } else {\n                return \"UTC\";\n            }\n        } catch (e) {\n            // Guess failed, fall back to UTC\n            log.debug(\"timezone\", \"Guessed an invalid timezone. Use UTC as fallback\");\n            return \"UTC\";\n        }\n    }\n\n    /**\n     * Get the current offset\n     * @returns {string} Time offset\n     */\n    getTimezoneOffset() {\n        return dayjs().format(\"Z\");\n    }\n\n    /**\n     * Throw an error if the timezone is invalid\n     * @param {string} timezone Timezone to test\n     * @returns {void}\n     * @throws The timezone is invalid\n     */\n    checkTimezone(timezone : string) {\n        try {\n            dayjs.utc(\"2013-11-18 11:55\").tz(timezone).format();\n        } catch (e) {\n            throw new Error(\"Invalid timezone:\" + timezone);\n        }\n    }\n\n    /**\n     * Initialize the data directory\n     */\n    initDataDir() {\n        if (! fs.existsSync(this.config.dataDir)) {\n            fs.mkdirSync(this.config.dataDir, { recursive: true });\n        }\n\n        // Check if a directory\n        if (!fs.lstatSync(this.config.dataDir).isDirectory()) {\n            throw new Error(`Fatal error: ${this.config.dataDir} is not a directory`);\n        }\n\n        // Create data/stacks directory\n        if (!fs.existsSync(this.stacksDir)) {\n            fs.mkdirSync(this.stacksDir, { recursive: true });\n        }\n\n        log.info(\"server\", `Data Dir: ${this.config.dataDir}`);\n    }\n\n    /**\n     * Init or reset JWT secret\n     * @returns  JWT secret\n     */\n    async initJWTSecret() : Promise<Bean> {\n        let jwtSecretBean = await R.findOne(\"setting\", \" `key` = ? \", [\n            \"jwtSecret\",\n        ]);\n\n        if (!jwtSecretBean) {\n            jwtSecretBean = R.dispense(\"setting\");\n            jwtSecretBean.key = \"jwtSecret\";\n        }\n\n        jwtSecretBean.value = generatePasswordHash(genSecret());\n        await R.store(jwtSecretBean);\n        return jwtSecretBean;\n    }\n\n    /**\n     * Send stack list to all connected sockets\n     * @param useCache\n     */\n    async sendStackList(useCache = false) {\n        let socketList = this.io.sockets.sockets.values();\n\n        let stackList;\n\n        for (let socket of socketList) {\n            let dockgeSocket = socket as DockgeSocket;\n\n            // Check if the room is a number (user id)\n            if (dockgeSocket.userID) {\n\n                // Get the list only if there is a logged in user\n                if (!stackList) {\n                    stackList = await Stack.getStackList(this, useCache);\n                }\n\n                let map : Map<string, object> = new Map();\n\n                for (let [ stackName, stack ] of stackList) {\n                    map.set(stackName, stack.toSimpleJSON(dockgeSocket.endpoint));\n                }\n\n                log.debug(\"server\", \"Send stack list to user: \" + dockgeSocket.id + \" (\" + dockgeSocket.endpoint + \")\");\n                dockgeSocket.emitAgent(\"stackList\", {\n                    ok: true,\n                    stackList: Object.fromEntries(map),\n                });\n            }\n        }\n    }\n\n    async getDockerNetworkList() : Promise<string[]> {\n        let res = await childProcessAsync.spawn(\"docker\", [ \"network\", \"ls\", \"--format\", \"{{.Name}}\" ], {\n            encoding: \"utf-8\",\n        });\n\n        if (!res.stdout) {\n            return [];\n        }\n\n        let list = res.stdout.toString().split(\"\\n\");\n\n        // Remove empty string item\n        list = list.filter((item) => {\n            return item !== \"\";\n        }).sort((a, b) => {\n            return a.localeCompare(b);\n        });\n\n        return list;\n    }\n\n    get stackDirFullPath() {\n        return path.resolve(this.stacksDir);\n    }\n\n    /**\n     * Shutdown the application\n     * Stops all monitors and closes the database connection.\n     * @param signal The signal that triggered this function to be called.\n     */\n    async shutdownFunction(signal : string | undefined) {\n        log.info(\"server\", \"Shutdown requested\");\n        log.info(\"server\", \"Called signal: \" + signal);\n\n        // TODO: Close all terminals?\n\n        await Database.close();\n        Settings.stopCacheCleaner();\n    }\n\n    /**\n     * Final function called before application exits\n     */\n    finalFunction() {\n        log.info(\"server\", \"Graceful shutdown successful!\");\n    }\n\n    /**\n     * Force connected sockets of a user to refresh and disconnect.\n     * Used for resetting password.\n     * @param {string} userID\n     * @param {string?} currentSocketID\n     */\n    disconnectAllSocketClients(userID: number | undefined, currentSocketID? : string) {\n        for (const rawSocket of this.io.sockets.sockets.values()) {\n            let socket = rawSocket as DockgeSocket;\n            if ((!userID || socket.userID === userID) && socket.id !== currentSocketID) {\n                try {\n                    socket.emit(\"refresh\");\n                    socket.disconnect();\n                } catch (e) {\n\n                }\n            }\n        }\n    }\n\n    isSSL() {\n        return this.config.sslKey && this.config.sslCert;\n    }\n\n    getLocalWebSocketURL() {\n        const protocol = this.isSSL() ? \"wss\" : \"ws\";\n        const host = this.config.hostname || \"localhost\";\n        return `${protocol}://${host}:${this.config.port}`;\n    }\n\n}\n"
  },
  {
    "path": "backend/index.ts",
    "content": "import { DockgeServer } from \"./dockge-server\";\nimport { log } from \"./log\";\n\nlog.info(\"server\", \"Welcome to dockge!\");\nconst server = new DockgeServer();\nawait server.serve();\n"
  },
  {
    "path": "backend/log.ts",
    "content": "// Console colors\n// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color\nimport { intHash, isDev } from \"../common/util-common\";\nimport dayjs from \"dayjs\";\n\nexport const CONSOLE_STYLE_Reset = \"\\x1b[0m\";\nexport const CONSOLE_STYLE_Bright = \"\\x1b[1m\";\nexport const CONSOLE_STYLE_Dim = \"\\x1b[2m\";\nexport const CONSOLE_STYLE_Underscore = \"\\x1b[4m\";\nexport const CONSOLE_STYLE_Blink = \"\\x1b[5m\";\nexport const CONSOLE_STYLE_Reverse = \"\\x1b[7m\";\nexport const CONSOLE_STYLE_Hidden = \"\\x1b[8m\";\n\nexport const CONSOLE_STYLE_FgBlack = \"\\x1b[30m\";\nexport const CONSOLE_STYLE_FgRed = \"\\x1b[31m\";\nexport const CONSOLE_STYLE_FgGreen = \"\\x1b[32m\";\nexport const CONSOLE_STYLE_FgYellow = \"\\x1b[33m\";\nexport const CONSOLE_STYLE_FgBlue = \"\\x1b[34m\";\nexport const CONSOLE_STYLE_FgMagenta = \"\\x1b[35m\";\nexport const CONSOLE_STYLE_FgCyan = \"\\x1b[36m\";\nexport const CONSOLE_STYLE_FgWhite = \"\\x1b[37m\";\nexport const CONSOLE_STYLE_FgGray = \"\\x1b[90m\";\nexport const CONSOLE_STYLE_FgOrange = \"\\x1b[38;5;208m\";\nexport const CONSOLE_STYLE_FgLightGreen = \"\\x1b[38;5;119m\";\nexport const CONSOLE_STYLE_FgLightBlue = \"\\x1b[38;5;117m\";\nexport const CONSOLE_STYLE_FgViolet = \"\\x1b[38;5;141m\";\nexport const CONSOLE_STYLE_FgBrown = \"\\x1b[38;5;130m\";\nexport const CONSOLE_STYLE_FgPink = \"\\x1b[38;5;219m\";\n\nexport const CONSOLE_STYLE_BgBlack = \"\\x1b[40m\";\nexport const CONSOLE_STYLE_BgRed = \"\\x1b[41m\";\nexport const CONSOLE_STYLE_BgGreen = \"\\x1b[42m\";\nexport const CONSOLE_STYLE_BgYellow = \"\\x1b[43m\";\nexport const CONSOLE_STYLE_BgBlue = \"\\x1b[44m\";\nexport const CONSOLE_STYLE_BgMagenta = \"\\x1b[45m\";\nexport const CONSOLE_STYLE_BgCyan = \"\\x1b[46m\";\nexport const CONSOLE_STYLE_BgWhite = \"\\x1b[47m\";\nexport const CONSOLE_STYLE_BgGray = \"\\x1b[100m\";\n\nconst consoleModuleColors = [\n    CONSOLE_STYLE_FgCyan,\n    CONSOLE_STYLE_FgGreen,\n    CONSOLE_STYLE_FgLightGreen,\n    CONSOLE_STYLE_FgBlue,\n    CONSOLE_STYLE_FgLightBlue,\n    CONSOLE_STYLE_FgMagenta,\n    CONSOLE_STYLE_FgOrange,\n    CONSOLE_STYLE_FgViolet,\n    CONSOLE_STYLE_FgBrown,\n    CONSOLE_STYLE_FgPink,\n];\n\nconst consoleLevelColors : Record<string, string> = {\n    \"INFO\": CONSOLE_STYLE_FgCyan,\n    \"WARN\": CONSOLE_STYLE_FgYellow,\n    \"ERROR\": CONSOLE_STYLE_FgRed,\n    \"DEBUG\": CONSOLE_STYLE_FgGray,\n};\n\nclass Logger {\n\n    /**\n     * DOCKGE_HIDE_LOG=debug_monitor,info_monitor\n     *\n     * Example:\n     *  [\n     *     \"debug_monitor\",          // Hide all logs that level is debug and the module is monitor\n     *     \"info_monitor\",\n     *  ]\n     */\n    hideLog : Record<string, string[]> = {\n        info: [],\n        warn: [],\n        error: [],\n        debug: [],\n    };\n\n    /**\n     *\n     */\n    constructor() {\n        if (typeof process !== \"undefined\" && process.env.DOCKGE_HIDE_LOG) {\n            const list = process.env.DOCKGE_HIDE_LOG.split(\",\").map(v => v.toLowerCase());\n\n            for (const pair of list) {\n                // split first \"_\" only\n                const values = pair.split(/_(.*)/s);\n\n                if (values.length >= 2) {\n                    this.hideLog[values[0]].push(values[1]);\n                }\n            }\n\n            this.debug(\"server\", \"DOCKGE_HIDE_LOG is set\");\n            this.debug(\"server\", this.hideLog);\n        }\n    }\n\n    /**\n     * Write a message to the log\n     * @param module The module the log comes from\n     * @param msg Message to write\n     * @param level Log level. One of INFO, WARN, ERROR, DEBUG or can be customized.\n     */\n    log(module: string, msg: unknown, level: string) {\n        if (level === \"DEBUG\" && !isDev) {\n            return;\n        }\n\n        if (this.hideLog[level] && this.hideLog[level].includes(module.toLowerCase())) {\n            return;\n        }\n\n        module = module.toUpperCase();\n        level = level.toUpperCase();\n\n        let now;\n        if (dayjs.tz) {\n            now = dayjs.tz(new Date()).format();\n        } else {\n            now = dayjs().format();\n        }\n\n        const levelColor = consoleLevelColors[level];\n        const moduleColor = consoleModuleColors[intHash(module, consoleModuleColors.length)];\n\n        let timePart = CONSOLE_STYLE_FgCyan + now + CONSOLE_STYLE_Reset;\n        const modulePart = \"[\" + moduleColor + module + CONSOLE_STYLE_Reset + \"]\";\n        const levelPart = levelColor + `${level}:` + CONSOLE_STYLE_Reset;\n\n        if (level === \"INFO\") {\n            console.info(timePart, modulePart, levelPart, msg);\n        } else if (level === \"WARN\") {\n            console.warn(timePart, modulePart, levelPart, msg);\n        } else if (level === \"ERROR\") {\n            let msgPart : unknown;\n            if (typeof msg === \"string\") {\n                msgPart = CONSOLE_STYLE_FgRed + msg + CONSOLE_STYLE_Reset;\n            } else {\n                msgPart = msg;\n            }\n            console.error(timePart, modulePart, levelPart, msgPart);\n        } else if (level === \"DEBUG\") {\n            if (isDev) {\n                timePart = CONSOLE_STYLE_FgGray + now + CONSOLE_STYLE_Reset;\n                let msgPart : unknown;\n                if (typeof msg === \"string\") {\n                    msgPart = CONSOLE_STYLE_FgGray + msg + CONSOLE_STYLE_Reset;\n                } else {\n                    msgPart = msg;\n                }\n                console.debug(timePart, modulePart, levelPart, msgPart);\n            }\n        } else {\n            console.log(timePart, modulePart, msg);\n        }\n    }\n\n    /**\n     * Log an INFO message\n     * @param module Module log comes from\n     * @param msg Message to write\n     */\n    info(module: string, msg: unknown) {\n        this.log(module, msg, \"info\");\n    }\n\n    /**\n     * Log a WARN message\n     * @param module Module log comes from\n     * @param msg Message to write\n     */\n    warn(module: string, msg: unknown) {\n        this.log(module, msg, \"warn\");\n    }\n\n    /**\n     * Log an ERROR message\n     * @param module Module log comes from\n     * @param msg Message to write\n     */\n    error(module: string, msg: unknown) {\n        this.log(module, msg, \"error\");\n    }\n\n    /**\n     * Log a DEBUG message\n     * @param module Module log comes from\n     * @param msg Message to write\n     */\n    debug(module: string, msg: unknown) {\n        this.log(module, msg, \"debug\");\n    }\n\n    /**\n     * Log an exception as an ERROR\n     * @param module Module log comes from\n     * @param exception The exception to include\n     * @param msg The message to write\n     */\n    exception(module: string, exception: unknown, msg: unknown) {\n        let finalMessage = exception;\n\n        if (msg) {\n            finalMessage = `${msg}: ${exception}`;\n        }\n\n        this.log(module, finalMessage, \"error\");\n    }\n}\n\nexport const log = new Logger();\n"
  },
  {
    "path": "backend/migrations/2023-10-20-0829-setting-table.ts",
    "content": "import { Knex } from \"knex\";\n\nexport async function up(knex: Knex): Promise<void> {\n    return knex.schema.createTable(\"setting\", (table) => {\n        table.increments(\"id\");\n        table.string(\"key\", 200).notNullable().unique().collate(\"utf8_general_ci\");\n        table.text(\"value\");\n        table.string(\"type\", 20);\n    });\n}\n\nexport async function down(knex: Knex): Promise<void> {\n    return knex.schema.dropTable(\"setting\");\n}\n"
  },
  {
    "path": "backend/migrations/2023-10-20-0829-user-table.ts",
    "content": "import { Knex } from \"knex\";\n\nexport async function up(knex: Knex): Promise<void> {\n    // Create the user table\n    return knex.schema.createTable(\"user\", (table) => {\n        table.increments(\"id\");\n        table.string(\"username\", 255).notNullable().unique().collate(\"utf8_general_ci\");\n        table.string(\"password\", 255);\n        table.boolean(\"active\").notNullable().defaultTo(true);\n        table.string(\"timezone\", 150);\n        table.string(\"twofa_secret\", 64);\n        table.boolean(\"twofa_status\").notNullable().defaultTo(false);\n        table.string(\"twofa_last_token\", 6);\n    });\n}\n\nexport async function down(knex: Knex): Promise<void> {\n    return knex.schema.dropTable(\"user\");\n}\n"
  },
  {
    "path": "backend/migrations/2023-12-20-2117-agent-table.ts",
    "content": "import { Knex } from \"knex\";\n\nexport async function up(knex: Knex): Promise<void> {\n    // Create the user table\n    return knex.schema.createTable(\"agent\", (table) => {\n        table.increments(\"id\");\n        table.string(\"url\", 255).notNullable().unique();\n        table.string(\"username\", 255).notNullable();\n        table.string(\"password\", 255).notNullable();\n        table.boolean(\"active\").notNullable().defaultTo(true);\n    });\n}\n\nexport async function down(knex: Knex): Promise<void> {\n    return knex.schema.dropTable(\"agent\");\n}\n"
  },
  {
    "path": "backend/models/agent.ts",
    "content": "import { BeanModel } from \"redbean-node/dist/bean-model\";\nimport { R } from \"redbean-node\";\nimport { LooseObject } from \"../../common/util-common\";\n\nexport class Agent extends BeanModel {\n\n    static async getAgentList() : Promise<Record<string, Agent>> {\n        let list = await R.findAll(\"agent\") as Agent[];\n        let result : Record<string, Agent> = {};\n        for (let agent of list) {\n            result[agent.endpoint] = agent;\n        }\n        return result;\n    }\n\n    get endpoint() : string {\n        let obj = new URL(this.url);\n        return obj.host;\n    }\n\n    toJSON() : LooseObject {\n        return {\n            url: this.url,\n            username: this.username,\n            endpoint: this.endpoint,\n        };\n    }\n\n}\n\nexport default Agent;\n"
  },
  {
    "path": "backend/models/user.ts",
    "content": "import jwt from \"jsonwebtoken\";\nimport { R } from \"redbean-node\";\nimport { BeanModel } from \"redbean-node/dist/bean-model\";\nimport { generatePasswordHash, shake256, SHAKE256_LENGTH } from \"../password-hash\";\n\nexport class User extends BeanModel {\n    /**\n     * Reset user password\n     * Fix #1510, as in the context reset-password.js, there is no auto model mapping. Call this static function instead.\n     * @param {number} userID ID of user to update\n     * @param {string} newPassword Users new password\n     * @returns {Promise<void>}\n     */\n    static async resetPassword(userID : number, newPassword : string) {\n        await R.exec(\"UPDATE `user` SET password = ? WHERE id = ? \", [\n            generatePasswordHash(newPassword),\n            userID\n        ]);\n    }\n\n    /**\n     * Reset this users password\n     * @param {string} newPassword\n     * @returns {Promise<void>}\n     */\n    async resetPassword(newPassword : string) {\n        await User.resetPassword(this.id, newPassword);\n        this.password = newPassword;\n    }\n\n    /**\n     * Create a new JWT for a user\n     * @param {User} user The User to create a JsonWebToken for\n     * @param {string} jwtSecret The key used to sign the JsonWebToken\n     * @returns {string} the JsonWebToken as a string\n     */\n    static createJWT(user : User, jwtSecret : string) {\n        return jwt.sign({\n            username: user.username,\n            h: shake256(user.password, SHAKE256_LENGTH),\n        }, jwtSecret);\n    }\n\n}\n\nexport default User;\n"
  },
  {
    "path": "backend/password-hash.ts",
    "content": "import bcrypt from \"bcryptjs\";\nimport crypto from \"crypto\";\nconst saltRounds = 10;\n\n/**\n * Hash a password\n * @param {string} password Password to hash\n * @returns {string} Hash\n */\nexport function generatePasswordHash(password : string) {\n    return bcrypt.hashSync(password, saltRounds);\n}\n\n/**\n * Verify a password against a hash\n * @param {string} password Password to verify\n * @param {string} hash Hash to verify against\n * @returns {boolean} Does the password match the hash?\n */\nexport function verifyPassword(password : string, hash : string) {\n    return bcrypt.compareSync(password, hash);\n}\n\n/**\n * Does the hash need to be rehashed?\n * @param {string} hash Hash to check\n * @returns {boolean} Needs to be rehashed?\n */\nexport function needRehashPassword(hash : string) : boolean {\n    return false;\n}\n\nexport const SHAKE256_LENGTH = 16;\n\n/**\n * @param {string} data The data to be hashed\n * @param {number} len Output length of the hash\n * @returns {string} The hashed data in hex format\n */\nexport function shake256(data : string, len : number) {\n    if (!data) {\n        return \"\";\n    }\n    return crypto.createHash(\"shake256\", { outputLength: len })\n        .update(data)\n        .digest(\"hex\");\n}\n"
  },
  {
    "path": "backend/rate-limiter.ts",
    "content": "// \"limit\" is bugged in Typescript, use \"limiter-es6-compat\" instead\n// See https://github.com/jhurliman/node-rate-limiter/issues/80\nimport { RateLimiter, RateLimiterOpts } from \"limiter-es6-compat\";\nimport { log } from \"./log\";\n\nexport interface KumaRateLimiterOpts extends RateLimiterOpts {\n    errorMessage : string;\n}\n\nexport type KumaRateLimiterCallback = (err : object) => void;\n\nclass KumaRateLimiter {\n\n    errorMessage : string;\n    rateLimiter : RateLimiter;\n\n    /**\n     * @param {object} config Rate limiter configuration object\n     */\n    constructor(config : KumaRateLimiterOpts) {\n        this.errorMessage = config.errorMessage;\n        this.rateLimiter = new RateLimiter(config);\n    }\n\n    /**\n     * Callback for pass\n     * @callback passCB\n     * @param {object} err Too many requests\n     */\n\n    /**\n     * Should the request be passed through\n     * @param callback Callback function to call with decision\n     * @param {number} num Number of tokens to remove\n     * @returns {Promise<boolean>} Should the request be allowed?\n     */\n    async pass(callback : KumaRateLimiterCallback, num = 1) {\n        const remainingRequests = await this.removeTokens(num);\n        log.info(\"rate-limit\", \"remaining requests: \" + remainingRequests);\n        if (remainingRequests < 0) {\n            if (callback) {\n                callback({\n                    ok: false,\n                    msg: this.errorMessage,\n                });\n            }\n            return false;\n        }\n        return true;\n    }\n\n    /**\n     * Remove a given number of tokens\n     * @param {number} num Number of tokens to remove\n     * @returns {Promise<number>} Number of remaining tokens\n     */\n    async removeTokens(num = 1) {\n        return await this.rateLimiter.removeTokens(num);\n    }\n}\n\nexport const loginRateLimiter = new KumaRateLimiter({\n    tokensPerInterval: 20,\n    interval: \"minute\",\n    fireImmediately: true,\n    errorMessage: \"Too frequently, try again later.\"\n});\n\nexport const apiRateLimiter = new KumaRateLimiter({\n    tokensPerInterval: 60,\n    interval: \"minute\",\n    fireImmediately: true,\n    errorMessage: \"Too frequently, try again later.\"\n});\n\nexport const twoFaRateLimiter = new KumaRateLimiter({\n    tokensPerInterval: 30,\n    interval: \"minute\",\n    fireImmediately: true,\n    errorMessage: \"Too frequently, try again later.\"\n});\n"
  },
  {
    "path": "backend/router.ts",
    "content": "import { DockgeServer } from \"./dockge-server\";\nimport { Express, Router as ExpressRouter } from \"express\";\n\nexport abstract class Router {\n    abstract create(app : Express, server : DockgeServer): ExpressRouter;\n}\n"
  },
  {
    "path": "backend/routers/main-router.ts",
    "content": "import { DockgeServer } from \"../dockge-server\";\nimport { Router } from \"../router\";\nimport express, { Express, Router as ExpressRouter } from \"express\";\n\nexport class MainRouter extends Router {\n    create(app: Express, server: DockgeServer): ExpressRouter {\n        const router = express.Router();\n\n        router.get(\"/\", (req, res) => {\n            res.send(server.indexHTML);\n        });\n\n        // Robots.txt\n        router.get(\"/robots.txt\", async (_request, response) => {\n            let txt = \"User-agent: *\\nDisallow: /\";\n            response.setHeader(\"Content-Type\", \"text/plain\");\n            response.send(txt);\n        });\n\n        return router;\n    }\n\n}\n"
  },
  {
    "path": "backend/settings.ts",
    "content": "import { R } from \"redbean-node\";\nimport { log } from \"./log\";\nimport { LooseObject } from \"../common/util-common\";\n\nexport class Settings {\n\n    /**\n     *  Example:\n     *      {\n     *         key1: {\n     *             value: \"value2\",\n     *             timestamp: 12345678\n     *         },\n     *         key2: {\n     *             value: 2,\n     *             timestamp: 12345678\n     *         },\n     *     }\n     */\n    static cacheList : LooseObject = {\n\n    };\n\n    static cacheCleaner? : NodeJS.Timeout;\n\n    /**\n     * Retrieve value of setting based on key\n     * @param key Key of setting to retrieve\n     * @returns Value\n     */\n    static async get(key : string) {\n\n        // Start cache clear if not started yet\n        if (!Settings.cacheCleaner) {\n            Settings.cacheCleaner = setInterval(() => {\n                log.debug(\"settings\", \"Cache Cleaner is just started.\");\n                for (key in Settings.cacheList) {\n                    if (Date.now() - Settings.cacheList[key].timestamp > 60 * 1000) {\n                        log.debug(\"settings\", \"Cache Cleaner deleted: \" + key);\n                        delete Settings.cacheList[key];\n                    }\n                }\n\n            }, 60 * 1000);\n        }\n\n        // Query from cache\n        if (key in Settings.cacheList) {\n            const v = Settings.cacheList[key].value;\n            log.debug(\"settings\", `Get Setting (cache): ${key}: ${v}`);\n            return v;\n        }\n\n        const value = await R.getCell(\"SELECT `value` FROM setting WHERE `key` = ? \", [\n            key,\n        ]);\n\n        try {\n            const v = JSON.parse(value);\n            log.debug(\"settings\", `Get Setting: ${key}: ${v}`);\n\n            Settings.cacheList[key] = {\n                value: v,\n                timestamp: Date.now()\n            };\n\n            return v;\n        } catch (e) {\n            return value;\n        }\n    }\n\n    /**\n     * Sets the specified setting to specified value\n     * @param key Key of setting to set\n     * @param value Value to set to\n     * @param {?string} type Type of setting\n     * @returns {Promise<void>}\n     */\n    static async set(key : string, value : object | string | number | boolean, type : string | null = null) {\n\n        let bean = await R.findOne(\"setting\", \" `key` = ? \", [\n            key,\n        ]);\n        if (!bean) {\n            bean = R.dispense(\"setting\");\n            bean.key = key;\n        }\n        bean.type = type;\n        bean.value = JSON.stringify(value);\n        await R.store(bean);\n\n        Settings.deleteCache([ key ]);\n    }\n\n    /**\n     * Get settings based on type\n     * @param type The type of setting\n     * @returns Settings\n     */\n    static async getSettings(type : string) {\n        const list = await R.getAll(\"SELECT `key`, `value` FROM setting WHERE `type` = ? \", [\n            type,\n        ]);\n\n        const result : LooseObject = {};\n\n        for (const row of list) {\n            try {\n                result[row.key] = JSON.parse(row.value);\n            } catch (e) {\n                result[row.key] = row.value;\n            }\n        }\n\n        return result;\n    }\n\n    /**\n     * Set settings based on type\n     * @param type Type of settings to set\n     * @param data Values of settings\n     * @returns {Promise<void>}\n     */\n    static async setSettings(type : string, data : LooseObject) {\n        const keyList = Object.keys(data);\n\n        const promiseList = [];\n\n        for (const key of keyList) {\n            let bean = await R.findOne(\"setting\", \" `key` = ? \", [\n                key\n            ]);\n\n            if (bean == null) {\n                bean = R.dispense(\"setting\");\n                bean.type = type;\n                bean.key = key;\n            }\n\n            if (bean.type === type) {\n                bean.value = JSON.stringify(data[key]);\n                promiseList.push(R.store(bean));\n            }\n        }\n\n        await Promise.all(promiseList);\n\n        Settings.deleteCache(keyList);\n    }\n\n    /**\n     * Delete selected keys from settings cache\n     * @param {string[]} keyList Keys to remove\n     * @returns {void}\n     */\n    static deleteCache(keyList : string[]) {\n        for (const key of keyList) {\n            delete Settings.cacheList[key];\n        }\n    }\n\n    /**\n     * Stop the cache cleaner if running\n     * @returns {void}\n     */\n    static stopCacheCleaner() {\n        if (Settings.cacheCleaner) {\n            clearInterval(Settings.cacheCleaner);\n            Settings.cacheCleaner = undefined;\n        }\n    }\n}\n\n"
  },
  {
    "path": "backend/socket-handler.ts",
    "content": "import { DockgeServer } from \"./dockge-server\";\nimport { DockgeSocket } from \"./util-server\";\n\nexport abstract class SocketHandler {\n    abstract create(socket : DockgeSocket, server : DockgeServer): void;\n}\n"
  },
  {
    "path": "backend/socket-handlers/agent-proxy-socket-handler.ts",
    "content": "import { SocketHandler } from \"../socket-handler.js\";\nimport { DockgeServer } from \"../dockge-server\";\nimport { log } from \"../log\";\nimport { checkLogin, DockgeSocket } from \"../util-server\";\nimport { AgentSocket } from \"../../common/agent-socket\";\nimport { ALL_ENDPOINTS } from \"../../common/util-common\";\n\nexport class AgentProxySocketHandler extends SocketHandler {\n\n    create2(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) {\n        // Agent - proxying requests if needed\n        socket.on(\"agent\", async (endpoint : unknown, eventName : unknown, ...args : unknown[]) => {\n            try {\n                checkLogin(socket);\n\n                // Check Type\n                if (typeof(endpoint) !== \"string\") {\n                    throw new Error(\"Endpoint must be a string: \" + endpoint);\n                }\n                if (typeof(eventName) !== \"string\") {\n                    throw new Error(\"Event name must be a string\");\n                }\n\n                if (endpoint === ALL_ENDPOINTS) {      // Send to all endpoints\n                    log.debug(\"agent\", \"Sending to all endpoints: \" + eventName);\n                    socket.instanceManager.emitToAllEndpoints(eventName, ...args);\n\n                } else if (!endpoint || endpoint === socket.endpoint) {      // Direct connection or matching endpoint\n                    log.debug(\"agent\", \"Matched endpoint: \" + eventName);\n                    agentSocket.call(eventName, ...args);\n\n                } else {\n                    log.debug(\"agent\", \"Proxying request to \" + endpoint + \" for \" + eventName);\n                    await socket.instanceManager.emitToEndpoint(endpoint, eventName, ...args);\n                }\n            } catch (e) {\n                if (e instanceof Error) {\n                    log.warn(\"agent\", e.message);\n                }\n            }\n        });\n    }\n\n    create(socket : DockgeSocket, server : DockgeServer) {\n        throw new Error(\"Method not implemented. Please use create2 instead.\");\n    }\n}\n"
  },
  {
    "path": "backend/socket-handlers/main-socket-handler.ts",
    "content": "// @ts-ignore\nimport composerize from \"composerize\";\nimport { SocketHandler } from \"../socket-handler.js\";\nimport { DockgeServer } from \"../dockge-server\";\nimport { log } from \"../log\";\nimport { R } from \"redbean-node\";\nimport { loginRateLimiter, twoFaRateLimiter } from \"../rate-limiter\";\nimport { generatePasswordHash, needRehashPassword, shake256, SHAKE256_LENGTH, verifyPassword } from \"../password-hash\";\nimport { User } from \"../models/user\";\nimport {\n    callbackError,\n    checkLogin,\n    DockgeSocket,\n    doubleCheckPassword,\n    JWTDecoded,\n    ValidationError\n} from \"../util-server\";\nimport { passwordStrength } from \"check-password-strength\";\nimport jwt from \"jsonwebtoken\";\nimport { Settings } from \"../settings\";\nimport fs, { promises as fsAsync } from \"fs\";\nimport path from \"path\";\n\nexport class MainSocketHandler extends SocketHandler {\n    create(socket : DockgeSocket, server : DockgeServer) {\n\n        // ***************************\n        // Public Socket API\n        // ***************************\n\n        // Setup\n        socket.on(\"setup\", async (username, password, callback) => {\n            try {\n                if (passwordStrength(password).value === \"Too weak\") {\n                    throw new Error(\"Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.\");\n                }\n\n                if ((await R.knex(\"user\").count(\"id as count\").first()).count !== 0) {\n                    throw new Error(\"Dockge has been initialized. If you want to run setup again, please delete the database.\");\n                }\n\n                const user = R.dispense(\"user\");\n                user.username = username;\n                user.password = generatePasswordHash(password);\n                await R.store(user);\n\n                server.needSetup = false;\n\n                callback({\n                    ok: true,\n                    msg: \"successAdded\",\n                    msgi18n: true,\n                });\n\n            } catch (e) {\n                if (e instanceof Error) {\n                    callback({\n                        ok: false,\n                        msg: e.message,\n                    });\n                }\n            }\n        });\n\n        // Login by token\n        socket.on(\"loginByToken\", async (token, callback) => {\n            const clientIP = await server.getClientIP(socket);\n\n            log.info(\"auth\", `Login by token. IP=${clientIP}`);\n\n            try {\n                const decoded = jwt.verify(token, server.jwtSecret) as JWTDecoded;\n\n                log.info(\"auth\", \"Username from JWT: \" + decoded.username);\n\n                const user = await R.findOne(\"user\", \" username = ? AND active = 1 \", [\n                    decoded.username,\n                ]) as User;\n\n                if (user) {\n                    // Check if the password changed\n                    if (decoded.h !== shake256(user.password, SHAKE256_LENGTH)) {\n                        throw new Error(\"The token is invalid due to password change or old token\");\n                    }\n\n                    log.debug(\"auth\", \"afterLogin\");\n                    await server.afterLogin(socket, user);\n                    log.debug(\"auth\", \"afterLogin ok\");\n\n                    log.info(\"auth\", `Successfully logged in user ${decoded.username}. IP=${clientIP}`);\n\n                    callback({\n                        ok: true,\n                    });\n                } else {\n\n                    log.info(\"auth\", `Inactive or deleted user ${decoded.username}. IP=${clientIP}`);\n\n                    callback({\n                        ok: false,\n                        msg: \"authUserInactiveOrDeleted\",\n                        msgi18n: true,\n                    });\n                }\n            } catch (error) {\n                if (!(error instanceof Error)) {\n                    console.error(\"Unknown error:\", error);\n                    return;\n                }\n                log.error(\"auth\", `Invalid token. IP=${clientIP}`);\n                if (error.message) {\n                    log.error(\"auth\", error.message + ` IP=${clientIP}`);\n                }\n                callback({\n                    ok: false,\n                    msg: \"authInvalidToken\",\n                    msgi18n: true,\n                });\n            }\n\n        });\n\n        // Login\n        socket.on(\"login\", async (data, callback) => {\n            const clientIP = await server.getClientIP(socket);\n\n            log.info(\"auth\", `Login by username + password. IP=${clientIP}`);\n\n            // Checking\n            if (typeof callback !== \"function\") {\n                return;\n            }\n\n            if (!data) {\n                return;\n            }\n\n            // Login Rate Limit\n            if (!await loginRateLimiter.pass(callback)) {\n                log.info(\"auth\", `Too many failed requests for user ${data.username}. IP=${clientIP}`);\n                return;\n            }\n\n            const user = await this.login(data.username, data.password);\n\n            if (user) {\n                if (user.twofa_status === 0) {\n                    server.afterLogin(socket, user);\n\n                    log.info(\"auth\", `Successfully logged in user ${data.username}. IP=${clientIP}`);\n\n                    callback({\n                        ok: true,\n                        token: User.createJWT(user, server.jwtSecret),\n                    });\n                }\n\n                if (user.twofa_status === 1 && !data.token) {\n\n                    log.info(\"auth\", `2FA token required for user ${data.username}. IP=${clientIP}`);\n\n                    callback({\n                        tokenRequired: true,\n                    });\n                }\n\n                if (data.token) {\n                    // @ts-ignore\n                    const verify = notp.totp.verify(data.token, user.twofa_secret, twoFAVerifyOptions);\n\n                    if (user.twofa_last_token !== data.token && verify) {\n                        server.afterLogin(socket, user);\n\n                        await R.exec(\"UPDATE `user` SET twofa_last_token = ? WHERE id = ? \", [\n                            data.token,\n                            socket.userID,\n                        ]);\n\n                        log.info(\"auth\", `Successfully logged in user ${data.username}. IP=${clientIP}`);\n\n                        callback({\n                            ok: true,\n                            token: User.createJWT(user, server.jwtSecret),\n                        });\n                    } else {\n\n                        log.warn(\"auth\", `Invalid token provided for user ${data.username}. IP=${clientIP}`);\n\n                        callback({\n                            ok: false,\n                            msg: \"authInvalidToken\",\n                            msgi18n: true,\n                        });\n                    }\n                }\n            } else {\n\n                log.warn(\"auth\", `Incorrect username or password for user ${data.username}. IP=${clientIP}`);\n\n                callback({\n                    ok: false,\n                    msg: \"authIncorrectCreds\",\n                    msgi18n: true,\n                });\n            }\n\n        });\n\n        // Change Password\n        socket.on(\"changePassword\", async (password, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (! password.newPassword) {\n                    throw new Error(\"Invalid new password\");\n                }\n\n                if (passwordStrength(password.newPassword).value === \"Too weak\") {\n                    throw new Error(\"Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.\");\n                }\n\n                let user = await doubleCheckPassword(socket, password.currentPassword);\n                await user.resetPassword(password.newPassword);\n\n                server.disconnectAllSocketClients(user.id, socket.id);\n\n                callback({\n                    ok: true,\n                    msg: \"Password has been updated successfully.\",\n                });\n\n            } catch (e) {\n                if (e instanceof Error) {\n                    callback({\n                        ok: false,\n                        msg: e.message,\n                    });\n                }\n            }\n        });\n\n        socket.on(\"getSettings\", async (callback) => {\n            try {\n                checkLogin(socket);\n                const data = await Settings.getSettings(\"general\");\n\n                if (fs.existsSync(path.join(server.stacksDir, \"global.env\"))) {\n                    data.globalENV = fs.readFileSync(path.join(server.stacksDir, \"global.env\"), \"utf-8\");\n                } else {\n                    data.globalENV = \"# VARIABLE=value #comment\";\n                }\n\n                callback({\n                    ok: true,\n                    data: data,\n                });\n\n            } catch (e) {\n                if (e instanceof Error) {\n                    callback({\n                        ok: false,\n                        msg: e.message,\n                    });\n                }\n            }\n        });\n\n        socket.on(\"setSettings\", async (data, currentPassword, callback) => {\n            try {\n                checkLogin(socket);\n\n                // If currently is disabled auth, don't need to check\n                // Disabled Auth + Want to Disable Auth => No Check\n                // Disabled Auth + Want to Enable Auth => No Check\n                // Enabled Auth + Want to Disable Auth => Check!!\n                // Enabled Auth + Want to Enable Auth => No Check\n                const currentDisabledAuth = await Settings.get(\"disableAuth\");\n                if (!currentDisabledAuth && data.disableAuth) {\n                    await doubleCheckPassword(socket, currentPassword);\n                }\n                // Handle global.env\n                if (data.globalENV && data.globalENV != \"# VARIABLE=value #comment\") {\n                    await fsAsync.writeFile(path.join(server.stacksDir, \"global.env\"), data.globalENV);\n                } else {\n                    await fsAsync.rm(path.join(server.stacksDir, \"global.env\"), {\n                        recursive: true,\n                        force: true\n                    });\n                }\n                delete data.globalENV;\n\n                await Settings.setSettings(\"general\", data);\n\n                callback({\n                    ok: true,\n                    msg: \"Saved\"\n                });\n\n                server.sendInfo(socket);\n\n            } catch (e) {\n                if (e instanceof Error) {\n                    callback({\n                        ok: false,\n                        msg: e.message,\n                    });\n                }\n            }\n        });\n\n        // Disconnect all other socket clients of the user\n        socket.on(\"disconnectOtherSocketClients\", async () => {\n            try {\n                checkLogin(socket);\n                server.disconnectAllSocketClients(socket.userID, socket.id);\n            } catch (e) {\n                if (e instanceof Error) {\n                    log.warn(\"disconnectOtherSocketClients\", e.message);\n                }\n            }\n        });\n\n        // composerize\n        socket.on(\"composerize\", async (dockerRunCommand : unknown, callback) => {\n            try {\n                checkLogin(socket);\n\n                if (typeof(dockerRunCommand) !== \"string\") {\n                    throw new ValidationError(\"dockerRunCommand must be a string\");\n                }\n\n                // Option: 'latest' | 'v2x' | 'v3x'\n                let composeTemplate = composerize(dockerRunCommand, \"\", \"latest\");\n\n                // Remove the first line \"name: <your project name>\"\n                composeTemplate = composeTemplate.split(\"\\n\").slice(1).join(\"\\n\");\n\n                callback({\n                    ok: true,\n                    composeTemplate,\n                });\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n    }\n\n    async login(username : string, password : string) : Promise<User | null> {\n        if (typeof username !== \"string\" || typeof password !== \"string\") {\n            return null;\n        }\n\n        const user = await R.findOne(\"user\", \" username = ? AND active = 1 \", [\n            username,\n        ]) as User;\n\n        if (user && verifyPassword(password, user.password)) {\n            // Upgrade the hash to bcrypt\n            if (needRehashPassword(user.password)) {\n                await R.exec(\"UPDATE `user` SET password = ? WHERE id = ? \", [\n                    generatePasswordHash(password),\n                    user.id,\n                ]);\n            }\n            return user;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "backend/socket-handlers/manage-agent-socket-handler.ts",
    "content": "import { SocketHandler } from \"../socket-handler.js\";\nimport { DockgeServer } from \"../dockge-server\";\nimport { log } from \"../log\";\nimport { callbackError, callbackResult, checkLogin, DockgeSocket } from \"../util-server\";\nimport { LooseObject } from \"../../common/util-common\";\n\nexport class ManageAgentSocketHandler extends SocketHandler {\n\n    create(socket : DockgeSocket, server : DockgeServer) {\n        // addAgent\n        socket.on(\"addAgent\", async (requestData : unknown, callback : unknown) => {\n            try {\n                log.debug(\"manage-agent-socket-handler\", \"addAgent\");\n                checkLogin(socket);\n\n                if (typeof(requestData) !== \"object\") {\n                    throw new Error(\"Data must be an object\");\n                }\n\n                let data = requestData as LooseObject;\n                let manager = socket.instanceManager;\n                await manager.test(data.url, data.username, data.password);\n                await manager.add(data.url, data.username, data.password);\n\n                // connect to the agent\n                manager.connect(data.url, data.username, data.password);\n\n                // Refresh another sockets\n                // It is a bit difficult to control another browser sessions to connect/disconnect agents, so force them to refresh the page will be easier.\n                server.disconnectAllSocketClients(undefined, socket.id);\n                manager.sendAgentList();\n\n                callbackResult({\n                    ok: true,\n                    msg: \"agentAddedSuccessfully\",\n                    msgi18n: true,\n                }, callback);\n\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n\n        // removeAgent\n        socket.on(\"removeAgent\", async (url : unknown, callback : unknown) => {\n            try {\n                log.debug(\"manage-agent-socket-handler\", \"removeAgent\");\n                checkLogin(socket);\n\n                if (typeof(url) !== \"string\") {\n                    throw new Error(\"URL must be a string\");\n                }\n\n                let manager = socket.instanceManager;\n                await manager.remove(url);\n\n                server.disconnectAllSocketClients(undefined, socket.id);\n                manager.sendAgentList();\n\n                callbackResult({\n                    ok: true,\n                    msg: \"agentRemovedSuccessfully\",\n                    msgi18n: true,\n                }, callback);\n            } catch (e) {\n                callbackError(e, callback);\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "backend/stack.ts",
    "content": "import { DockgeServer } from \"./dockge-server\";\nimport fs, { promises as fsAsync } from \"fs\";\nimport { log } from \"./log\";\nimport yaml from \"yaml\";\nimport { DockgeSocket, fileExists, ValidationError } from \"./util-server\";\nimport path from \"path\";\nimport {\n    acceptedComposeFileNames,\n    COMBINED_TERMINAL_COLS,\n    COMBINED_TERMINAL_ROWS,\n    CREATED_FILE,\n    CREATED_STACK,\n    EXITED, getCombinedTerminalName,\n    getComposeTerminalName, getContainerExecTerminalName,\n    PROGRESS_TERMINAL_ROWS,\n    RUNNING, TERMINAL_ROWS,\n    UNKNOWN\n} from \"../common/util-common\";\nimport { InteractiveTerminal, Terminal } from \"./terminal\";\nimport childProcessAsync from \"promisify-child-process\";\nimport { Settings } from \"./settings\";\n\nexport class Stack {\n\n    name: string;\n    protected _status: number = UNKNOWN;\n    protected _composeYAML?: string;\n    protected _composeENV?: string;\n    protected _configFilePath?: string;\n    protected _composeFileName: string = \"compose.yaml\";\n    protected server: DockgeServer;\n\n    protected combinedTerminal? : Terminal;\n\n    protected static managedStackList: Map<string, Stack> = new Map();\n\n    constructor(server : DockgeServer, name : string, composeYAML? : string, composeENV? : string, skipFSOperations = false) {\n        this.name = name;\n        this.server = server;\n        this._composeYAML = composeYAML;\n        this._composeENV = composeENV;\n\n        if (!skipFSOperations) {\n            // Check if compose file name is different from compose.yaml\n            for (const filename of acceptedComposeFileNames) {\n                if (fs.existsSync(path.join(this.path, filename))) {\n                    this._composeFileName = filename;\n                    break;\n                }\n            }\n        }\n    }\n\n    async toJSON(endpoint : string) : Promise<object> {\n\n        // Since we have multiple agents now, embed primary hostname in the stack object too.\n        let primaryHostname = await Settings.get(\"primaryHostname\");\n        if (!primaryHostname) {\n            if (!endpoint) {\n                primaryHostname = \"localhost\";\n            } else {\n                // Use the endpoint as the primary hostname\n                try {\n                    primaryHostname = (new URL(\"https://\" + endpoint).hostname);\n                } catch (e) {\n                    // Just in case if the endpoint is in a incorrect format\n                    primaryHostname = \"localhost\";\n                }\n            }\n        }\n\n        let obj = this.toSimpleJSON(endpoint);\n        return {\n            ...obj,\n            composeYAML: this.composeYAML,\n            composeENV: this.composeENV,\n            primaryHostname,\n        };\n    }\n\n    toSimpleJSON(endpoint : string) : object {\n        return {\n            name: this.name,\n            status: this._status,\n            tags: [],\n            isManagedByDockge: this.isManagedByDockge,\n            composeFileName: this._composeFileName,\n            endpoint,\n        };\n    }\n\n    /**\n     * Get the status of the stack from `docker compose ps --format json`\n     */\n    async ps() : Promise<object> {\n        let res = await childProcessAsync.spawn(\"docker\", this.getComposeOptions(\"ps\", \"--format\", \"json\"), {\n            cwd: this.path,\n            encoding: \"utf-8\",\n        });\n        if (!res.stdout) {\n            return {};\n        }\n        return JSON.parse(res.stdout.toString());\n    }\n\n    get isManagedByDockge() : boolean {\n        return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();\n    }\n\n    get status() : number {\n        return this._status;\n    }\n\n    validate() {\n        // Check name, allows [a-z][0-9] _ - only\n        if (!this.name.match(/^[a-z0-9_-]+$/)) {\n            throw new ValidationError(\"Stack name can only contain [a-z][0-9] _ - only\");\n        }\n\n        // Check YAML format\n        yaml.parse(this.composeYAML);\n\n        let lines = this.composeENV.split(\"\\n\");\n\n        // Check if the .env is able to pass docker-compose\n        // Prevent \"setenv: The parameter is incorrect\"\n        // It only happens when there is one line and it doesn't contain \"=\"\n        if (lines.length === 1 && !lines[0].includes(\"=\") && lines[0].length > 0) {\n            throw new ValidationError(\"Invalid .env format\");\n        }\n    }\n\n    get composeYAML() : string {\n        if (this._composeYAML === undefined) {\n            try {\n                this._composeYAML = fs.readFileSync(path.join(this.path, this._composeFileName), \"utf-8\");\n            } catch (e) {\n                this._composeYAML = \"\";\n            }\n        }\n        return this._composeYAML;\n    }\n\n    get composeENV() : string {\n        if (this._composeENV === undefined) {\n            try {\n                this._composeENV = fs.readFileSync(path.join(this.path, \".env\"), \"utf-8\");\n            } catch (e) {\n                this._composeENV = \"\";\n            }\n        }\n        return this._composeENV;\n    }\n\n    get path() : string {\n        return path.join(this.server.stacksDir, this.name);\n    }\n\n    get fullPath() : string {\n        let dir = this.path;\n\n        // Compose up via node-pty\n        let fullPathDir;\n\n        // if dir is relative, make it absolute\n        if (!path.isAbsolute(dir)) {\n            fullPathDir = path.join(process.cwd(), dir);\n        } else {\n            fullPathDir = dir;\n        }\n        return fullPathDir;\n    }\n\n    /**\n     * Save the stack to the disk\n     * @param isAdd\n     */\n    async save(isAdd : boolean) {\n        this.validate();\n\n        let dir = this.path;\n\n        // Check if the name is used if isAdd\n        if (isAdd) {\n            if (await fileExists(dir)) {\n                throw new ValidationError(\"Stack name already exists\");\n            }\n\n            // Create the stack folder\n            await fsAsync.mkdir(dir);\n        } else {\n            if (!await fileExists(dir)) {\n                throw new ValidationError(\"Stack not found\");\n            }\n        }\n\n        // Write or overwrite the compose.yaml\n        await fsAsync.writeFile(path.join(dir, this._composeFileName), this.composeYAML);\n\n        const envPath = path.join(dir, \".env\");\n\n        // Write or overwrite the .env\n        // If .env is not existing and the composeENV is empty, we don't need to write it\n        if (await fileExists(envPath) || this.composeENV.trim() !== \"\") {\n            await fsAsync.writeFile(envPath, this.composeENV);\n        }\n    }\n\n    async deploy(socket : DockgeSocket) : Promise<number> {\n        const terminalName = getComposeTerminalName(socket.endpoint, this.name);\n        let exitCode = await Terminal.exec(this.server, socket, terminalName, \"docker\", this.getComposeOptions(\"up\", \"-d\", \"--remove-orphans\"), this.path);\n        if (exitCode !== 0) {\n            throw new Error(\"Failed to deploy, please check the terminal output for more information.\");\n        }\n        return exitCode;\n    }\n\n    async delete(socket: DockgeSocket) : Promise<number> {\n        const terminalName = getComposeTerminalName(socket.endpoint, this.name);\n        let exitCode = await Terminal.exec(this.server, socket, terminalName, \"docker\", this.getComposeOptions(\"down\", \"--remove-orphans\"), this.path);\n        if (exitCode !== 0) {\n            throw new Error(\"Failed to delete, please check the terminal output for more information.\");\n        }\n\n        // Remove the stack folder\n        await fsAsync.rm(this.path, {\n            recursive: true,\n            force: true\n        });\n\n        return exitCode;\n    }\n\n    async updateStatus() {\n        let statusList = await Stack.getStatusList();\n        let status = statusList.get(this.name);\n\n        if (status) {\n            this._status = status;\n        } else {\n            this._status = UNKNOWN;\n        }\n    }\n\n    /**\n     * Checks if a compose file exists in the specified directory.\n     * @async\n     * @static\n     * @param {string} stacksDir - The directory of the stack.\n     * @param {string} filename - The name of the directory to check for the compose file.\n     * @returns {Promise<boolean>} A promise that resolves to a boolean indicating whether any compose file exists.\n     */\n    static async composeFileExists(stacksDir : string, filename : string) : Promise<boolean> {\n        let filenamePath = path.join(stacksDir, filename);\n        // Check if any compose file exists\n        for (const filename of acceptedComposeFileNames) {\n            let composeFile = path.join(filenamePath, filename);\n            if (await fileExists(composeFile)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    static async getStackList(server : DockgeServer, useCacheForManaged = false) : Promise<Map<string, Stack>> {\n        let stacksDir = server.stacksDir;\n        let stackList : Map<string, Stack>;\n\n        // Use cached stack list?\n        if (useCacheForManaged && this.managedStackList.size > 0) {\n            stackList = this.managedStackList;\n        } else {\n            stackList = new Map<string, Stack>();\n\n            // Scan the stacks directory, and get the stack list\n            let filenameList = await fsAsync.readdir(stacksDir);\n\n            for (let filename of filenameList) {\n                try {\n                    // Check if it is a directory\n                    let stat = await fsAsync.stat(path.join(stacksDir, filename));\n                    if (!stat.isDirectory()) {\n                        continue;\n                    }\n                    // If no compose file exists, skip it\n                    if (!await Stack.composeFileExists(stacksDir, filename)) {\n                        continue;\n                    }\n                    let stack = await this.getStack(server, filename);\n                    stack._status = CREATED_FILE;\n                    stackList.set(filename, stack);\n                } catch (e) {\n                    if (e instanceof Error) {\n                        log.warn(\"getStackList\", `Failed to get stack ${filename}, error: ${e.message}`);\n                    }\n                }\n            }\n\n            // Cache by copying\n            this.managedStackList = new Map(stackList);\n        }\n\n        // Get status from docker compose ls\n        let res = await childProcessAsync.spawn(\"docker\", [ \"compose\", \"ls\", \"--all\", \"--format\", \"json\" ], {\n            encoding: \"utf-8\",\n        });\n\n        if (!res.stdout) {\n            return stackList;\n        }\n\n        let composeList = JSON.parse(res.stdout.toString());\n\n        for (let composeStack of composeList) {\n            let stack = stackList.get(composeStack.Name);\n\n            // This stack probably is not managed by Dockge, but we still want to show it\n            if (!stack) {\n                // Skip the dockge stack if it is not managed by Dockge\n                if (composeStack.Name === \"dockge\") {\n                    continue;\n                }\n                stack = new Stack(server, composeStack.Name);\n                stackList.set(composeStack.Name, stack);\n            }\n\n            stack._status = this.statusConvert(composeStack.Status);\n            stack._configFilePath = composeStack.ConfigFiles;\n        }\n\n        return stackList;\n    }\n\n    /**\n     * Get the status list, it will be used to update the status of the stacks\n     * Not all status will be returned, only the stack that is deployed or created to `docker compose` will be returned\n     */\n    static async getStatusList() : Promise<Map<string, number>> {\n        let statusList = new Map<string, number>();\n\n        let res = await childProcessAsync.spawn(\"docker\", [ \"compose\", \"ls\", \"--all\", \"--format\", \"json\" ], {\n            encoding: \"utf-8\",\n        });\n\n        if (!res.stdout) {\n            return statusList;\n        }\n\n        let composeList = JSON.parse(res.stdout.toString());\n\n        for (let composeStack of composeList) {\n            statusList.set(composeStack.Name, this.statusConvert(composeStack.Status));\n        }\n\n        return statusList;\n    }\n\n    /**\n     * Convert the status string from `docker compose ls` to the status number\n     * Input Example: \"exited(1), running(1)\"\n     * @param status\n     */\n    static statusConvert(status : string) : number {\n        if (status.startsWith(\"created\")) {\n            return CREATED_STACK;\n        } else if (status.includes(\"exited\")) {\n            // If one of the service is exited, we consider the stack is exited\n            return EXITED;\n        } else if (status.startsWith(\"running\")) {\n            // If there is no exited services, there should be only running services\n            return RUNNING;\n        } else {\n            return UNKNOWN;\n        }\n    }\n\n    static async getStack(server: DockgeServer, stackName: string, skipFSOperations = false) : Promise<Stack> {\n        let dir = path.join(server.stacksDir, stackName);\n\n        if (!skipFSOperations) {\n            if (!await fileExists(dir) || !(await fsAsync.stat(dir)).isDirectory()) {\n                // Maybe it is a stack managed by docker compose directly\n                let stackList = await this.getStackList(server, true);\n                let stack = stackList.get(stackName);\n\n                if (stack) {\n                    return stack;\n                } else {\n                    // Really not found\n                    throw new ValidationError(\"Stack not found\");\n                }\n            }\n        } else {\n            //log.debug(\"getStack\", \"Skip FS operations\");\n        }\n\n        let stack : Stack;\n\n        if (!skipFSOperations) {\n            stack = new Stack(server, stackName);\n        } else {\n            stack = new Stack(server, stackName, undefined, undefined, true);\n        }\n\n        stack._status = UNKNOWN;\n        stack._configFilePath = path.resolve(dir);\n        return stack;\n    }\n\n    getComposeOptions(command : string, ...extraOptions : string[]) {\n        //--env-file ./../global.env --env-file .env\n        let options = [ \"compose\", command, ...extraOptions ];\n        if (fs.existsSync(path.join(this.server.stacksDir, \"global.env\"))) {\n            if (fs.existsSync(path.join(this.path, \".env\"))) {\n                options.splice(1, 0, \"--env-file\", \"./.env\");\n            }\n            options.splice(1, 0, \"--env-file\", \"../global.env\");\n        }\n        console.log(options);\n        return options;\n    }\n\n    async start(socket: DockgeSocket) {\n        const terminalName = getComposeTerminalName(socket.endpoint, this.name);\n        let exitCode = await Terminal.exec(this.server, socket, terminalName, \"docker\", this.getComposeOptions(\"up\", \"-d\", \"--remove-orphans\"), this.path);\n        if (exitCode !== 0) {\n            throw new Error(\"Failed to start, please check the terminal output for more information.\");\n        }\n        return exitCode;\n    }\n\n    async stop(socket: DockgeSocket) : Promise<number> {\n        const terminalName = getComposeTerminalName(socket.endpoint, this.name);\n        let exitCode = await Terminal.exec(this.server, socket, terminalName, \"docker\", this.getComposeOptions(\"stop\"), this.path);\n        if (exitCode !== 0) {\n            throw new Error(\"Failed to stop, please check the terminal output for more information.\");\n        }\n        return exitCode;\n    }\n\n    async restart(socket: DockgeSocket) : Promise<number> {\n        const terminalName = getComposeTerminalName(socket.endpoint, this.name);\n        let exitCode = await Terminal.exec(this.server, socket, terminalName, \"docker\", this.getComposeOptions(\"restart\"), this.path);\n        if (exitCode !== 0) {\n            throw new Error(\"Failed to restart, please check the terminal output for more information.\");\n        }\n        return exitCode;\n    }\n\n    async down(socket: DockgeSocket) : Promise<number> {\n        const terminalName = getComposeTerminalName(socket.endpoint, this.name);\n        let exitCode = await Terminal.exec(this.server, socket, terminalName, \"docker\", this.getComposeOptions(\"down\"), this.path);\n        if (exitCode !== 0) {\n            throw new Error(\"Failed to down, please check the terminal output for more information.\");\n        }\n        return exitCode;\n    }\n\n    async update(socket: DockgeSocket) {\n        const terminalName = getComposeTerminalName(socket.endpoint, this.name);\n        let exitCode = await Terminal.exec(this.server, socket, terminalName, \"docker\", this.getComposeOptions(\"pull\"), this.path);\n        if (exitCode !== 0) {\n            throw new Error(\"Failed to pull, please check the terminal output for more information.\");\n        }\n\n        // If the stack is not running, we don't need to restart it\n        await this.updateStatus();\n        log.debug(\"update\", \"Status: \" + this.status);\n        if (this.status !== RUNNING) {\n            return exitCode;\n        }\n\n        exitCode = await Terminal.exec(this.server, socket, terminalName, \"docker\", this.getComposeOptions(\"up\", \"-d\", \"--remove-orphans\"), this.path);\n        if (exitCode !== 0) {\n            throw new Error(\"Failed to restart, please check the terminal output for more information.\");\n        }\n        return exitCode;\n    }\n\n    async joinCombinedTerminal(socket: DockgeSocket) {\n        const terminalName = getCombinedTerminalName(socket.endpoint, this.name);\n        const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, \"docker\", this.getComposeOptions(\"logs\", \"-f\", \"--tail\", \"100\"), this.path);\n        terminal.enableKeepAlive = true;\n        terminal.rows = COMBINED_TERMINAL_ROWS;\n        terminal.cols = COMBINED_TERMINAL_COLS;\n        terminal.join(socket);\n        terminal.start();\n    }\n\n    async leaveCombinedTerminal(socket: DockgeSocket) {\n        const terminalName = getCombinedTerminalName(socket.endpoint, this.name);\n        const terminal = Terminal.getTerminal(terminalName);\n        if (terminal) {\n            terminal.leave(socket);\n        }\n    }\n\n    async joinContainerTerminal(socket: DockgeSocket, serviceName: string, shell : string = \"sh\", index: number = 0) {\n        const terminalName = getContainerExecTerminalName(socket.endpoint, this.name, serviceName, index);\n        let terminal = Terminal.getTerminal(terminalName);\n\n        if (!terminal) {\n            terminal = new InteractiveTerminal(this.server, terminalName, \"docker\", this.getComposeOptions(\"exec\", serviceName, shell), this.path);\n            terminal.rows = TERMINAL_ROWS;\n            log.debug(\"joinContainerTerminal\", \"Terminal created\");\n        }\n\n        terminal.join(socket);\n        terminal.start();\n    }\n\n    async getServiceStatusList() {\n        let statusList = new Map<string, { state: string, ports: string[] }>();\n\n        try {\n            let res = await childProcessAsync.spawn(\"docker\", this.getComposeOptions(\"ps\", \"--format\", \"json\"), {\n                cwd: this.path,\n                encoding: \"utf-8\",\n            });\n\n            if (!res.stdout) {\n                return statusList;\n            }\n\n            let lines = res.stdout?.toString().split(\"\\n\");\n\n            for (let line of lines) {\n                try {\n                    let obj = JSON.parse(line);\n                    let ports = (obj.Ports as string).split(/,\\s*/).filter((s) => {\n                        return s.indexOf(\"->\") >= 0;\n                    });\n                    if (obj.Health === \"\") {\n                        statusList.set(obj.Service, {\n                            state: obj.State,\n                            ports: ports\n                        });\n                    } else {\n                        statusList.set(obj.Service, {\n                            state: obj.Health,\n                            ports: ports\n                        });\n                    }\n                } catch (e) {\n                }\n            }\n\n            return statusList;\n        } catch (e) {\n            log.error(\"getServiceStatusList\", e);\n            return statusList;\n        }\n\n    }\n}\n"
  },
  {
    "path": "backend/terminal.ts",
    "content": "import { DockgeServer } from \"./dockge-server\";\nimport * as os from \"node:os\";\nimport * as pty from \"@homebridge/node-pty-prebuilt-multiarch\";\nimport { LimitQueue } from \"./utils/limit-queue\";\nimport { DockgeSocket } from \"./util-server\";\nimport {\n    PROGRESS_TERMINAL_ROWS,\n    TERMINAL_COLS,\n    TERMINAL_ROWS\n} from \"../common/util-common\";\nimport { sync as commandExistsSync } from \"command-exists\";\nimport { log } from \"./log\";\n\n/**\n * Terminal for running commands, no user interaction\n */\nexport class Terminal {\n    protected static terminalMap : Map<string, Terminal> = new Map();\n\n    protected _ptyProcess? : pty.IPty;\n    protected server : DockgeServer;\n    protected buffer : LimitQueue<string> = new LimitQueue(100);\n    protected _name : string;\n\n    protected file : string;\n    protected args : string | string[];\n    protected cwd : string;\n    protected callback? : (exitCode : number) => void;\n\n    protected _rows : number = TERMINAL_ROWS;\n    protected _cols : number = TERMINAL_COLS;\n\n    public enableKeepAlive : boolean = false;\n    protected keepAliveInterval? : NodeJS.Timeout;\n    protected kickDisconnectedClientsInterval? : NodeJS.Timeout;\n\n    protected socketList : Record<string, DockgeSocket> = {};\n\n    constructor(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) {\n        this.server = server;\n        this._name = name;\n        //this._name = \"terminal-\" + Date.now() + \"-\" + getCryptoRandomInt(0, 1000000);\n        this.file = file;\n        this.args = args;\n        this.cwd = cwd;\n\n        Terminal.terminalMap.set(this.name, this);\n    }\n\n    get rows() {\n        return this._rows;\n    }\n\n    set rows(rows : number) {\n        this._rows = rows;\n        try {\n            this.ptyProcess?.resize(this.cols, this.rows);\n        } catch (e) {\n            if (e instanceof Error) {\n                log.debug(\"Terminal\", \"Failed to resize terminal: \" + e.message);\n            }\n        }\n    }\n\n    get cols() {\n        return this._cols;\n    }\n\n    set cols(cols : number) {\n        this._cols = cols;\n        log.debug(\"Terminal\", `Terminal cols: ${this._cols}`); // Added to check if cols is being set when changing terminal size.\n        try {\n            this.ptyProcess?.resize(this.cols, this.rows);\n        } catch (e) {\n            if (e instanceof Error) {\n                log.debug(\"Terminal\", \"Failed to resize terminal: \" + e.message);\n            }\n        }\n    }\n\n    public start() {\n        if (this._ptyProcess) {\n            return;\n        }\n\n        this.kickDisconnectedClientsInterval = setInterval(() => {\n            for (const socketID in this.socketList) {\n                const socket = this.socketList[socketID];\n                if (!socket.connected) {\n                    log.debug(\"Terminal\", \"Kicking disconnected client \" + socket.id + \" from terminal \" + this.name);\n                    this.leave(socket);\n                }\n            }\n        }, 60 * 1000);\n\n        if (this.enableKeepAlive) {\n            log.debug(\"Terminal\", \"Keep alive enabled for terminal \" + this.name);\n\n            // Close if there is no clients\n            this.keepAliveInterval = setInterval(() => {\n                const numClients = Object.keys(this.socketList).length;\n\n                if (numClients === 0) {\n                    log.debug(\"Terminal\", \"Terminal \" + this.name + \" has no client, closing...\");\n                    this.close();\n                } else {\n                    log.debug(\"Terminal\", \"Terminal \" + this.name + \" has \" + numClients + \" client(s)\");\n                }\n            }, 60 * 1000);\n        } else {\n            log.debug(\"Terminal\", \"Keep alive disabled for terminal \" + this.name);\n        }\n\n        try {\n            this._ptyProcess = pty.spawn(this.file, this.args, {\n                name: this.name,\n                cwd: this.cwd,\n                cols: TERMINAL_COLS,\n                rows: this.rows,\n            });\n\n            // On Data\n            this._ptyProcess.onData((data) => {\n                this.buffer.pushItem(data);\n\n                for (const socketID in this.socketList) {\n                    const socket = this.socketList[socketID];\n                    socket.emitAgent(\"terminalWrite\", this.name, data);\n                }\n            });\n\n            // On Exit\n            this._ptyProcess.onExit(this.exit);\n        } catch (error) {\n            if (error instanceof Error) {\n                clearInterval(this.keepAliveInterval);\n\n                log.error(\"Terminal\", \"Failed to start terminal: \" + error.message);\n                const exitCode = Number(error.message.split(\" \").pop());\n                this.exit({\n                    exitCode,\n                });\n            }\n        }\n    }\n\n    /**\n     * Exit event handler\n     * @param res\n     */\n    protected exit = (res : {exitCode: number, signal?: number | undefined}) => {\n        for (const socketID in this.socketList) {\n            const socket = this.socketList[socketID];\n            socket.emitAgent(\"terminalExit\", this.name, res.exitCode);\n        }\n\n        // Remove all clients\n        this.socketList = {};\n\n        Terminal.terminalMap.delete(this.name);\n        log.debug(\"Terminal\", \"Terminal \" + this.name + \" exited with code \" + res.exitCode);\n\n        clearInterval(this.keepAliveInterval);\n        clearInterval(this.kickDisconnectedClientsInterval);\n\n        if (this.callback) {\n            this.callback(res.exitCode);\n        }\n    };\n\n    public onExit(callback : (exitCode : number) => void) {\n        this.callback = callback;\n    }\n\n    public join(socket : DockgeSocket) {\n        this.socketList[socket.id] = socket;\n    }\n\n    public leave(socket : DockgeSocket) {\n        delete this.socketList[socket.id];\n    }\n\n    public get ptyProcess() {\n        return this._ptyProcess;\n    }\n\n    public get name() {\n        return this._name;\n    }\n\n    /**\n     * Get the terminal output string\n     */\n    getBuffer() : string {\n        if (this.buffer.length === 0) {\n            return \"\";\n        }\n        return this.buffer.join(\"\");\n    }\n\n    close() {\n        clearInterval(this.keepAliveInterval);\n        // Send Ctrl+C to the terminal\n        this.ptyProcess?.write(\"\\x03\");\n    }\n\n    /**\n     * Get a running and non-exited terminal\n     * @param name\n     */\n    public static getTerminal(name : string) : Terminal | undefined {\n        return Terminal.terminalMap.get(name);\n    }\n\n    public static getOrCreateTerminal(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) : Terminal {\n        // Since exited terminal will be removed from the map, it is safe to get the terminal from the map\n        let terminal = Terminal.getTerminal(name);\n        if (!terminal) {\n            terminal = new Terminal(server, name, file, args, cwd);\n        }\n        return terminal;\n    }\n\n    public static exec(server : DockgeServer, socket : DockgeSocket | undefined, terminalName : string, file : string, args : string | string[], cwd : string) : Promise<number> {\n        return new Promise((resolve, reject) => {\n            // check if terminal exists\n            if (Terminal.terminalMap.has(terminalName)) {\n                reject(\"Another operation is already running, please try again later.\");\n                return;\n            }\n\n            let terminal = new Terminal(server, terminalName, file, args, cwd);\n            terminal.rows = PROGRESS_TERMINAL_ROWS;\n\n            if (socket) {\n                terminal.join(socket);\n            }\n\n            terminal.onExit((exitCode : number) => {\n                resolve(exitCode);\n            });\n            terminal.start();\n        });\n    }\n\n    public static getTerminalCount() {\n        return Terminal.terminalMap.size;\n    }\n}\n\n/**\n * Interactive terminal\n * Mainly used for container exec\n */\nexport class InteractiveTerminal extends Terminal {\n    public write(input : string) {\n        this.ptyProcess?.write(input);\n    }\n\n    resetCWD() {\n        const cwd = process.cwd();\n        this.ptyProcess?.write(`cd \"${cwd}\"\\r`);\n    }\n}\n\n/**\n * User interactive terminal that use bash or powershell with limited commands such as docker, ls, cd, dir\n */\nexport class MainTerminal extends InteractiveTerminal {\n    constructor(server : DockgeServer, name : string) {\n        let shell;\n\n        // Throw an error if console is not enabled\n        if (!server.config.enableConsole) {\n            throw new Error(\"Console is not enabled.\");\n        }\n\n        if (os.platform() === \"win32\") {\n            if (commandExistsSync(\"pwsh.exe\")) {\n                shell = \"pwsh.exe\";\n            } else {\n                shell = \"powershell.exe\";\n            }\n        } else {\n            shell = \"bash\";\n        }\n        super(server, name, shell, [], server.stacksDir);\n    }\n\n    public write(input : string) {\n        super.write(input);\n    }\n}\n"
  },
  {
    "path": "backend/util-server.ts",
    "content": "import { Socket } from \"socket.io\";\nimport { Terminal } from \"./terminal\";\nimport { randomBytes } from \"crypto\";\nimport { log } from \"./log\";\nimport { ERROR_TYPE_VALIDATION } from \"../common/util-common\";\nimport { R } from \"redbean-node\";\nimport { verifyPassword } from \"./password-hash\";\nimport fs from \"fs\";\nimport { AgentManager } from \"./agent-manager\";\n\nexport interface JWTDecoded {\n    username : string;\n    h? : string;\n}\n\nexport interface DockgeSocket extends Socket {\n    userID: number;\n    consoleTerminal? : Terminal;\n    instanceManager : AgentManager;\n    endpoint : string;\n    emitAgent : (eventName : string, ...args : unknown[]) => void;\n}\n\n// For command line arguments, so they are nullable\nexport interface Arguments {\n    sslKey? : string;\n    sslCert? : string;\n    sslKeyPassphrase? : string;\n    port? : number;\n    hostname? : string;\n    dataDir? : string;\n    stacksDir? : string;\n    enableConsole? : boolean;\n}\n\n// Some config values are required\nexport interface Config extends Arguments {\n    dataDir : string;\n    stacksDir : string;\n}\n\nexport function checkLogin(socket : DockgeSocket) {\n    if (!socket.userID) {\n        throw new Error(\"You are not logged in.\");\n    }\n}\n\nexport class ValidationError extends Error {\n    constructor(message : string) {\n        super(message);\n    }\n}\n\nexport function callbackError(error : unknown, callback : unknown) {\n    if (typeof(callback) !== \"function\") {\n        log.error(\"console\", \"Callback is not a function\");\n        return;\n    }\n\n    if (error instanceof Error) {\n        callback({\n            ok: false,\n            msg: error.message,\n            msgi18n: true,\n        });\n    } else if (error instanceof ValidationError) {\n        callback({\n            ok: false,\n            type: ERROR_TYPE_VALIDATION,\n            msg: error.message,\n            msgi18n: true,\n        });\n    } else {\n        log.debug(\"console\", \"Unknown error: \" + error);\n    }\n}\n\nexport function callbackResult(result : unknown, callback : unknown) {\n    if (typeof(callback) !== \"function\") {\n        log.error(\"console\", \"Callback is not a function\");\n        return;\n    }\n    callback(result);\n}\n\nexport async function doubleCheckPassword(socket : DockgeSocket, currentPassword : unknown) {\n    if (typeof currentPassword !== \"string\") {\n        throw new Error(\"Wrong data type?\");\n    }\n\n    let user = await R.findOne(\"user\", \" id = ? AND active = 1 \", [\n        socket.userID,\n    ]);\n\n    if (!user || !verifyPassword(currentPassword, user.password)) {\n        throw new Error(\"Incorrect current password\");\n    }\n\n    return user;\n}\n\nexport function fileExists(file : string) {\n    return fs.promises.access(file, fs.constants.F_OK)\n        .then(() => true)\n        .catch(() => false);\n}\n"
  },
  {
    "path": "backend/utils/limit-queue.ts",
    "content": "/**\n * Limit Queue\n * The first element will be removed when the length exceeds the limit\n */\nexport class LimitQueue<T> extends Array<T> {\n    __limit;\n    __onExceed? : (item : T | undefined) => void;\n\n    constructor(limit: number) {\n        super();\n        this.__limit = limit;\n    }\n\n    pushItem(value : T) {\n        super.push(value);\n        if (this.length > this.__limit) {\n            const item = this.shift();\n            if (this.__onExceed) {\n                this.__onExceed(item);\n            }\n        }\n    }\n\n}\n"
  },
  {
    "path": "common/agent-socket.ts",
    "content": "export class AgentSocket {\n\n    eventList : Map<string, (...args : unknown[]) => void> = new Map();\n\n    on(event : string, callback : (...args : unknown[]) => void) {\n        this.eventList.set(event, callback);\n    }\n\n    call(eventName : string, ...args : unknown[]) {\n        const callback = this.eventList.get(eventName);\n        if (callback) {\n            callback(...args);\n        }\n    }\n}\n"
  },
  {
    "path": "common/util-common.ts",
    "content": "/*\n * Common utilities for backend and frontend\n */\nimport yaml, { Document, Pair, Scalar } from \"yaml\";\nimport { DotenvParseOutput } from \"dotenv\";\n\n// Init dayjs\nimport dayjs from \"dayjs\";\nimport timezone from \"dayjs/plugin/timezone\";\nimport utc from \"dayjs/plugin/utc\";\nimport relativeTime from \"dayjs/plugin/relativeTime\";\n// @ts-ignore\nimport { replaceVariablesSync } from \"@inventage/envsubst\";\n\ndayjs.extend(utc);\ndayjs.extend(timezone);\ndayjs.extend(relativeTime);\n\nexport interface LooseObject {\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    [key: string]: any\n}\n\nexport interface BaseRes {\n    ok: boolean;\n    msg?: string;\n}\n\nlet randomBytes : (numBytes: number) => Uint8Array;\ninitRandomBytes();\n\nasync function initRandomBytes() {\n    if (typeof window !== \"undefined\" && window.crypto) {\n        randomBytes = function randomBytes(numBytes: number) {\n            const bytes = new Uint8Array(numBytes);\n            for (let i = 0; i < numBytes; i += 65536) {\n                window.crypto.getRandomValues(bytes.subarray(i, i + Math.min(numBytes - i, 65536)));\n            }\n            return bytes;\n        };\n    } else {\n        randomBytes = (await import(\"node:crypto\")).randomBytes;\n    }\n}\n\nexport const ALL_ENDPOINTS = \"##ALL_DOCKGE_ENDPOINTS##\";\n\n// Stack Status\nexport const UNKNOWN = 0;\nexport const CREATED_FILE = 1;\nexport const CREATED_STACK = 2;\nexport const RUNNING = 3;\nexport const EXITED = 4;\n\nexport function statusName(status : number) : string {\n    switch (status) {\n        case CREATED_FILE:\n            return \"draft\";\n        case CREATED_STACK:\n            return \"created_stack\";\n        case RUNNING:\n            return \"running\";\n        case EXITED:\n            return \"exited\";\n        default:\n            return \"unknown\";\n    }\n}\n\nexport function statusNameShort(status : number) : string {\n    switch (status) {\n        case CREATED_FILE:\n            return \"inactive\";\n        case CREATED_STACK:\n            return \"inactive\";\n        case RUNNING:\n            return \"active\";\n        case EXITED:\n            return \"exited\";\n        default:\n            return \"?\";\n    }\n}\n\nexport function statusColor(status : number) : string {\n    switch (status) {\n        case CREATED_FILE:\n            return \"dark\";\n        case CREATED_STACK:\n            return \"dark\";\n        case RUNNING:\n            return \"primary\";\n        case EXITED:\n            return \"danger\";\n        default:\n            return \"secondary\";\n    }\n}\n\nexport const isDev = process.env.NODE_ENV === \"development\";\nexport const TERMINAL_COLS = 105;\nexport const TERMINAL_ROWS = 10;\nexport const PROGRESS_TERMINAL_ROWS = 8;\n\nexport const COMBINED_TERMINAL_COLS = 58;\nexport const COMBINED_TERMINAL_ROWS = 20;\n\nexport const ERROR_TYPE_VALIDATION = 1;\n\nexport const acceptedComposeFileNames = [\n    \"compose.yaml\",\n    \"docker-compose.yaml\",\n    \"docker-compose.yml\",\n    \"compose.yml\",\n];\n\n/**\n * Generate a decimal integer number from a string\n * @param str Input\n * @param length Default is 10 which means 0 - 9\n */\nexport function intHash(str : string, length = 10) : number {\n    // A simple hashing function (you can use more complex hash functions if needed)\n    let hash = 0;\n    for (let i = 0; i < str.length; i++) {\n        hash += str.charCodeAt(i);\n    }\n    // Normalize the hash to the range [0, 10]\n    return (hash % length + length) % length; // Ensure the result is non-negative\n}\n\n/**\n * Delays for specified number of seconds\n * @param ms Number of milliseconds to sleep for\n */\nexport function sleep(ms: number) {\n    return new Promise(resolve => setTimeout(resolve, ms));\n}\n\n/**\n * Generate a random alphanumeric string of fixed length\n * @param length Length of string to generate\n * @returns string\n */\nexport function genSecret(length = 64) {\n    let secret = \"\";\n    const chars = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\";\n    const charsLength = chars.length;\n    for ( let i = 0; i < length; i++ ) {\n        secret += chars.charAt(getCryptoRandomInt(0, charsLength - 1));\n    }\n    return secret;\n}\n\n/**\n * Get a random integer suitable for use in cryptography between upper\n * and lower bounds.\n * @param min Minimum value of integer\n * @param max Maximum value of integer\n * @returns Cryptographically suitable random integer\n */\nexport function getCryptoRandomInt(min: number, max: number):number {\n    // synchronous version of: https://github.com/joepie91/node-random-number-csprng\n\n    const range = max - min;\n    if (range >= Math.pow(2, 32)) {\n        console.log(\"Warning! Range is too large.\");\n    }\n\n    let tmpRange = range;\n    let bitsNeeded = 0;\n    let bytesNeeded = 0;\n    let mask = 1;\n\n    while (tmpRange > 0) {\n        if (bitsNeeded % 8 === 0) {\n            bytesNeeded += 1;\n        }\n        bitsNeeded += 1;\n        mask = mask << 1 | 1;\n        tmpRange = tmpRange >>> 1;\n    }\n\n    const bytes = randomBytes(bytesNeeded);\n    let randomValue = 0;\n\n    for (let i = 0; i < bytesNeeded; i++) {\n        randomValue |= bytes[i] << 8 * i;\n    }\n\n    randomValue = randomValue & mask;\n\n    if (randomValue <= range) {\n        return min + randomValue;\n    } else {\n        return getCryptoRandomInt(min, max);\n    }\n}\n\nexport function getComposeTerminalName(endpoint : string, stack : string) {\n    return \"compose-\" + endpoint + \"-\" + stack;\n}\n\nexport function getCombinedTerminalName(endpoint : string, stack : string) {\n    return \"combined-\" + endpoint + \"-\" + stack;\n}\n\nexport function getContainerTerminalName(endpoint : string, container : string) {\n    return \"container-\" + endpoint + \"-\" + container;\n}\n\nexport function getContainerExecTerminalName(endpoint : string, stackName : string, container : string, index : number) {\n    return \"container-exec-\" + endpoint + \"-\" + stackName + \"-\" + container + \"-\" + index;\n}\n\nexport function copyYAMLComments(doc : Document, src : Document) {\n    doc.comment = src.comment;\n    doc.commentBefore = src.commentBefore;\n\n    if (doc && doc.contents && src && src.contents) {\n        // @ts-ignore\n        copyYAMLCommentsItems(doc.contents.items, src.contents.items);\n    }\n}\n\n/**\n * Copy yaml comments from srcItems to items\n * Attempts to preserve comments by matching content rather than just array indices\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction copyYAMLCommentsItems(items: any, srcItems: any) {\n    if (!items || !srcItems) {\n        return;\n    }\n\n    // First pass - try to match items by their content\n    for (let i = 0; i < items.length; i++) {\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        const item: any = items[i];\n\n        // Try to find matching source item by content\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        const srcIndex = srcItems.findIndex((srcItem: any) =>\n            JSON.stringify(srcItem.value) === JSON.stringify(item.value) &&\n            JSON.stringify(srcItem.key) === JSON.stringify(item.key)\n        );\n\n        if (srcIndex !== -1) {\n            // eslint-disable-next-line @typescript-eslint/no-explicit-any\n            const srcItem: any = srcItems[srcIndex];\n            // eslint-disable-next-line @typescript-eslint/no-explicit-any\n            const nextSrcItem: any = srcItems[srcIndex + 1];\n\n            if (item.key && srcItem.key) {\n                item.key.comment = srcItem.key.comment;\n                item.key.commentBefore = srcItem.key.commentBefore;\n            }\n\n            if (srcItem.comment) {\n                item.comment = srcItem.comment;\n            }\n\n            // Handle comments between array items\n            if (nextSrcItem && nextSrcItem.commentBefore) {\n                if (items[i + 1]) {\n                    items[i + 1].commentBefore = nextSrcItem.commentBefore;\n                }\n            }\n\n            // Handle trailing comments after array items\n            if (srcItem.value && srcItem.value.comment) {\n                if (item.value) {\n                    item.value.comment = srcItem.value.comment;\n                }\n            }\n\n            if (item.value && srcItem.value) {\n                if (typeof item.value === \"object\" && typeof srcItem.value === \"object\") {\n                    item.value.comment = srcItem.value.comment;\n                    item.value.commentBefore = srcItem.value.commentBefore;\n\n                    if (item.value.items && srcItem.value.items) {\n                        copyYAMLCommentsItems(item.value.items, srcItem.value.items);\n                    }\n                }\n            }\n        }\n    }\n}\n\n/**\n * Possible Inputs:\n * ports:\n *   - \"3000\"\n *   - \"3000-3005\"\n *   - \"8000:8000\"\n *   - \"9090-9091:8080-8081\"\n *   - \"49100:22\"\n *   - \"8000-9000:80\"\n *   - \"127.0.0.1:8001:8001\"\n *   - \"127.0.0.1:5000-5010:5000-5010\"\n *   - \"0.0.0.0:8080->8080/tcp\"\n *   - \"6060:6060/udp\"\n * @param input\n * @param hostname\n */\nexport function parseDockerPort(input : string, hostname : string) {\n    let port;\n    let display;\n\n    const parts = input.split(\"/\");\n    let part1 = parts[0];\n    let protocol = parts[1] || \"tcp\";\n\n    // coming from docker ps, split host part\n    const arrow = part1.indexOf(\"->\");\n    if (arrow >= 0) {\n        part1 = part1.split(\"->\")[0];\n        const colon = part1.indexOf(\":\");\n        if (colon >= 0) {\n            part1 = part1.split(\":\")[1];\n        }\n    }\n\n    // Split the last \":\"\n    const lastColon = part1.lastIndexOf(\":\");\n\n    if (lastColon === -1) {\n        // No colon, so it's just a port or port range\n        // Check if it's a port range\n        const dash = part1.indexOf(\"-\");\n        if (dash === -1) {\n            // No dash, so it's just a port\n            port = part1;\n        } else {\n            // Has dash, so it's a port range, use the first port\n            port = part1.substring(0, dash);\n        }\n\n        display = part1;\n\n    } else {\n        // Has colon, so it's a port mapping\n        let hostPart = part1.substring(0, lastColon);\n        display = hostPart;\n\n        // Check if it's a port range\n        const dash = part1.indexOf(\"-\");\n\n        if (dash !== -1) {\n            // Has dash, so it's a port range, use the first port\n            hostPart = part1.substring(0, dash);\n        }\n\n        // Check if it has a ip (ip:port)\n        const colon = hostPart.indexOf(\":\");\n\n        if (colon !== -1) {\n            // Has colon, so it's a ip:port\n            hostname = hostPart.substring(0, colon);\n            port = hostPart.substring(colon + 1);\n        } else {\n            // No colon, so it's just a port\n            port = hostPart;\n        }\n    }\n\n    let portInt = parseInt(port);\n\n    if (portInt == 443) {\n        protocol = \"https\";\n    } else if (protocol === \"tcp\") {\n        protocol = \"http\";\n    }\n\n    return {\n        url: protocol + \"://\" + hostname + \":\" + portInt,\n        display: display,\n    };\n}\n\nexport function envsubst(string : string, variables : LooseObject) : string {\n    return replaceVariablesSync(string, variables)[0];\n}\n\n/**\n * Traverse all values in the yaml and for each value, if there are template variables, replace it environment variables\n * Emulates the behavior of how docker-compose handles environment variables in yaml files\n * @param content Yaml string\n * @param env Environment variables\n * @returns string Yaml string with environment variables replaced\n */\nexport function envsubstYAML(content : string, env : DotenvParseOutput) : string {\n    const doc = yaml.parseDocument(content);\n    if (doc.contents) {\n        // @ts-ignore\n        for (const item of doc.contents.items) {\n            traverseYAML(item, env);\n        }\n    }\n    return doc.toString();\n}\n\n/**\n * Used for envsubstYAML(...)\n * @param pair\n * @param env\n */\nfunction traverseYAML(pair : Pair, env : DotenvParseOutput) : void {\n    // @ts-ignore\n    if (pair.value && pair.value.items) {\n        // @ts-ignore\n        for (const item of pair.value.items) {\n            if (item instanceof Pair) {\n                traverseYAML(item, env);\n            } else if (item instanceof Scalar) {\n                let value = item.value as unknown;\n\n                if (typeof(value) === \"string\") {\n                    item.value = envsubst(value, env);\n                }\n            }\n        }\n    // @ts-ignore\n    } else if (pair.value && typeof(pair.value.value) === \"string\") {\n        // @ts-ignore\n        pair.value.value = envsubst(pair.value.value, env);\n    }\n}\n\n"
  },
  {
    "path": "compose.yaml",
    "content": "services:\n  dockge:\n    image: louislam/dockge:1\n    restart: unless-stopped\n    ports:\n      # Host Port : Container Port\n      - 5001:5001\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n      - ./data:/app/data\n        \n      # If you want to use private registries, you need to share the auth file with Dockge:\n      # - /root/.docker/:/root/.docker\n\n      # Stacks Directory\n      # ⚠️ READ IT CAREFULLY. If you did it wrong, your data could end up writing into a WRONG PATH.\n      # ⚠️ 1. FULL path only. No relative path (MUST)\n      # ⚠️ 2. Left Stacks Path === Right Stacks Path (MUST)\n      - /opt/stacks:/opt/stacks\n    environment:\n      # Tell Dockge where is your stacks directory\n      - DOCKGE_STACKS_DIR=/opt/stacks\n"
  },
  {
    "path": "docker/Base.Dockerfile",
    "content": "FROM node:22-bookworm-slim\nRUN apt update && apt install --yes --no-install-recommends \\\n    curl \\\n    ca-certificates \\\n    gnupg \\\n    unzip \\\n    dumb-init \\\n    && install -m 0755 -d /etc/apt/keyrings \\\n    && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \\\n    && chmod a+r /etc/apt/keyrings/docker.gpg \\\n    && echo \\\n         \"deb [arch=\"$(dpkg --print-architecture)\" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \\\n         \"$(. /etc/os-release && echo \"$VERSION_CODENAME\")\" stable\" | \\\n         tee /etc/apt/sources.list.d/docker.list > /dev/null \\\n    && apt update \\\n    && apt --yes --no-install-recommends install \\\n         docker-ce-cli \\\n         docker-compose-plugin \\\n    && rm -rf /var/lib/apt/lists/* \\\n    && npm install -g tsx\n"
  },
  {
    "path": "docker/BuildHealthCheck.Dockerfile",
    "content": "############################################\n# Build in Golang\n############################################\nFROM golang:1.21.4-bookworm\nWORKDIR /app\nARG TARGETPLATFORM\nCOPY ./extra/healthcheck.go ./extra/healthcheck.go\n\n# Compile healthcheck.go\nRUN go build -x -o ./extra/healthcheck ./extra/healthcheck.go\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "############################################\n# Healthcheck Binary\n############################################\nFROM louislam/dockge:build-healthcheck AS build_healthcheck\n\n############################################\n# Build\n############################################\nFROM louislam/dockge:base AS build\nWORKDIR /app\nCOPY --chown=node:node  ./package.json ./package.json\nCOPY --chown=node:node  ./package-lock.json ./package-lock.json\nRUN npm ci --omit=dev\n\n############################################\n# ⭐ Main Image\n############################################\nFROM louislam/dockge:base AS release\nWORKDIR /app\nCOPY --chown=node:node --from=build_healthcheck /app/extra/healthcheck /app/extra/healthcheck\nCOPY --from=build /app/node_modules /app/node_modules\nCOPY --chown=node:node  . .\nRUN mkdir ./data\n\n\n# It is just for safe, as by default, it is disabled in the latest Node.js now.\n# Read more:\n# - https://github.com/sagemathinc/cocalc/issues/6963\n# - https://github.com/microsoft/node-pty/issues/630#issuecomment-1987212447\nENV UV_USE_IO_URING=0\n\nVOLUME /app/data\nEXPOSE 5001\nHEALTHCHECK --interval=60s --timeout=30s --start-period=60s --retries=5 CMD extra/healthcheck\nENTRYPOINT [\"/usr/bin/dumb-init\", \"--\"]\nCMD [\"tsx\", \"./backend/index.ts\"]\n\n############################################\n# Mark as Nightly\n############################################\nFROM release AS nightly\nRUN npm run mark-as-nightly\n"
  },
  {
    "path": "extra/close-incorrect-issue.js",
    "content": "import github from \"@actions/github\";\n\n(async () => {\n    try {\n        const token = process.argv[2];\n        const issueNumber = process.argv[3];\n        const username = process.argv[4];\n\n        const client = github.getOctokit(token).rest;\n\n        const issue = {\n            owner: \"louislam\",\n            repo: \"dockge\",\n            number: issueNumber,\n        };\n\n        const labels = (\n            await client.issues.listLabelsOnIssue({\n                owner: issue.owner,\n                repo: issue.repo,\n                issue_number: issue.number\n            })\n        ).data.map(({ name }) => name);\n\n        if (labels.length === 0) {\n            console.log(\"Bad format here\");\n\n            await client.issues.addLabels({\n                owner: issue.owner,\n                repo: issue.repo,\n                issue_number: issue.number,\n                labels: [ \"invalid-format\" ]\n            });\n\n            // Add the issue closing comment\n            await client.issues.createComment({\n                owner: issue.owner,\n                repo: issue.repo,\n                issue_number: issue.number,\n                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.`\n            });\n\n            // Close the issue\n            await client.issues.update({\n                owner: issue.owner,\n                repo: issue.repo,\n                issue_number: issue.number,\n                state: \"closed\"\n            });\n        } else {\n            console.log(\"Pass!\");\n        }\n    } catch (e) {\n        console.log(e);\n    }\n\n})();\n"
  },
  {
    "path": "extra/env2arg.js",
    "content": "#!/usr/bin/env node\n\nimport childProcess from \"child_process\";\n\nlet env = process.env;\n\nlet cmd = process.argv[2];\nlet args = process.argv.slice(3);\nlet replacedArgs = [];\n\nfor (let arg of args) {\n    for (let key in env) {\n        arg = arg.replaceAll(`$${key}`, env[key]);\n    }\n    replacedArgs.push(arg);\n}\n\nlet child = childProcess.spawn(cmd, replacedArgs);\nchild.stdout.pipe(process.stdout);\nchild.stderr.pipe(process.stderr);\n"
  },
  {
    "path": "extra/healthcheck.go",
    "content": "/*\n * If changed, have to run `npm run build-docker-builder-go`.\n * This script should be run after a period of time (180s), because the server may need some time to prepare.\n */\npackage main\n\nimport (\n\t\"crypto/tls\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc main() {\n\t// Is K8S + \"dockge\" as the container name\n\t// See https://github.com/louislam/uptime-kuma/pull/2083\n\tisK8s := strings.HasPrefix(os.Getenv(\"DOCKGE_PORT\"), \"tcp://\")\n\n\t// process.env.NODE_TLS_REJECT_UNAUTHORIZED = \"0\";\n\thttp.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{\n\t\tInsecureSkipVerify: true,\n\t}\n\n\tclient := http.Client{\n\t\tTimeout: 28 * time.Second,\n\t}\n\n\tsslKey := os.Getenv(\"DOCKGE_SSL_KEY\")\n\tsslCert := os.Getenv(\"DOCKGE_SSL_CERT\")\n\n\thostname := os.Getenv(\"DOCKGE_HOST\")\n\tif len(hostname) == 0 {\n\t\thostname = \"127.0.0.1\"\n\t}\n\n\tport := \"\"\n\t// DOCKGE_PORT is override by K8S unexpectedly,\n\tif !isK8s {\n\t\tport = os.Getenv(\"DOCKGE_PORT\")\n\t}\n\tif len(port) == 0 {\n\t\tport = \"5001\"\n\t}\n\n\tprotocol := \"\"\n\tif len(sslKey) != 0 && len(sslCert) != 0 {\n\t\tprotocol = \"https\"\n\t} else {\n\t\tprotocol = \"http\"\n\t}\n\n\turl := protocol + \"://\" + hostname + \":\" + port\n\n\tlog.Println(\"Checking \" + url)\n\tresp, err := client.Get(url)\n\n\tif err != nil {\n\t\tlog.Fatalln(err)\n\t}\n\n\tdefer resp.Body.Close()\n\n\t_, err = ioutil.ReadAll(resp.Body)\n\n\tif err != nil {\n\t\tlog.Fatalln(err)\n\t}\n\n\tlog.Printf(\"Health Check OK [Res Code: %d]\\n\", resp.StatusCode)\n\n}\n"
  },
  {
    "path": "extra/mark-as-nightly.ts",
    "content": "import pkg from \"../package.json\";\nimport fs from \"fs\";\nimport dayjs from \"dayjs\";\n\nconst oldVersion = pkg.version;\nconst newVersion = oldVersion + \"-nightly-\" + dayjs().format(\"YYYYMMDDHHmmss\");\n\nconsole.log(\"Old Version: \" + oldVersion);\nconsole.log(\"New Version: \" + newVersion);\n\nif (newVersion) {\n    // Process package.json\n    pkg.version = newVersion;\n    //pkg.scripts.setup = pkg.scripts.setup.replaceAll(oldVersion, newVersion);\n    //pkg.scripts[\"build-docker\"] = pkg.scripts[\"build-docker\"].replaceAll(oldVersion, newVersion);\n    fs.writeFileSync(\"package.json\", JSON.stringify(pkg, null, 4) + \"\\n\");\n\n    // Process README.md\n    if (fs.existsSync(\"README.md\")) {\n        fs.writeFileSync(\"README.md\", fs.readFileSync(\"README.md\", \"utf8\").replaceAll(oldVersion, newVersion));\n    }\n}\n"
  },
  {
    "path": "extra/reformat-changelog.ts",
    "content": "// Generate on GitHub\nconst input = `\n* Fixed envsubst issue by @louislam in https://github.com/louislam/dockge/pull/301\n* Fix: Only adding folders to stack with a compose file. by @Ozy-Viking in https://github.com/louislam/dockge/pull/299\n* Terminal text cols adjusts to terminal container.  by @Ozy-Viking in https://github.com/louislam/dockge/pull/285\n* Update Docker Dompose plugin to 2.23.3 by @louislam in https://github.com/louislam/dockge/pull/303\n* Translations update from Kuma Weblate by @UptimeKumaBot in https://github.com/louislam/dockge/pull/302\n`;\n\nconst template = `\n\n> [!WARNING]\n>\n\n### 🆕 New Features\n-\n\n### ⬆️ Improvements\n-\n\n### 🐛 Bug Fixes\n-\n\n### 🦎 Translation Contributions\n-\n\n### ⬆️ Security Fixes\n-\n\n### Others\n- Other small changes, code refactoring and comment/doc updates in this repo:\n- \n\nPlease 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.\n`;\n\nconst lines = input.split(\"\\n\").filter((line) => line.trim() !== \"\");\n\nfor (const line of lines) {\n    // Split the last \" by \"\n    const usernamePullRequesURL = line.split(\" by \").pop();\n\n    if (!usernamePullRequesURL) {\n        console.log(\"Unable to parse\", line);\n        continue;\n    }\n\n    const [ username, pullRequestURL ] = usernamePullRequesURL.split(\" in \");\n    const pullRequestID = \"#\" + pullRequestURL.split(\"/\").pop();\n    let message = line.split(\" by \").shift();\n\n    if (!message) {\n        console.log(\"Unable to parse\", line);\n        continue;\n    }\n\n    message = message.split(\"* \").pop();\n\n    let thanks = \"\";\n    if (username != \"@louislam\") {\n        thanks = `(Thanks ${username})`;\n    }\n\n    console.log(pullRequestID, message, thanks);\n}\nconsole.log(template);\n"
  },
  {
    "path": "extra/reset-password.ts",
    "content": "import { Database } from \"../backend/database\";\nimport { R } from \"redbean-node\";\nimport readline from \"readline\";\nimport { User } from \"../backend/models/user\";\nimport { DockgeServer } from \"../backend/dockge-server\";\nimport { log } from \"../backend/log\";\nimport { io } from \"socket.io-client\";\nimport { BaseRes } from \"../common/util-common\";\n\nconsole.log(\"== Dockge Reset Password Tool ==\");\n\nconst rl = readline.createInterface({\n    input: process.stdin,\n    output: process.stdout\n});\n\nconst server = new DockgeServer();\n\nexport const main = async () => {\n    // Check if\n    console.log(\"Connecting the database\");\n    try {\n        await Database.init(server);\n    } catch (e) {\n        if (e instanceof Error) {\n            log.error(\"server\", \"Failed to connect to your database: \" + e.message);\n        }\n        process.exit(1);\n    }\n\n    try {\n        // No need to actually reset the password for testing, just make sure no connection problem. It is ok for now.\n        if (!process.env.TEST_BACKEND) {\n            const user = await R.findOne(\"user\");\n            if (! user) {\n                throw new Error(\"user not found, have you installed?\");\n            }\n\n            console.log(\"Found user: \" + user.username);\n\n            while (true) {\n                let password = await question(\"New Password: \");\n                let confirmPassword = await question(\"Confirm New Password: \");\n\n                if (password === confirmPassword) {\n                    await User.resetPassword(user.id, password);\n\n                    // Reset all sessions by reset jwt secret\n                    await server.initJWTSecret();\n\n                    console.log(\"Password reset successfully.\");\n\n                    // Disconnect all other socket clients of the user\n                    await disconnectAllSocketClients(user.username, password);\n\n                    break;\n                } else {\n                    console.log(\"Passwords do not match, please try again.\");\n                }\n            }\n        }\n    } catch (e) {\n        if (e instanceof Error) {\n            console.error(\"Error: \" + e.message);\n        }\n    }\n\n    await Database.close();\n    rl.close();\n\n    console.log(\"Finished.\");\n};\n\n/**\n * Ask question of user\n * @param question Question to ask\n * @returns Users response\n */\nfunction question(question : string) : Promise<string> {\n    return new Promise((resolve) => {\n        rl.question(question, (answer) => {\n            resolve(answer);\n        });\n    });\n}\n\nfunction disconnectAllSocketClients(username : string, password : string) : Promise<void> {\n    return new Promise((resolve) => {\n        const url = server.getLocalWebSocketURL();\n\n        console.log(\"Connecting to \" + url + \" to disconnect all other socket clients\");\n\n        // Disconnect all socket connections\n        const socket = io(url, {\n            reconnection: false,\n            timeout: 5000,\n        });\n        socket.on(\"connect\", () => {\n            socket.emit(\"login\", {\n                username,\n                password,\n            }, (res : BaseRes) => {\n                if (res.ok) {\n                    console.log(\"Logged in.\");\n                    socket.emit(\"disconnectOtherSocketClients\");\n                } else {\n                    console.warn(\"Login failed.\");\n                    console.warn(\"Please restart the server to disconnect all sessions.\");\n                }\n                socket.close();\n            });\n        });\n\n        socket.on(\"connect_error\", function () {\n            // The localWebSocketURL is not guaranteed to be working for some complicated Uptime Kuma setup\n            // Ask the user to restart the server manually\n            console.warn(\"Failed to connect to \" + url);\n            console.warn(\"Please restart the server to disconnect all sessions manually.\");\n            resolve();\n        });\n        socket.on(\"disconnect\", () => {\n            resolve();\n        });\n    });\n}\n\nif (!process.env.TEST_BACKEND) {\n    main();\n}\n"
  },
  {
    "path": "extra/templates/mariadb/compose.yaml",
    "content": "services:\n  mariadb:\n    image: mariadb:latest\n    restart: unless-stopped\n    ports:\n      - 3306:3306\n    environment:\n      - MARIADB_ROOT_PASSWORD=123456\n"
  },
  {
    "path": "extra/templates/nginx-proxy-manager/compose.yaml",
    "content": "services:\n  nginx-proxy-manager:\n    image: 'jc21/nginx-proxy-manager:latest'\n    restart: unless-stopped\n    ports:\n      - '80:80'\n      - '81:81'\n      - '443:443'\n    volumes:\n      - ./data:/data\n      - ./letsencrypt:/etc/letsencrypt\n"
  },
  {
    "path": "extra/templates/uptime-kuma/compose.yaml",
    "content": "services:\n  uptime-kuma:\n    image: louislam/uptime-kuma:1\n    volumes:\n      - ./data:/app/data\n    ports:\n      - \"3001:3001\"\n    restart: always\n"
  },
  {
    "path": "extra/test-docker.ts",
    "content": "// Check if docker is running\nimport { exec } from \"child_process\";\n\nexec(\"docker ps\", (err, stdout, stderr) => {\n    if (err) {\n        console.error(\"Docker is not running. Please start docker and try again.\");\n        process.exit(1);\n    }\n});\n"
  },
  {
    "path": "extra/update-version.ts",
    "content": "import pkg from \"../package.json\";\nimport childProcess from \"child_process\";\nimport fs from \"fs\";\n\nconst newVersion = process.env.VERSION;\n\nconsole.log(\"New Version: \" + newVersion);\n\nif (! newVersion) {\n    console.error(\"invalid version\");\n    process.exit(1);\n}\n\nconst exists = tagExists(newVersion);\n\nif (! exists) {\n    // Process package.json\n    pkg.version = newVersion;\n    fs.writeFileSync(\"package.json\", JSON.stringify(pkg, null, 4) + \"\\n\");\n    commit(newVersion);\n    tag(newVersion);\n} else {\n    console.log(\"version exists\");\n}\n\n/**\n * Commit updated files\n * @param {string} version Version to update to\n */\nfunction commit(version) {\n    let msg = \"Update to \" + version;\n\n    let res = childProcess.spawnSync(\"git\", [ \"commit\", \"-m\", msg, \"-a\" ]);\n    let stdout = res.stdout.toString().trim();\n    console.log(stdout);\n\n    if (stdout.includes(\"no changes added to commit\")) {\n        throw new Error(\"commit error\");\n    }\n}\n\n/**\n * Create a tag with the specified version\n * @param {string} version Tag to create\n */\nfunction tag(version) {\n    let res = childProcess.spawnSync(\"git\", [ \"tag\", version ]);\n    console.log(res.stdout.toString().trim());\n}\n\n/**\n * Check if a tag exists for the specified version\n * @param {string} version Version to check\n * @returns {boolean} Does the tag already exist\n */\nfunction tagExists(version) {\n    if (! version) {\n        throw new Error(\"invalid version\");\n    }\n\n    let res = childProcess.spawnSync(\"git\", [ \"tag\", \"-l\", version ]);\n\n    return res.stdout.toString().trim() === version;\n}\n"
  },
  {
    "path": "frontend/components.d.ts",
    "content": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// Generated by unplugin-vue-components\n// Read more: https://github.com/vuejs/core/pull/3399\nexport {}\n\ndeclare module 'vue' {\n  export interface GlobalComponents {\n    About: typeof import('./src/components/settings/About.vue')['default']\n    Appearance: typeof import('./src/components/settings/Appearance.vue')['default']\n    ArrayInput: typeof import('./src/components/ArrayInput.vue')['default']\n    ArraySelect: typeof import('./src/components/ArraySelect.vue')['default']\n    BDropdown: typeof import('bootstrap-vue-next')['BDropdown']\n    BDropdownItem: typeof import('bootstrap-vue-next')['BDropdownItem']\n    BModal: typeof import('bootstrap-vue-next')['BModal']\n    Confirm: typeof import('./src/components/Confirm.vue')['default']\n    Container: typeof import('./src/components/Container.vue')['default']\n    General: typeof import('./src/components/settings/General.vue')['default']\n    GlobalEnv: typeof import('./src/components/settings/GlobalEnv.vue')['default']\n    HiddenInput: typeof import('./src/components/HiddenInput.vue')['default']\n    Login: typeof import('./src/components/Login.vue')['default']\n    NetworkInput: typeof import('./src/components/NetworkInput.vue')['default']\n    RouterLink: typeof import('vue-router')['RouterLink']\n    RouterView: typeof import('vue-router')['RouterView']\n    Security: typeof import('./src/components/settings/Security.vue')['default']\n    StackList: typeof import('./src/components/StackList.vue')['default']\n    StackListItem: typeof import('./src/components/StackListItem.vue')['default']\n    Terminal: typeof import('./src/components/Terminal.vue')['default']\n    TwoFADialog: typeof import('./src/components/TwoFADialog.vue')['default']\n    Uptime: typeof import('./src/components/Uptime.vue')['default']\n  }\n}\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\" />\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\">\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/icon.svg\" />\n    <link rel=\"manifest\" href=\"/manifest.json\" />\n    <meta name=\"theme-color\" id=\"theme-color\" content=\"\" />\n    <meta name=\"description\" content=\"\" />\n    <title>Dockge</title>\n    <style>\n        .noscript-message {\n            font-size: 20px;\n            text-align: center;\n            padding: 10px;\n            max-width: 500px;\n            margin: 0 auto;\n        }\n    </style>\n</head>\n<body>\n<noscript>\n    <div class=\"noscript-message\">\n        Sorry, you don't seem to have JavaScript enabled or your browser\n        doesn't support it.<br />This website requires JavaScript to function.\n        Please enable JavaScript in your browser settings to continue.\n    </div>\n</noscript>\n<div id=\"app\"></div>\n<script type=\"module\" src=\"/src/main.ts\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "frontend/public/manifest.json",
    "content": "{\n    \"name\": \"Dockge\",\n    \"short_name\": \"Dockge\",\n    \"start_url\": \"/\",\n    \"background_color\": \"#fff\",\n    \"display\": \"standalone\",\n    \"icons\": [\n        {\n            \"src\": \"icon-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"icon-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ]\n}\n"
  },
  {
    "path": "frontend/src/App.vue",
    "content": "<template>\n    <router-view />\n</template>\n\n<script>\nexport default {\n\n};\n</script>\n"
  },
  {
    "path": "frontend/src/components/ArrayInput.vue",
    "content": "<template>\n    <div>\n        <div v-if=\"valid\">\n            <ul v-if=\"isArrayInited\" class=\"list-group\">\n                <li v-for=\"(value, index) in array\" :key=\"index\" class=\"list-group-item\">\n                    <input v-model=\"array[index]\" type=\"text\" class=\"no-bg domain-input\" :placeholder=\"placeholder\" />\n                    <font-awesome-icon icon=\"times\" class=\"action remove ms-2 me-3 text-danger\" @click=\"remove(index)\" />\n                </li>\n            </ul>\n\n            <button class=\"btn btn-normal btn-sm mt-3\" @click=\"addField\">{{ $t(\"addListItem\", [ displayName ]) }}</button>\n        </div>\n        <div v-else>\n            {{ $t(\"LongSyntaxNotSupported\") }}\n        </div>\n    </div>\n</template>\n\n<script>\nexport default {\n    props: {\n        name: {\n            type: String,\n            required: true,\n        },\n        placeholder: {\n            type: String,\n            default: \"\",\n        },\n        displayName: {\n            type: String,\n            required: true,\n        },\n        objectType: {\n            type: String,\n            default: \"service\",\n        }\n    },\n    data() {\n        return {\n\n        };\n    },\n    computed: {\n        array() {\n            // Create the array if not exists, it should be safe.\n            if (!this.service[this.name]) {\n                return [];\n            }\n            return this.service[this.name];\n        },\n\n        /**\n         * Check if the array is inited before called v-for.\n         * Prevent empty arrays inserted to the YAML file.\n         * @return {boolean}\n         */\n        isArrayInited() {\n            return this.service[this.name] !== undefined;\n        },\n\n        /**\n         * Not a good name, but it is used to get the object.\n         */\n        service() {\n            if (this.objectType === \"service\") {\n                // Used in Container.vue\n                return this.$parent.$parent.service;\n            } else if (this.objectType === \"x-dockge\") {\n\n                if (!this.$parent.$parent.jsonConfig[\"x-dockge\"]) {\n                    return {};\n                }\n\n                // Used in Compose.vue\n                return this.$parent.$parent.jsonConfig[\"x-dockge\"];\n            } else {\n                return {};\n            }\n        },\n\n        valid() {\n            // Check if the array is actually an array\n            if (!Array.isArray(this.array)) {\n                return false;\n            }\n\n            // Check if the array contains non-object only.\n            for (let item of this.array) {\n                if (typeof item === \"object\") {\n                    return false;\n                }\n            }\n            return true;\n        }\n\n    },\n    created() {\n\n    },\n    methods: {\n        addField() {\n\n            // Create the object if not exists.\n            if (this.objectType === \"x-dockge\") {\n                if (!this.$parent.$parent.jsonConfig[\"x-dockge\"]) {\n                    this.$parent.$parent.jsonConfig[\"x-dockge\"] = {};\n                }\n            }\n\n            // Create the array if not exists.\n            if (!this.service[this.name]) {\n                this.service[this.name] = [];\n            }\n\n            this.array.push(\"\");\n        },\n        remove(index) {\n            this.array.splice(index, 1);\n        },\n    }\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../styles/vars.scss\";\n\n.list-group {\n    background-color: $dark-bg2;\n\n    li {\n        display: flex;\n        align-items: center;\n        padding: 10px 0 10px 10px;\n\n        .domain-input {\n            flex-grow: 1;\n            background-color: $dark-bg2;\n            border: none;\n            color: $dark-font-color;\n            outline: none;\n\n            &::placeholder {\n                color: #1d2634;\n            }\n        }\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/ArraySelect.vue",
    "content": "<template>\n    <div>\n        <div v-if=\"valid\">\n            <ul v-if=\"isArrayInited\" class=\"list-group\">\n                <li v-for=\"(value, index) in array\" :key=\"index\" class=\"list-group-item\">\n                    <select v-model=\"array[index]\" class=\"no-bg domain-input\">\n                        <option value=\"\">{{ $t(`Select a network...`) }}</option>\n                        <option v-for=\"option in options\" :key=\"option\" :value=\"option\">{{ option }}</option>\n                    </select>\n\n                    <font-awesome-icon icon=\"times\" class=\"action remove ms-2 me-3 text-danger\" @click=\"remove(index)\" />\n                </li>\n            </ul>\n\n            <button class=\"btn btn-normal btn-sm mt-3\" @click=\"addField\">{{ $t(\"addListItem\", [ displayName ]) }}</button>\n        </div>\n        <div v-else>\n            Long syntax is not supported here. Please use the YAML editor.\n        </div>\n    </div>\n</template>\n\n<script>\nexport default {\n    props: {\n        name: {\n            type: String,\n            required: true,\n        },\n        placeholder: {\n            type: String,\n            default: \"\",\n        },\n        displayName: {\n            type: String,\n            required: true,\n        },\n        options: {\n            type: Array,\n            required: true,\n        },\n    },\n    data() {\n        return {\n\n        };\n    },\n    computed: {\n        array() {\n            // Create the array if not exists, it should be safe.\n            if (!this.service[this.name]) {\n                return [];\n            }\n            return this.service[this.name];\n        },\n\n        /**\n         * Check if the array is inited before called v-for.\n         * Prevent empty arrays inserted to the YAML file.\n         * @return {boolean}\n         */\n        isArrayInited() {\n            return this.service[this.name] !== undefined;\n        },\n\n        service() {\n            return this.$parent.$parent.service;\n        },\n\n        valid() {\n            // Check if the array is actually an array\n            if (!Array.isArray(this.array)) {\n                return false;\n            }\n\n            // Check if the array contains non-object only.\n            for (let item of this.array) {\n                if (typeof item === \"object\") {\n                    return false;\n                }\n            }\n            return true;\n        }\n\n    },\n    created() {\n\n    },\n    methods: {\n        addField() {\n            // Create the array if not exists.\n            if (!this.service[this.name]) {\n                this.service[this.name] = [];\n            }\n            this.array.push(\"\");\n        },\n        remove(index) {\n            this.array.splice(index, 1);\n        },\n    }\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../styles/vars.scss\";\n\n.list-group {\n    background-color: $dark-bg2;\n\n    li {\n        display: flex;\n        align-items: center;\n        padding: 10px 0 10px 10px;\n\n        .domain-input {\n            flex-grow: 1;\n            background-color: $dark-bg2;\n            border: none;\n            color: $dark-font-color;\n            outline: none;\n\n            &::placeholder {\n                color: #1d2634;\n            }\n        }\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/Confirm.vue",
    "content": "<template>\n    <div ref=\"modal\" class=\"modal fade\" tabindex=\"-1\">\n        <div class=\"modal-dialog\">\n            <div class=\"modal-content\">\n                <div class=\"modal-header\">\n                    <h5 id=\"exampleModalLabel\" class=\"modal-title\">\n                        {{ title || $t(\"Confirm\") }}\n                    </h5>\n                    <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\" />\n                </div>\n                <div class=\"modal-body\">\n                    <slot />\n                </div>\n                <div class=\"modal-footer\">\n                    <button type=\"button\" class=\"btn\" :class=\"btnStyle\" data-bs-dismiss=\"modal\" @click=\"yes\">\n                        {{ yesText }}\n                    </button>\n                    <button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\" @click=\"no\">\n                        {{ noText }}\n                    </button>\n                </div>\n            </div>\n        </div>\n    </div>\n</template>\n\n<script>\nimport { Modal } from \"bootstrap\";\n\nexport default {\n    props: {\n        /** Style of button */\n        btnStyle: {\n            type: String,\n            default: \"btn-primary\",\n        },\n        /** Text to use as yes */\n        yesText: {\n            type: String,\n            default: \"Yes\",     // TODO: No idea what to translate this\n        },\n        /** Text to use as no */\n        noText: {\n            type: String,\n            default: \"No\",\n        },\n        /** Title to show on modal. Defaults to translated version of \"Config\" */\n        title: {\n            type: String,\n            default: null,\n        }\n    },\n    emits: [ \"yes\", \"no\" ],\n    data: () => ({\n        modal: null,\n    }),\n    mounted() {\n        this.modal = new Modal(this.$refs.modal);\n    },\n    methods: {\n        /**\n         * Show the confirm dialog\n         * @returns {void}\n         */\n        show() {\n            this.modal.show();\n        },\n        /**\n         * @fires string \"yes\" Notify the parent when Yes is pressed\n         * @returns {void}\n         */\n        yes() {\n            this.$emit(\"yes\");\n        },\n        /**\n         * @fires string \"no\" Notify the parent when No is pressed\n         * @returns {void}\n         */\n        no() {\n            this.$emit(\"no\");\n        }\n    },\n};\n</script>\n"
  },
  {
    "path": "frontend/src/components/Container.vue",
    "content": "<template>\n    <div class=\"shadow-box big-padding mb-3 container\">\n        <div class=\"row\">\n            <div class=\"col-7\">\n                <h4>{{ name }}</h4>\n                <div class=\"image mb-2\">\n                    <span class=\"me-1\">{{ imageName }}:</span><span class=\"tag\">{{ imageTag }}</span>\n                </div>\n                <div v-if=\"!isEditMode\">\n                    <span class=\"badge me-1\" :class=\"bgStyle\">{{ status }}</span>\n\n                    <a v-for=\"port in (ports ?? envsubstService.ports)\" :key=\"port\" :href=\"parsePort(port).url\" target=\"_blank\">\n                        <span class=\"badge me-1 bg-secondary\">{{ parsePort(port).display }}</span>\n                    </a>\n                </div>\n            </div>\n            <div class=\"col-5\">\n                <div class=\"function\">\n                    <router-link v-if=\"!isEditMode\" class=\"btn btn-normal\" :to=\"terminalRouteLink\" disabled=\"\">\n                        <font-awesome-icon icon=\"terminal\" />\n                        Bash\n                    </router-link>\n                </div>\n            </div>\n        </div>\n\n        <div v-if=\"isEditMode\" class=\"mt-2\">\n            <button class=\"btn btn-normal me-2\" @click=\"showConfig = !showConfig\">\n                <font-awesome-icon icon=\"edit\" />\n                {{ $t(\"Edit\") }}\n            </button>\n            <button v-if=\"false\" class=\"btn btn-normal me-2\">Rename</button>\n            <button class=\"btn btn-danger me-2\" @click=\"remove\">\n                <font-awesome-icon icon=\"trash\" />\n                {{ $t(\"deleteContainer\") }}\n            </button>\n        </div>\n\n        <transition name=\"slide-fade\" appear>\n            <div v-if=\"isEditMode && showConfig\" class=\"config mt-3\">\n                <!-- Image -->\n                <div class=\"mb-4\">\n                    <label class=\"form-label\">\n                        {{ $t(\"dockerImage\") }}\n                    </label>\n                    <div class=\"input-group mb-3\">\n                        <input\n                            v-model=\"service.image\"\n                            class=\"form-control\"\n                            list=\"image-datalist\"\n                        />\n                    </div>\n\n                    <!-- TODO: Search online: https://hub.docker.com/api/content/v1/products/search?q=louislam%2Fuptime&source=community&page=1&page_size=4 -->\n                    <datalist id=\"image-datalist\">\n                        <option value=\"louislam/uptime-kuma:1\" />\n                    </datalist>\n                    <div class=\"form-text\"></div>\n                </div>\n\n                <!-- Ports -->\n                <div class=\"mb-4\">\n                    <label class=\"form-label\">\n                        {{ $tc(\"port\", 2) }}\n                    </label>\n                    <ArrayInput name=\"ports\" :display-name=\"$t('port')\" placeholder=\"HOST:CONTAINER\" />\n                </div>\n\n                <!-- Volumes -->\n                <div class=\"mb-4\">\n                    <label class=\"form-label\">\n                        {{ $tc(\"volume\", 2) }}\n                    </label>\n                    <ArrayInput name=\"volumes\" :display-name=\"$t('volume')\" placeholder=\"HOST:CONTAINER\" />\n                </div>\n\n                <!-- Restart Policy -->\n                <div class=\"mb-4\">\n                    <label class=\"form-label\">\n                        {{ $t(\"restartPolicy\") }}\n                    </label>\n                    <select v-model=\"service.restart\" class=\"form-select\">\n                        <option value=\"always\">{{ $t(\"restartPolicyAlways\") }}</option>\n                        <option value=\"unless-stopped\">{{ $t(\"restartPolicyUnlessStopped\") }}</option>\n                        <option value=\"on-failure\">{{ $t(\"restartPolicyOnFailure\") }}</option>\n                        <option value=\"no\">{{ $t(\"restartPolicyNo\") }}</option>\n                    </select>\n                </div>\n\n                <!-- Environment Variables -->\n                <div class=\"mb-4\">\n                    <label class=\"form-label\">\n                        {{ $tc(\"environmentVariable\", 2) }}\n                    </label>\n                    <ArrayInput name=\"environment\" :display-name=\"$t('environmentVariable')\" placeholder=\"KEY=VALUE\" />\n                </div>\n\n                <!-- Container Name -->\n                <div v-if=\"false\" class=\"mb-4\">\n                    <label class=\"form-label\">\n                        {{ $t(\"containerName\") }}\n                    </label>\n                    <div class=\"input-group mb-3\">\n                        <input\n                            v-model=\"service.container_name\"\n                            class=\"form-control\"\n                        />\n                    </div>\n                    <div class=\"form-text\"></div>\n                </div>\n\n                <!-- Network -->\n                <div class=\"mb-4\">\n                    <label class=\"form-label\">\n                        {{ $tc(\"network\", 2) }}\n                    </label>\n\n                    <div v-if=\"networkList.length === 0 && service.networks && service.networks.length > 0\" class=\"text-warning mb-3\">\n                        {{ $t(\"NoNetworksAvailable\") }}\n                    </div>\n\n                    <ArraySelect name=\"networks\" :display-name=\"$t('network')\" placeholder=\"Network Name\" :options=\"networkList\" />\n                </div>\n\n                <!-- Depends on -->\n                <div class=\"mb-4\">\n                    <label class=\"form-label\">\n                        {{ $t(\"dependsOn\") }}\n                    </label>\n                    <ArrayInput name=\"depends_on\" :display-name=\"$t('dependsOn')\" :placeholder=\"$t(`containerName`)\" />\n                </div>\n            </div>\n        </transition>\n    </div>\n</template>\n\n<script>\nimport { defineComponent } from \"vue\";\nimport { FontAwesomeIcon } from \"@fortawesome/vue-fontawesome\";\nimport { parseDockerPort } from \"../../../common/util-common\";\n\nexport default defineComponent({\n    components: {\n        FontAwesomeIcon,\n    },\n    props: {\n        name: {\n            type: String,\n            required: true,\n        },\n        isEditMode: {\n            type: Boolean,\n            default: false,\n        },\n        first: {\n            type: Boolean,\n            default: false,\n        },\n        status: {\n            type: String,\n            default: \"N/A\",\n        },\n        ports: {\n            type: Array,\n            default: null\n        }\n    },\n    emits: [\n    ],\n    data() {\n        return {\n            showConfig: false,\n        };\n    },\n    computed: {\n\n        networkList() {\n            let list = [];\n            for (const networkName in this.jsonObject.networks) {\n                list.push(networkName);\n            }\n            return list;\n        },\n\n        bgStyle() {\n            if (this.status === \"running\" || this.status === \"healthy\") {\n                return \"bg-primary\";\n            } else if (this.status === \"unhealthy\") {\n                return \"bg-danger\";\n            } else {\n                return \"bg-secondary\";\n            }\n        },\n\n        terminalRouteLink() {\n            if (this.endpoint) {\n                return {\n                    name: \"containerTerminalEndpoint\",\n                    params: {\n                        endpoint: this.endpoint,\n                        stackName: this.stackName,\n                        serviceName: this.name,\n                        type: \"bash\",\n                    },\n                };\n            } else {\n                return {\n                    name: \"containerTerminal\",\n                    params: {\n                        stackName: this.stackName,\n                        serviceName: this.name,\n                        type: \"bash\",\n                    },\n                };\n            }\n        },\n\n        endpoint() {\n            return this.$parent.$parent.endpoint;\n        },\n\n        stack() {\n            return this.$parent.$parent.stack;\n        },\n\n        stackName() {\n            return this.$parent.$parent.stack.name;\n        },\n\n        service() {\n            if (!this.jsonObject.services[this.name]) {\n                return {};\n            }\n            return this.jsonObject.services[this.name];\n        },\n\n        jsonObject() {\n            return this.$parent.$parent.jsonConfig;\n        },\n\n        envsubstJSONConfig() {\n            return this.$parent.$parent.envsubstJSONConfig;\n        },\n\n        envsubstService() {\n            if (!this.envsubstJSONConfig.services[this.name]) {\n                return {};\n            }\n            return this.envsubstJSONConfig.services[this.name];\n        },\n\n        imageName() {\n            if (this.envsubstService.image) {\n                return this.envsubstService.image.split(\":\")[0];\n            } else {\n                return \"\";\n            }\n        },\n\n        imageTag() {\n            if (this.envsubstService.image) {\n                let tag = this.envsubstService.image.split(\":\")[1];\n\n                if (tag) {\n                    return tag;\n                } else {\n                    return \"latest\";\n                }\n            } else {\n                return \"\";\n            }\n        },\n    },\n    mounted() {\n        if (this.first) {\n            //this.showConfig = true;\n        }\n    },\n    methods: {\n        parsePort(port) {\n            if (this.stack.endpoint) {\n                return parseDockerPort(port, this.stack.primaryHostname);\n            } else {\n                let hostname = this.$root.info.primaryHostname || location.hostname;\n                return parseDockerPort(port, hostname);\n            }\n        },\n        remove() {\n            delete this.jsonObject.services[this.name];\n        },\n    }\n});\n</script>\n\n<style scoped lang=\"scss\">\n@import \"../styles/vars\";\n\n.container {\n    .image {\n        font-size: 0.8rem;\n        color: #6c757d;\n        .tag {\n            color: #33383b;\n        }\n    }\n\n    .function {\n        align-content: center;\n        display: flex;\n        height: 100%;\n        width: 100%;\n        align-items: center;\n        justify-content: end;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/HiddenInput.vue",
    "content": "<template>\n    <div class=\"input-group mb-3\">\n        <input\n            ref=\"input\"\n            v-model=\"model\"\n            :type=\"visibility\"\n            class=\"form-control\"\n            :placeholder=\"placeholder\"\n            :maxlength=\"maxlength\"\n            :autocomplete=\"autocomplete\"\n            :required=\"required\"\n            :readonly=\"readonly\"\n        >\n\n        <a v-if=\"visibility == 'password'\" class=\"btn btn-outline-primary\" @click=\"showInput()\">\n            <font-awesome-icon icon=\"eye\" />\n        </a>\n        <a v-if=\"visibility == 'text'\" class=\"btn btn-outline-primary\" @click=\"hideInput()\">\n            <font-awesome-icon icon=\"eye-slash\" />\n        </a>\n    </div>\n</template>\n\n<script>\nexport default {\n    props: {\n        /** The value of the input */\n        modelValue: {\n            type: String,\n            default: \"\"\n        },\n        /** A placeholder to use */\n        placeholder: {\n            type: String,\n            default: \"\"\n        },\n        /** Maximum length of the input */\n        maxlength: {\n            type: Number,\n            default: 255\n        },\n        /** Should the field auto complete */\n        autocomplete: {\n            type: String,\n            default: \"new-password\",\n        },\n        /** Is the input required? */\n        required: {\n            type: Boolean\n        },\n        /** Should the input be read only? */\n        readonly: {\n            type: String,\n            default: undefined,\n        },\n    },\n    emits: [ \"update:modelValue\" ],\n    data() {\n        return {\n            visibility: \"password\",\n        };\n    },\n    computed: {\n        model: {\n            get() {\n                return this.modelValue;\n            },\n            set(value) {\n                this.$emit(\"update:modelValue\", value);\n            }\n        }\n    },\n    created() {\n\n    },\n    methods: {\n        /** Show users input in plain text */\n        showInput() {\n            this.visibility = \"text\";\n        },\n        /** Censor users input */\n        hideInput() {\n            this.visibility = \"password\";\n        },\n    }\n};\n</script>\n"
  },
  {
    "path": "frontend/src/components/Login.vue",
    "content": "<template>\n    <div class=\"form-container\">\n        <div class=\"form\">\n            <form @submit.prevent=\"submit\">\n                <h1 class=\"h3 mb-3 fw-normal\" />\n\n                <div v-if=\"!tokenRequired\" class=\"form-floating\">\n                    <input id=\"floatingInput\" v-model=\"username\" type=\"text\" class=\"form-control\" placeholder=\"Username\" autocomplete=\"username\" required>\n                    <label for=\"floatingInput\">{{ $t(\"Username\") }}</label>\n                </div>\n\n                <div v-if=\"!tokenRequired\" class=\"form-floating mt-3\">\n                    <input id=\"floatingPassword\" v-model=\"password\" type=\"password\" class=\"form-control\" placeholder=\"Password\" autocomplete=\"current-password\" required>\n                    <label for=\"floatingPassword\">{{ $t(\"Password\") }}</label>\n                </div>\n\n                <div v-if=\"tokenRequired\">\n                    <div class=\"form-floating mt-3\">\n                        <input id=\"otp\" v-model=\"token\" type=\"text\" maxlength=\"6\" class=\"form-control\" placeholder=\"123456\" autocomplete=\"one-time-code\" required>\n                        <label for=\"otp\">{{ $t(\"Token\") }}</label>\n                    </div>\n                </div>\n\n                <div class=\"form-check mb-3 mt-3 d-flex justify-content-center pe-4\">\n                    <div class=\"form-check\">\n                        <input id=\"remember\" v-model=\"$root.remember\" type=\"checkbox\" value=\"remember-me\" class=\"form-check-input\">\n\n                        <label class=\"form-check-label\" for=\"remember\">\n                            {{ $t(\"Remember me\") }}\n                        </label>\n                    </div>\n                </div>\n                <button class=\"w-100 btn btn-primary\" type=\"submit\" :disabled=\"processing\">\n                    {{ $t(\"Login\") }}\n                </button>\n\n                <div v-if=\"res && !res.ok\" class=\"alert alert-danger mt-3\" role=\"alert\">\n                    {{ $t(res.msg) }}\n                </div>\n            </form>\n        </div>\n    </div>\n</template>\n\n<script>\nexport default {\n    data() {\n        return {\n            processing: false,\n            username: \"\",\n            password: \"\",\n            token: \"\",\n            res: null,\n            tokenRequired: false,\n        };\n    },\n\n    mounted() {\n        document.title += \" - Login\";\n    },\n\n    unmounted() {\n        document.title = document.title.replace(\" - Login\", \"\");\n    },\n\n    methods: {\n        /**\n         * Submit the user details and attempt to log in\n         * @returns {void}\n         */\n        submit() {\n            this.processing = true;\n\n            this.$root.login(this.username, this.password, this.token, (res) => {\n                this.processing = false;\n\n                if (res.tokenRequired) {\n                    this.tokenRequired = true;\n                } else {\n                    this.res = res;\n                }\n            });\n        },\n\n    },\n};\n</script>\n\n<style lang=\"scss\" scoped>\n.form-container {\n    display: flex;\n    align-items: center;\n    padding-top: 40px;\n    padding-bottom: 40px;\n}\n\n.form-floating {\n    > label {\n        padding-left: 1.3rem;\n    }\n\n    > .form-control {\n        padding-left: 1.3rem;\n    }\n}\n\n.form {\n    width: 100%;\n    max-width: 330px;\n    padding: 15px;\n    margin: auto;\n    text-align: center;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/NetworkInput.vue",
    "content": "<template>\n    <div>\n        <h5>{{ $t(\"Internal Networks\") }}</h5>\n        <ul class=\"list-group\">\n            <li v-for=\"(networkRow, index) in networkList\" :key=\"index\" class=\"list-group-item\">\n                <input v-model=\"networkRow.key\" type=\"text\" class=\"no-bg domain-input\" :placeholder=\"$t(`Network name...`)\" />\n                <font-awesome-icon icon=\"times\" class=\"action remove ms-2 me-3 text-danger\" @click=\"remove(index)\" />\n            </li>\n        </ul>\n\n        <button class=\"btn btn-normal btn-sm mt-3 me-2\" @click=\"addField\">{{ $t(\"addInternalNetwork\") }}</button>\n\n        <h5 class=\"mt-3\">{{ $t(\"External Networks\") }}</h5>\n\n        <div v-if=\"externalNetworkList.length === 0\">\n            {{ $t(\"No External Networks\") }}\n        </div>\n\n        <div v-for=\"(networkName, index) in externalNetworkList\" :key=\"networkName\" class=\"form-check form-switch my-3\">\n            <input :id=\" 'external-network' + index\" v-model=\"selectedExternalList[networkName]\" class=\"form-check-input\" type=\"checkbox\">\n\n            <label class=\"form-check-label\" :for=\" 'external-network' +index\">\n                {{ networkName }}\n            </label>\n\n            <span v-if=\"false\" class=\"text-danger ms-2 delete\">Delete</span>\n        </div>\n\n        <div v-if=\"false\" class=\"input-group mb-3\">\n            <input\n                placeholder=\"New external network name...\"\n                class=\"form-control\"\n                @keyup.enter=\"createExternelNetwork\"\n            />\n            <button class=\"btn btn-normal btn-sm  me-2\" type=\"button\">\n                {{ $t(\"createExternalNetwork\") }}\n            </button>\n        </div>\n\n        <div v-if=\"false\">\n            <button class=\"btn btn-primary btn-sm mt-3 me-2\" @click=\"applyToYAML\">{{ $t(\"applyToYAML\") }}</button>\n        </div>\n    </div>\n</template>\n\n<script>\nexport default {\n    data() {\n        return {\n            networkList: [],\n            externalList: {},\n            selectedExternalList: {},\n            externalNetworkList: [],\n        };\n    },\n    computed: {\n        jsonConfig() {\n            return this.$parent.$parent.jsonConfig;\n        },\n\n        stack() {\n            return this.$parent.$parent.stack;\n        },\n\n        editorFocus() {\n            return this.$parent.$parent.editorFocus;\n        },\n\n        endpoint() {\n            return this.$parent.$parent.endpoint;\n        },\n    },\n    watch: {\n        \"jsonConfig.networks\": {\n            handler() {\n                if (this.editorFocus) {\n                    console.debug(\"jsonConfig.networks changed\");\n                    this.loadNetworkList();\n                }\n            },\n            deep: true,\n        },\n\n        \"selectedExternalList\": {\n            handler() {\n                for (const networkName in this.selectedExternalList) {\n                    const enable = this.selectedExternalList[networkName];\n\n                    if (enable) {\n                        if (!this.externalList[networkName]) {\n                            this.externalList[networkName] = {};\n                        }\n                        this.externalList[networkName].external = true;\n                    } else {\n                        delete this.externalList[networkName];\n                    }\n                }\n                this.applyToYAML();\n            },\n            deep: true,\n        },\n\n        \"networkList\": {\n            handler() {\n                this.applyToYAML();\n            },\n            deep: true,\n        }\n\n    },\n    mounted() {\n        this.loadNetworkList();\n        this.loadExternalNetworkList();\n    },\n    methods: {\n        loadNetworkList() {\n            this.networkList = [];\n            this.externalList = {};\n\n            for (const key in this.jsonConfig.networks) {\n                let obj = {\n                    key: key,\n                    value: this.jsonConfig.networks[key],\n                };\n\n                if (obj.value && obj.value.external) {\n                    this.externalList[key] = Object.assign({}, obj.value);\n                } else {\n                    this.networkList.push(obj);\n                }\n            }\n\n            // Restore selectedExternalList\n            this.selectedExternalList = {};\n            for (const networkName in this.externalList) {\n                this.selectedExternalList[networkName] = true;\n            }\n        },\n\n        loadExternalNetworkList() {\n            this.$root.emitAgent(this.endpoint, \"getDockerNetworkList\", (res) => {\n                if (res.ok) {\n                    this.externalNetworkList = res.dockerNetworkList.filter((n) => {\n                        // Filter out this stack networks\n                        if (n.startsWith(this.stack.name + \"_\")) {\n                            return false;\n                        }\n                        // They should be not supported.\n                        // https://docs.docker.com/compose/compose-file/06-networks/#host-or-none\n                        if (n === \"none\" || n === \"host\" || n === \"bridge\") {\n                            return false;\n                        }\n                        return true;\n                    });\n                } else {\n                    this.$root.toastRes(res);\n                }\n            });\n        },\n\n        addField() {\n            this.networkList.push({\n                key: \"\",\n                value: {},\n            });\n        },\n\n        remove(index) {\n            this.networkList.splice(index, 1);\n            this.applyToYAML();\n        },\n\n        applyToYAML() {\n            if (this.editorFocus) {\n                return;\n            }\n\n            this.jsonConfig.networks = {};\n\n            // Internal networks\n            for (const networkRow of this.networkList) {\n                this.jsonConfig.networks[networkRow.key] = networkRow.value;\n            }\n\n            // External networks\n            for (const networkName in this.externalList) {\n                this.jsonConfig.networks[networkName] = this.externalList[networkName];\n            }\n\n            console.debug(\"applyToYAML\", this.jsonConfig.networks);\n        }\n\n    },\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../styles/vars.scss\";\n\n.list-group {\n    background-color: $dark-bg2;\n\n    li {\n        display: flex;\n        align-items: center;\n        padding: 10px 0 10px 10px;\n\n        .domain-input {\n            flex-grow: 1;\n            background-color: $dark-bg2;\n            border: none;\n            color: $dark-font-color;\n            outline: none;\n\n            &::placeholder {\n                color: #1d2634;\n            }\n        }\n    }\n}\n\n.delete {\n    text-decoration: underline;\n    font-size: 13px;\n    cursor: pointer;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/StackList.vue",
    "content": "<template>\n    <div class=\"shadow-box mb-3\" :style=\"boxStyle\">\n        <div class=\"list-header\">\n            <div class=\"header-top\">\n                <!-- TODO -->\n                <button v-if=\"false\" class=\"btn btn-outline-normal ms-2\" :class=\"{ 'active': selectMode }\" type=\"button\" @click=\"selectMode = !selectMode\">\n                    {{ $t(\"Select\") }}\n                </button>\n\n                <div class=\"placeholder\"></div>\n                <div class=\"search-wrapper\">\n                    <a v-if=\"searchText == ''\" class=\"search-icon\">\n                        <font-awesome-icon icon=\"search\" />\n                    </a>\n                    <a v-if=\"searchText != ''\" class=\"search-icon\" style=\"cursor: pointer\" @click=\"clearSearchText\">\n                        <font-awesome-icon icon=\"times\" />\n                    </a>\n                    <form>\n                        <input v-model=\"searchText\" class=\"form-control search-input\" autocomplete=\"off\" />\n                    </form>\n                </div>\n            </div>\n\n            <!-- TODO -->\n            <div v-if=\"false\" class=\"header-filter\">\n                <!--<StackListFilter :filterState=\"filterState\" @update-filter=\"updateFilter\" />-->\n            </div>\n\n            <!-- TODO: Selection Controls -->\n            <div v-if=\"selectMode && false\" class=\"selection-controls px-2 pt-2\">\n                <input\n                    v-model=\"selectAll\"\n                    class=\"form-check-input select-input\"\n                    type=\"checkbox\"\n                />\n\n                <button class=\"btn-outline-normal\" @click=\"pauseDialog\"><font-awesome-icon icon=\"pause\" size=\"sm\" /> {{ $t(\"Pause\") }}</button>\n                <button class=\"btn-outline-normal\" @click=\"resumeSelected\"><font-awesome-icon icon=\"play\" size=\"sm\" /> {{ $t(\"Resume\") }}</button>\n\n                <span v-if=\"selectedStackCount > 0\">\n                    {{ $t(\"selectedStackCount\", [ selectedStackCount ]) }}\n                </span>\n            </div>\n        </div>\n        <div ref=\"stackList\" class=\"stack-list\" :class=\"{ scrollbar: scrollbar }\" :style=\"stackListStyle\">\n            <div v-if=\"Object.keys(sortedStackList).length === 0\" class=\"text-center mt-3\">\n                <router-link to=\"/compose\">{{ $t(\"addFirstStackMsg\") }}</router-link>\n            </div>\n\n            <StackListItem\n                v-for=\"(item, index) in sortedStackList\"\n                :key=\"index\"\n                :stack=\"item\"\n                :isSelectMode=\"selectMode\"\n                :isSelected=\"isSelected\"\n                :select=\"select\"\n                :deselect=\"deselect\"\n            />\n        </div>\n    </div>\n\n    <Confirm ref=\"confirmPause\" :yes-text=\"$t('Yes')\" :no-text=\"$t('No')\" @yes=\"pauseSelected\">\n        {{ $t(\"pauseStackMsg\") }}\n    </Confirm>\n</template>\n\n<script>\nimport Confirm from \"../components/Confirm.vue\";\nimport StackListItem from \"../components/StackListItem.vue\";\nimport { CREATED_FILE, CREATED_STACK, EXITED, RUNNING, UNKNOWN } from \"../../../common/util-common\";\n\nexport default {\n    components: {\n        Confirm,\n        StackListItem,\n    },\n    props: {\n        /** Should the scrollbar be shown */\n        scrollbar: {\n            type: Boolean,\n        },\n    },\n    data() {\n        return {\n            searchText: \"\",\n            selectMode: false,\n            selectAll: false,\n            disableSelectAllWatcher: false,\n            selectedStacks: {},\n            windowTop: 0,\n            filterState: {\n                status: null,\n                active: null,\n                tags: null,\n            }\n        };\n    },\n    computed: {\n        /**\n         * Improve the sticky appearance of the list by increasing its\n         * height as user scrolls down.\n         * Not used on mobile.\n         * @returns {object} Style for stack list\n         */\n        boxStyle() {\n            if (window.innerWidth > 550) {\n                return {\n                    height: `calc(100vh - 160px + ${this.windowTop}px)`,\n                };\n            } else {\n                return {\n                    height: \"calc(100vh - 160px)\",\n                };\n            }\n\n        },\n\n        /**\n         * Returns a sorted list of stacks based on the applied filters and search text.\n         * @returns {Array} The sorted list of stacks.\n         */\n        sortedStackList() {\n            let result = Object.values(this.$root.completeStackList);\n\n            result = result.filter(stack => {\n                // filter by search text\n                // finds stack name, tag name or tag value\n                let searchTextMatch = true;\n                if (this.searchText !== \"\") {\n                    const loweredSearchText = this.searchText.toLowerCase();\n                    searchTextMatch =\n                        stack.name.toLowerCase().includes(loweredSearchText)\n                        || stack.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)\n                            || tag.value?.toLowerCase().includes(loweredSearchText));\n                }\n\n                // filter by active\n                let activeMatch = true;\n                if (this.filterState.active != null && this.filterState.active.length > 0) {\n                    activeMatch = this.filterState.active.includes(stack.active);\n                }\n\n                // filter by tags\n                let tagsMatch = true;\n                if (this.filterState.tags != null && this.filterState.tags.length > 0) {\n                    tagsMatch = stack.tags.map(tag => tag.tag_id) // convert to array of tag IDs\n                        .filter(stackTagId => this.filterState.tags.includes(stackTagId)) // perform Array Intersaction between filter and stack's tags\n                        .length > 0;\n                }\n\n                return searchTextMatch && activeMatch && tagsMatch;\n            });\n\n            result.sort((m1, m2) => {\n\n                // sort by managed by dockge\n                if (m1.isManagedByDockge && !m2.isManagedByDockge) {\n                    return -1;\n                } else if (!m1.isManagedByDockge && m2.isManagedByDockge) {\n                    return 1;\n                }\n\n                // sort by status\n                if (m1.status !== m2.status) {\n                    if (m2.status === RUNNING) {\n                        return 1;\n                    } else if (m1.status === RUNNING) {\n                        return -1;\n                    } else if (m2.status === EXITED) {\n                        return 1;\n                    } else if (m1.status === EXITED) {\n                        return -1;\n                    } else if (m2.status === CREATED_STACK) {\n                        return 1;\n                    } else if (m1.status === CREATED_STACK) {\n                        return -1;\n                    } else if (m2.status === CREATED_FILE) {\n                        return 1;\n                    } else if (m1.status === CREATED_FILE) {\n                        return -1;\n                    } else if (m2.status === UNKNOWN) {\n                        return 1;\n                    } else if (m1.status === UNKNOWN) {\n                        return -1;\n                    }\n                }\n                return m1.name.localeCompare(m2.name);\n            });\n\n            return result;\n        },\n\n        isDarkTheme() {\n            return document.body.classList.contains(\"dark\");\n        },\n\n        stackListStyle() {\n            //let listHeaderHeight = 107;\n            let listHeaderHeight = 60;\n\n            if (this.selectMode) {\n                listHeaderHeight += 42;\n            }\n\n            return {\n                \"height\": `calc(100% - ${listHeaderHeight}px)`\n            };\n        },\n\n        selectedStackCount() {\n            return Object.keys(this.selectedStacks).length;\n        },\n\n        /**\n         * Determines if any filters are active.\n         * @returns {boolean} True if any filter is active, false otherwise.\n         */\n        filtersActive() {\n            return this.filterState.status != null || this.filterState.active != null || this.filterState.tags != null || this.searchText !== \"\";\n        }\n    },\n    watch: {\n        searchText() {\n            for (let stack of this.sortedStackList) {\n                if (!this.selectedStacks[stack.id]) {\n                    if (this.selectAll) {\n                        this.disableSelectAllWatcher = true;\n                        this.selectAll = false;\n                    }\n                    break;\n                }\n            }\n        },\n        selectAll() {\n            if (!this.disableSelectAllWatcher) {\n                this.selectedStacks = {};\n\n                if (this.selectAll) {\n                    this.sortedStackList.forEach((item) => {\n                        this.selectedStacks[item.id] = true;\n                    });\n                }\n            } else {\n                this.disableSelectAllWatcher = false;\n            }\n        },\n        selectMode() {\n            if (!this.selectMode) {\n                this.selectAll = false;\n                this.selectedStacks = {};\n            }\n        },\n    },\n    mounted() {\n        window.addEventListener(\"scroll\", this.onScroll);\n    },\n    beforeUnmount() {\n        window.removeEventListener(\"scroll\", this.onScroll);\n    },\n    methods: {\n        /**\n         * Handle user scroll\n         * @returns {void}\n         */\n        onScroll() {\n            if (window.top.scrollY <= 133) {\n                this.windowTop = window.top.scrollY;\n            } else {\n                this.windowTop = 133;\n            }\n        },\n\n        /**\n         * Clear the search bar\n         * @returns {void}\n         */\n        clearSearchText() {\n            this.searchText = \"\";\n        },\n        /**\n         * Update the StackList Filter\n         * @param {object} newFilter Object with new filter\n         * @returns {void}\n         */\n        updateFilter(newFilter) {\n            this.filterState = newFilter;\n        },\n        /**\n         * Deselect a stack\n         * @param {number} id ID of stack\n         * @returns {void}\n         */\n        deselect(id) {\n            delete this.selectedStacks[id];\n        },\n        /**\n         * Select a stack\n         * @param {number} id ID of stack\n         * @returns {void}\n         */\n        select(id) {\n            this.selectedStacks[id] = true;\n        },\n        /**\n         * Determine if stack is selected\n         * @param {number} id ID of stack\n         * @returns {bool} Is the stack selected?\n         */\n        isSelected(id) {\n            return id in this.selectedStacks;\n        },\n        /**\n         * Disable select mode and reset selection\n         * @returns {void}\n         */\n        cancelSelectMode() {\n            this.selectMode = false;\n            this.selectedStacks = {};\n        },\n        /**\n         * Show dialog to confirm pause\n         * @returns {void}\n         */\n        pauseDialog() {\n            this.$refs.confirmPause.show();\n        },\n        /**\n         * Pause each selected stack\n         * @returns {void}\n         */\n        pauseSelected() {\n            Object.keys(this.selectedStacks)\n                .filter(id => this.$root.stackList[id].active)\n                .forEach(id => this.$root.getSocket().emit(\"pauseStack\", id, () => {}));\n\n            this.cancelSelectMode();\n        },\n        /**\n         * Resume each selected stack\n         * @returns {void}\n         */\n        resumeSelected() {\n            Object.keys(this.selectedStacks)\n                .filter(id => !this.$root.stackList[id].active)\n                .forEach(id => this.$root.getSocket().emit(\"resumeStack\", id, () => {}));\n\n            this.cancelSelectMode();\n        },\n    },\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../styles/vars.scss\";\n\n.shadow-box {\n    height: calc(100vh - 150px);\n    position: sticky;\n    top: 10px;\n}\n\n.small-padding {\n    padding-left: 5px !important;\n    padding-right: 5px !important;\n}\n\n.list-header {\n    border-bottom: 1px solid #dee2e6;\n    border-radius: 10px 10px 0 0;\n    margin: -10px;\n    margin-bottom: 10px;\n    padding: 10px;\n\n    .dark & {\n        background-color: $dark-header-bg;\n        border-bottom: 0;\n    }\n}\n\n.header-top {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n}\n\n.header-filter {\n    display: flex;\n    align-items: center;\n}\n\n@media (max-width: 770px) {\n    .list-header {\n        margin: -20px;\n        margin-bottom: 10px;\n        padding: 5px;\n    }\n}\n\n.search-wrapper {\n    display: flex;\n    align-items: center;\n}\n\n.search-icon {\n    padding: 10px;\n    color: #c0c0c0;\n\n    // Clear filter button (X)\n    svg[data-icon=\"times\"] {\n        cursor: pointer;\n        transition: all ease-in-out 0.1s;\n\n        &:hover {\n            opacity: 0.5;\n        }\n    }\n}\n\n.search-input {\n    max-width: 15em;\n}\n\n.stack-item {\n    width: 100%;\n}\n\n.tags {\n    margin-top: 4px;\n    padding-left: 67px;\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0;\n}\n\n.bottom-style {\n    padding-left: 67px;\n    margin-top: 5px;\n}\n\n.selection-controls {\n    margin-top: 5px;\n    display: flex;\n    align-items: center;\n    gap: 10px;\n}\n\n</style>\n"
  },
  {
    "path": "frontend/src/components/StackListItem.vue",
    "content": "<template>\n    <router-link :to=\"url\" :class=\"{ 'dim' : !stack.isManagedByDockge }\" class=\"item\">\n        <Uptime :stack=\"stack\" :fixed-width=\"true\" class=\"me-2\" />\n        <div class=\"title\">\n            <span>{{ stackName }}</span>\n            <div v-if=\"$root.agentCount > 1\" class=\"endpoint\">{{ endpointDisplay }}</div>\n        </div>\n    </router-link>\n</template>\n\n<script>\nimport Uptime from \"./Uptime.vue\";\n\nexport default {\n    components: {\n        Uptime\n    },\n    props: {\n        /** Stack this represents */\n        stack: {\n            type: Object,\n            default: null,\n        },\n        /** If the user is in select mode */\n        isSelectMode: {\n            type: Boolean,\n            default: false,\n        },\n        /** How many ancestors are above this stack */\n        depth: {\n            type: Number,\n            default: 0,\n        },\n        /** Callback to determine if stack is selected */\n        isSelected: {\n            type: Function,\n            default: () => {}\n        },\n        /** Callback fired when stack is selected */\n        select: {\n            type: Function,\n            default: () => {}\n        },\n        /** Callback fired when stack is deselected */\n        deselect: {\n            type: Function,\n            default: () => {}\n        },\n    },\n    data() {\n        return {\n            isCollapsed: true,\n        };\n    },\n    computed: {\n        endpointDisplay() {\n            return this.$root.endpointDisplayFunction(this.stack.endpoint);\n        },\n        url() {\n            if (this.stack.endpoint) {\n                return `/compose/${this.stack.name}/${this.stack.endpoint}`;\n            } else {\n                return `/compose/${this.stack.name}`;\n            }\n        },\n        depthMargin() {\n            return {\n                marginLeft: `${31 * this.depth}px`,\n            };\n        },\n        stackName() {\n            return this.stack.name;\n        }\n    },\n    watch: {\n        isSelectMode() {\n            // TODO: Resize the heartbeat bar, but too slow\n            // this.$refs.heartbeatBar.resize();\n        }\n    },\n    beforeMount() {\n\n    },\n    methods: {\n        /**\n         * Changes the collapsed value of the current stack and saves\n         * it to local storage\n         * @returns {void}\n         */\n        changeCollapsed() {\n            this.isCollapsed = !this.isCollapsed;\n\n            // Save collapsed value into local storage\n            let storage = window.localStorage.getItem(\"stackCollapsed\");\n            let storageObject = {};\n            if (storage !== null) {\n                storageObject = JSON.parse(storage);\n            }\n            storageObject[`stack_${this.stack.id}`] = this.isCollapsed;\n\n            window.localStorage.setItem(\"stackCollapsed\", JSON.stringify(storageObject));\n        },\n\n        /**\n         * Toggle selection of stack\n         * @returns {void}\n         */\n        toggleSelection() {\n            if (this.isSelected(this.stack.id)) {\n                this.deselect(this.stack.id);\n            } else {\n                this.select(this.stack.id);\n            }\n        },\n    },\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../styles/vars.scss\";\n\n.small-padding {\n    padding-left: 5px !important;\n    padding-right: 5px !important;\n}\n\n.collapse-padding {\n    padding-left: 8px !important;\n    padding-right: 2px !important;\n}\n\n.item {\n    text-decoration: none;\n    display: flex;\n    align-items: center;\n    min-height: 52px;\n    border-radius: 10px;\n    transition: all ease-in-out 0.15s;\n    width: 100%;\n    padding: 5px 8px;\n    &.disabled {\n        opacity: 0.3;\n    }\n    &:hover {\n        background-color: $highlight-white;\n    }\n    &.active {\n        background-color: #cdf8f4;\n    }\n    .title {\n        margin-top: -4px;\n    }\n    .endpoint {\n        font-size: 12px;\n        color: $dark-font-color3;\n    }\n}\n\n.collapsed {\n    transform: rotate(-90deg);\n}\n\n.animated {\n    transition: all 0.2s $easing-in;\n}\n\n.select-input-wrapper {\n    float: left;\n    margin-top: 15px;\n    margin-left: 3px;\n    margin-right: 10px;\n    padding-left: 4px;\n    position: relative;\n    z-index: 15;\n}\n\n.dim {\n    opacity: 0.5;\n}\n\n</style>\n"
  },
  {
    "path": "frontend/src/components/Terminal.vue",
    "content": "<template>\n    <div class=\"shadow-box\">\n        <div v-pre ref=\"terminal\" class=\"main-terminal\"></div>\n    </div>\n</template>\n\n<script>\nimport { Terminal } from \"@xterm/xterm\";\nimport { FitAddon } from \"@xterm/addon-fit\";\nimport { TERMINAL_COLS, TERMINAL_ROWS } from \"../../../common/util-common\";\n\nexport default {\n    /**\n     * @type {Terminal}\n     */\n    terminal: null,\n    components: {\n\n    },\n    props: {\n        name: {\n            type: String,\n            require: true,\n        },\n\n        endpoint: {\n            type: String,\n            require: true,\n        },\n\n        // Require if mode is interactive\n        stackName: {\n            type: String,\n        },\n\n        // Require if mode is interactive\n        serviceName: {\n            type: String,\n        },\n\n        // Require if mode is interactive\n        shell: {\n            type: String,\n            default: \"bash\",\n        },\n\n        rows: {\n            type: Number,\n            default: TERMINAL_ROWS,\n        },\n\n        cols: {\n            type: Number,\n            default: TERMINAL_COLS,\n        },\n\n        // Mode\n        // displayOnly: Only display terminal output\n        // mainTerminal: Allow input limited commands and output\n        // interactive: Free input and output\n        mode: {\n            type: String,\n            default: \"displayOnly\",\n        }\n    },\n    emits: [ \"has-data\" ],\n    data() {\n        return {\n            first: true,\n            terminalInputBuffer: \"\",\n            cursorPosition: 0,\n        };\n    },\n    created() {\n\n    },\n    mounted() {\n        let cursorBlink = true;\n\n        if (this.mode === \"displayOnly\") {\n            cursorBlink = false;\n        }\n\n        this.terminal = new Terminal({\n            fontSize: 14,\n            fontFamily: \"'JetBrains Mono', monospace\",\n            cursorBlink,\n            cols: this.cols,\n            rows: this.rows,\n        });\n\n        if (this.mode === \"mainTerminal\") {\n            this.mainTerminalConfig();\n        } else if (this.mode === \"interactive\") {\n            this.interactiveTerminalConfig();\n        }\n\n        //this.terminal.loadAddon(new WebLinksAddon());\n\n        // Bind to a div\n        this.terminal.open(this.$refs.terminal);\n        this.terminal.focus();\n\n        // Add right-click context menu handler for paste\n        this.$refs.terminal.addEventListener('contextmenu', this.handleContextMenu);\n\n        // Add selection handler for copy to clipboard\n        this.terminal.onSelectionChange(() => {\n            this.handleSelection();\n        });\n\n        // Notify parent component when data is received\n        this.terminal.onCursorMove(() => {\n            console.debug(\"onData triggered\");\n            if (this.first) {\n                this.$emit(\"has-data\");\n                this.first = false;\n            }\n        });\n\n        this.bind();\n\n        // Create a new Terminal\n        if (this.mode === \"mainTerminal\") {\n            this.$root.emitAgent(this.endpoint, \"mainTerminal\", this.name, (res) => {\n                if (!res.ok) {\n                    this.$root.toastRes(res);\n                }\n            });\n        } else if (this.mode === \"interactive\") {\n            console.debug(\"Create Interactive terminal:\", this.name);\n            this.$root.emitAgent(this.endpoint, \"interactiveTerminal\", this.stackName, this.serviceName, this.shell, (res) => {\n                if (!res.ok) {\n                    this.$root.toastRes(res);\n                }\n            });\n        }\n        // Fit the terminal width to the div container size after terminal is created.\n        this.updateTerminalSize();\n    },\n\n    unmounted() {\n        window.removeEventListener(\"resize\", this.onResizeEvent); // Remove the resize event listener from the window object.\n        this.$root.unbindTerminal(this.name);\n        this.terminal.dispose();\n        this.$refs.terminal?.removeEventListener('contextmenu', this.handleContextMenu);\n    },\n\n    methods: {\n        bind(endpoint, name) {\n            // Workaround: normally this.name should be set, but it is not sometimes, so we use the parameter, but eventually this.name and name must be the same name\n            if (name) {\n                this.$root.unbindTerminal(name);\n                this.$root.bindTerminal(endpoint, name, this.terminal);\n                console.debug(\"Terminal bound via parameter: \" + name);\n            } else if (this.name) {\n                this.$root.unbindTerminal(this.name);\n                this.$root.bindTerminal(this.endpoint, this.name, this.terminal);\n                console.debug(\"Terminal bound: \" + this.name);\n            } else {\n                console.debug(\"Terminal name not set\");\n            }\n        },\n\n        removeInput() {\n            const textAfterCursorLength = this.terminalInputBuffer.length - this.cursorPosition;\n            const spaces = \" \".repeat(textAfterCursorLength);\n            const backspaceCount = this.terminalInputBuffer.length;\n            const backspaces = \"\\b \\b\".repeat(backspaceCount);\n            this.cursorPosition = 0;\n            this.terminal.write(spaces + backspaces);\n            this.terminalInputBuffer = \"\";\n        },\n\n        clearCurrentLine() {\n            // Move cursor to the beginning of the input and clear it\n            const backspaces = \"\\b\".repeat(this.cursorPosition);\n            const spaces = \" \".repeat(this.terminalInputBuffer.length);\n            const moreBackspaces = \"\\b\".repeat(this.terminalInputBuffer.length);\n            this.terminal.write(backspaces + spaces + moreBackspaces);\n        },\n\n        mainTerminalConfig() {\n            this.terminal.onKey(e => {\n                // Optional: keep for debugging\n                // console.debug(\"Encode: \" + JSON.stringify(e.key));\n\n                if (e.key === \"\\r\") {\n                    // Return if no input\n                    if (this.terminalInputBuffer.length === 0) {\n                        return;\n                    }\n\n                    const buffer = this.terminalInputBuffer;\n\n                    // Remove the input from the terminal\n                    this.removeInput();\n\n                    this.$root.emitAgent(this.endpoint, \"terminalInput\", this.name, buffer + e.key, (err) => {\n                        this.$root.toastError(err.msg);\n                    });\n                } else if (e.key === \"\\u007F\") {      // Backspace\n                    if (this.cursorPosition > 0) {\n                        // Remove character to the left of cursor\n                        const beforeCursor = this.terminalInputBuffer.slice(0, this.cursorPosition - 1);\n                        const afterCursor = this.terminalInputBuffer.slice(this.cursorPosition);\n                        this.terminalInputBuffer = beforeCursor + afterCursor;\n                        this.cursorPosition--;\n                        \n                        // Redraw the line\n                        this.terminal.write(\"\\b\" + afterCursor + \" \\b\".repeat(afterCursor.length + 1));\n                    }\n                } else if (e.key === \"\\u001B\\u005B\\u0033\\u007E\") { // Delete key\n                    if (this.cursorPosition < this.terminalInputBuffer.length) {\n                        // Remove character to the right of cursor\n                        const beforeCursor = this.terminalInputBuffer.slice(0, this.cursorPosition);\n                        const afterCursor = this.terminalInputBuffer.slice(this.cursorPosition + 1);\n                        this.terminalInputBuffer = beforeCursor + afterCursor;\n                        \n                        // Redraw the line from cursor position\n                        this.terminal.write(afterCursor + \" \\b\".repeat(afterCursor.length + 1));\n                    }\n                } else if (e.key === \"\\u001B\\u005B\\u0041\" || e.key === \"\\u001B\\u005B\\u0042\") {      // UP OR DOWN\n                    // Do nothing\n                } else if (e.key === \"\\u001B\\u005B\\u0043\") {      // RIGHT\n                    if (this.cursorPosition < this.terminalInputBuffer.length) {\n                        this.terminal.write(this.terminalInputBuffer[this.cursorPosition]);\n                        this.cursorPosition++;\n                    }\n                } else if (e.key === \"\\u001B\\u005B\\u0044\") {      // LEFT\n                    if (this.cursorPosition > 0) {\n                        this.terminal.write(\"\\b\");\n                        this.cursorPosition--;\n                    }\n                } else if (e.key === \"\\u0003\") {      // Ctrl + C\n                    console.debug(\"Ctrl + C\");\n                    this.$root.emitAgent(this.endpoint, \"terminalInput\", this.name, e.key);\n                    this.removeInput();\n                } else if (e.key === \"\\u0016\" || (e.domEvent?.ctrlKey && e.key.toLowerCase() === \"v\")) {      // Ctrl + V\n                    this.handlePaste();\n                } else if (e.key === \"\\u0009\" || e.key.startsWith(\"\\u001B\")) {      // TAB or other special keys\n                    // Do nothing\n                } else {\n                    const textBeforeCursor = this.terminalInputBuffer.slice(0, this.cursorPosition);\n                    const textAfterCursor = this.terminalInputBuffer.slice(this.cursorPosition);\n                    this.terminalInputBuffer = textBeforeCursor + e.key + textAfterCursor;\n                    this.terminal.write(e.key + textAfterCursor + \"\\b\".repeat(textAfterCursor.length));\n                    this.cursorPosition++;\n                }\n            });\n        },\n\n        interactiveTerminalConfig() {\n            this.terminal.onKey(e => {\n                // Handle Ctrl+V for paste\n                if (e.key === \"\\u0016\" || (e.domEvent?.ctrlKey && e.key.toLowerCase() === \"v\")) {\n                    this.handlePaste();\n                    return;\n                }\n\n                this.$root.emitAgent(this.endpoint, \"terminalInput\", this.name, e.key, (res) => {\n                    if (!res.ok) {\n                        this.$root.toastRes(res);\n                    }\n                });\n            });\n        },\n\n        /**\n         * Update the terminal size to fit the container size.\n         *\n         * If the terminalFitAddOn is not created, creates it, loads it and then fits the terminal to the appropriate size.\n         * It then addes an event listener to the window object to listen for resize events and calls the fit method of the terminalFitAddOn.\n         */\n        updateTerminalSize() {\n            if (!Object.hasOwn(this, \"terminalFitAddOn\")) {\n                this.terminalFitAddOn = new FitAddon();\n                this.terminal.loadAddon(this.terminalFitAddOn);\n                window.addEventListener(\"resize\", this.onResizeEvent);\n            }\n            this.terminalFitAddOn.fit();\n        },\n        /**\n         * Handles the resize event of the terminal component.\n         */\n        onResizeEvent() {\n            this.terminalFitAddOn.fit();\n            let rows = this.terminal.rows;\n            let cols = this.terminal.cols;\n            this.$root.emitAgent(this.endpoint, \"terminalResize\", this.name, rows, cols);\n        },\n\n        /**\n         * Handle clipboard paste operation\n         */\n        async handlePaste() {\n            try {\n                const text = await navigator.clipboard.readText();\n                if (text) {\n                    this.pasteText(text);\n                }\n            } catch (error) {\n                console.error(\"Failed to read from clipboard:\", error);\n            }\n        },\n\n        /**\n         * Paste text into the terminal based on current mode\n         */\n        pasteText(text) {\n            if (this.mode === \"mainTerminal\") {\n                // For main terminal, insert text at current cursor position\n                const beforeCursor = this.terminalInputBuffer.slice(0, this.cursorPosition);\n                const afterCursor = this.terminalInputBuffer.slice(this.cursorPosition);\n                \n                // Update the buffer with inserted text\n                this.terminalInputBuffer = beforeCursor + text + afterCursor;\n                \n                // Clear the current line and rewrite it\n                this.clearCurrentLine();\n                this.terminal.write(this.terminalInputBuffer);\n                \n                // Move cursor to the correct position (after the pasted text)\n                this.cursorPosition += text.length;\n                const backspaces = \"\\b\".repeat(afterCursor.length);\n                this.terminal.write(backspaces);\n                \n            } else if (this.mode === \"interactive\") {\n                // For interactive terminal, send directly to server\n                this.$root.emitAgent(this.endpoint, \"terminalInput\", this.name, text, (res) => {\n                    if (!res.ok) {\n                        this.$root.toastRes(res);\n                    }\n                });\n            }\n        },\n\n        /**\n         * Handle right-click context menu for paste operation\n         */\n        handleContextMenu(event) {\n            // Prevent default context menu\n            event.preventDefault();\n            \n            // Only handle paste for modes that support input\n            if (this.mode === \"mainTerminal\" || this.mode === \"interactive\") {\n                this.handlePaste();\n            }\n        },\n\n        /**\n         * Handle text selection in terminal - copy to clipboard\n         */\n        handleSelection() {\n            const selectedText = this.terminal.getSelection();\n            if (selectedText && selectedText.length > 0) {\n                this.copyToClipboard(selectedText);\n            }\n        },\n\n        /**\n         * Copy text to clipboard\n         */\n        async copyToClipboard(text) {\n            try {\n                await navigator.clipboard.writeText(text);\n                console.debug(\"Text copied to clipboard:\", text);\n            } catch (error) {\n                console.error(\"Failed to copy to clipboard:\", error);\n            }\n        },\n    }\n};\n</script>\n\n<style scoped lang=\"scss\">\n.main-terminal {\n    height: 100%;\n}\n</style>\n\n<style lang=\"scss\">\n.terminal {\n    background-color: black !important;\n    height: 100%;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/TwoFADialog.vue",
    "content": "<template>\n    <form @submit.prevent=\"submit\">\n        <div ref=\"modal\" class=\"modal fade\" tabindex=\"-1\" data-bs-backdrop=\"static\">\n            <div class=\"modal-dialog\">\n                <div class=\"modal-content\">\n                    <div class=\"modal-header\">\n                        <h5 class=\"modal-title\">\n                            {{ $t(\"Setup 2FA\") }}\n                            <span v-if=\"twoFAStatus == true\" class=\"badge bg-primary\">{{ $t(\"Active\") }}</span>\n                            <span v-if=\"twoFAStatus == false\" class=\"badge bg-primary\">{{ $t(\"Inactive\") }}</span>\n                        </h5>\n                        <button :disabled=\"processing\" type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\" />\n                    </div>\n                    <div class=\"modal-body\">\n                        <div class=\"mb-3\">\n                            <div v-if=\"uri && twoFAStatus == false\" class=\"mx-auto text-center\" style=\"width: 210px;\">\n                                <vue-qrcode :key=\"uri\" :value=\"uri\" type=\"image/png\" :quality=\"1\" :color=\"{ light: '#ffffffff' }\" />\n                                <button v-show=\"!showURI\" type=\"button\" class=\"btn btn-outline-primary btn-sm mt-2\" @click=\"showURI = true\">{{ $t(\"Show URI\") }}</button>\n                            </div>\n                            <p v-if=\"showURI && twoFAStatus == false\" class=\"text-break mt-2\">{{ uri }}</p>\n\n                            <div v-if=\"!(uri && twoFAStatus == false)\" class=\"mb-3\">\n                                <label for=\"current-password\" class=\"form-label\">\n                                    {{ $t(\"Current Password\") }}\n                                </label>\n                                <input\n                                    id=\"current-password\"\n                                    v-model=\"currentPassword\"\n                                    type=\"password\"\n                                    class=\"form-control\"\n                                    autocomplete=\"current-password\"\n                                    required\n                                />\n                            </div>\n\n                            <button v-if=\"uri == null && twoFAStatus == false\" class=\"btn btn-primary\" type=\"button\" @click=\"prepare2FA()\">\n                                {{ $t(\"Enable 2FA\") }}\n                            </button>\n\n                            <button v-if=\"twoFAStatus == true\" class=\"btn btn-danger\" type=\"button\" :disabled=\"processing\" @click=\"confirmDisableTwoFA()\">\n                                {{ $t(\"Disable 2FA\") }}\n                            </button>\n\n                            <div v-if=\"uri && twoFAStatus == false\" class=\"mt-3\">\n                                <label for=\"basic-url\" class=\"form-label\">{{ $t(\"twoFAVerifyLabel\") }}</label>\n                                <div class=\"input-group\">\n                                    <input v-model=\"token\" type=\"text\" maxlength=\"6\" class=\"form-control\" autocomplete=\"one-time-code\" required>\n                                    <button class=\"btn btn-outline-primary\" type=\"button\" @click=\"verifyToken()\">{{ $t(\"Verify Token\") }}</button>\n                                </div>\n                                <p v-show=\"tokenValid\" class=\"mt-2\" style=\"color: green;\">{{ $t(\"tokenValidSettingsMsg\") }}</p>\n                            </div>\n                        </div>\n                    </div>\n\n                    <div v-if=\"uri && twoFAStatus == false\" class=\"modal-footer\">\n                        <button type=\"submit\" class=\"btn btn-primary\" :disabled=\"processing || tokenValid == false\" @click=\"confirmEnableTwoFA()\">\n                            <div v-if=\"processing\" class=\"spinner-border spinner-border-sm me-1\"></div>\n                            {{ $t(\"Save\") }}\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </form>\n\n    <Confirm ref=\"confirmEnableTwoFA\" btn-style=\"btn-danger\" :yes-text=\"$t('Yes')\" :no-text=\"$t('No')\" @yes=\"save2FA\">\n        {{ $t(\"confirmEnableTwoFAMsg\") }}\n    </Confirm>\n\n    <Confirm ref=\"confirmDisableTwoFA\" btn-style=\"btn-danger\" :yes-text=\"$t('Yes')\" :no-text=\"$t('No')\" @yes=\"disable2FA\">\n        {{ $t(\"confirmDisableTwoFAMsg\") }}\n    </Confirm>\n</template>\n\n<script lang=\"ts\">\nimport { Modal } from \"bootstrap\";\nimport Confirm from \"./Confirm.vue\";\nimport VueQrcode from \"vue-qrcode\";\nimport { useToast } from \"vue-toastification\";\nconst toast = useToast();\n\nexport default {\n    components: {\n        Confirm,\n        VueQrcode,\n    },\n    props: {},\n    data() {\n        return {\n            currentPassword: \"\",\n            processing: false,\n            uri: null,\n            tokenValid: false,\n            twoFAStatus: null,\n            token: null,\n            showURI: false,\n        };\n    },\n    mounted() {\n        this.modal = new Modal(this.$refs.modal);\n        this.getStatus();\n    },\n    methods: {\n        /** Show the dialog */\n        show() {\n            this.modal.show();\n        },\n\n        /** Show dialog to confirm enabling 2FA */\n        confirmEnableTwoFA() {\n            this.$refs.confirmEnableTwoFA.show();\n        },\n\n        /** Show dialog to confirm disabling 2FA */\n        confirmDisableTwoFA() {\n            this.$refs.confirmDisableTwoFA.show();\n        },\n\n        /** Prepare 2FA configuration */\n        prepare2FA() {\n            this.processing = true;\n\n            this.$root.getSocket().emit(\"prepare2FA\", this.currentPassword, (res) => {\n                this.processing = false;\n\n                if (res.ok) {\n                    this.uri = res.uri;\n                } else {\n                    toast.error(res.msg);\n                }\n            });\n        },\n\n        /** Save the current 2FA configuration */\n        save2FA() {\n            this.processing = true;\n\n            this.$root.getSocket().emit(\"save2FA\", this.currentPassword, (res) => {\n                this.processing = false;\n\n                if (res.ok) {\n                    this.$root.toastRes(res);\n                    this.getStatus();\n                    this.currentPassword = \"\";\n                    this.modal.hide();\n                } else {\n                    toast.error(res.msg);\n                }\n            });\n        },\n\n        /** Disable 2FA for this user */\n        disable2FA() {\n            this.processing = true;\n\n            this.$root.getSocket().emit(\"disable2FA\", this.currentPassword, (res) => {\n                this.processing = false;\n\n                if (res.ok) {\n                    this.$root.toastRes(res);\n                    this.getStatus();\n                    this.currentPassword = \"\";\n                    this.modal.hide();\n                } else {\n                    toast.error(res.msg);\n                }\n            });\n        },\n\n        /** Verify the token generated by the user */\n        verifyToken() {\n            this.$root.getSocket().emit(\"verifyToken\", this.token, this.currentPassword, (res) => {\n                if (res.ok) {\n                    this.tokenValid = res.valid;\n                } else {\n                    toast.error(res.msg);\n                }\n            });\n        },\n\n        /** Get current status of 2FA */\n        getStatus() {\n            this.$root.getSocket().emit(\"twoFAStatus\", (res) => {\n                if (res.ok) {\n                    this.twoFAStatus = res.status;\n                } else {\n                    toast.error(res.msg);\n                }\n            });\n        },\n    },\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../styles/vars.scss\";\n\n.dark {\n    .modal-dialog .form-text, .modal-dialog p {\n        color: $dark-font-color;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/Uptime.vue",
    "content": "<template>\n    <span :class=\"className\">{{ statusName }}</span>\n</template>\n\n<script>\nimport { statusColor, statusNameShort } from \"../../../common/util-common\";\n\nexport default {\n    props: {\n        stack: {\n            type: Object,\n            default: null,\n        },\n        fixedWidth: {\n            type: Boolean,\n            default: false,\n        },\n    },\n\n    computed: {\n        uptime() {\n            return this.$t(\"notAvailableShort\");\n        },\n\n        color() {\n            return statusColor(this.stack?.status);\n        },\n\n        statusName() {\n            return this.$t(statusNameShort(this.stack?.status));\n        },\n\n        className() {\n            let className = `badge rounded-pill bg-${this.color}`;\n\n            if (this.fixedWidth) {\n                className += \" fixed-width\";\n            }\n            return className;\n        },\n    },\n};\n</script>\n\n<style scoped>\n.badge {\n    min-width: 62px;\n\n}\n\n.fixed-width {\n    width: 62px;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/settings/About.vue",
    "content": "<template>\n    <div class=\"d-flex justify-content-center align-items-center\">\n        <div class=\"logo d-flex flex-column justify-content-center align-items-center\">\n            <object class=\"my-4\" width=\"200\" height=\"200\" data=\"/icon.svg\" />\n            <div class=\"fs-4 fw-bold\">Dockge</div>\n            <div>{{ $t(\"Version\") }}: {{ $root.info.version }}</div>\n            <div class=\"frontend-version\">{{ $t(\"Frontend Version\") }}: {{ $root.frontendVersion }}</div>\n\n            <div v-if=\"!$root.isFrontendBackendVersionMatched\" class=\"alert alert-warning mt-4\" role=\"alert\">\n                ⚠️ {{ $t(\"Frontend Version do not match backend version!\") }}\n            </div>\n\n            <div class=\"my-3 update-link\"><a href=\"https://github.com/louislam/dockge/releases\" target=\"_blank\" rel=\"noopener\">{{ $t(\"Check Update On GitHub\") }}</a></div>\n\n            <div class=\"mt-1\">\n                <div class=\"form-check\">\n                    <label><input v-model=\"settings.checkUpdate\" type=\"checkbox\" @change=\"saveSettings()\" /> {{ $t(\"Show update if available\") }}</label>\n                </div>\n\n                <div class=\"form-check\">\n                    <label><input v-model=\"settings.checkBeta\" type=\"checkbox\" :disabled=\"!settings.checkUpdate\" @change=\"saveSettings()\" /> {{ $t(\"Also check beta release\") }}</label>\n                </div>\n            </div>\n        </div>\n    </div>\n</template>\n\n<script>\nexport default {\n    computed: {\n        settings() {\n            return this.$parent.$parent.$parent.settings;\n        },\n        saveSettings() {\n            return this.$parent.$parent.$parent.saveSettings;\n        },\n        settingsLoaded() {\n            return this.$parent.$parent.$parent.settingsLoaded;\n        },\n    },\n\n    watch: {\n\n    }\n};\n</script>\n\n<style lang=\"scss\" scoped>\n.logo {\n    margin: 4em 1em;\n}\n\n.update-link {\n    font-size: 0.8em;\n}\n\n.frontend-version {\n    font-size: 0.9em;\n    color: #cccccc;\n\n    .dark & {\n        color: #333333;\n    }\n}\n\n</style>\n"
  },
  {
    "path": "frontend/src/components/settings/Appearance.vue",
    "content": "<template>\n    <div>\n        <div class=\"my-4\">\n            <label for=\"language\" class=\"form-label\">\n                {{ $t(\"Language\") }}\n            </label>\n            <select id=\"language\" v-model=\"$root.language\" class=\"form-select\">\n                <option\n                    v-for=\"(lang, i) in $i18n.availableLocales\"\n                    :key=\"`Lang${i}`\"\n                    :value=\"lang\"\n                >\n                    {{ $i18n.messages[lang].languageName }}\n                </option>\n            </select>\n        </div>\n        <div v-show=\"true\" class=\"my-4\">\n            <label for=\"timezone\" class=\"form-label\">{{ $t(\"Theme\") }}</label>\n            <div>\n                <div\n                    class=\"btn-group\"\n                    role=\"group\"\n                    aria-label=\"Basic checkbox toggle button group\"\n                >\n                    <input\n                        id=\"btncheck1\"\n                        v-model=\"$root.userTheme\"\n                        type=\"radio\"\n                        class=\"btn-check\"\n                        name=\"theme\"\n                        autocomplete=\"off\"\n                        value=\"light\"\n                    />\n                    <label class=\"btn btn-outline-primary\" for=\"btncheck1\">\n                        {{ $t(\"Light\") }}\n                    </label>\n\n                    <input\n                        id=\"btncheck2\"\n                        v-model=\"$root.userTheme\"\n                        type=\"radio\"\n                        class=\"btn-check\"\n                        name=\"theme\"\n                        autocomplete=\"off\"\n                        value=\"dark\"\n                    />\n                    <label class=\"btn btn-outline-primary\" for=\"btncheck2\">\n                        {{ $t(\"Dark\") }}\n                    </label>\n\n                    <input\n                        id=\"btncheck3\"\n                        v-model=\"$root.userTheme\"\n                        type=\"radio\"\n                        class=\"btn-check\"\n                        name=\"theme\"\n                        autocomplete=\"off\"\n                        value=\"auto\"\n                    />\n                    <label class=\"btn btn-outline-primary\" for=\"btncheck3\">\n                        {{ $t(\"Auto\") }}\n                    </label>\n                </div>\n            </div>\n        </div>\n    </div>\n</template>\n\n<script>\nexport default {\n\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../../styles/vars.scss\";\n\n.btn-check:active + .btn-outline-primary,\n.btn-check:checked + .btn-outline-primary,\n.btn-check:hover + .btn-outline-primary {\n    color: #fff;\n\n    .dark & {\n        color: #000;\n    }\n}\n\n.dark {\n    .list-group-item {\n        background-color: $dark-bg2;\n        color: $dark-font-color;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/settings/General.vue",
    "content": "<template>\n    <div>\n        <form class=\"my-4\" autocomplete=\"off\" @submit.prevent=\"saveGeneral\">\n            <!-- Client side Timezone -->\n            <div v-if=\"false\" class=\"mb-4\">\n                <label for=\"timezone\" class=\"form-label\">\n                    {{ $t(\"Display Timezone\") }}\n                </label>\n                <select id=\"timezone\" v-model=\"$root.userTimezone\" class=\"form-select\">\n                    <option value=\"auto\">\n                        {{ $t(\"Auto\") }}: {{ guessTimezone }}\n                    </option>\n                    <option\n                        v-for=\"(timezone, index) in timezoneList\"\n                        :key=\"index\"\n                        :value=\"timezone.value\"\n                    >\n                        {{ timezone.name }}\n                    </option>\n                </select>\n            </div>\n\n            <!-- Server Timezone -->\n            <div v-if=\"false\" class=\"mb-4\">\n                <label for=\"timezone\" class=\"form-label\">\n                    {{ $t(\"Server Timezone\") }}\n                </label>\n                <select id=\"timezone\" v-model=\"settings.serverTimezone\" class=\"form-select\">\n                    <option value=\"UTC\">UTC</option>\n                    <option\n                        v-for=\"(timezone, index) in timezoneList\"\n                        :key=\"index\"\n                        :value=\"timezone.value\"\n                    >\n                        {{ timezone.name }}\n                    </option>\n                </select>\n            </div>\n\n            <!-- Primary Hostname -->\n            <div class=\"mb-4\">\n                <label class=\"form-label\" for=\"primaryBaseURL\">\n                    {{ $t(\"primaryHostname\") }}\n                </label>\n\n                <div class=\"input-group mb-3\">\n                    <input\n                        v-model=\"settings.primaryHostname\"\n                        class=\"form-control\"\n                        :placeholder=\"$t(`CurrentHostname`)\"\n                    />\n                    <button class=\"btn btn-outline-primary\" type=\"button\" @click=\"autoGetPrimaryHostname\">\n                        {{ $t(\"autoGet\") }}\n                    </button>\n                </div>\n\n                <div class=\"form-text\"></div>\n            </div>\n\n            <!-- Save Button -->\n            <div>\n                <button class=\"btn btn-primary\" type=\"submit\">\n                    {{ $t(\"Save\") }}\n                </button>\n            </div>\n        </form>\n    </div>\n</template>\n\n<script>\n\nimport dayjs from \"dayjs\";\nimport { timezoneList } from \"../../util-frontend\";\n\nexport default {\n    components: {\n\n    },\n\n    data() {\n        return {\n            timezoneList: timezoneList(),\n        };\n    },\n\n    computed: {\n        settings() {\n            return this.$parent.$parent.$parent.settings;\n        },\n        saveSettings() {\n            return this.$parent.$parent.$parent.saveSettings;\n        },\n        settingsLoaded() {\n            return this.$parent.$parent.$parent.settingsLoaded;\n        },\n        guessTimezone() {\n            return dayjs.tz.guess();\n        }\n    },\n\n    methods: {\n        /** Save the settings */\n        saveGeneral() {\n            localStorage.timezone = this.$root.userTimezone;\n            this.saveSettings();\n        },\n        /** Get the base URL of the application */\n        autoGetPrimaryHostname() {\n            this.settings.primaryHostname = location.hostname;\n        },\n    },\n};\n</script>\n\n"
  },
  {
    "path": "frontend/src/components/settings/GlobalEnv.vue",
    "content": "<template>\n    <div>\n        <div v-if=\"settingsLoaded\" class=\"my-4\">\n            <form class=\"my-4\" autocomplete=\"off\" @submit.prevent=\"saveGeneral\">\n                <div class=\"shadow-box mb-3 editor-box edit-mode\">\n                    <code-mirror\n                        ref=\"editor\"\n                        v-model=\"settings.globalENV\"\n                        :extensions=\"extensionsEnv\"\n                        minimal\n                        wrap=\"true\"\n                        dark=\"true\"\n                        tab=\"true\"\n                        :hasFocus=\"editorFocus\"\n                        @change=\"onChange\"\n                    />\n                </div>\n\n                <div class=\"my-4\">\n                    <!-- Save Button -->\n                    <div>\n                        <button class=\"btn btn-primary\" type=\"submit\">\n                            {{ $t(\"Save\") }}\n                        </button>\n                    </div>\n                </div>\n            </form>\n        </div>\n    </div>\n</template>\n\n<script>\nimport CodeMirror from \"vue-codemirror6\";\nimport { python } from \"@codemirror/lang-python\"; // good enough for .env key=value highlighting\nimport { dracula as editorTheme } from \"thememirror\";\nimport { lineNumbers, EditorView } from \"@codemirror/view\";\nimport { ref } from \"vue\";\n\nexport default {\n    name: \"GlobalEnv\",\n    components: {\n        CodeMirror,\n    },\n\n    setup() {\n        const editorFocus = ref(false);\n\n        const focusEffectHandler = (state, focusing) => {\n            editorFocus.value = focusing;\n            return null;\n        };\n\n        const extensionsEnv = [\n            editorTheme,\n            python(),\n            lineNumbers(),\n            EditorView.focusChangeEffect.of(focusEffectHandler),\n        ];\n\n        return { editorFocus, extensionsEnv };\n    },\n\n    computed: {\n        settings() {\n            return this.$parent.$parent.$parent.settings;\n        },\n        saveSettings() {\n            return this.$parent.$parent.$parent.saveSettings;\n        },\n        settingsLoaded() {\n            return this.$parent.$parent.$parent.settingsLoaded;\n        },\n    },\n\n    methods: {\n        /** Save the settings */\n        saveGeneral() {\n            this.saveSettings();\n        },\n\n        onChange() {\n            // hook for future live validation if desired\n        },\n    },\n};\n</script>\n\n<style scoped lang=\"scss\">\n.editor-box {\n    font-family: 'JetBrains Mono', monospace;\n    font-size: 14px;\n\n    &.edit-mode {\n        background-color: #2c2f38 !important;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/settings/Security.vue",
    "content": "<template>\n    <div>\n        <div v-if=\"settingsLoaded\" class=\"my-4\">\n            <!-- Change Password -->\n            <template v-if=\"!settings.disableAuth\">\n                <p>\n                    {{ $t(\"Current User\") }}: <strong>{{ $root.username }}</strong>\n                    <button v-if=\"! settings.disableAuth\" id=\"logout-btn\" class=\"btn btn-danger ms-4 me-2 mb-2\" @click=\"$root.logout\">{{ $t(\"Logout\") }}</button>\n                </p>\n\n                <h5 class=\"my-4 settings-subheading\">{{ $t(\"Change Password\") }}</h5>\n                <form class=\"mb-3\" @submit.prevent=\"savePassword\">\n                    <div class=\"mb-3\">\n                        <label for=\"current-password\" class=\"form-label\">\n                            {{ $t(\"Current Password\") }}\n                        </label>\n                        <input\n                            id=\"current-password\"\n                            v-model=\"password.currentPassword\"\n                            type=\"password\"\n                            class=\"form-control\"\n                            autocomplete=\"current-password\"\n                            required\n                        />\n                    </div>\n\n                    <div class=\"mb-3\">\n                        <label for=\"new-password\" class=\"form-label\">\n                            {{ $t(\"New Password\") }}\n                        </label>\n                        <input\n                            id=\"new-password\"\n                            v-model=\"password.newPassword\"\n                            type=\"password\"\n                            class=\"form-control\"\n                            autocomplete=\"new-password\"\n                            required\n                        />\n                    </div>\n\n                    <div class=\"mb-3\">\n                        <label for=\"repeat-new-password\" class=\"form-label\">\n                            {{ $t(\"Repeat New Password\") }}\n                        </label>\n                        <input\n                            id=\"repeat-new-password\"\n                            v-model=\"password.repeatNewPassword\"\n                            type=\"password\"\n                            class=\"form-control\"\n                            :class=\"{ 'is-invalid': invalidPassword }\"\n                            autocomplete=\"new-password\"\n                            required\n                        />\n                        <div class=\"invalid-feedback\">\n                            {{ $t(\"passwordNotMatchMsg\") }}\n                        </div>\n                    </div>\n\n                    <div>\n                        <button class=\"btn btn-primary\" type=\"submit\">\n                            {{ $t(\"Update Password\") }}\n                        </button>\n                    </div>\n                </form>\n            </template>\n\n            <!-- TODO: Hidden for now -->\n            <div v-if=\"! settings.disableAuth && false\" class=\"mt-5 mb-3\">\n                <h5 class=\"my-4 settings-subheading\">\n                    {{ $t(\"Two Factor Authentication\") }}\n                </h5>\n                <div class=\"mb-4\">\n                    <button\n                        class=\"btn btn-primary me-2\"\n                        type=\"button\"\n                        @click=\"$refs.TwoFADialog.show()\"\n                    >\n                        {{ $t(\"2FA Settings\") }}\n                    </button>\n                </div>\n            </div>\n\n            <div class=\"my-4\">\n                <!-- Advanced -->\n                <h5 class=\"my-4 settings-subheading\">{{ $t(\"Advanced\") }}</h5>\n\n                <div class=\"mb-4\">\n                    <button v-if=\"settings.disableAuth\" id=\"enableAuth-btn\" class=\"btn btn-outline-primary me-2 mb-2\" @click=\"enableAuth\">{{ $t(\"Enable Auth\") }}</button>\n                    <button v-if=\"! settings.disableAuth\" id=\"disableAuth-btn\" class=\"btn btn-primary me-2 mb-2\" @click=\"confirmDisableAuth\">{{ $t(\"Disable Auth\") }}</button>\n                </div>\n            </div>\n        </div>\n\n        <TwoFADialog ref=\"TwoFADialog\" />\n\n        <Confirm ref=\"confirmDisableAuth\" btn-style=\"btn-danger\" :yes-text=\"$t('I understand, please disable')\" :no-text=\"$t('Leave')\" @yes=\"disableAuth\">\n            <!-- eslint-disable-next-line vue/no-v-html -->\n            <p v-html=\"$t('disableauth.message1')\"></p>\n            <!-- eslint-disable-next-line vue/no-v-html -->\n            <p v-html=\"$t('disableauth.message2')\"></p>\n            <p>{{ $t(\"Please use this option carefully!\") }}</p>\n\n            <div class=\"mb-3\">\n                <label for=\"current-password2\" class=\"form-label\">\n                    {{ $t(\"Current Password\") }}\n                </label>\n                <input\n                    id=\"current-password2\"\n                    v-model=\"password.currentPassword\"\n                    type=\"password\"\n                    class=\"form-control\"\n                    required\n                />\n            </div>\n        </Confirm>\n    </div>\n</template>\n\n<script>\nimport Confirm from \"../../components/Confirm.vue\";\nimport TwoFADialog from \"../../components/TwoFADialog.vue\";\n\nexport default {\n    components: {\n        Confirm,\n        TwoFADialog\n    },\n\n    data() {\n        return {\n            invalidPassword: false,\n            password: {\n                currentPassword: \"\",\n                newPassword: \"\",\n                repeatNewPassword: \"\",\n            }\n        };\n    },\n\n    computed: {\n        settings() {\n            return this.$parent.$parent.$parent.settings;\n        },\n        saveSettings() {\n            return this.$parent.$parent.$parent.saveSettings;\n        },\n        settingsLoaded() {\n            return this.$parent.$parent.$parent.settingsLoaded;\n        }\n    },\n\n    watch: {\n        \"password.repeatNewPassword\"() {\n            this.invalidPassword = false;\n        },\n    },\n\n    methods: {\n        /** Check new passwords match before saving them */\n        savePassword() {\n            if (this.password.newPassword !== this.password.repeatNewPassword) {\n                this.invalidPassword = true;\n            } else {\n                this.$root\n                    .getSocket()\n                    .emit(\"changePassword\", this.password, (res) => {\n                        this.$root.toastRes(res);\n                        if (res.ok) {\n                            this.password.currentPassword = \"\";\n                            this.password.newPassword = \"\";\n                            this.password.repeatNewPassword = \"\";\n                        }\n                    });\n            }\n        },\n\n        /** Disable authentication for web app access */\n        disableAuth() {\n            this.settings.disableAuth = true;\n\n            // Need current password to disable auth\n            // Set it to empty if done\n            this.saveSettings(() => {\n                this.password.currentPassword = \"\";\n                this.$root.username = null;\n                this.$root.socketIO.token = \"autoLogin\";\n            }, this.password.currentPassword);\n        },\n\n        /** Enable authentication for web app access */\n        enableAuth() {\n            this.settings.disableAuth = false;\n            this.saveSettings();\n            this.$root.storage().removeItem(\"token\");\n            location.reload();\n        },\n\n        /** Show confirmation dialog for disable auth */\n        confirmDisableAuth() {\n            this.$refs.confirmDisableAuth.show();\n        },\n\n    },\n};\n</script>\n"
  },
  {
    "path": "frontend/src/i18n.ts",
    "content": "// @ts-ignore Performance issue when using \"vue-i18n\", so we use \"vue-i18n/dist/vue-i18n.esm-browser.prod.js\", but typescript doesn't like that.\nimport { createI18n } from \"vue-i18n/dist/vue-i18n.esm-browser.prod.js\";\nimport en from \"./lang/en.json\";\n\nconst languageList = {\n    \"bg-BG\": \"Български\",\n    \"es\": \"Español\",\n    \"de\": \"Deutsch\",\n    \"fr\": \"Français\",\n    \"pl-PL\": \"Polski\",\n    \"pt\": \"Português\",\n    \"pt-BR\": \"Português-Brasil\",\n    \"sl\": \"Slovenščina\",\n    \"tr\": \"Türkçe\",\n    \"zh-CN\": \"简体中文\",\n    \"zh-TW\": \"繁體中文(台灣)\",\n    \"ur\": \"Urdu\",\n    \"ko-KR\": \"한국어\",\n    \"ru\": \"Русский\",\n    \"cs-CZ\": \"Čeština\",\n    \"ar\": \"العربية\",\n    \"th\": \"ไทย\",\n    \"it-IT\": \"Italiano\",\n    \"sv-SE\": \"Svenska\",\n    \"uk-UA\": \"Українська\",\n    \"da\": \"Dansk\",\n    \"ja\": \"日本語\",\n    \"nl\": \"Nederlands\",\n    \"ro\": \"Română\",\n    \"id\": \"Bahasa Indonesia (Indonesian)\",\n    \"vi\": \"Tiếng Việt\",\n    \"hu\": \"Magyar\",\n    \"ca\": \"Català\",\n    \"ga\": \"Gaeilge\",\n    \"de-CH\": \"Schwiizerdütsch\",\n    \"mag\": \"मगही\",\n    \"mai\": \"मैथिली\",\n};\n\nlet messages = {\n    en,\n};\n\nfor (let lang in languageList) {\n    messages[lang] = {\n        languageName: languageList[lang]\n    };\n}\n\nconst rtlLangs = [ \"fa\", \"ar-SY\", \"ur\", \"ar\" ];\n\nexport const currentLocale = () => localStorage.locale\n    || languageList[navigator.language] && navigator.language\n    || languageList[navigator.language.substring(0, 2)] && navigator.language.substring(0, 2)\n    || \"en\";\n\nexport const localeDirection = () => {\n    return rtlLangs.includes(currentLocale()) ? \"rtl\" : \"ltr\";\n};\n\nexport const i18n = createI18n({\n    locale: currentLocale(),\n    fallbackLocale: \"en\",\n    silentFallbackWarn: true,\n    silentTranslationWarn: true,\n    messages: messages,\n});\n"
  },
  {
    "path": "frontend/src/icon.ts",
    "content": "import { library } from \"@fortawesome/fontawesome-svg-core\";\nimport { FontAwesomeIcon } from \"@fortawesome/vue-fontawesome\";\n\n// Add Free Font Awesome Icons\n// https://fontawesome.com/v6/icons?d=gallery&p=2&s=solid&m=free\n// In order to add an icon, you have to:\n// 1) add the icon name in the import statement below;\n// 2) add the icon name to the library.add() statement below.\nimport {\n    faArrowAltCircleUp,\n    faCog,\n    faEdit,\n    faEye,\n    faEyeSlash,\n    faList,\n    faPause,\n    faStop,\n    faPlay,\n    faPlus,\n    faSearch,\n    faTachometerAlt,\n    faTimes,\n    faTimesCircle,\n    faTrash,\n    faCheckCircle,\n    faStream,\n    faSave,\n    faExclamationCircle,\n    faBullhorn,\n    faArrowsAltV,\n    faUnlink,\n    faQuestionCircle,\n    faImages,\n    faUpload,\n    faCopy,\n    faCheck,\n    faFile,\n    faAward,\n    faLink,\n    faChevronDown,\n    faSignOutAlt,\n    faPen,\n    faExternalLinkSquareAlt,\n    faSpinner,\n    faUndo,\n    faPlusCircle,\n    faAngleDown,\n    faWrench,\n    faHeartbeat,\n    faFilter,\n    faInfoCircle,\n    faClone,\n    faCertificate,\n    faTerminal, faWarehouse, faHome, faRocket,\n    faRotate,\n    faCloudArrowDown, faArrowsRotate,\n} from \"@fortawesome/free-solid-svg-icons\";\n\nlibrary.add(\n    faArrowAltCircleUp,\n    faCog,\n    faEdit,\n    faEye,\n    faEyeSlash,\n    faList,\n    faPause,\n    faStop,\n    faPlay,\n    faPlus,\n    faSearch,\n    faTachometerAlt,\n    faTimes,\n    faTimesCircle,\n    faTrash,\n    faCheckCircle,\n    faStream,\n    faSave,\n    faExclamationCircle,\n    faBullhorn,\n    faArrowsAltV,\n    faUnlink,\n    faQuestionCircle,\n    faImages,\n    faUpload,\n    faCopy,\n    faCheck,\n    faFile,\n    faAward,\n    faLink,\n    faChevronDown,\n    faSignOutAlt,\n    faPen,\n    faExternalLinkSquareAlt,\n    faSpinner,\n    faUndo,\n    faPlusCircle,\n    faAngleDown,\n    faLink,\n    faWrench,\n    faHeartbeat,\n    faFilter,\n    faInfoCircle,\n    faClone,\n    faCertificate,\n    faTerminal,\n    faWarehouse,\n    faHome,\n    faRocket,\n    faRotate,\n    faCloudArrowDown,\n    faArrowsRotate,\n);\n\nexport { FontAwesomeIcon };\n\n"
  },
  {
    "path": "frontend/src/lang/README.md",
    "content": "# 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-2023) Updated\r\n\r\n1. Go to <https://weblate.kuma.pet>\r\n2. Register an account on Weblate\r\n3. Make sure your GitHub email is matched with Weblate's account, so that it could show you as a contributor on GitHub\r\n4. Choose your language on Weblate and start translating.\r\n\r\n## How to add a new language in the dropdown\r\n\r\n1. Add your Language at <https://weblate.kuma.pet/projects/dockge/dockge/>.\r\n2. Find the language code (You can find it at the end of the URL)\r\n3. Add your language at the end of `languageList` in `frontend/src/i18n.ts`, format: `\"zh-TW\": \"繁體中文 (台灣)\"`,\r\n4. Commit to new branch and make a new Pull Request for me to approve.\r\n"
  },
  {
    "path": "frontend/src/lang/ar.json",
    "content": "{\n    \"languageName\": \"العربية\",\n    \"Create your admin account\": \"إنشاء حساب المشرف\",\n    \"authIncorrectCreds\": \"اسم المستخدم أو كلمة المرور غير صحيحة.\",\n    \"PasswordsDoNotMatch\": \"كلمة المرور غير مطابقة.\",\n    \"Repeat Password\": \"أعد كتابة كلمة السر\",\n    \"Create\": \"إنشاء\",\n    \"signedInDisp\": \"تم تسجيل الدخول باسم {0}\",\n    \"signedInDispDisabled\": \"تم تعطيل المصادقة.\",\n    \"home\": \"الرئيسية\",\n    \"console\": \"سطر الأوامر\",\n    \"registry\": \"السجل\",\n    \"compose\": \"أنشاء كمبوز\",\n    \"addFirstStackMsg\": \"أنشيء أول كمبوز!\",\n    \"stackName\": \"اسم المكدسة\",\n    \"deployStack\": \"نشر\",\n    \"deleteStack\": \"حذف\",\n    \"stopStack\": \"إيقاف\",\n    \"restartStack\": \"إعادة تشغيل\",\n    \"updateStack\": \"تحديث\",\n    \"startStack\": \"تشغيل\",\n    \"downStack\": \"أيقاف\",\n    \"editStack\": \"تعديل\",\n    \"discardStack\": \"إهمال\",\n    \"saveStackDraft\": \"حفظ\",\n    \"notAvailableShort\": \"غير متوفر\",\n    \"deleteStackMsg\": \"هل أنت متأكد أنك تريد حذف هذه المكدسة؟\",\n    \"stackNotManagedByDockgeMsg\": \"لا يتم إدارة هذه المكدس بواسطة Dockge.\",\n    \"primaryHostname\": \"اسم المضيف الرئيسي\",\n    \"general\": \"عام\",\n    \"container\": \"حاوية | حاويات\",\n    \"scanFolder\": \"مسح مجلد المكدسات\",\n    \"dockerImage\": \"صورة\",\n    \"restartPolicyUnlessStopped\": \"ما لم يوقف\",\n    \"restartPolicyAlways\": \"دائماً\",\n    \"restartPolicyOnFailure\": \"عند الفشل\",\n    \"restartPolicyNo\": \"لا\",\n    \"environmentVariable\": \"متغير | متغيرات\",\n    \"restartPolicy\": \"سياسة إعادة التشغيل\",\n    \"containerName\": \"اسم الحاوية\",\n    \"port\": \"منفذ | منافذ\",\n    \"volume\": \"مجلد | مجلدات\",\n    \"network\": \"شبكة | شبكات\",\n    \"dependsOn\": \"تبعية الحاوية | تبعية الحاويات\",\n    \"addListItem\": \"إضافة {0}\",\n    \"deleteContainer\": \"حذف\",\n    \"addContainer\": \"أضافة حاوية\",\n    \"addNetwork\": \"أضافة شبكة\",\n    \"disableauth.message1\": \"هل أنت متأكد أنك تريد <strong>تعطيل المصادقة</strong>؟\",\n    \"disableauth.message2\": \"إنه مصمم للحالات <strong>التي تنوي فيها مصادقة الطرف الثالث</strong> أمام Dockge مثل Cloudflare Access, Authelia أو أي من آليات المصادقة الأخرى.\",\n    \"passwordNotMatchMsg\": \"كلمة المرور المكررة غير متطابقة.\",\n    \"autoGet\": \"الجلب التلقائي\",\n    \"add\": \"إضافة\",\n    \"Edit\": \"تعديل\",\n    \"applyToYAML\": \"تطبيق على YAML\",\n    \"createExternalNetwork\": \"إنشاء\",\n    \"addInternalNetwork\": \"إضافة\",\n    \"Save\": \"حفظ\",\n    \"Language\": \"اللغة\",\n    \"Current User\": \"المستخدم الحالي\",\n    \"Change Password\": \"تعديل كلمة المرور\",\n    \"Current Password\": \"كلمة المرور الحالية\",\n    \"New Password\": \"كلمة مرور جديدة\",\n    \"Repeat New Password\": \"أعد تكرار كلمة المرور\",\n    \"Update Password\": \"تحديث كلمة المرور\",\n    \"Advanced\": \"متقدم\",\n    \"Please use this option carefully!\": \"من فضلك استخدم هذا الخيار بعناية!\",\n    \"Enable Auth\": \"تفعيل المصادقة\",\n    \"Disable Auth\": \"تعطيل المصادقة\",\n    \"I understand, please disable\": \"أتفهم, أرجو التعطيل\",\n    \"Leave\": \"مغادرة\",\n    \"Frontend Version\": \"لإصدار الواجهة الأمامية\",\n    \"Check Update On GitHub\": \"تحق من التحديث على GitHub\",\n    \"Show update if available\": \"اعرض التحديث إذا كان متاحًا\",\n    \"Also check beta release\": \"تحقق أيضًا من إصدار النسخة التجريبية\",\n    \"Remember me\": \"تذكرني\",\n    \"Login\": \"تسجيل الدخول\",\n    \"Username\": \"اسم المستخدم\",\n    \"Password\": \"كلمة المرور\",\n    \"Settings\": \"الاعدادات\",\n    \"Logout\": \"تسجيل الخروج\",\n    \"Lowercase only\": \"أحرف صغيرة فقط\",\n    \"Convert to Compose\": \"تحويل إلى كومبوز\",\n    \"Docker Run\": \"تشغيل Docker\",\n    \"active\": \"نشيط\",\n    \"exited\": \"تم الخروج\",\n    \"inactive\": \"غير نشيط\",\n    \"Appearance\": \"المظهر\",\n    \"Security\": \"الأمان\",\n    \"About\": \"حول\",\n    \"Allowed commands:\": \"الأوامر المسموح بها:\",\n    \"Internal Networks\": \"الشبكات الداخلية\",\n    \"External Networks\": \"الشبكات الخارجية\",\n    \"No External Networks\": \"لا توجد شبكات خارجية\",\n    \"reverseProxyMsg2\": \"تحقق كيف يتم إعداده لمقبس ويب\",\n    \"Cannot connect to the socket server.\": \"تعذر الاتصال بخادم المقبس.\",\n    \"reconnecting...\": \"إعادة الاتصال…\",\n    \"url\": \"رابط | روابط\",\n    \"extra\": \"إضافات\",\n    \"reverseProxyMsg1\": \"هل تستدخم خادم عكسي؟\",\n    \"connecting...\": \"جاري الاتصال بخادم المقبس…\",\n    \"newUpdate\": \"تحديث جديد\",\n    \"currentEndpoint\": \"السياق: الوكيل الحالي\",\n    \"dockgeURL\": \"رابط Dockge (مثلا http://127.0.0.1:5001)\",\n    \"agentOnline\": \"متصل\",\n    \"agentOffline\": \"غير متصل\",\n    \"connecting\": \"جاري الإتصال\",\n    \"connect\": \"ارتبط\",\n    \"dockgeAgent\": \"سيرفر Dockge\",\n    \"removeAgent\": \"حذف الوكيل\",\n    \"removeAgentMsg\": \"هل انت متأكد من حذف هذا الوكيل؟\",\n    \"LongSyntaxNotSupported\": \"كتابة النصوص المدعومة غير المدعومة هنا. الرجاء استخدام محرر YAML.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/be.json",
    "content": "{\n    \"active\": \"акт.\",\n    \"LongSyntaxNotSupported\": \"Доўгі сінтаксіс тут не падтрымліваецца. Выкарыстоўвайце рэдактар YAML.\",\n    \"removeAgentMsg\": \"Вы ўпэўнены, што хочаце выдаліць гэтага агента?\",\n    \"languageName\": \"Беларуская\",\n    \"Create your admin account\": \"Стварыце ўліковы запіс адміністратара\",\n    \"authIncorrectCreds\": \"Няправільны лагін ці пароль.\",\n    \"PasswordsDoNotMatch\": \"Паролі не супадаюць.\",\n    \"Repeat Password\": \"Паўтарыце пароль\",\n    \"Create\": \"Стварыць\",\n    \"signedInDisp\": \"Аўтарызаваны як {0}\",\n    \"signedInDispDisabled\": \"Аўтарызацыя выключана.\",\n    \"home\": \"Галоўная\",\n    \"console\": \"Кансоль\",\n    \"registry\": \"Рэестр (Registry)\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Стварыце свой першы стэк!\",\n    \"stackName\": \"Назва стэка\",\n    \"deployStack\": \"Разгарнуць\",\n    \"deleteStack\": \"Выдаліць\",\n    \"stopStack\": \"Спыніць\",\n    \"restartStack\": \"Перазапусціць\",\n    \"updateStack\": \"Абнавіць\",\n    \"startStack\": \"Запусціць\",\n    \"downStack\": \"Спыніць і дэактываваць\",\n    \"editStack\": \"Рэдагаваць\",\n    \"discardStack\": \"Скасаваць\",\n    \"saveStackDraft\": \"Захаваць\",\n    \"notAvailableShort\": \"Н/Д\",\n    \"deleteStackMsg\": \"Вы ўпэўнены, што хочаце выдаліць гэты стэк?\",\n    \"stackNotManagedByDockgeMsg\": \"Дадзены стэк не кіруецца Dockge.\",\n    \"primaryHostname\": \"Імя хоста\",\n    \"general\": \"Агульныя\",\n    \"container\": \"Кантэйнер | Кантэйнеры\",\n    \"scanFolder\": \"Сканаваць папку стэкаў\",\n    \"dockerImage\": \"Вобраз\",\n    \"restartPolicyUnlessStopped\": \"Пакуль не будзе спынены\",\n    \"restartPolicyAlways\": \"Заўсёды\",\n    \"restartPolicyOnFailure\": \"Пры падзенні\",\n    \"restartPolicyNo\": \"Ніколі\",\n    \"environmentVariable\": \"Зменная асяроддзя | Зменныя асяроддзя\",\n    \"restartPolicy\": \"Палітыка рэстарту\",\n    \"containerName\": \"Імя кантэйнера\",\n    \"port\": \"Порт | Порты\",\n    \"volume\": \"Сховішча | Сховішчы\",\n    \"network\": \"Сетка | Сеткі\",\n    \"dependsOn\": \"Залежнасць кантэйнера | Залежнасці кантэйнера\",\n    \"addListItem\": \"Дадаць {0}\",\n    \"deleteContainer\": \"Выдаліць\",\n    \"addContainer\": \"Дадаць кантэйнер\",\n    \"addNetwork\": \"Дадаць сетку\",\n    \"disableauth.message1\": \"Вы ўпэўнены, што хочаце <strong>адключыць аўтэнтыфікацыю</strong>?\",\n    \"Show update if available\": \"Паказаць абнаўленне, калі яно даступна\",\n    \"Also check beta release\": \"Атрымліваць бэта-версіі\",\n    \"disableauth.message2\": \"Гэта прызначана для сцэнарыяў, <strong>калі вы збіраецеся выкарыстоўваць староннюю аўтэнтыфікацыю</strong> перад Dockge, напрыклад, Cloudflare Access, Authelia або іншыя механізмы аўтэнтыфікацыі.\",\n    \"passwordNotMatchMsg\": \"Паўторны пароль не супадае.\",\n    \"autoGet\": \"Аўта\",\n    \"add\": \"Дадаць\",\n    \"Edit\": \"Змяніць\",\n    \"applyToYAML\": \"Ужыць да YAML\",\n    \"createExternalNetwork\": \"Стварыць\",\n    \"addInternalNetwork\": \"Дадаць\",\n    \"Save\": \"Захаваць\",\n    \"Language\": \"Мова\",\n    \"Current User\": \"Бягучы карыстальнік\",\n    \"Change Password\": \"Змяніць пароль\",\n    \"Current Password\": \"Бягучы пароль\",\n    \"New Password\": \"Новы пароль\",\n    \"Repeat New Password\": \"Паўтарыце новы пароль\",\n    \"Update Password\": \"Абнавіць пароль\",\n    \"Advanced\": \"Пашыраныя\",\n    \"Please use this option carefully!\": \"Выкарыстоўвайце гэтую опцыю асцярожна!\",\n    \"Enable Auth\": \"Уключыць аўтэнтыфікацыю\",\n    \"Disable Auth\": \"Адключыць аўтэнтыфікацыю\",\n    \"I understand, please disable\": \"Я разумею, адключыце\",\n    \"Leave\": \"Пакінуць\",\n    \"Frontend Version\": \"Версія знешняга інтэрфейсу\",\n    \"Check Update On GitHub\": \"Праверыць абнаўленні на GitHub\",\n    \"Remember me\": \"Запомніць мяне\",\n    \"Login\": \"Лагін\",\n    \"Username\": \"Імя карыстальніка\",\n    \"Password\": \"Пароль\",\n    \"Settings\": \"Налады\",\n    \"Logout\": \"Выйсці\",\n    \"Lowercase only\": \"Толькі ніжні рэгістр\",\n    \"Convert to Compose\": \"Пераўтварыць у Compose\",\n    \"Docker Run\": \"Docker Run\",\n    \"exited\": \"спын.\",\n    \"inactive\": \"неакт.\",\n    \"Appearance\": \"Знешні выгляд\",\n    \"Security\": \"Бяспека\",\n    \"About\": \"Аб праграме\",\n    \"Allowed commands:\": \"Дазволеныя каманды:\",\n    \"Internal Networks\": \"Унутраныя сеткі\",\n    \"External Networks\": \"Знешнія сеткі\",\n    \"No External Networks\": \"Няма знешніх сетак\",\n    \"reverseProxyMsg1\": \"Выкарыстоўваеце зваротны проксі?\",\n    \"reverseProxyMsg2\": \"Праверце, як наладзіць яго для WebSocket\",\n    \"Cannot connect to the socket server.\": \"Не ўдалося падключыцца да сокет-сервера.\",\n    \"reconnecting...\": \"Перападключэнне…\",\n    \"connecting...\": \"Падключэнне да сокет-сервера…\",\n    \"url\": \"URL-адрас | URL-адрасы\",\n    \"extra\": \"Дадаткова\",\n    \"newUpdate\": \"Даступна абнаўленне\",\n    \"dockgeAgent\": \"Агент Dockge | Агенты Dockge\",\n    \"currentEndpoint\": \"Бягучы\",\n    \"dockgeURL\": \"URL-адрас Dockge (напрыклад: http://127.0.0.1:5001)\",\n    \"agentOnline\": \"У сетцы\",\n    \"agentOffline\": \"Не ў сетцы\",\n    \"connecting\": \"Падключэнне\",\n    \"connect\": \"Падключыць\",\n    \"addAgent\": \"Дадаць Агента\",\n    \"agentAddedSuccessfully\": \"Агент паспяхова дададзены.\",\n    \"agentRemovedSuccessfully\": \"Агент паспяхова выдалены.\",\n    \"removeAgent\": \"Выдаліць агента\"\n}\n"
  },
  {
    "path": "frontend/src/lang/bg-BG.json",
    "content": "{\n    \"languageName\": \"Български\",\n    \"Create your admin account\": \"Създайте администраторски профил\",\n    \"authIncorrectCreds\": \"Грешно име или парола.\",\n    \"PasswordsDoNotMatch\": \"Паролите не съвпадат.\",\n    \"Repeat Password\": \"Повторете паролата\",\n    \"Create\": \"Създай\",\n    \"signedInDisp\": \"Вписан като {0}\",\n    \"signedInDispDisabled\": \"Удостоверяването е изключено.\",\n    \"home\": \"Начало\",\n    \"console\": \"Конзола\",\n    \"registry\": \"Регистър\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Създайте вашия първи стак!\",\n    \"stackName\": \"Име на стак\",\n    \"deployStack\": \"Разположи\",\n    \"deleteStack\": \"Изтрий\",\n    \"stopStack\": \"Спри\",\n    \"restartStack\": \"Рестартирай\",\n    \"updateStack\": \"Актуализирай\",\n    \"startStack\": \"Стартирай\",\n    \"editStack\": \"Редактирай\",\n    \"discardStack\": \"Отхвърли\",\n    \"saveStackDraft\": \"Запази\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"Сигурни ли сте, че желаете да изтриете този стак?\",\n    \"stackNotManagedByDockgeMsg\": \"Този стак не се управлява от Dockge.\",\n    \"primaryHostname\": \"Основно име на хост\",\n    \"general\": \"Общи\",\n    \"container\": \"Контейнер | Контейнери\",\n    \"scanFolder\": \"Сканиране папката със стакове\",\n    \"dockerImage\": \"Изображение\",\n    \"restartPolicyUnlessStopped\": \"Докато не бъде спрян\",\n    \"restartPolicyAlways\": \"Винаги\",\n    \"restartPolicyOnFailure\": \"При неуспех\",\n    \"restartPolicyNo\": \"Не\",\n    \"environmentVariable\": \"Променлива на средата | Променливи на средата\",\n    \"restartPolicy\": \"Правила за рестартиране\",\n    \"containerName\": \"Име на контейнер\",\n    \"port\": \"Порт | Портове\",\n    \"volume\": \"Том | Томове\",\n    \"network\": \"Мрежа | Мрежи\",\n    \"dependsOn\": \"Зависимост от контейнер | Зависимост от контейнери\",\n    \"addListItem\": \"Добави {0}\",\n    \"deleteContainer\": \"Изтрий\",\n    \"addContainer\": \"Добави контейнер\",\n    \"addNetwork\": \"Добави мрежа\",\n    \"disableauth.message1\": \"Сигурни ли сте, че желаете да <strong>изключите удостоверяването</strong>?\",\n    \"disableauth.message2\": \"Използва се в случаите, <strong>когато има настроен алтернативен метод за удостоверяване</strong> преди Dockge, например Cloudflare Access, Authelia или друг механизъм за удостоверяване.\",\n    \"passwordNotMatchMsg\": \"Повторената парола не съвпада.\",\n    \"autoGet\": \"Автоматично получаване\",\n    \"add\": \"Добави\",\n    \"Edit\": \"Редактирай\",\n    \"applyToYAML\": \"Приложи към YAML\",\n    \"createExternalNetwork\": \"Създай\",\n    \"addInternalNetwork\": \"Добави\",\n    \"Save\": \"Запиши\",\n    \"Language\": \"Език\",\n    \"Current User\": \"Текущ потребител\",\n    \"Change Password\": \"Промени парола\",\n    \"Current Password\": \"Текуща парола\",\n    \"New Password\": \"Нова парола\",\n    \"Repeat New Password\": \"Повторете новата парола\",\n    \"Update Password\": \"Актуализирай парола\",\n    \"Advanced\": \"Разширени\",\n    \"Please use this option carefully!\": \"Моля, използвайте с повишено внимание!\",\n    \"Enable Auth\": \"Включи удостоверяване\",\n    \"Disable Auth\": \"Изключи удостоверяване\",\n    \"I understand, please disable\": \"Разбирам. Моля, изключи\",\n    \"Leave\": \"Напусни\",\n    \"Frontend Version\": \"Фронтенд версия\",\n    \"Check Update On GitHub\": \"Проверка за актуализация в GitHub\",\n    \"Show update if available\": \"Покажи актуализация, ако е налична\",\n    \"Also check beta release\": \"Проверявай и за бета версии\",\n    \"Remember me\": \"Запомни ме\",\n    \"Login\": \"Вписване\",\n    \"Username\": \"Потребител\",\n    \"Password\": \"Парола\",\n    \"Settings\": \"Настройки\",\n    \"Logout\": \"Изход\",\n    \"Lowercase only\": \"Само малки букви\",\n    \"Convert to Compose\": \"Конвертирай в \\\"Compose\\\" формат\",\n    \"Docker Run\": \"Стартирай Docker\",\n    \"active\": \"активен\",\n    \"exited\": \"излязъл\",\n    \"inactive\": \"неактивен\",\n    \"Appearance\": \"Изглед\",\n    \"Security\": \"Сигурност\",\n    \"About\": \"Относно\",\n    \"Allowed commands:\": \"Позволени команди:\",\n    \"Internal Networks\": \"Вътрешни мрежи\",\n    \"External Networks\": \"Външни мрежи\",\n    \"No External Networks\": \"Не са налични външни мрежи\",\n    \"reverseProxyMsg2\": \"Проверете как да го конфигурирате за WebSocket\",\n    \"downStack\": \"Спри & Неактивен\",\n    \"reverseProxyMsg1\": \"Използвате ревърс прокси?\",\n    \"Cannot connect to the socket server.\": \"Не може да се свърже със сокет сървъра.\",\n    \"url\": \"URL адрес | URL адреси\",\n    \"extra\": \"Допълнително\",\n    \"reconnecting...\": \"Повторно свързване…\",\n    \"connecting...\": \"Свързване със сокет сървъра…\",\n    \"newUpdate\": \"Нова актуализация\",\n    \"currentEndpoint\": \"Текущ\",\n    \"dockgeURL\": \"Dockge URL адрес (напр. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Онлайн\",\n    \"agentOffline\": \"Офлайн\",\n    \"connect\": \"Свържи\",\n    \"addAgent\": \"Добави агент\",\n    \"agentAddedSuccessfully\": \"Агентът е добавен успешно.\",\n    \"removeAgent\": \"Премахни агент\",\n    \"removeAgentMsg\": \"Сигурни ли сте, че желаете да премахнете този агент?\",\n    \"dockgeAgent\": \"Dockge агент | Dockge агенти\",\n    \"connecting\": \"Свързване\",\n    \"agentRemovedSuccessfully\": \"Агентът е премахнат успешно.\",\n    \"LongSyntaxNotSupported\": \"Дългият синтаксис не се поддържа тук. Моля, използвайте YAML редактора.\",\n    \"Started\": \"Стартиран\",\n    \"Updated\": \"Актуализиран\",\n    \"Deleted\": \"Изтрит\",\n    \"Deployed\": \"Внедрен\",\n    \"Stopped\": \"Спрян\",\n    \"Restarted\": \"Рестартиран\",\n    \"Switch to sh\": \"Превключи на \\\"sh\\\"\",\n    \"terminal\": \"Терминал\",\n    \"New Container Name...\": \"Ново име на контейнер...\",\n    \"Network name...\": \"Име на мрежата...\",\n    \"Select a network...\": \"Изберете мрежа...\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Изгубена връзка със сокет сървъра. Повторно свързване...\",\n    \"Saved\": \"Запазено\",\n    \"Downed\": \"Свален\",\n    \"CurrentHostname\": \"(Не е зададено: Следвай текущото име на хост)\",\n    \"NoNetworksAvailable\": \"Няма налични мрежи. Първо трябва да добавите вътрешни мрежи или да активирате външни мрежи в дясната страна.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/ca.json",
    "content": "{\n    \"Create your admin account\": \"Crea el teu compte d'administrador\",\n    \"Repeat Password\": \"Repeteix la contrasenya\",\n    \"Create\": \"Crea\",\n    \"signedInDisp\": \"S'ha iniciat sessió com a {0}\",\n    \"home\": \"Inici\",\n    \"console\": \"Consola\",\n    \"registry\": \"Registre\",\n    \"compose\": \"Compondre\",\n    \"addFirstStackMsg\": \"Compondre la teva primera pila!\",\n    \"stackName\": \"Nom de la pila\",\n    \"deployStack\": \"Desplegar\",\n    \"deleteStack\": \"Eliminar\",\n    \"stopStack\": \"Aturar\",\n    \"restartStack\": \"Reiniciar\",\n    \"updateStack\": \"Actualitzar\",\n    \"startStack\": \"Inicia\",\n    \"downStack\": \"Atura i inactiva\",\n    \"languageName\": \"Català\",\n    \"authIncorrectCreds\": \"Usuari o contrasenya incorrecte.\",\n    \"PasswordsDoNotMatch\": \"Les contrasenyes no coincideixen.\",\n    \"signedInDispDisabled\": \"Autenticació deshabilitada.\",\n    \"discardStack\": \"Descartar\",\n    \"saveStackDraft\": \"Guardar\",\n    \"notAvailableShort\": \"N/D\",\n    \"primaryHostname\": \"Nom del host primari\",\n    \"general\": \"General\",\n    \"container\": \"Contenidor | Contenidors\",\n    \"scanFolder\": \"Escaneja la carpeta de piles\",\n    \"dockerImage\": \"Imatge\",\n    \"restartPolicyAlways\": \"Sempre\",\n    \"restartPolicyOnFailure\": \"En cas de fallada\",\n    \"restartPolicyNo\": \"No\",\n    \"environmentVariable\": \"Variable d'entorn | Variables d'entorn\",\n    \"restartPolicy\": \"Política de reinici\",\n    \"containerName\": \"Nom del contenidor\",\n    \"port\": \"Port | Ports\",\n    \"volume\": \"Volum | Volums\",\n    \"network\": \"Xarxa | Xarxes\",\n    \"addListItem\": \"Afegir {0}\",\n    \"deleteContainer\": \"Eliminar\",\n    \"addContainer\": \"Afegir contenidor\",\n    \"addNetwork\": \"Afegir xarxa\",\n    \"passwordNotMatchMsg\": \"La contrasenya repetida no coincideix.\",\n    \"autoGet\": \"Obtenir automàticament\",\n    \"add\": \"Afegir\",\n    \"Edit\": \"Editar\",\n    \"applyToYAML\": \"Aplicar a YAML\",\n    \"createExternalNetwork\": \"Crear\",\n    \"addInternalNetwork\": \"Afegir\",\n    \"Save\": \"Guardar\",\n    \"Language\": \"Idioma\",\n    \"Current User\": \"Usuari actual\",\n    \"Change Password\": \"Canviar la contrasenya\",\n    \"Current Password\": \"Contrasenya actual\",\n    \"New Password\": \"Nova contrasenya\",\n    \"stackNotManagedByDockgeMsg\": \"Aquesta pila no està gestionada per Dockge.\",\n    \"Update Password\": \"Actualitzar contrasenya\",\n    \"Advanced\": \"Avançat\",\n    \"Disable Auth\": \"Deshabilitar autenticació\",\n    \"Leave\": \"Sortir\",\n    \"Frontend Version\": \"Versió del frontend\",\n    \"Check Update On GitHub\": \"Comprova les actualitzacions a GitHub\",\n    \"Show update if available\": \"Mostra si hi ha disponible una nova actualització\",\n    \"Also check beta release\": \"Comprovar també la versió beta\",\n    \"Remember me\": \"Recorda'm\",\n    \"Login\": \"Inici de sesió\",\n    \"Username\": \"Usuari\",\n    \"Settings\": \"Configuració\",\n    \"Logout\": \"Tanca sessió\",\n    \"Lowercase only\": \"Només minúscules\",\n    \"Convert to Compose\": \"Convertir a Compose\",\n    \"Docker Run\": \"Executar Docker\",\n    \"active\": \"actiu\",\n    \"exited\": \"finalitzat\",\n    \"inactive\": \"inactiu\",\n    \"Appearance\": \"Aparença\",\n    \"Security\": \"Seguretat\",\n    \"About\": \"Sobre\",\n    \"Allowed commands:\": \"Comandes permeses:\",\n    \"Internal Networks\": \"Xarxes internes\",\n    \"External Networks\": \"Xarxes externes\",\n    \"No External Networks\": \"No hi ha xarxes externes\",\n    \"reverseProxyMsg1\": \"Estàs fent servir un proxy invers?\",\n    \"reverseProxyMsg2\": \"Comproveu com configurar-lo per a WebSocket\",\n    \"Cannot connect to the socket server.\": \"No es pot connectar al servidor del socket.\",\n    \"reconnecting...\": \"S'està tornant a connectar…\",\n    \"connecting...\": \"S'està connectant al servidor del socket…\",\n    \"url\": \"URL | URLs\",\n    \"extra\": \"Extra\",\n    \"newUpdate\": \"Nova actualització\",\n    \"dockgeAgent\": \"Agent Dockge | Agents Dockge\",\n    \"currentEndpoint\": \"Actual\",\n    \"dockgeURL\": \"URL de Dockge (ex. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"En línia\",\n    \"agentOffline\": \"Fora de línia\",\n    \"connecting\": \"Connectant\",\n    \"connect\": \"Connectar\",\n    \"addAgent\": \"Afegir agent\",\n    \"agentAddedSuccessfully\": \"Agent afegit correctament.\",\n    \"agentRemovedSuccessfully\": \"Agent eliminat correctament.\",\n    \"removeAgent\": \"Eliminar agent\",\n    \"removeAgentMsg\": \"Esteu segur que voleu eliminar aquest agent?\",\n    \"editStack\": \"Editar\",\n    \"deleteStackMsg\": \"Estàs segur que vols eliminar aquesta pila?\",\n    \"restartPolicyUnlessStopped\": \"A menys que s'aturi\",\n    \"dependsOn\": \"Dependència del contenidor | Dependències del contenidor\",\n    \"disableauth.message1\": \"Esteu segur que voleu <strong>desactivar l'autenticació</strong>?\",\n    \"disableauth.message2\": \"Està dissenyat per a escenaris <strong>on voleu implementar l'autenticació de tercers</strong> per davant de Dockge, com ara Cloudflare Access, Authelia o altres mecanismes d'autenticació.\",\n    \"Repeat New Password\": \"Repetiu la nova contrasenya\",\n    \"Please use this option carefully!\": \"Si us plau, utilitzeu aquesta opció amb cura!\",\n    \"Enable Auth\": \"Habilitar autenticació\",\n    \"I understand, please disable\": \"Ho entenc, si us plau deshabilita\",\n    \"Password\": \"Contrasenya\",\n    \"LongSyntaxNotSupported\": \"La sintaxi llarga no està suportada aquí. Si us plau, fes servir l'editor YAML.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/cs-CZ.json",
    "content": "{\n    \"languageName\": \"Čeština\",\n    \"Create your admin account\": \"Vytvořit účet administrátora\",\n    \"authIncorrectCreds\": \"Nesprávné uživatelské jméno nebo heslo.\",\n    \"PasswordsDoNotMatch\": \"Hesla se neshodují.\",\n    \"Repeat Password\": \"Napište Heslo Znovu\",\n    \"Create\": \"Vytvořit\",\n    \"signedInDisp\": \"Přihlášen jako {0}\",\n    \"signedInDispDisabled\": \"Ověření Zakázáno.\",\n    \"home\": \"Domů\",\n    \"console\": \"Konzole\",\n    \"registry\": \"Registry\",\n    \"compose\": \"Komponovat\",\n    \"addFirstStackMsg\": \"Vytvořte svůj první zásobník!\",\n    \"stackName\": \"Název Zásobníku\",\n    \"deployStack\": \"Nainstalovat\",\n    \"deleteStack\": \"Smazat\",\n    \"stopStack\": \"Zastavit\",\n    \"restartStack\": \"Restartovat\",\n    \"updateStack\": \"Aktualizovat\",\n    \"startStack\": \"Spustit\",\n    \"downStack\": \"Zastavit & Zneaktivnit\",\n    \"editStack\": \"Upravit\",\n    \"discardStack\": \"Zahodit\",\n    \"saveStackDraft\": \"Uložit\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"Opravdu chcete smazat tento zásobník?\",\n    \"stackNotManagedByDockgeMsg\": \"Tento stack není spravován systémem Dockge.\",\n    \"primaryHostname\": \"Primární název hostitele\",\n    \"general\": \"Obecné\",\n    \"container\": \"Kontejner | Kontejnery\",\n    \"scanFolder\": \"Prohledat složku se zásobníky\",\n    \"dockerImage\": \"Obrázek\",\n    \"restartPolicyUnlessStopped\": \"Pokud není zastaveno\",\n    \"restartPolicyAlways\": \"Vždy\",\n    \"restartPolicyOnFailure\": \"Při Selhání\",\n    \"restartPolicyNo\": \"Ne\",\n    \"environmentVariable\": \"Proměnná Prostředí | Proměnné Prostředí\",\n    \"restartPolicy\": \"Politika restartu\",\n    \"containerName\": \"Název kontejneru\",\n    \"port\": \"Port | Porty\",\n    \"volume\": \"Svazek | Svazky\",\n    \"network\": \"Síť | Sítě\",\n    \"dependsOn\": \"Závisí na kontejneru | Závislosti na kontejneru\",\n    \"addListItem\": \"Přidat {0}\",\n    \"deleteContainer\": \"Smazat\",\n    \"addContainer\": \"Přidat kontejner\",\n    \"addNetwork\": \"Přidat síť\",\n    \"disableauth.message1\": \"Opravdu chcete <strong>zakázat ověřování</strong>?\",\n    \"disableauth.message2\": \"Je navrženo pro scénáře, kde <strong>plánujete implementovat ověřování třetí strany</strong> před Dockge, například Cloudflare Access, Authelia nebo jiné ověřovací mechanismy.\",\n    \"passwordNotMatchMsg\": \"Hesla se neshodují.\",\n    \"autoGet\": \"Automaticky získat\",\n    \"add\": \"Přidat\",\n    \"Edit\": \"Upravit\",\n    \"applyToYAML\": \"Použít na YAML\",\n    \"createExternalNetwork\": \"Vytvořit\",\n    \"addInternalNetwork\": \"Přidat\",\n    \"Save\": \"Uložit\",\n    \"Language\": \"Jazyk\",\n    \"Current User\": \"Aktuální uživatel\",\n    \"Change Password\": \"Změnit heslo\",\n    \"Current Password\": \"Aktuální heslo\",\n    \"New Password\": \"Nové heslo\",\n    \"Repeat New Password\": \"Opakujte nové heslo\",\n    \"Update Password\": \"Aktualizovat heslo\",\n    \"Advanced\": \"Pokročilé\",\n    \"Please use this option carefully!\": \"Používejte tuto možnost opatrně!\",\n    \"Enable Auth\": \"Povolit ověřování\",\n    \"Disable Auth\": \"Zakázat ověřování\",\n    \"I understand, please disable\": \"Rozumím, prosím zakážte\",\n    \"Leave\": \"Opustit\",\n    \"Frontend Version\": \"Verze rozhraní\",\n    \"Check Update On GitHub\": \"Zkontrolovat aktualizaci na GitHubu\",\n    \"Show update if available\": \"Zobrazit aktualizaci, pokud je k dispozici\",\n    \"Also check beta release\": \"Zkontrolovat také beta verzi\",\n    \"Remember me\": \"Zapamatovat údaje\",\n    \"Login\": \"Přihlásit se\",\n    \"Username\": \"Uživatelské jméno\",\n    \"Password\": \"Heslo\",\n    \"Settings\": \"Nastavení\",\n    \"Logout\": \"Odhlásit se\",\n    \"Lowercase only\": \"Pouze malá písmena\",\n    \"Convert to Compose\": \"Převést na Compose\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"Aktivní\",\n    \"exited\": \"Ukončený\",\n    \"inactive\": \"Neaktivní\",\n    \"Appearance\": \"Vzhled\",\n    \"Security\": \"Zabezpečení\",\n    \"About\": \"O aplikaci\",\n    \"Allowed commands:\": \"Povolené příkazy:\",\n    \"Internal Networks\": \"Interní sítě\",\n    \"External Networks\": \"Externí sítě\",\n    \"No External Networks\": \"Žádné externí sítě\",\n    \"reconnecting...\": \"Opětovné připojení…\",\n    \"url\": \"Adresa URL | Adresy URL\",\n    \"extra\": \"Extra\",\n    \"reverseProxyMsg1\": \"Používáte Reverzní proxy server?\",\n    \"reverseProxyMsg2\": \"Podívat se jak to nastavit pro WebSocket\",\n    \"Cannot connect to the socket server.\": \"Nelze se připojit k serveru .\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Ztraceno spojení se serverem. Obnovuji spojení...\",\n    \"newUpdate\": \"Nová aktualizace\",\n    \"dockgeAgent\": \"Dockge Agent | Dockge Agenti\",\n    \"agentOnline\": \"Online\",\n    \"connecting\": \"Připojování\",\n    \"agentOffline\": \"Offline\",\n    \"dockgeURL\": \"Dockge URL (např. http://127.0.0.1:5001)\",\n    \"LongSyntaxNotSupported\": \"Dlouhá syntaxe zde není podporována. Použijte, prosím, YAML editor.\",\n    \"connecting...\": \"Připojování k socket serveru…\",\n    \"connect\": \"Připojit\",\n    \"addAgent\": \"Přidat Agenta\",\n    \"agentAddedSuccessfully\": \"Agent byl úspěšně přidán.\",\n    \"agentRemovedSuccessfully\": \"Agend byl úspěšně odebrán.\",\n    \"removeAgent\": \"Odebrat Agenta\",\n    \"removeAgentMsg\": \"Opravdu chcete tohoto agenta odebrat?\",\n    \"Saved\": \"Uloženo\",\n    \"Deployed\": \"Nasazeno\",\n    \"Deleted\": \"Odstraněno\",\n    \"Updated\": \"Aktualizovat\",\n    \"Started\": \"Spuštěno\",\n    \"Stopped\": \"Zastaveno\",\n    \"Restarted\": \"Restartováno\",\n    \"Switch to sh\": \"Přepnout na sh shell\",\n    \"terminal\": \"Terminál\",\n    \"New Container Name...\": \"Název nového kontejneru...\",\n    \"Network name...\": \"Název sítě...\",\n    \"Select a network...\": \"Vyberte síť...\",\n    \"NoNetworksAvailable\": \"Žádná síť není dostupná. Musíte přidat interní síť nebo povolit externí sítě v pravé části.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/da.json",
    "content": "{\n    \"languageName\": \"Dansk\",\n    \"authIncorrectCreds\": \"Forkert brugernavn eller adgangskode.\",\n    \"PasswordsDoNotMatch\": \"Adgangskoder stemmer ikke overens.\",\n    \"Repeat Password\": \"Gentag adgangskode\",\n    \"Create\": \"Opret\",\n    \"signedInDisp\": \"Logget ind som {0}\",\n    \"signedInDispDisabled\": \"Godkendelse deaktiveret.\",\n    \"home\": \"Hjem\",\n    \"console\": \"Konsol\",\n    \"registry\": \"Register\",\n    \"compose\": \"Komponer\",\n    \"stackName\": \"Stak-navn\",\n    \"deployStack\": \"Udrulle\",\n    \"deleteStack\": \"Slet\",\n    \"stopStack\": \"Stop\",\n    \"restartStack\": \"Genstart\",\n    \"updateStack\": \"Opdater\",\n    \"startStack\": \"Start\",\n    \"downStack\": \"Stop & Deaktiver\",\n    \"editStack\": \"Rediger\",\n    \"discardStack\": \"Kassér\",\n    \"saveStackDraft\": \"Gem\",\n    \"notAvailableShort\": \"N/A\",\n    \"stackNotManagedByDockgeMsg\": \"Denne stak administreres ikke af Dockge.\",\n    \"primaryHostname\": \"Primært værtsnavn\",\n    \"general\": \"Generelt\",\n    \"container\": \"Container | Containere\",\n    \"scanFolder\": \"Scan Stak-mappe\",\n    \"dockerImage\": \"Billede\",\n    \"restartPolicyUnlessStopped\": \"Medmindre stoppet\",\n    \"restartPolicyAlways\": \"Altid\",\n    \"restartPolicyOnFailure\": \"Ved fejl\",\n    \"restartPolicyNo\": \"Nej\",\n    \"restartPolicy\": \"Genstart politik\",\n    \"containerName\": \"Container navn\",\n    \"port\": \"Port | Porte\",\n    \"volume\": \"Volumen | Voluminer\",\n    \"network\": \"Netværk | Netværker\",\n    \"dependsOn\": \"Containerafhængighed | Containerafhængigheder\",\n    \"addListItem\": \"Tilføj {0}\",\n    \"deleteContainer\": \"Slet\",\n    \"addNetwork\": \"Tilføj Netværk\",\n    \"passwordNotMatchMsg\": \"Koden du gentog stemmer ikke overens.\",\n    \"autoGet\": \"Auto Get\",\n    \"add\": \"Tilføj\",\n    \"Edit\": \"Redigere\",\n    \"applyToYAML\": \"Anvend til YAML\",\n    \"createExternalNetwork\": \"Skab\",\n    \"addInternalNetwork\": \"Tilføj\",\n    \"Save\": \"Gem\",\n    \"Language\": \"Sprog\",\n    \"Current User\": \"Nuværende bruger\",\n    \"Change Password\": \"Ændre adgangskode\",\n    \"Current Password\": \"Nuværende adgangskode\",\n    \"New Password\": \"Ny adgangskode\",\n    \"Repeat New Password\": \"Gentag ny adgangskode\",\n    \"Update Password\": \"Opdater adgangskode\",\n    \"Advanced\": \"Avanceret\",\n    \"Please use this option carefully!\": \"Brug venligst denne indstilling forsigtigt!\",\n    \"Enable Auth\": \"Aktiver godkendelse\",\n    \"Disable Auth\": \"Deaktiver godkendelse\",\n    \"I understand, please disable\": \"Jeg forstår, venligst deaktiver\",\n    \"Leave\": \"Forlad\",\n    \"Frontend Version\": \"Version\",\n    \"Check Update On GitHub\": \"Tjek opdatering på GitHub\",\n    \"Also check beta release\": \"Tjek også betaversionen\",\n    \"Remember me\": \"Husk mig\",\n    \"Login\": \"Login\",\n    \"Username\": \"Brugernavn\",\n    \"Password\": \"Adgangskode\",\n    \"Settings\": \"Indstillinger\",\n    \"Logout\": \"Log ud\",\n    \"Convert to Compose\": \"Konverter til Compose\",\n    \"active\": \"aktiv\",\n    \"exited\": \"forladt\",\n    \"inactive\": \"inaktive\",\n    \"Appearance\": \"Udseende\",\n    \"Security\": \"Sikkerhed\",\n    \"Docker Run\": \"Docker Kør\",\n    \"About\": \"Om\",\n    \"Allowed commands:\": \"Tilladte kommandoer:\",\n    \"Internal Networks\": \"Interne netværk\",\n    \"External Networks\": \"Eksterne netværk\",\n    \"No External Networks\": \"Ingen eksterne netværk\",\n    \"reverseProxyMsg1\": \"Bruger du en Reverse-Proxy?\",\n    \"reverseProxyMsg2\": \"Tjek, hvordan du konfigurerer det til WebSocket\",\n    \"Cannot connect to the socket server.\": \"Kan ikke oprette forbindelse til socket-serveren.\",\n    \"reconnecting...\": \"Genopretter forbindelse…\",\n    \"connecting...\": \"Opretter forbindelse til socket-serveren…\",\n    \"url\": \"URL | URL'er\",\n    \"extra\": \"Ekstra\",\n    \"Create your admin account\": \"Opret din administratorkonto\",\n    \"addFirstStackMsg\": \"Komponer din første stak!\",\n    \"deleteStackMsg\": \"Er du sikker på, at du vil slette denne stak?\",\n    \"environmentVariable\": \"Miljøvariabel | miljøvariabler\",\n    \"addContainer\": \"Tilføj Container\",\n    \"disableauth.message1\": \"Er du sikker på, at du vil <strong>deaktivere godkendelse</strong>?\",\n    \"disableauth.message2\": \"Det er designet til scenarier <strong>hvor du har til hensigt at implementere tredjepartsgodkendelse</strong> foran Dockge såsom Cloudflare Access, Authelia eller andre godkendelsesmekanismer.\",\n    \"Show update if available\": \"Vis opdatering, hvis tilgængelig\",\n    \"Lowercase only\": \"Kun små bogstaver\",\n    \"newUpdate\": \"Ny Opdatering\",\n    \"dockgeAgent\": \"Dockge Agent | Dockge Agenter\",\n    \"currentEndpoint\": \"Nuværende\",\n    \"dockgeURL\": \"Dockge URL (f.eks. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"Tilslutter\",\n    \"connect\": \"Tilslut\",\n    \"addAgent\": \"Tilføj agent\",\n    \"agentAddedSuccessfully\": \"Agent succesfuld tilføjet.\",\n    \"agentRemovedSuccessfully\": \"Agent succesfuld fjernet.\",\n    \"removeAgent\": \"Fjern agent\",\n    \"removeAgentMsg\": \"Er du sikker på at du vil fjerne denne agent?\",\n    \"LongSyntaxNotSupported\": \"Langt syntaks er ikke understøttet her. Forsøg venligst med YAML-editoren.\",\n    \"Saved\": \"Gemt\",\n    \"Deleted\": \"Slettet\",\n    \"Updated\": \"Opdateret\",\n    \"Started\": \"Startet\",\n    \"Stopped\": \"Stoppet\",\n    \"Restarted\": \"Genstartet\",\n    \"terminal\": \"Terminal\",\n    \"Network name...\": \"Netværksnavn...\",\n    \"Select a network...\": \"Vælg et netværk...\",\n    \"Deployed\": \"Udrullet\"\n}\n"
  },
  {
    "path": "frontend/src/lang/de-CH.json",
    "content": "{\n    \"languageName\": \"Schwiizerdütsch\",\n    \"Create your admin account\": \"Erstell dis Admin-Konto\",\n    \"authIncorrectCreds\": \"Falsche Benutzername oder falsches Passwort.\",\n    \"PasswordsDoNotMatch\": \"Passwörter stimmed nöd überein.\",\n    \"Repeat Password\": \"Passwort wiederhole\",\n    \"Create\": \"Erstelle\",\n    \"signedInDisp\": \"Agmeldet als {0}\",\n    \"signedInDispDisabled\": \"Ameldig deaktiviert.\",\n    \"home\": \"Startsiite\",\n    \"console\": \"Konsole\",\n    \"registry\": \"Container Registry\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Stell din erste Stack zämme!\",\n    \"stackName\": \"Stack-Name\",\n    \"deployStack\": \"Deploye\",\n    \"deleteStack\": \"Lösche\",\n    \"stopStack\": \"Ahalte\",\n    \"restartStack\": \"Neustarte\",\n    \"updateStack\": \"Aktualisiere\",\n    \"startStack\": \"Starte\",\n    \"editStack\": \"Bearbeite\",\n    \"discardStack\": \"Verwerfe\",\n    \"saveStackDraft\": \"Speicher\",\n    \"notAvailableShort\": \"N/V\",\n    \"deleteStackMsg\": \"Wotsch de Stack würklich lösche?\",\n    \"stackNotManagedByDockgeMsg\": \"De Stack wird nöd vo Dockge verwaltet.\",\n    \"primaryHostname\": \"Primäre Hostname\",\n    \"general\": \"Allgemein\",\n    \"container\": \"Container\",\n    \"scanFolder\": \"Stacks-Ordner durchsueche\",\n    \"dockerImage\": \"Image\",\n    \"restartPolicyUnlessStopped\": \"Falls nöd gstoppt\",\n    \"restartPolicyAlways\": \"Immer\",\n    \"restartPolicyOnFailure\": \"Bimene Fehler\",\n    \"restartPolicyNo\": \"Kein Neustart\",\n    \"environmentVariable\": \"Umgebigsvariable\",\n    \"restartPolicy\": \"Neustart Richtlinie\",\n    \"containerName\": \"Container-Name\",\n    \"port\": \"Port / Ports\",\n    \"volume\": \"Volume / Volumes\",\n    \"network\": \"Netzwerk | Netzwerke\",\n    \"dependsOn\": \"Container-Abhängigkeit/e\",\n    \"addListItem\": \"{0} hinzuefüege\",\n    \"deleteContainer\": \"Lösche\",\n    \"addContainer\": \"Container hinzuefüege\",\n    \"addNetwork\": \"Netzwerk hinzuefüege\",\n    \"disableauth.message1\": \"Bisch der sicher, dass du d'<strong>Ameldung deaktiviere</strong> wotsch?\",\n    \"disableauth.message2\": \"Es isch für Szenarien vorgseh, <strong>in dene du beabsichtigsch, e Drittabüter-Authentifizierig</strong> vor Dockge z'implementiere, wie zum Bispiel Cloudflare Access, Authelia oder anderi Authentifizierigsmechanisme.\",\n    \"passwordNotMatchMsg\": \"s'wiederholte Passwort stimmt nöd überein.\",\n    \"autoGet\": \"Automatisch lade\",\n    \"add\": \"Hinzuefüege\",\n    \"Edit\": \"Bearbeite\",\n    \"applyToYAML\": \"Uf s'YAML awende\",\n    \"createExternalNetwork\": \"Erstelle\",\n    \"addInternalNetwork\": \"Hinzuefüege\",\n    \"Save\": \"Speichere\",\n    \"Language\": \"Sprach\",\n    \"Current User\": \"Aktuelle Benutzer\",\n    \"Change Password\": \"Passwort ändere\",\n    \"Current Password\": \"Aktuells Passwort\",\n    \"New Password\": \"Neus Passwort\",\n    \"Repeat New Password\": \"Neus Passwort wiederhole\",\n    \"Update Password\": \"Passwort aktualisiere\",\n    \"Advanced\": \"Erwiitert\",\n    \"Please use this option carefully!\": \"Bitte verwend die Option sorgfältig!\",\n    \"Enable Auth\": \"Ameldig aktiviere\",\n    \"Disable Auth\": \"Ameldig deaktiviere\",\n    \"I understand, please disable\": \"Ich verstah, bitte deaktiviere\",\n    \"Leave\": \"Verlah\",\n    \"Frontend Version\": \"Frontend Version\",\n    \"Check Update On GitHub\": \"Update uf GitHub überprüefe\",\n    \"Show update if available\": \"Update azeige, wenn verfüegbar\",\n    \"Also check beta release\": \"Au Beta-Version überprüefe\",\n    \"Remember me\": \"Agmeldet blibe\",\n    \"Login\": \"Amelde\",\n    \"Username\": \"Benutzername\",\n    \"Password\": \"Passwort\",\n    \"Settings\": \"Istellige\",\n    \"Logout\": \"Abmelde\",\n    \"Lowercase only\": \"Nur Chliibuechstabe\",\n    \"Convert to Compose\": \"In Compose-Syntax umwandle\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"aktiv\",\n    \"exited\": \"beendet\",\n    \"inactive\": \"inaktiv\",\n    \"Appearance\": \"Erschiinigsbild\",\n    \"Security\": \"Sicherheit\",\n    \"About\": \"Über\",\n    \"Allowed commands:\": \"Zueglasseni Befehl:\",\n    \"Internal Networks\": \"Interni Netzwerk\",\n    \"External Networks\": \"Externi Netzwerk\",\n    \"No External Networks\": \"Kei externi Netzwerk\",\n    \"Cannot connect to the socket server.\": \"Kei Verbindig zum Socket Server.\",\n    \"reverseProxyMsg1\": \"Wird en Reverse Proxy benutzt?\",\n    \"reconnecting...\": \"Erneute Verbindigsufbau…\",\n    \"downStack\": \"Stoppe & Deaktiviere\",\n    \"extra\": \"Extra\",\n    \"url\": \"URL / URLs\",\n    \"reverseProxyMsg2\": \"Lern wie er für WebSockets z'konfiguriere isch.\",\n    \"connecting...\": \"Verbindigsufbau zum Socket Server…\",\n    \"newUpdate\": \"Neues Update\",\n    \"dockgeAgent\": \"Dockge Agent | Dockge Agente\",\n    \"currentEndpoint\": \"Aktuell\",\n    \"dockgeURL\": \"Dockge URL (z. B. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"Verbinde\",\n    \"connect\": \"Verbinde\",\n    \"addAgent\": \"Agent Hinzuefüege\",\n    \"agentAddedSuccessfully\": \"Agent erfolgriich hinzuegfüegt.\",\n    \"agentRemovedSuccessfully\": \"Agent erfolgriich entfernt.\",\n    \"removeAgent\": \"Agent Entferne\",\n    \"removeAgentMsg\": \"Bisch der sicher, dass du de Agent entferne wotsch?\",\n    \"LongSyntaxNotSupported\": \"Lange Syntax wird nöd unterstützt. Bitte verwend de YAML-Editor.\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Verbindig zum Socket Server verlore. Verbinde...\",\n    \"Saved\": \"Gspeicheret\",\n    \"Deleted\": \"Glöscht\",\n    \"Started\": \"Gstartet\",\n    \"Stopped\": \"Gstoppt\",\n    \"Restarted\": \"Neugstartet\",\n    \"New Container Name...\": \"Neue Container Name...\",\n    \"Network name...\": \"Netzwerkname...\",\n    \"Select a network...\": \"Netzwerk uswähle...\",\n    \"Updated\": \"Aktualisiert\",\n    \"Deployed\": \"Deployed\",\n    \"Switch to sh\": \"Zu sh wechsle\",\n    \"terminal\": \"Terminal\",\n    \"CurrentHostname\": \"(nöd gsetzt: verwendet aktuelli Hostname)\",\n    \"Downed\": \"Abegfahre\",\n    \"NoNetworksAvailable\": \"Kei Netzwerk verfüegbar. Du muesch zersch interni Netzwerk hinzuefüege oder externi Netzwerk uf de rechte Siite aktiviere.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/de.json",
    "content": "{\n    \"languageName\": \"Deutsch\",\n    \"Create your admin account\": \"Erstelle dein Admin-Konto\",\n    \"authIncorrectCreds\": \"Falscher Benutzername oder falsches Passwort.\",\n    \"PasswordsDoNotMatch\": \"Passwörter stimmen nicht überein.\",\n    \"Repeat Password\": \"Passwort wiederholen\",\n    \"Create\": \"Erstellen\",\n    \"signedInDisp\": \"Angemeldet als {0}\",\n    \"signedInDispDisabled\": \"Anmeldung deaktiviert.\",\n    \"home\": \"Startseite\",\n    \"console\": \"Konsole\",\n    \"registry\": \"Container Registry\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Stelle deinen ersten Stack zusammen!\",\n    \"stackName\": \"Stack-Name\",\n    \"deployStack\": \"Deployen\",\n    \"deleteStack\": \"Löschen\",\n    \"stopStack\": \"Anhalten\",\n    \"restartStack\": \"Neustarten\",\n    \"updateStack\": \"Aktualisieren\",\n    \"startStack\": \"Starten\",\n    \"editStack\": \"Bearbeiten\",\n    \"discardStack\": \"Verwerfen\",\n    \"saveStackDraft\": \"Speichern\",\n    \"notAvailableShort\": \"N/V\",\n    \"deleteStackMsg\": \"Möchtest du diesen Stack wirklich löschen?\",\n    \"stackNotManagedByDockgeMsg\": \"Dieser Stack wird nicht von Dockge verwaltet.\",\n    \"primaryHostname\": \"Primärer Hostname\",\n    \"general\": \"Allgemein\",\n    \"container\": \"Container\",\n    \"scanFolder\": \"Stacks-Ordner durchsuchen\",\n    \"dockerImage\": \"Image\",\n    \"restartPolicyUnlessStopped\": \"Falls nicht gestoppt\",\n    \"restartPolicyAlways\": \"Immer\",\n    \"restartPolicyOnFailure\": \"Bei Fehler\",\n    \"restartPolicyNo\": \"Kein Neustart\",\n    \"environmentVariable\": \"Umgebungsvariable/n\",\n    \"restartPolicy\": \"Neustart Richtlinie\",\n    \"containerName\": \"Container-Name\",\n    \"port\": \"Port / Ports\",\n    \"volume\": \"Volume / Volumes\",\n    \"network\": \"Netzwerk | Netzwerke\",\n    \"dependsOn\": \"Container-Abhängigkeit/en\",\n    \"addListItem\": \"{0} hinzufügen\",\n    \"deleteContainer\": \"Löschen\",\n    \"addContainer\": \"Container hinzufügen\",\n    \"addNetwork\": \"Netzwerk hinzufügen\",\n    \"disableauth.message1\": \"Bist du sicher, dass du die <strong>Anmeldung deaktivieren</strong> möchtest?\",\n    \"disableauth.message2\": \"Es ist für Szenarien vorgesehen, <strong>in denen du beabsichtigst, eine Drittanbieter-Authentifizierung</strong> vor Dockge zu implementieren, wie zum Beispiel Cloudflare Access, Authelia oder andere Authentifizierungsmechanismen.\",\n    \"passwordNotMatchMsg\": \"Das wiederholte Passwort stimmt nicht überein.\",\n    \"autoGet\": \"Automatisch laden\",\n    \"add\": \"Hinzufügen\",\n    \"Edit\": \"Bearbeiten\",\n    \"applyToYAML\": \"Auf YAML anwenden\",\n    \"createExternalNetwork\": \"Erstellen\",\n    \"addInternalNetwork\": \"Hinzufügen\",\n    \"Save\": \"Speichern\",\n    \"Language\": \"Sprache\",\n    \"Current User\": \"Aktueller Benutzer\",\n    \"Change Password\": \"Passwort ändern\",\n    \"Current Password\": \"Aktuelles Passwort\",\n    \"New Password\": \"Neues Passwort\",\n    \"Repeat New Password\": \"Neues Passwort wiederholen\",\n    \"Update Password\": \"Passwort aktualisieren\",\n    \"Advanced\": \"Erweitert\",\n    \"Please use this option carefully!\": \"Bitte verwende diese Option sorgfältig!\",\n    \"Enable Auth\": \"Anmeldung aktivieren\",\n    \"Disable Auth\": \"Anmeldung deaktivieren\",\n    \"I understand, please disable\": \"Ich verstehe, bitte deaktivieren\",\n    \"Leave\": \"Verlassen\",\n    \"Frontend Version\": \"Frontend Version\",\n    \"Check Update On GitHub\": \"Update auf GitHub überprüfen\",\n    \"Show update if available\": \"Update anzeigen, wenn verfügbar\",\n    \"Also check beta release\": \"Auch Beta-Version überprüfen\",\n    \"Remember me\": \"Angemeldet bleiben\",\n    \"Login\": \"Anmelden\",\n    \"Username\": \"Benutzername\",\n    \"Password\": \"Passwort\",\n    \"Settings\": \"Einstellungen\",\n    \"Logout\": \"Abmelden\",\n    \"Lowercase only\": \"Nur Kleinbuchstaben\",\n    \"Convert to Compose\": \"In Compose-Syntax umwandeln\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"aktiv\",\n    \"exited\": \"beendet\",\n    \"inactive\": \"inaktiv\",\n    \"Appearance\": \"Erscheinungsbild\",\n    \"Security\": \"Sicherheit\",\n    \"About\": \"Über\",\n    \"Allowed commands:\": \"Zugelassene Befehle:\",\n    \"Internal Networks\": \"Interne Netzwerke\",\n    \"External Networks\": \"Externe Netzwerke\",\n    \"No External Networks\": \"Keine externen Netzwerke\",\n    \"Cannot connect to the socket server.\": \"Keine Verbindung zum Socket Server.\",\n    \"reverseProxyMsg1\": \"Wird ein Reverse Proxy genutzt?\",\n    \"reconnecting...\": \"Erneuter Verbindungsaufbau…\",\n    \"downStack\": \"Stoppen & Deaktivieren\",\n    \"extra\": \"Extra\",\n    \"url\": \"URL / URLs\",\n    \"reverseProxyMsg2\": \"Lerne wie dieser für WebSockets zu konfigurieren ist.\",\n    \"connecting...\": \"Verbindungsaufbau zum Socket Server…\",\n    \"newUpdate\": \"Neues Update\",\n    \"dockgeAgent\": \"Dockge Agent | Dockge Agenten\",\n    \"currentEndpoint\": \"Aktuell\",\n    \"dockgeURL\": \"Dockge URL (z. B. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"Verbinden\",\n    \"connect\": \"Verbinden\",\n    \"addAgent\": \"Agent Hinzufügen\",\n    \"agentAddedSuccessfully\": \"Agent erfolgreich hinzugefügt.\",\n    \"agentRemovedSuccessfully\": \"Agent erfolgreich entfernt.\",\n    \"removeAgent\": \"Agent Entfernen\",\n    \"removeAgentMsg\": \"Bist Du sicher, dass Du diesen Agent entfernen möchtest?\",\n    \"LongSyntaxNotSupported\": \"Lange Syntax wird nicht unterstützt. Bitte verwende den YAML-Editor.\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Verbindung zu Socket Server verloren. Verbinden...\",\n    \"Saved\": \"Gespeichert\",\n    \"Deleted\": \"Gelöscht\",\n    \"Started\": \"Gestartet\",\n    \"Stopped\": \"Gestoppt\",\n    \"Restarted\": \"Neugestartet\",\n    \"New Container Name...\": \"Neuer Container Name...\",\n    \"Network name...\": \"Netzwerkname...\",\n    \"Select a network...\": \"Netzwerk auswählen...\",\n    \"Updated\": \"Aktualisiert\",\n    \"Deployed\": \"Deployed\",\n    \"Switch to sh\": \"Zu sh wechseln\",\n    \"terminal\": \"Terminal\",\n    \"CurrentHostname\": \"(nicht gesetzt: verwende aktuellen Hostname)\",\n    \"Downed\": \"Heruntergefahren\",\n    \"NoNetworksAvailable\": \"Keine Netzwerke verfügbar. Du musst zunächst interne Netzwerke hinzufügen oder externe Netzwerke auf der rechten Seite aktivieren.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/en.json",
    "content": "{\n    \"languageName\": \"English\",\n    \"Create your admin account\": \"Create your admin account\",\n    \"authIncorrectCreds\": \"Incorrect username or password.\",\n    \"PasswordsDoNotMatch\": \"Passwords do not match.\",\n    \"Repeat Password\": \"Repeat Password\",\n    \"Create\": \"Create\",\n    \"signedInDisp\": \"Signed in as {0}\",\n    \"signedInDispDisabled\": \"Auth Disabled.\",\n    \"home\": \"Home\",\n    \"console\": \"Console\",\n    \"registry\": \"Registry\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Compose your first stack!\",\n    \"stackName\": \"Stack Name\",\n    \"deployStack\": \"Deploy\",\n    \"deleteStack\": \"Delete\",\n    \"stopStack\": \"Stop\",\n    \"restartStack\": \"Restart\",\n    \"updateStack\": \"Update\",\n    \"startStack\": \"Start\",\n    \"downStack\": \"Stop & Inactive\",\n    \"editStack\": \"Edit\",\n    \"discardStack\": \"Discard\",\n    \"saveStackDraft\": \"Save\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"Are you sure you want to delete this stack?\",\n    \"cancel\": \"Cancel\",\n    \"stackNotManagedByDockgeMsg\": \"This stack is not managed by Dockge.\",\n    \"primaryHostname\": \"Primary Hostname\",\n    \"general\": \"General\",\n    \"container\": \"Container | Containers\",\n    \"scanFolder\": \"Scan Stacks Folder\",\n    \"dockerImage\": \"Image\",\n    \"restartPolicyUnlessStopped\": \"Unless Stopped\",\n    \"restartPolicyAlways\": \"Always\",\n    \"restartPolicyOnFailure\": \"On Failure\",\n    \"restartPolicyNo\": \"No\",\n    \"environmentVariable\": \"Environment Variable | Environment Variables\",\n    \"restartPolicy\": \"Restart Policy\",\n    \"containerName\": \"Container Name\",\n    \"port\": \"Port | Ports\",\n    \"volume\": \"Volume | Volumes\",\n    \"network\": \"Network | Networks\",\n    \"dependsOn\": \"Container Dependency | Container Dependencies\",\n    \"addListItem\": \"Add {0}\",\n    \"deleteContainer\": \"Delete\",\n    \"addContainer\": \"Add Container\",\n    \"addNetwork\": \"Add Network\",\n    \"disableauth.message1\": \"Are you sure want to <strong>disable authentication</strong>?\",\n    \"disableauth.message2\": \"It is designed for scenarios <strong>where you intend to implement third-party authentication</strong> in front of Dockge such as Cloudflare Access, Authelia or other authentication mechanisms.\",\n    \"passwordNotMatchMsg\": \"The repeat password does not match.\",\n    \"autoGet\": \"Auto Get\",\n    \"add\": \"Add\",\n    \"Edit\": \"Edit\",\n    \"applyToYAML\": \"Apply to YAML\",\n    \"createExternalNetwork\": \"Create\",\n    \"addInternalNetwork\": \"Add\",\n    \"Save\": \"Save\",\n    \"Language\": \"Language\",\n    \"Current User\": \"Current User\",\n    \"Change Password\": \"Change Password\",\n    \"Current Password\": \"Current Password\",\n    \"New Password\": \"New Password\",\n    \"Repeat New Password\": \"Repeat New Password\",\n    \"Update Password\": \"Update Password\",\n    \"Advanced\": \"Advanced\",\n    \"Please use this option carefully!\": \"Please use this option carefully!\",\n    \"Enable Auth\": \"Enable Auth\",\n    \"Disable Auth\": \"Disable Auth\",\n    \"I understand, please disable\": \"I understand, please disable\",\n    \"Leave\": \"Leave\",\n    \"Frontend Version\": \"Frontend Version\",\n    \"Check Update On GitHub\": \"Check Update On GitHub\",\n    \"Show update if available\": \"Show update if available\",\n    \"Also check beta release\": \"Also check beta release\",\n    \"Remember me\": \"Remember me\",\n    \"Login\": \"Login\",\n    \"Username\": \"Username\",\n    \"Password\": \"Password\",\n    \"Settings\": \"Settings\",\n    \"Logout\": \"Logout\",\n    \"Lowercase only\": \"Lowercase only\",\n    \"Convert to Compose\": \"Convert to Compose\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"active\",\n    \"exited\": \"exited\",\n    \"inactive\": \"inactive\",\n    \"Appearance\": \"Appearance\",\n    \"Security\": \"Security\",\n    \"About\": \"About\",\n    \"Allowed commands:\": \"Allowed commands:\",\n    \"Internal Networks\": \"Internal Networks\",\n    \"External Networks\": \"External Networks\",\n    \"No External Networks\": \"No External Networks\",\n    \"reverseProxyMsg1\": \"Using a Reverse Proxy?\",\n    \"reverseProxyMsg2\": \"Check how to config it for WebSocket\",\n    \"Cannot connect to the socket server.\": \"Cannot connect to the socket server.\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Lost connection to the socket server. Reconnecting...\",\n    \"reconnecting...\": \"Reconnecting…\",\n    \"connecting...\": \"Connecting to the socket server…\",\n    \"url\": \"URL | URLs\",\n    \"extra\": \"Extra\",\n    \"newUpdate\": \"New Update\",\n    \"dockgeAgent\": \"Dockge Agent | Dockge Agents\",\n    \"currentEndpoint\": \"Current\",\n    \"dockgeURL\": \"Dockge URL (e.g. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"Connecting\",\n    \"connect\": \"Connect\",\n    \"addAgent\": \"Add Agent\",\n    \"agentAddedSuccessfully\": \"Agent added successfully.\",\n    \"agentRemovedSuccessfully\": \"Agent removed successfully.\",\n    \"removeAgent\": \"Remove Agent\",\n    \"removeAgentMsg\": \"Are you sure you want to remove this agent?\",\n    \"GlobalEnv\": \"Global .env\",\n    \"LongSyntaxNotSupported\": \"Long syntax is not supported here. Please use the YAML editor.\",\n    \"Saved\": \"Saved\",\n    \"Deployed\": \"Deployed\",\n    \"Deleted\": \"Deleted\",\n    \"Updated\": \"Updated\",\n    \"Started\": \"Started\",\n    \"Stopped\": \"Stopped\",\n    \"Restarted\": \"Restarted\",\n    \"Downed\": \"Downed\",\n    \"Switch to sh\": \"Switch to sh\",\n    \"terminal\": \"Terminal\",\n    \"CurrentHostname\": \"(Unset: Follow current hostname)\",\n    \"New Container Name...\": \"New Container Name...\",\n    \"Network name...\": \"Network name...\",\n    \"Select a network...\": \"Select a network...\",\n    \"NoNetworksAvailable\": \"No networks available. You need to add internal networks or enable external networks in the right side first.\",\n    \"Console is not enabled\": \"Console is not enabled\",\n    \"ConsoleNotEnabledMSG1\": \"Console is a powerful tool that allows you to execute any commands such as <code>docker</code>, <code>rm</code> within the Dockge's container in this Web UI.\",\n    \"ConsoleNotEnabledMSG2\": \"It might be dangerous since this Dockge container is connecting to the host's Docker daemon. Also Dockge could be possibly taken down by commands like <code>rm -rf</code>\" ,\n    \"ConsoleNotEnabledMSG3\": \"If you understand the risk, you can enable it by setting <code>DOCKGE_ENABLE_CONSOLE=true</code> in the environment variables.\",\n    \"confirmLeaveStack\": \"You are currently editing a stack. Are you sure you want to leave?\"\n}\n"
  },
  {
    "path": "frontend/src/lang/es.json",
    "content": "{\n    \"languageName\": \"Inglés\",\n    \"Create your admin account\": \"Crea tu cuenta de administrador\",\n    \"authIncorrectCreds\": \"Nombre de usuario o contraseña incorrectos.\",\n    \"PasswordsDoNotMatch\": \"Las contraseñas no coinciden.\",\n    \"Repeat Password\": \"Repetir Contraseña\",\n    \"Create\": \"Crear\",\n    \"signedInDisp\": \"Sesión iniciada como {0}\",\n    \"signedInDispDisabled\": \"Autenticación deshabilitada.\",\n    \"home\": \"Inicio\",\n    \"console\": \"Consola\",\n    \"registry\": \"Registro\",\n    \"compose\": \"Componer\",\n    \"addFirstStackMsg\": \"¡Compón tu primera pila!\",\n    \"stackName\": \"Nombre de la Pila\",\n    \"deployStack\": \"Desplegar\",\n    \"deleteStack\": \"Eliminar\",\n    \"stopStack\": \"Detener\",\n    \"restartStack\": \"Reiniciar\",\n    \"updateStack\": \"Actualizar\",\n    \"startStack\": \"Iniciar\",\n    \"editStack\": \"Editar\",\n    \"discardStack\": \"Descartar\",\n    \"saveStackDraft\": \"Guardar\",\n    \"notAvailableShort\": \"N/D\",\n    \"deleteStackMsg\": \"¿Estás seguro de que quieres eliminar esta pila?\",\n    \"stackNotManagedByDockgeMsg\": \"Esta pila no está gestionada por Dockge.\",\n    \"primaryHostname\": \"Nombre de Host Primario\",\n    \"general\": \"General\",\n    \"container\": \"Contenedor | Contenedores\",\n    \"scanFolder\": \"Escanear Carpeta de Pilas\",\n    \"dockerImage\": \"Imagen\",\n    \"restartPolicyUnlessStopped\": \"A menos que se detenga\",\n    \"restartPolicyAlways\": \"Siempre\",\n    \"restartPolicyOnFailure\": \"En caso de fallo\",\n    \"restartPolicyNo\": \"No\",\n    \"environmentVariable\": \"Variable de Entorno | Variables de Entorno\",\n    \"restartPolicy\": \"Política de Reinicio\",\n    \"containerName\": \"Nombre del Contenedor\",\n    \"port\": \"Puerto | Puertos\",\n    \"volume\": \"Volumen | Volúmenes\",\n    \"network\": \"Red | Redes\",\n    \"dependsOn\": \"Dependencia del Contenedor | Dependencias del Contenedor\",\n    \"addListItem\": \"Agregar {0}\",\n    \"deleteContainer\": \"Eliminar\",\n    \"addContainer\": \"Agregar Contenedor\",\n    \"addNetwork\": \"Agregar Red\",\n    \"disableauth.message1\": \"¿Estás seguro de que deseas <strong>desactivar la autenticación</strong>?\",\n    \"disableauth.message2\": \"Está diseñado para escenarios <strong>donde pretendes implementar autenticación de terceros</strong> frente a Dockge, como Cloudflare Access, Authelia u otros mecanismos de autenticación.\",\n    \"passwordNotMatchMsg\": \"La contraseña repetida no coincide.\",\n    \"autoGet\": \"Obtener Automáticamente\",\n    \"add\": \"Agregar\",\n    \"Edit\": \"Editar\",\n    \"applyToYAML\": \"Aplicar a YAML\",\n    \"createExternalNetwork\": \"Crear\",\n    \"addInternalNetwork\": \"Agregar\",\n    \"Save\": \"Guardar\",\n    \"Language\": \"Idioma\",\n    \"Current User\": \"Usuario Actual\",\n    \"Change Password\": \"Cambiar Contraseña\",\n    \"Current Password\": \"Contraseña Actual\",\n    \"New Password\": \"Nueva Contraseña\",\n    \"Repeat New Password\": \"Repetir Nueva Contraseña\",\n    \"Update Password\": \"Actualizar Contraseña\",\n    \"Advanced\": \"Avanzado\",\n    \"Please use this option carefully!\": \"¡Por favor, usa esta opción con cuidado!\",\n    \"Enable Auth\": \"Habilitar Autenticación\",\n    \"Disable Auth\": \"Deshabilitar Autenticación\",\n    \"I understand, please disable\": \"Entiendo, por favor deshabilitar\",\n    \"Leave\": \"Salir\",\n    \"Frontend Version\": \"Versión del Frontend\",\n    \"Check Update On GitHub\": \"Comprobar Actualización en GitHub\",\n    \"Show update if available\": \"Mostrar actualización si está disponible\",\n    \"Also check beta release\": \"También verificar la versión beta\",\n    \"Remember me\": \"Recuérdame\",\n    \"Login\": \"Iniciar Sesión\",\n    \"Username\": \"Nombre de Usuario\",\n    \"Password\": \"Contraseña\",\n    \"Settings\": \"Configuración\",\n    \"Logout\": \"Cerrar Sesión\",\n    \"Lowercase only\": \"Solo minúsculas\",\n    \"Convert to Compose\": \"Convertir a Compose\",\n    \"Docker Run\": \"Ejecutar Docker\",\n    \"active\": \"activo\",\n    \"exited\": \"finalizado\",\n    \"inactive\": \"inactivo\",\n    \"Appearance\": \"Apariencia\",\n    \"Security\": \"Seguridad\",\n    \"About\": \"Acerca de\",\n    \"Allowed commands:\": \"Comandos permitidos:\",\n    \"Internal Networks\": \"Redes Internas\",\n    \"External Networks\": \"Redes Externas\",\n    \"No External Networks\": \"Sin Redes Externas\",\n    \"reverseProxyMsg1\": \"¿Usando un proxy inverso?\",\n    \"reverseProxyMsg2\": \"Compruebe cómo configurarlo para WebSocket\",\n    \"newUpdate\": \"Nueva actualización\",\n    \"downStack\": \"Detener y desactivar\",\n    \"Cannot connect to the socket server.\": \"No se puede conectar al servidor del socket.\",\n    \"reconnecting...\": \"Reconectando…\",\n    \"connecting...\": \"Conectando al servidor del socket…\",\n    \"url\": \"Dirección URL | Direcciones URLs\",\n    \"extra\": \"Addicional\",\n    \"currentEndpoint\": \"Actual\",\n    \"dockgeURL\": \"URL de Dockge (ej. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"En línea\",\n    \"agentOffline\": \"Desconectado\",\n    \"connect\": \"Conectar\",\n    \"addAgent\": \"Añadir Agente\",\n    \"agentAddedSuccessfully\": \"Agente añadido satisfactoriamente.\",\n    \"removeAgent\": \"Eliminar Agente\",\n    \"removeAgentMsg\": \"¿Estás seguro que deseas eliminar este agente?\",\n    \"dockgeAgent\": \"Agentes Dockge\",\n    \"connecting\": \"Conectando\",\n    \"agentRemovedSuccessfully\": \"Agente eliminado satisfactoriamente.\",\n    \"LongSyntaxNotSupported\": \"No hay soporte para la sintaxis larga. Por favor use el editor de YAML.\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Se ha perdido la conexión con el servidor de socket. Reconectando...\",\n    \"Saved\": \"Guardado\",\n    \"Deployed\": \"Desplegado\",\n    \"Deleted\": \"Eliminado\",\n    \"Updated\": \"Actualizado\",\n    \"Started\": \"Arrancado\",\n    \"Stopped\": \"Parado\",\n    \"Restarted\": \"Reseteado\",\n    \"Downed\": \"Caído\",\n    \"Switch to sh\": \"Cambiar a sh\",\n    \"terminal\": \"Terminal\",\n    \"CurrentHostname\": \"(Vacío: Seguir hostname actual)\",\n    \"New Container Name...\": \"Nombre del nuevo Container...\",\n    \"Network name...\": \"Nombre de la red...\",\n    \"Select a network...\": \"Selecciona una red...\",\n    \"NoNetworksAvailable\": \"No hay redes disponibles. Primero debes agregar redes internas o habilitar redes externas en el lado derecho.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/fr.json",
    "content": "{\n    \"languageName\": \"Français\",\n    \"Create your admin account\": \"Créez votre compte administrateur\",\n    \"authIncorrectCreds\": \"identifiant ou mot de passe incorrect.\",\n    \"Repeat Password\": \"Répéter le mot de passe\",\n    \"PasswordsDoNotMatch\": \"Les mots de passe ne correspondent pas.\",\n    \"Create\": \"Créer\",\n    \"signedInDisp\": \"Connecté en tant que {0}\",\n    \"signedInDispDisabled\": \"Authentification désactivée.\",\n    \"home\": \"Accueil\",\n    \"console\": \"Console\",\n    \"registry\": \"Registre\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Créez votre première pile !\",\n    \"stackName\": \"Nom de la pile\",\n    \"deployStack\": \"Déployer\",\n    \"deleteStack\": \"Supprimer\",\n    \"stopStack\": \"Arrêter\",\n    \"restartStack\": \"Redémarrer\",\n    \"updateStack\": \"Mettre à jour\",\n    \"startStack\": \"Démarrer\",\n    \"editStack\": \"Modifier\",\n    \"discardStack\": \"Ignorer\",\n    \"saveStackDraft\": \"Sauvegarder\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"Êtes-vous sûr de vouloir supprimer cette pile ?\",\n    \"stackNotManagedByDockgeMsg\": \"Cette pile n'est pas gérée par Dockge.\",\n    \"primaryHostname\": \"Nom d'hôte principal\",\n    \"general\": \"Général\",\n    \"container\": \"Conteneur | Conteneurs\",\n    \"scanFolder\": \"Analyser le dossier des piles\",\n    \"dockerImage\": \"Image\",\n    \"restartPolicyUnlessStopped\": \"Sauf arrêt\",\n    \"restartPolicyAlways\": \"Toujours\",\n    \"restartPolicyOnFailure\": \"En cas d'échec\",\n    \"restartPolicyNo\": \"Non\",\n    \"environmentVariable\": \"Variable d'environnement | Variables d'environnement\",\n    \"restartPolicy\": \"Politique de redémarrage\",\n    \"containerName\": \"Nom du conteneur\",\n    \"port\": \"Port | Ports\",\n    \"volume\": \"Volume | Volumes\",\n    \"network\": \"Réseau | Réseaux\",\n    \"dependsOn\": \"Dépendance du conteneur | Dépendances du conteneur\",\n    \"addListItem\": \"Ajouter {0}\",\n    \"deleteContainer\": \"Supprimer\",\n    \"addContainer\": \"Ajouter un conteneur\",\n    \"addNetwork\": \"Ajouter un réseau\",\n    \"disableauth.message1\": \"Voulez-vous vraiment <strong>désactiver l'authentification</strong> ?\",\n    \"disableauth.message2\": \"Il est conçu pour les scénarios <strong>dans lesquels vous avez l'intention d'implémenter une authentification tierce</strong> devant Dockge, comme Cloudflare Access, Authelia ou d'autres mécanismes d'authentification.\",\n    \"passwordNotMatchMsg\": \"Le mot de passe de confirmation ne correspond pas.\",\n    \"autoGet\": \"Obtention automatique\",\n    \"add\": \"Ajouter\",\n    \"Edit\": \"Modifier\",\n    \"applyToYAML\": \"Appliquer au YAML\",\n    \"createExternalNetwork\": \"Créer\",\n    \"addInternalNetwork\": \"Ajouter\",\n    \"Save\": \"Enregistrer\",\n    \"Language\": \"Langue\",\n    \"Current User\": \"Utilisateur Actuel\",\n    \"Change Password\": \"Changer le Mot de Passe\",\n    \"Current Password\": \"Mot de passe actuel\",\n    \"New Password\": \"Nouveau Mot de Passe\",\n    \"Repeat New Password\": \"Répéter le Nouveau Mot de Passe\",\n    \"Update Password\": \"Mettre à Jour le Mot de Passe\",\n    \"Advanced\": \"Avancé\",\n    \"Please use this option carefully!\": \"Veuillez utiliser cette option avec précaution !\",\n    \"Enable Auth\": \"Activer l'Authentification\",\n    \"Disable Auth\": \"Désactiver l'Authentification\",\n    \"I understand, please disable\": \"Je comprends, veuillez désactiver\",\n    \"Leave\": \"Quitter\",\n    \"Frontend Version\": \"Version Frontend\",\n    \"Check Update On GitHub\": \"Vérifier la Mise à Jour sur GitHub\",\n    \"Show update if available\": \"Afficher la mise à jour si disponible\",\n    \"Also check beta release\": \"Vérifier également la version bêta\",\n    \"Remember me\": \"Se souvenir de moi\",\n    \"Login\": \"Connexion\",\n    \"Username\": \"Nom d'utilisateur\",\n    \"Password\": \"Mot de Passe\",\n    \"Settings\": \"Paramètres\",\n    \"Logout\": \"Déconnexion\",\n    \"Lowercase only\": \"Minuscules uniquement\",\n    \"Convert to Compose\": \"Convertir en Compose\",\n    \"Docker Run\": \"Exécution Docker\",\n    \"active\": \"actif\",\n    \"exited\": \"arrêté\",\n    \"inactive\": \"inactif\",\n    \"Appearance\": \"Apparence\",\n    \"Security\": \"Sécurité\",\n    \"About\": \"À propos\",\n    \"Allowed commands:\": \"Commandes autorisées :\",\n    \"Internal Networks\": \"Réseaux Internes\",\n    \"External Networks\": \"Réseaux Externes\",\n    \"No External Networks\": \"Aucun Réseau Externe\",\n    \"reverseProxyMsg2\": \"Vérifier comment le configurer pour WebSocket\",\n    \"connecting...\": \"Connexion au serveur socket…\",\n    \"url\": \"URL | URLs\",\n    \"extra\": \"Supplémentaire\",\n    \"downStack\": \"Arrêtez et rendre inactif\",\n    \"reverseProxyMsg1\": \"Utilisez vous un proxy inverse ?\",\n    \"Cannot connect to the socket server.\": \"Impossible de se connecter au serveur socket.\",\n    \"reconnecting...\": \"Reconnexion…\",\n    \"newUpdate\": \"Nouvelle mise à jour\",\n    \"dockgeURL\": \"URL de Dockge (e.g. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"En ligne\",\n    \"agentOffline\": \"Hors ligne\",\n    \"connecting\": \"Connexion\",\n    \"addAgent\": \"Ajouter un agent\",\n    \"agentAddedSuccessfully\": \"Agent ajouté avec succès.\",\n    \"agentRemovedSuccessfully\": \"Agent supprimé avec succès.\",\n    \"removeAgent\": \"Supprimer l'agent\",\n    \"dockgeAgent\": \"Dockge Agent | Dockge Agents\",\n    \"currentEndpoint\": \"Actuel\",\n    \"connect\": \"Connecter\",\n    \"removeAgentMsg\": \"Êtes-vous sûr de vouloir supprimer cet agent ?\",\n    \"LongSyntaxNotSupported\": \"La syntaxe longue n'est pas prise en charge ici. Veuillez utiliser l'éditeur YAML.\",\n    \"Saved\": \"Enregistré\",\n    \"Deployed\": \"Déployé\",\n    \"Deleted\": \"Supprimé\",\n    \"Updated\": \"Mis à jour\",\n    \"Started\": \"démarrer\",\n    \"Stopped\": \"Arrêté\",\n    \"Restarted\": \"Redémarré\",\n    \"Switch to sh\": \"Passer à sh\",\n    \"terminal\": \"Terminal\",\n    \"New Container Name...\": \"Nouveau nom du conteneur...\",\n    \"Network name...\": \"Nom du réseau...\",\n    \"Select a network...\": \"Sélectionnez un réseau...\",\n    \"Downed\": \"Abattu\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Connexion au serveur socket perdue. Reconnexion...\",\n    \"CurrentHostname\": \"(Non défini : suivre le nom d'hôte actuel)\",\n    \"NoNetworksAvailable\": \"Aucun réseau disponible. Vous devez d'abord ajouter des réseaux internes ou activer les réseaux externes sur le côté droit.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/ga.json",
    "content": "{\n    \"Create your admin account\": \"Cruthaigh do chuntas riaracháin\",\n    \"authIncorrectCreds\": \"Ainm úsáideora nó pasfhocal mícheart.\",\n    \"PasswordsDoNotMatch\": \"Níl na pasfhocail comhthráthacha.\",\n    \"Repeat Password\": \"Athscríobh an Pasfhocal\",\n    \"Create\": \"Cruthaigh\",\n    \"signedInDisp\": \"Sínithe isteach mar {0}\",\n    \"languageName\": \"Gaeilge\",\n    \"console\": \"Consól\",\n    \"registry\": \"Clárlann\",\n    \"compose\": \"Scríobh\",\n    \"stackName\": \"Ainm an Staca\",\n    \"deployStack\": \"Deighil\",\n    \"deleteStack\": \"Scrios\",\n    \"stopStack\": \"Stad\",\n    \"restartStack\": \"Atosaigh\",\n    \"updateStack\": \"Nuashonraigh\",\n    \"startStack\": \"Tosaigh\",\n    \"downStack\": \"Stad & Neamhghníomhach\",\n    \"editStack\": \"Cuir in Eagar\",\n    \"discardStack\": \"Caith amach\",\n    \"saveStackDraft\": \"Sábháil\",\n    \"deleteStackMsg\": \"An bhfuil tú cinnte go bhfuil tú ag iarraidh an staca seo a scriosadh?\",\n    \"primaryHostname\": \"Príomhainm óstáin\",\n    \"general\": \"Ginearálta\",\n    \"container\": \"Coimeádán | Coimeádáin\",\n    \"scanFolder\": \"Scanáil Fillteáin na dStacanna\",\n    \"dockerImage\": \"Íomha\",\n    \"restartPolicyUnlessStopped\": \"Mura Stadfar\",\n    \"restartPolicyAlways\": \"I gcónaí\",\n    \"restartPolicyOnFailure\": \"Ar theip\",\n    \"restartPolicyNo\": \"Níl\",\n    \"environmentVariable\": \"Athróg Timpeallacht | Athróga Timpeallacht\",\n    \"restartPolicy\": \"Polasaí Atosaigh\",\n    \"port\": \"Port | Portanna\",\n    \"volume\": \"Toirt | Toirteanna\",\n    \"network\": \"Líonra | Líonraí\",\n    \"dependsOn\": \"Spleáchas Coimeádán | Spleáchais Coimeádán\",\n    \"addListItem\": \"Cuir {0}\",\n    \"deleteContainer\": \"Scrios\",\n    \"addContainer\": \"Cuir Coimeádán leis\",\n    \"addNetwork\": \"Cuir Líonra leis\",\n    \"add\": \"Cuir\",\n    \"Edit\": \"Cuir in eagar\",\n    \"applyToYAML\": \"Déan iarratas ar YAML\",\n    \"createExternalNetwork\": \"Cruthaigh\",\n    \"disableauth.message1\": \"An bhfuil tú cinnte gur mhaith leat <strong>fíordheimhniú a dhíchumasú</strong>?\",\n    \"passwordNotMatchMsg\": \"Ní hionann an pasfhocal athfhillteach.\",\n    \"autoGet\": \"Faigh Uathoibríoch\",\n    \"addInternalNetwork\": \"Cuir\",\n    \"Save\": \"Sábháil\",\n    \"Language\": \"Teanga\",\n    \"Current User\": \"Úsáideoir Reatha\",\n    \"New Password\": \"Pasfhocal Nua\",\n    \"Current Password\": \"Pasfhocal Reatha\",\n    \"Change Password\": \"Athraigh do Phasfhocal\",\n    \"Repeat New Password\": \"Déan Pasfhocal Nua arís\",\n    \"Update Password\": \"Nuashonraigh Pasfhocal\",\n    \"Advanced\": \"Ardleibhéal\",\n    \"Please use this option carefully!\": \"Bain úsáid as an rogha seo go cúramach, le do thoil!\",\n    \"Enable Auth\": \"Cumasaigh Auth\",\n    \"Disable Auth\": \"Auth dhíchumasú\",\n    \"I understand, please disable\": \"Tuigim, le do thoil, múch\",\n    \"Leave\": \"Fág\",\n    \"Frontend Version\": \"Leagan Frontend\",\n    \"Check Update On GitHub\": \"Seiceáil an Nuashonrú ar GitHub\",\n    \"Show update if available\": \"Taispeáin an Nuashonrú más ar fáil\",\n    \"Also check beta release\": \"Seiceáil an scaoileadh beta freisin\",\n    \"Remember me\": \"Cuimhnigh orm\",\n    \"Login\": \"Logáil isteach\",\n    \"Username\": \"Ainm úsáideora\",\n    \"Password\": \"Pasfhocal\",\n    \"Logout\": \"Logáil Amach\",\n    \"Lowercase only\": \"Cás íochtair amháin\",\n    \"Convert to Compose\": \"Tiontaigh go Compóidh\",\n    \"Docker Run\": \"Docker Rith\",\n    \"exited\": \"scoir\",\n    \"inactive\": \"neamhghníomhach\",\n    \"Appearance\": \"Dealramh\",\n    \"Security\": \"Slándáil\",\n    \"About\": \"Maidir le\",\n    \"Allowed commands:\": \"Orduithe ceadaithe:\",\n    \"Internal Networks\": \"Líonraí Inmheánacha\",\n    \"External Networks\": \"Líonraí Seachtracha\",\n    \"No External Networks\": \"Gan Líonraí Seachtracha\",\n    \"reverseProxyMsg1\": \"Ag Úsáid Seachfhreastalaí Réabhlóideach?\",\n    \"reverseProxyMsg2\": \"Seiceáil conas é a shocraigh don WebSocket\",\n    \"Cannot connect to the socket server.\": \"Ní féidir ceangal a dhéanamh leis an freastalaí soicéad.\",\n    \"reconnecting...\": \"Ag athcheangal…\",\n    \"connecting...\": \"Ag nascadh leis an freastalaí soicéad…\",\n    \"url\": \"URL | URLanna\",\n    \"extra\": \"Breise\",\n    \"newUpdate\": \"Nuashonrú Nua\",\n    \"dockgeAgent\": \"Aighne Dockge | Aighnithe Dockge\",\n    \"currentEndpoint\": \"Reatha\",\n    \"dockgeURL\": \"Dockge URL (e.g. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Ar Líne\",\n    \"agentOffline\": \"As Líne\",\n    \"connecting\": \"Ag Nascadh\",\n    \"connect\": \"Ceangail\",\n    \"addAgent\": \"Cuir Aighne\",\n    \"agentAddedSuccessfully\": \"Aighne curtha leis go rathúil.\",\n    \"agentRemovedSuccessfully\": \"Aighne bhaint as go rathúil.\",\n    \"removeAgent\": \"Bain Aighne\",\n    \"removeAgentMsg\": \"An bhfuil tú cinnte gur mhaith leat an t-aighne seo a bhaint?\",\n    \"LongSyntaxNotSupported\": \"Ní thacaítear leis an níochán fada anseo. Úsáid an Eagarthóir YAML, le do thoil.\",\n    \"signedInDispDisabled\": \"Auth Díchumasaithe.\",\n    \"home\": \"Abhaile\",\n    \"addFirstStackMsg\": \"Scríobh do chéad stac!\",\n    \"notAvailableShort\": \"Níl ar Fáil\",\n    \"stackNotManagedByDockgeMsg\": \"Ní bhainistítear an staca seo ag Dockge.\",\n    \"containerName\": \"Ainm na gCoimeádán\",\n    \"disableauth.message2\": \"Tá sé deartha do chúinsí <strong>ina bhfuil sé beartaithe agat tríú páirtí athbhreithniú a chur i bhfeidhm</strong> os comhair Dockge cosúil le Rochtain Cloudflare, Authelia nó múnlaí deimhniú eile.\",\n    \"Settings\": \"Socruithe\",\n    \"active\": \"gníomhach\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Ceangal caillte leis an bhfreastalaí soicéad. Ag athcheangal...\",\n    \"Saved\": \"Shábháil\",\n    \"Deployed\": \"Imlonnaithe\",\n    \"Deleted\": \"Scriosta\",\n    \"Updated\": \"Nuashonraithe\",\n    \"Started\": \"Thosaigh\",\n    \"Stopped\": \"Stopadh\",\n    \"Restarted\": \"Atosaigh\",\n    \"Downed\": \"Tugtha anuas\",\n    \"Switch to sh\": \"Athraigh go sh\",\n    \"terminal\": \"Teirminéal\",\n    \"CurrentHostname\": \"(Díshuiteáil: Lean an t-óstainm reatha)\",\n    \"New Container Name...\": \"Ainm Gabhdáin Nua...\",\n    \"Network name...\": \"Ainm líonra...\",\n    \"Select a network...\": \"Roghnaigh líonra...\",\n    \"NoNetworksAvailable\": \"Níl líonraí ar fáil. Ní mór duit líonraí inmheánacha a chur leis nó líonraí seachtracha a chumasú ar an taobh deas ar dtús.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/hu.json",
    "content": "{\n    \"languageName\": \"Angol\",\n    \"Repeat Password\": \"Jelszó Ismétlése\",\n    \"Create\": \"Létrehozás\",\n    \"signedInDisp\": \"Bejelentkezve {0}-ként\",\n    \"home\": \"Főképernyő\",\n    \"registry\": \"Bejegyzések\",\n    \"compose\": \"Összeállít\",\n    \"addFirstStackMsg\": \"Állítsd össze az első stack-odat!\",\n    \"stackName\": \"Stack Neve\",\n    \"deployStack\": \"Telepítés\",\n    \"deleteStack\": \"Törlés\",\n    \"stopStack\": \"Leállítás\",\n    \"restartStack\": \"Újraindítás\",\n    \"downStack\": \"Leállítva\",\n    \"editStack\": \"Szerkesztés\",\n    \"discardStack\": \"Eldobás\",\n    \"saveStackDraft\": \"Mentés\",\n    \"notAvailableShort\": \"N/A\",\n    \"stackNotManagedByDockgeMsg\": \"Ez a stack nem a Dockge kezelése alatt áll.\",\n    \"primaryHostname\": \"Elsődleges Gazdagépnév\",\n    \"general\": \"Általános\",\n    \"container\": \"Konténer | Konténerek\",\n    \"scanFolder\": \"Stack Mappa Beolvasása\",\n    \"dockerImage\": \"Applikáció-kép\",\n    \"restartPolicyNo\": \"Nem\",\n    \"environmentVariable\": \"Környezeti Változó | Környezeti Változók\",\n    \"containerName\": \"Konténer Neve\",\n    \"port\": \"Port | Portok\",\n    \"volume\": \"Tárhely | Tárhelyek\",\n    \"network\": \"Hálózat | Hálózatok\",\n    \"addListItem\": \"{0} Hozzáadása\",\n    \"deleteContainer\": \"Törlés\",\n    \"addContainer\": \"Konténer Hozzáadása\",\n    \"addNetwork\": \"Hálózat Hozzáadása\",\n    \"add\": \"Hozzáadás\",\n    \"Edit\": \"Szerkesztés\",\n    \"applyToYAML\": \"Alkalmazás YAML Formátumba\",\n    \"addInternalNetwork\": \"Hozzáadás\",\n    \"Save\": \"Mentés\",\n    \"Language\": \"Nyelv\",\n    \"Current User\": \"Jelenlegi Felhasználó\",\n    \"Change Password\": \"Jelszó Módosítása\",\n    \"Current Password\": \"Jelenlegi Jelszó\",\n    \"New Password\": \"Új Jelszó\",\n    \"Update Password\": \"Jelszó Cserélése\",\n    \"Advanced\": \"Fejlett\",\n    \"Enable Auth\": \"Hitelesítés Bekapcsolása\",\n    \"Disable Auth\": \"Hitelesítés Kikapcsolása\",\n    \"I understand, please disable\": \"Megértettem, kérem kapcsolja ki\",\n    \"Leave\": \"Kilépés\",\n    \"Frontend Version\": \"Frontend Verzió\",\n    \"Also check beta release\": \"Béta kiadás is\",\n    \"Remember me\": \"Emlékezz rám\",\n    \"Login\": \"Belépés\",\n    \"Username\": \"Felhasználónév\",\n    \"Password\": \"Jelszó\",\n    \"Settings\": \"Beállítások\",\n    \"Convert to Compose\": \"Átalakítás Compose-ra\",\n    \"Docker Run\": \"Docker Futtatása\",\n    \"active\": \"aktív\",\n    \"inactive\": \"inaktív\",\n    \"Appearance\": \"Megjelenés\",\n    \"Security\": \"Biztonság\",\n    \"Allowed commands:\": \"Megengedett parancsok:\",\n    \"Internal Networks\": \"Belső Hálózatok\",\n    \"External Networks\": \"Kölső Hálózatok\",\n    \"No External Networks\": \"Nincs Külső Hálózat\",\n    \"reverseProxyMsg1\": \"Proxy-t használ?\",\n    \"reverseProxyMsg2\": \"Javasolt WebSocket konfiguráció megtekintése\",\n    \"reconnecting...\": \"Újracsatlakozás…\",\n    \"extra\": \"Extra\",\n    \"newUpdate\": \"Új Frissítés\",\n    \"currentEndpoint\": \"Jelenlegi\",\n    \"agentOnline\": \"Online\",\n    \"dockgeAgent\": \"Dockge Felügyelő | Dockge Felügyelők\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"Csatlakozás\",\n    \"connect\": \"Csatlakozás\",\n    \"agentAddedSuccessfully\": \"Felügyelő hozzáadva.\",\n    \"agentRemovedSuccessfully\": \"Felügyelő törölve.\",\n    \"removeAgent\": \"Felügyelő Törlése\",\n    \"addAgent\": \"Felügyelő Hozzáadása\",\n    \"removeAgentMsg\": \"Biztos hogy törli ezt a Felügyelőt?\",\n    \"Create your admin account\": \"Adminisztrátor felhasználó létrehozása\",\n    \"authIncorrectCreds\": \"Helytelen felhasználónév vagy jelszó.\",\n    \"PasswordsDoNotMatch\": \"Jelszavak nem eggyeznek.\",\n    \"signedInDispDisabled\": \"Hitelesítés Kikapcsolva.\",\n    \"console\": \"Konzol\",\n    \"updateStack\": \"Frissítés\",\n    \"startStack\": \"Indítás\",\n    \"deleteStackMsg\": \"Biztos hogy törli ezt a stack-ot?\",\n    \"restartPolicyUnlessStopped\": \"Ha Nincs Leállítva\",\n    \"restartPolicyAlways\": \"Mindig\",\n    \"restartPolicyOnFailure\": \"Hibába Futáskor\",\n    \"restartPolicy\": \"Újraindítási Elv\",\n    \"dependsOn\": \"Konténer Függés | Konténer Függései\",\n    \"disableauth.message1\": \"Biztos hogy szeretné a <strong>hitelesítést kikapcsolni</strong>?\",\n    \"disableauth.message2\": \"Olyan esetekre ahol <strong>harmadfél általi hitelesítést szeretne alkalmazni</strong> a Dockge elött, mint például Cloudflare Access, Authelia vagy egyéb hitelesítő program.\",\n    \"passwordNotMatchMsg\": \"Az ismételt jelszó nem eggyezik.\",\n    \"autoGet\": \"Automatikus Megszerzés\",\n    \"createExternalNetwork\": \"Készítés\",\n    \"Repeat New Password\": \"Új Jelszó Megerősítése\",\n    \"Please use this option carefully!\": \"Ezt a lehetőséget használja óvatosan!\",\n    \"Check Update On GitHub\": \"Fríssítés Keresése Github-on\",\n    \"Show update if available\": \"Elérhető frissítések megjelenítése\",\n    \"Logout\": \"Kilépés\",\n    \"Lowercase only\": \"Csak kisbetűvel\",\n    \"exited\": \"végzett\",\n    \"About\": \"Az Alkalmazásról\",\n    \"Cannot connect to the socket server.\": \"A Socket csatlakozás nem elérhető.\",\n    \"connecting...\": \"Csatlakozás a socket szerver-hez…\",\n    \"url\": \"URL | URL-ek\",\n    \"dockgeURL\": \"Dockge URL (pl. http://127.0.0.1:5001)\",\n    \"LongSyntaxNotSupported\": \"A hosszú szintaxis itt nem támogatott. Használja a YAML szerkesztőt.\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Megszakadt a kapcsolat a socket szerverrel. Újracsatlakozás...\",\n    \"Saved\": \"Elmentve\",\n    \"Deployed\": \"Telepítve\",\n    \"Deleted\": \"Törölve\",\n    \"Updated\": \"Frissítve\",\n    \"Started\": \"Indult\",\n    \"Stopped\": \"Megállítva\",\n    \"Restarted\": \"Újraindítva\",\n    \"Downed\": \"Leállított\",\n    \"Switch to sh\": \"Váltás shell-re\",\n    \"terminal\": \"Terminál\",\n    \"CurrentHostname\": \"(Nincs beállítva: Az aktuális gazdagépnév követése)\",\n    \"New Container Name...\": \"Új konténer név...\",\n    \"Network name...\": \"Hálózat név...\",\n    \"Select a network...\": \"Válasszon ki egy hálózatot...\",\n    \"NoNetworksAvailable\": \"Nincs elérhető hálózat. Először hozzá kell adnia belső hálózatokat, vagy engedélyeznie kell a külső hálózatokat a jobb oldalon.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/id.json",
    "content": "{\n    \"Create your admin account\": \"Buat akun admin Anda\",\n    \"PasswordsDoNotMatch\": \"Kata sandi tidak sama.\",\n    \"Repeat Password\": \"Ulangi Kata Sandi\",\n    \"Create\": \"Buat\",\n    \"signedInDisp\": \"Masuk sebagai {0}\",\n    \"signedInDispDisabled\": \"Otentikasi Dinonaktifkan.\",\n    \"home\": \"Beranda\",\n    \"console\": \"Konsol\",\n    \"registry\": \"Registri\",\n    \"compose\": \"Menyusun\",\n    \"addFirstStackMsg\": \"Buat tumpukan pertama Anda!\",\n    \"stackName\": \"Nama Tumpukan\",\n    \"deployStack\": \"Terapkan\",\n    \"stopStack\": \"Hentikan\",\n    \"restartStack\": \"Mulai ulang\",\n    \"updateStack\": \"Pembaruan\",\n    \"downStack\": \"Hentikan & Tidak aktif\",\n    \"editStack\": \"Sunting\",\n    \"discardStack\": \"Buang\",\n    \"saveStackDraft\": \"Simpan\",\n    \"notAvailableShort\": \"T/A\",\n    \"stackNotManagedByDockgeMsg\": \"Tumpukan ini tidak dikelola oleh Dockge.\",\n    \"primaryHostname\": \"Nama Host Utama\",\n    \"general\": \"Umum\",\n    \"container\": \"Kontainer | Wadah\",\n    \"scanFolder\": \"Pindai Folder Tumpukan\",\n    \"restartPolicyUnlessStopped\": \"Kecuali Dihentikan\",\n    \"restartPolicyAlways\": \"Selalu\",\n    \"restartPolicyNo\": \"Tidak\",\n    \"environmentVariable\": \"Variabel Lingkungan | Variabel Lingkungan\",\n    \"dockerImage\": \"Image\",\n    \"startStack\": \"Mulai\",\n    \"restartPolicy\": \"Kebijakan Mulai Ulang\",\n    \"containerName\": \"Nama Kontainer\",\n    \"network\": \"Jaringan\",\n    \"dependsOn\": \"Ketergantungan Kontainer\",\n    \"addListItem\": \"Tambah {0}\",\n    \"deleteContainer\": \"Hapus\",\n    \"addContainer\": \"Tambah Kontainer\",\n    \"addNetwork\": \"Tambah Jaringan\",\n    \"disableauth.message1\": \"Apakah Anda yakin untuk <strong>mematikan otentikasi</strong>?\",\n    \"passwordNotMatchMsg\": \"Kata sandi berulang tidak cocok.\",\n    \"autoGet\": \"Otomatis Dapatkan\",\n    \"add\": \"Tambah\",\n    \"Edit\": \"Sunting\",\n    \"port\": \"Port\",\n    \"volume\": \"Volume\",\n    \"createExternalNetwork\": \"Buat\",\n    \"addInternalNetwork\": \"Tambah\",\n    \"Save\": \"Simpan\",\n    \"Language\": \"Bahasa\",\n    \"Change Password\": \"Ubah Kata Sandi\",\n    \"Current Password\": \"Ubah Kata Sandi\",\n    \"New Password\": \"Kata Sandi Baru\",\n    \"Repeat New Password\": \"Ulangi Kata Sandi\",\n    \"Update Password\": \"Perbarui Kata Sandi\",\n    \"Advanced\": \"Lanjutan\",\n    \"Enable Auth\": \"Hidupkan Otentikasi\",\n    \"Disable Auth\": \"Matikan Otentikasi\",\n    \"I understand, please disable\": \"Saya mengerti, tolong nonaktifkan\",\n    \"Leave\": \"Pergi\",\n    \"Frontend Version\": \"Versi Antarmuka\",\n    \"Check Update On GitHub\": \"Cek pembaruan di Github\",\n    \"Show update if available\": \"Tampilkan pembaruan jika tersedia\",\n    \"Remember me\": \"Ingat saya\",\n    \"Login\": \"Masuk\",\n    \"Username\": \"Nama Pengguna\",\n    \"Password\": \"Kata Sandi\",\n    \"Settings\": \"Pengaturan\",\n    \"Logout\": \"Keluar\",\n    \"Lowercase only\": \"Huruf kecil saja\",\n    \"Convert to Compose\": \"Ubah ke Tumpukan\",\n    \"active\": \"aktif\",\n    \"exited\": \"keluar\",\n    \"inactive\": \"nonaktif\",\n    \"Appearance\": \"Tampilan\",\n    \"Security\": \"Keamanan\",\n    \"About\": \"Tentang\",\n    \"Internal Networks\": \"Jaringan internal\",\n    \"External Networks\": \"Jaringan eksternal\",\n    \"No External Networks\": \"Tanpa Jaringan Eksternal\",\n    \"reverseProxyMsg1\": \"Menggunakan Reverse Proxy ?\",\n    \"Cannot connect to the socket server.\": \"Tidak bisa terhubung dengan server socket.\",\n    \"reconnecting...\": \"Menghubungkan kembali…\",\n    \"connecting...\": \"Menyambungkan ke server socket…\",\n    \"url\": \"TAUTAN\",\n    \"extra\": \"Ekstra\",\n    \"Docker Run\": \"Jalankan Docker\",\n    \"newUpdate\": \"Pembaruan Baru\",\n    \"languageName\": \"Bahasa Indonesia (Indonesian)\",\n    \"authIncorrectCreds\": \"Nama pengguna atau sandi salah.\",\n    \"deleteStack\": \"Hapus\",\n    \"deleteStackMsg\": \"Apakah Anda yakin Anda ingin menghapus tumpukan ini ?\",\n    \"restartPolicyOnFailure\": \"Ketika Gagal\",\n    \"disableauth.message2\": \"Ini dirancang untuk skenario <strong>di mana Anda bermaksud untuk mengimplementasikan otentikasi pihak ketiga</strong> di depan Dockge seperti Cloudflare Access, Authelia, atau mekanisme otentikasi lainnya.\",\n    \"applyToYAML\": \"Aplikasikan ke YAML\",\n    \"Current User\": \"Pengguna Saat Ini\",\n    \"Please use this option carefully!\": \"Mohon berhati - hati menggunakan opsi ini!\",\n    \"Also check beta release\": \"Juga cek keluaran beta\",\n    \"Allowed commands:\": \"Perintah yang diperbolehkan:\",\n    \"reverseProxyMsg2\": \"Lihat cara mengonfigurasinya untuk WebSocket\",\n    \"dockgeURL\": \"Alamat Dockge (cth. http://127.0.0.1:5001)\",\n    \"connecting\": \"Menghubungkan\",\n    \"addAgent\": \"Tambah Agen\",\n    \"agentAddedSuccessfully\": \"Agen sukses ditambahkan.\",\n    \"agentRemovedSuccessfully\": \"Agen sukses dihapus.\",\n    \"removeAgent\": \"Hapus Agen\",\n    \"connect\": \"Hubungkan\",\n    \"dockgeAgent\": \"Agen Dockge\",\n    \"currentEndpoint\": \"Sekarang\",\n    \"agentOnline\": \"Terhubung\",\n    \"agentOffline\": \"Terputus\",\n    \"removeAgentMsg\": \"Apakah anda yakin untuk menghapus agen ini?\",\n    \"LongSyntaxNotSupported\": \"Sintaks yang panjang tidak didukung di sini. Silakan gunakan editor YAML.\",\n    \"Saved\": \"Tersimpan\",\n    \"Deleted\": \"Terhapus\",\n    \"Updated\": \"Telah diperbaharui\",\n    \"Started\": \"Aplikasi dimulai\",\n    \"Stopped\": \"Aplikasi dihentikan\",\n    \"Restarted\": \"Aplikasi memuat kembali\"\n}\n"
  },
  {
    "path": "frontend/src/lang/it-IT.json",
    "content": "{\n    \"languageName\": \"Italiano\",\n    \"Create your admin account\": \"Crea il tuo account amministratore\",\n    \"authIncorrectCreds\": \"Username e/o password errati.\",\n    \"PasswordsDoNotMatch\": \"Le password non corrispondono.\",\n    \"Repeat Password\": \"Ripetere la password\",\n    \"Create\": \"Crea\",\n    \"signedInDisp\": \"Autenticato come {0}\",\n    \"signedInDispDisabled\": \"Autenticazione disabilitata.\",\n    \"home\": \"Home\",\n    \"console\": \"Console\",\n    \"registry\": \"Registro\",\n    \"compose\": \"Componi\",\n    \"addFirstStackMsg\": \"Componi il tuo primo stack!\",\n    \"stackName\": \"Nome dello stack\",\n    \"deployStack\": \"Rilascia\",\n    \"deleteStack\": \"Cancella\",\n    \"stopStack\": \"Stop\",\n    \"restartStack\": \"Riavvia\",\n    \"updateStack\": \"Aggiorna\",\n    \"startStack\": \"Avvia\",\n    \"downStack\": \"Stop & Inattivo\",\n    \"editStack\": \"Modifica\",\n    \"discardStack\": \"Annulla\",\n    \"saveStackDraft\": \"Salva\",\n    \"notAvailableShort\": \"N/D\",\n    \"deleteStackMsg\": \"Sei sicuro di voler eliminare questo stack?\",\n    \"stackNotManagedByDockgeMsg\": \"Questo stack non è gestito da Dockge.\",\n    \"primaryHostname\": \"Hostname primario\",\n    \"general\": \"Generale\",\n    \"container\": \"Container | Container\",\n    \"scanFolder\": \"Scansiona la cartella degli stack\",\n    \"dockerImage\": \"Immagine\",\n    \"restartPolicyUnlessStopped\": \"A meno che non venga fermato\",\n    \"restartPolicyAlways\": \"Sempre\",\n    \"restartPolicyOnFailure\": \"Quando fallisce\",\n    \"restartPolicyNo\": \"No\",\n    \"environmentVariable\": \"Variabile d'ambiente | Variabili d'ambiente\",\n    \"restartPolicy\": \"Politica di riavvio\",\n    \"containerName\": \"Nome del container\",\n    \"port\": \"Porta | Porte\",\n    \"volume\": \"Volume | Volumi\",\n    \"network\": \"Rete | Reti\",\n    \"dependsOn\": \"Dipendenza del container | Dipendenze del container\",\n    \"addListItem\": \"Aggiungi {0}\",\n    \"deleteContainer\": \"Elimina\",\n    \"addContainer\": \"Aggiungi container\",\n    \"addNetwork\": \"Aggiungi rete\",\n    \"disableauth.message1\": \"Sei sicuro di voler <strong>disabilitare l'autenticazione</strong>?\",\n    \"disableauth.message2\": \"È stato progettato per scenari <strong>in cui intendi implementare un'autenticazione di terze parti</strong> davanti a Dockge come ad esempio Cloudflare Access, Authelia o altri meccanismi di autenticazione.\",\n    \"passwordNotMatchMsg\": \"La password ripetuta non corrisponde.\",\n    \"autoGet\": \"Ottieni automaticamente\",\n    \"add\": \"Aggiungi\",\n    \"Edit\": \"Modifica\",\n    \"applyToYAML\": \"Applica al file YAML\",\n    \"createExternalNetwork\": \"Crea\",\n    \"addInternalNetwork\": \"Aggiungi\",\n    \"Save\": \"Salva\",\n    \"Language\": \"Lingua\",\n    \"Current User\": \"Utente corrente\",\n    \"Change Password\": \"Cambia la password\",\n    \"Current Password\": \"Password corrente\",\n    \"New Password\": \"Nuova password\",\n    \"Repeat New Password\": \"Ripeti la nuova password\",\n    \"Update Password\": \"Aggiornamento password\",\n    \"Advanced\": \"Avanzato\",\n    \"Please use this option carefully!\": \"Per favore usa questa opzione con cautela!\",\n    \"Enable Auth\": \"Abilita l'autenticazione\",\n    \"Disable Auth\": \"Disabilita l'autenticazione\",\n    \"I understand, please disable\": \"Lo capisco, disabilita\",\n    \"Leave\": \"Lascia\",\n    \"Frontend Version\": \"Versione del frontend\",\n    \"Check Update On GitHub\": \"Controlla la presenza di aggiornamenti su GitHub\",\n    \"Show update if available\": \"Mostra l'aggiornamento se è disponibile\",\n    \"Also check beta release\": \"Controlla anche le release in beta\",\n    \"Remember me\": \"Ricordami\",\n    \"Login\": \"Login\",\n    \"Username\": \"Nome Utente\",\n    \"Password\": \"Password\",\n    \"Settings\": \"Impostazioni\",\n    \"Logout\": \"Logout\",\n    \"Lowercase only\": \"Solo lettere minuscole\",\n    \"Convert to Compose\": \"Converti a Compose\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"attivo\",\n    \"exited\": \"uscito\",\n    \"inactive\": \"inattivo\",\n    \"Appearance\": \"Aspetto\",\n    \"Security\": \"Sicurezza\",\n    \"About\": \"Informazioni su\",\n    \"Allowed commands:\": \"Comandi permessi:\",\n    \"Internal Networks\": \"Reti interne\",\n    \"External Networks\": \"Reti esterne\",\n    \"No External Networks\": \"Nessuna rete esterna\",\n    \"reverseProxyMsg1\": \"Stai usando Reverse Proxy?\",\n    \"reverseProxyMsg2\": \"Verifica come configurarlo per il WebSocket\",\n    \"Cannot connect to the socket server.\": \"impossibile collegarsi al socket server\",\n    \"connecting...\": \"connettendosi al socket server…\",\n    \"extra\": \"Extra\",\n    \"reconnecting...\": \"Riconnessione…\",\n    \"url\": \"URL | URLs\",\n    \"newUpdate\": \"Nuovo aggiornamento\",\n    \"dockgeAgent\": \"Agente Dockge | Agenti Dockge\",\n    \"currentEndpoint\": \"Corrente\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"In connessione\",\n    \"connect\": \"Connetti\",\n    \"dockgeURL\": \"Dockge URL (ad esempio http://127.0.0.1:5001)\",\n    \"agentRemovedSuccessfully\": \"Agente rimosso con successo.\",\n    \"removeAgent\": \"Rimuovi Agente\",\n    \"removeAgentMsg\": \"Sei sicuro di voler rimuovere questo agente?\",\n    \"addAgent\": \"Aggungi Agente\",\n    \"agentAddedSuccessfully\": \"Agente aggiunto correttamente.\",\n    \"LongSyntaxNotSupported\": \"La sintassi lunga non è supportata qui. Utilizzare l'editor YAML.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/ja.json",
    "content": "{\n    \"authIncorrectCreds\": \"ユーザーネームまたはパスワードが正しくありません。\",\n    \"PasswordsDoNotMatch\": \"パスワードが一致しません。\",\n    \"Repeat Password\": \"パスワードを再度入力してください\",\n    \"Create\": \"作成\",\n    \"signedInDispDisabled\": \"認証が無効化されています。\",\n    \"home\": \"ホーム\",\n    \"console\": \"コンソール\",\n    \"registry\": \"レジストリ\",\n    \"stackName\": \"スタック名\",\n    \"deployStack\": \"デプロイ\",\n    \"deleteStack\": \"削除\",\n    \"stopStack\": \"停止\",\n    \"restartStack\": \"再起動\",\n    \"updateStack\": \"更新\",\n    \"startStack\": \"起動\",\n    \"editStack\": \"編集\",\n    \"discardStack\": \"破棄\",\n    \"saveStackDraft\": \"保存\",\n    \"stackNotManagedByDockgeMsg\": \"このスタックはDockgeによって管理されていません。\",\n    \"general\": \"一般\",\n    \"scanFolder\": \"スタックフォルダをスキャン\",\n    \"dockerImage\": \"イメージ\",\n    \"environmentVariable\": \"環境変数\",\n    \"restartPolicy\": \"再起動ポリシー\",\n    \"containerName\": \"コンテナ名\",\n    \"port\": \"ポート\",\n    \"volume\": \"ボリューム\",\n    \"network\": \"ネットワーク\",\n    \"addListItem\": \"{0} を追加\",\n    \"addContainer\": \"コンテナを追加\",\n    \"addNetwork\": \"ネットワークを追加\",\n    \"compose\": \"Compose\",\n    \"primaryHostname\": \"主ホスト名\",\n    \"container\": \"コンテナ\",\n    \"dependsOn\": \"コンテナ依存関係\",\n    \"downStack\": \"停止して非アクティブ化\",\n    \"notAvailableShort\": \"N/A\",\n    \"restartPolicyUnlessStopped\": \"手動で停止されるまで\",\n    \"restartPolicyAlways\": \"常時\",\n    \"restartPolicyOnFailure\": \"失敗時\",\n    \"restartPolicyNo\": \"しない\",\n    \"passwordNotMatchMsg\": \"繰り返しのパスワードが一致しません。\",\n    \"autoGet\": \"自動取得\",\n    \"add\": \"追加\",\n    \"Edit\": \"編集\",\n    \"applyToYAML\": \"YAMLに適用\",\n    \"createExternalNetwork\": \"作成\",\n    \"addInternalNetwork\": \"追加\",\n    \"Save\": \"保存\",\n    \"Language\": \"言語\",\n    \"Change Password\": \"パスワードを変更する\",\n    \"Current Password\": \"現在のパスワード\",\n    \"New Password\": \"新しいパスワード\",\n    \"Update Password\": \"パスワードを更新\",\n    \"Advanced\": \"高度\",\n    \"Please use this option carefully!\": \"このオプションは注意して使用してください！\",\n    \"Enable Auth\": \"認証を有効化\",\n    \"Disable Auth\": \"認証を無効化\",\n    \"Check Update On GitHub\": \"GitHubで更新を確認\",\n    \"Show update if available\": \"アップデートがある場合表示\",\n    \"Also check beta release\": \"ベータ版のリリースも確認する\",\n    \"Login\": \"ログイン\",\n    \"Username\": \"ユーザー名\",\n    \"Password\": \"パスワード\",\n    \"Settings\": \"設定\",\n    \"Logout\": \"ログアウト\",\n    \"Convert to Compose\": \"Composeに変換\",\n    \"Appearance\": \"外観\",\n    \"Security\": \"セキュリティ\",\n    \"Allowed commands:\": \"許可されたコマンド:\",\n    \"Internal Networks\": \"内部ネットワーク\",\n    \"External Networks\": \"外部ネットワーク\",\n    \"reverseProxyMsg2\": \"WebSocketの設定方法を確認\",\n    \"Cannot connect to the socket server.\": \"ソケットサーバーに接続できません。\",\n    \"reconnecting...\": \"再接続中…\",\n    \"Leave\": \"やめる\",\n    \"Frontend Version\": \"フロントエンドバージョン\",\n    \"Remember me\": \"覚えておく\",\n    \"No External Networks\": \"外部ネットワークなし\",\n    \"exited\": \"終了済み\",\n    \"inactive\": \"非アクティブ\",\n    \"active\": \"アクティブ\",\n    \"languageName\": \"日本語\",\n    \"Create your admin account\": \"管理者アカウントを作成してください\",\n    \"signedInDisp\": \"{0} としてログイン中\",\n    \"addFirstStackMsg\": \"最初のスタックを組み立てましょう！\",\n    \"deleteStackMsg\": \"本当にこのスタックを削除しますか？\",\n    \"deleteContainer\": \"削除\",\n    \"disableauth.message1\": \"本当に<strong>認証を無効化</strong>しますか?\",\n    \"disableauth.message2\": \"これはCloudflare AccessやAutheliaなどの認証手段をDockgeの前段に置いて<strong>サードパーティー認証を実装することをあなたが意図している</strong>場合のために設計されています。\",\n    \"Current User\": \"現在のユーザー\",\n    \"Repeat New Password\": \"新しいパスワードを繰り返してください\",\n    \"I understand, please disable\": \"理解しました。無効化してください\",\n    \"Lowercase only\": \"小文字のみ\",\n    \"reverseProxyMsg1\": \"リバースプロキシを使用していますか？\",\n    \"connecting...\": \"ソケットサーバーに接続中…\",\n    \"newUpdate\": \"新しいバージョン\",\n    \"dockgeAgent\": \"Dockge エージェント\",\n    \"dockgeURL\": \"DockgeのURL (例: http://127.0.0.1:5001)\",\n    \"agentOnline\": \"オンライン\",\n    \"agentOffline\": \"オフライン\",\n    \"connecting\": \"接続中\",\n    \"connect\": \"接続\",\n    \"addAgent\": \"エージェントを追加\",\n    \"agentAddedSuccessfully\": \"エージェントが正常に追加されました。\",\n    \"agentRemovedSuccessfully\": \"エージェントは正常に削除されました。\",\n    \"removeAgent\": \"エージェントを削除\",\n    \"removeAgentMsg\": \"本当にこのエージェントを削除しますか?\",\n    \"url\": \"URL | URL\",\n    \"About\": \"Dockgeについて\",\n    \"Docker Run\": \"Docker Run to Compose\",\n    \"LongSyntaxNotSupported\": \"長い構文はここではサポートされていません。YAMLエディタを使用してください。\",\n    \"Lost connection to the socket server. Reconnecting...\": \"ソケットサーバーとの接続が失われました。再接続中です...\",\n    \"extra\": \"追加設定\",\n    \"Saved\": \"保存済み\",\n    \"Deployed\": \"デプロイ済み\",\n    \"Deleted\": \"削除済み\",\n    \"Updated\": \"アップデート済み\",\n    \"Started\": \"開始済み\",\n    \"Stopped\": \"停止済み\",\n    \"Restarted\": \"再起動済み\",\n    \"Switch to sh\": \"shに切り替え\",\n    \"terminal\": \"ターミナル\",\n    \"New Container Name...\": \"新しいコンテナ名...\",\n    \"Network name...\": \"ネットワーク名...\",\n    \"Select a network...\": \"ネットワークを選択...\",\n    \"NoNetworksAvailable\": \"利用可能なネットワークがありません。まず右側の内部ネットワークを追加するか、外部ネットワークを有効にする必要があります。\"\n}\n"
  },
  {
    "path": "frontend/src/lang/ko-KR.json",
    "content": "{\n    \"languageName\": \"한국어\",\n    \"Create your admin account\": \"관리자 계정 만들기\",\n    \"authIncorrectCreds\": \"사용자명 또는 비밀번호가 일치하지 않아요.\",\n    \"PasswordsDoNotMatch\": \"비밀번호가 일치하지 않아요.\",\n    \"Repeat Password\": \"비밀번호 재입력\",\n    \"Create\": \"생성\",\n    \"signedInDisp\": \"{0}(으)로 로그인됨\",\n    \"signedInDispDisabled\": \"인증 비활성화됨.\",\n    \"home\": \"홈\",\n    \"console\": \"콘솔\",\n    \"registry\": \"레지스트리\",\n    \"compose\": \"생성\",\n    \"addFirstStackMsg\": \"첫 번째 스택을 만들어 보세요!\",\n    \"stackName\": \"스택 이름\",\n    \"deployStack\": \"배포\",\n    \"deleteStack\": \"삭제\",\n    \"stopStack\": \"정지\",\n    \"restartStack\": \"재시작\",\n    \"updateStack\": \"업데이트\",\n    \"startStack\": \"시작\",\n    \"editStack\": \"수정\",\n    \"discardStack\": \"취소\",\n    \"saveStackDraft\": \"저장\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"정말로 이 스택을 삭제하시겠습니까?\",\n    \"stackNotManagedByDockgeMsg\": \"이 스택은 Dockge에 의해 관리되지 않아요.\",\n    \"primaryHostname\": \"주 호스트명\",\n    \"general\": \"일반\",\n    \"container\": \"컨테이너\",\n    \"scanFolder\": \"스택 폴더 스캔\",\n    \"dockerImage\": \"이미지\",\n    \"restartPolicyUnlessStopped\": \"종료되기 전까지\",\n    \"restartPolicyAlways\": \"항상\",\n    \"restartPolicyOnFailure\": \"오류 발생 시\",\n    \"restartPolicyNo\": \"안 함\",\n    \"environmentVariable\": \"환경 변수\",\n    \"restartPolicy\": \"재시작 정책\",\n    \"containerName\": \"컨테이너 이름\",\n    \"port\": \"포트\",\n    \"volume\": \"볼륨\",\n    \"network\": \"네트워크\",\n    \"dependsOn\": \"컨테이너 의존성\",\n    \"addListItem\": \"{0} 추가\",\n    \"deleteContainer\": \"삭제\",\n    \"addContainer\": \"컨테이너 추가\",\n    \"addNetwork\": \"네트워크 추가\",\n    \"disableauth.message1\": \"정말로 <strong>인증을 비활성화</strong>하시겠습니까?\",\n    \"disableauth.message2\": \"이 기능은 Dockge 앞에 Cloudflare Access, Authelia 등과 같은 <strong>서드 파티 인증을 사용하려는 경우</strong>에 사용하기 위해서 만들어졌어요.\",\n    \"passwordNotMatchMsg\": \"비밀번호 재입력이 일치하지 않아요..\",\n    \"autoGet\": \"자동으로 가져오기\",\n    \"add\": \"추가\",\n    \"Edit\": \"수정\",\n    \"applyToYAML\": \"YAML에 적용\",\n    \"createExternalNetwork\": \"생성\",\n    \"addInternalNetwork\": \"추가\",\n    \"Save\": \"저장\",\n    \"Language\": \"언어\",\n    \"Current User\": \"현재 사용자\",\n    \"Change Password\": \"비밀번호 변경\",\n    \"Current Password\": \"현재 비밀번호\",\n    \"New Password\": \"새 비밀번호\",\n    \"Repeat New Password\": \"새 비밀번호 재입력\",\n    \"Update Password\": \"비밀번호 변경\",\n    \"Advanced\": \"고급\",\n    \"Please use this option carefully!\": \"이 설정은 신중히 사용하세요!\",\n    \"Enable Auth\": \"인증 활성화\",\n    \"Disable Auth\": \"인증 비활성화\",\n    \"I understand, please disable\": \"이해하고 있습니다. 비활성화해 주세요\",\n    \"Leave\": \"취소\",\n    \"Frontend Version\": \"프론트엔드 버전\",\n    \"Check Update On GitHub\": \"GitHub에서 업데이트 확인\",\n    \"Show update if available\": \"업데이트가 있을 때 표시\",\n    \"Also check beta release\": \"베타 버전도 확인\",\n    \"Remember me\": \"기억하기\",\n    \"Login\": \"로그인\",\n    \"Username\": \"사용자명\",\n    \"Password\": \"비밀번호\",\n    \"Settings\": \"설정\",\n    \"Logout\": \"로그아웃\",\n    \"Lowercase only\": \"소문자만\",\n    \"Convert to Compose\": \"Compose로 변환\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"활성\",\n    \"exited\": \"종료됨\",\n    \"inactive\": \"비활성\",\n    \"Appearance\": \"디스플레이\",\n    \"Security\": \"보안\",\n    \"About\": \"정보\",\n    \"Allowed commands:\": \"허용된 명령어:\",\n    \"Internal Networks\": \"내부 네트워크\",\n    \"External Networks\": \"외부 네트워크\",\n    \"No External Networks\": \"외부 네트워크 없음\",\n    \"reverseProxyMsg2\": \"여기서 WebSocket을 위한 설정을 확인해 보세요\",\n    \"downStack\": \"정지 & 비활성화\",\n    \"reverseProxyMsg1\": \"리버스 프록시를 사용하고 계신가요?\",\n    \"Cannot connect to the socket server.\": \"소켓 서버에 연결하지 못했습니다.\",\n    \"connecting...\": \"소켓 서버에 연결하는 중…\",\n    \"extra\": \"기타\",\n    \"url\": \"URL | URL\",\n    \"reconnecting...\": \"재연결 중…\",\n    \"newUpdate\": \"새 업데이트\",\n    \"dockgeURL\": \"Dockge URL (예. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"온라인\",\n    \"agentOffline\": \"오프라인\",\n    \"connect\": \"연결\",\n    \"addAgent\": \"에이전트 추가\",\n    \"agentAddedSuccessfully\": \"에이전트를 성공적으로 추가했습니다.\",\n    \"removeAgent\": \"에이전트 삭제\",\n    \"removeAgentMsg\": \"정말로 이 에이전트를 삭제하시겠습니까?\",\n    \"dockgeAgent\": \"Dockge 에이전트\",\n    \"currentEndpoint\": \"현재\",\n    \"connecting\": \"연결 중\",\n    \"agentRemovedSuccessfully\": \"에이전트를 성공적으로 삭제했습니다.\",\n    \"LongSyntaxNotSupported\": \"긴 문법은 여기서 지원되지 않습니다. YAML 에디터를 사용하세요.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/nb_NO.json",
    "content": "{\n    \"Create your admin account\": \"Lag din administrator konto\",\n    \"authIncorrectCreds\": \"Brukernavn eller passord stemmer ikke.\",\n    \"PasswordsDoNotMatch\": \"Passord stemmer ikke.\",\n    \"Repeat Password\": \"Gjenta passord\",\n    \"Create\": \"Lag\",\n    \"signedInDisp\": \"Logg in som {0}\",\n    \"signedInDispDisabled\": \"Auth deaktivert.\",\n    \"home\": \"Hjem\",\n    \"console\": \"Konsoll\",\n    \"registry\": \"Register\",\n    \"compose\": \"Skriv\",\n    \"addFirstStackMsg\": \"Lag din første stack!\",\n    \"stackName\": \"Navn på stack\",\n    \"deployStack\": \"Utplassere\",\n    \"deleteStack\": \"Slett\",\n    \"stopStack\": \"Stoppe\",\n    \"restartStack\": \"Omstart\",\n    \"updateStack\": \"Oppdater\",\n    \"downStack\": \"Stop & Inaktiver\",\n    \"editStack\": \"Rediger\",\n    \"discardStack\": \"Kast\",\n    \"saveStackDraft\": \"Lagre\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"Er du sikker på at du vil slette denne stacken?\",\n    \"stackNotManagedByDockgeMsg\": \"Denne stacken er ikke styrt av Dockge.\",\n    \"primaryHostname\": \"Primært vertsnavn\",\n    \"general\": \"Generell\",\n    \"container\": \"Container | Containers\",\n    \"scanFolder\": \"Skann Stacks mappe\",\n    \"dockerImage\": \"Bilde\",\n    \"languageName\": \"Engelsk\",\n    \"startStack\": \"Start\"\n}\n"
  },
  {
    "path": "frontend/src/lang/nl.json",
    "content": "{\n    \"languageName\": \"Nederlands\",\n    \"authIncorrectCreds\": \"Onjuiste gebruikersnaam of wachtwoord.\",\n    \"PasswordsDoNotMatch\": \"Wachtwoorden komen niet overeen.\",\n    \"Repeat Password\": \"Herhaal wachtwoord\",\n    \"Create\": \"Aanmaken\",\n    \"signedInDisp\": \"Ingelogd als {0}\",\n    \"home\": \"Home\",\n    \"console\": \"Console\",\n    \"registry\": \"Register\",\n    \"compose\": \"Nieuwe stack\",\n    \"stackName\": \"Stack naam\",\n    \"deployStack\": \"Opzetten\",\n    \"deleteStack\": \"Verwijder\",\n    \"stopStack\": \"Stop\",\n    \"restartStack\": \"Herstart\",\n    \"updateStack\": \"Update\",\n    \"startStack\": \"Start\",\n    \"downStack\": \"Stop & Afsluiten\",\n    \"editStack\": \"Bewerken\",\n    \"discardStack\": \"Verwijderen\",\n    \"saveStackDraft\": \"Opslaan\",\n    \"notAvailableShort\": \"n.v.t.\",\n    \"stackNotManagedByDockgeMsg\": \"Deze stack wordt niet beheerd door Dockge.\",\n    \"primaryHostname\": \"Primaire hostnaam\",\n    \"general\": \"Algemeen\",\n    \"scanFolder\": \"Scan stacks folder\",\n    \"dockerImage\": \"Image\",\n    \"restartPolicyUnlessStopped\": \"Tenzij gestopt\",\n    \"restartPolicyAlways\": \"Altijd\",\n    \"restartPolicyOnFailure\": \"Bij fout\",\n    \"restartPolicyNo\": \"Neen\",\n    \"environmentVariable\": \"Omgevings variabele(n)\",\n    \"restartPolicy\": \"Herstart policy\",\n    \"containerName\": \"Containernaam\",\n    \"port\": \"Poort(en)\",\n    \"volume\": \"Volume(s)\",\n    \"network\": \"Netwerk(en)\",\n    \"addListItem\": \"Voeg {0} toe\",\n    \"deleteContainer\": \"Verwijder\",\n    \"addContainer\": \"Container toevoegen\",\n    \"addNetwork\": \"Netwerk toevoegen\",\n    \"signedInDispDisabled\": \"Aanmelden uitgeschakeld.\",\n    \"container\": \"Container(s)\",\n    \"autoGet\": \"Auto ophalen\",\n    \"add\": \"Toevoegen\",\n    \"Edit\": \"Bewerken\",\n    \"applyToYAML\": \"Toevoegen aan YAML\",\n    \"createExternalNetwork\": \"Aanmaken\",\n    \"addInternalNetwork\": \"Toevoegen\",\n    \"Save\": \"Opslaan\",\n    \"Language\": \"Taal\",\n    \"Change Password\": \"Verander wachtwoord\",\n    \"Current Password\": \"Huidig wachtwoord\",\n    \"New Password\": \"Nieuw wachtwoord\",\n    \"Repeat New Password\": \"Herhaal nieuw wachtwoord\",\n    \"Update Password\": \"Update wachtwoord\",\n    \"Advanced\": \"Geavanceerd\",\n    \"I understand, please disable\": \"Begrepen, dit uitschakelen\",\n    \"Disable Auth\": \"Aanmelden uitschakelen\",\n    \"Enable Auth\": \"Aanmelden inschakelen\",\n    \"Leave\": \"Afmelden\",\n    \"Frontend Version\": \"Frontend versie\",\n    \"Check Update On GitHub\": \"Controleer via GitHub op updates\",\n    \"Show update if available\": \"Toon update indien beschikbaar\",\n    \"Remember me\": \"Onthoud mij\",\n    \"Login\": \"Inloggen\",\n    \"Username\": \"Gebruikersnaam\",\n    \"Password\": \"Wachtwoord\",\n    \"Settings\": \"Instellingen\",\n    \"Logout\": \"Uitloggen\",\n    \"Lowercase only\": \"Geen hoofdletters\",\n    \"Docker Run\": \"Docker run\",\n    \"active\": \"actief\",\n    \"exited\": \"gestopt\",\n    \"inactive\": \"inactief\",\n    \"Appearance\": \"Uiterlijk\",\n    \"Security\": \"Beveiliging\",\n    \"About\": \"Over\",\n    \"Allowed commands:\": \"Toegelaten commando's:\",\n    \"Internal Networks\": \"Interne netwerken\",\n    \"No External Networks\": \"Geen externe netwerken\",\n    \"reverseProxyMsg1\": \"Reverse proxy in gebruik?\",\n    \"reverseProxyMsg2\": \"Controleer hoe te configureren voor WebSocket\",\n    \"Cannot connect to the socket server.\": \"Kan geen verbinding maken met de socket server.\",\n    \"reconnecting...\": \"Herverbinden…\",\n    \"connecting...\": \"Verbinden met de socket server…\",\n    \"url\": \"Adres(sen)\",\n    \"extra\": \"Extra\",\n    \"Create your admin account\": \"Creëer je beheerders-account\",\n    \"addFirstStackMsg\": \"Maak je eerste stack!\",\n    \"deleteStackMsg\": \"Zeker dat je deze stack wilt verwijderen?\",\n    \"dependsOn\": \"Container afhankelijkheid | afhankelijkheden\",\n    \"disableauth.message1\": \"Zeker dat u <strong>aanmelden</strong> wilt uitschakelen?\",\n    \"disableauth.message2\": \"Dit is enkel bedoeld om te gebruiken wanneer je<strong> third-party autorisatie wilt gebruiken voor Dockge</strong>, zoals Cloudflare Acces, Authelia, ...\",\n    \"passwordNotMatchMsg\": \"De wachtwoorden komen niet overeen.\",\n    \"Current User\": \"Huidige gebruiker\",\n    \"Please use this option carefully!\": \"Wees voorzichtig met deze optie!\",\n    \"Also check beta release\": \"Controleer ook op beta releases\",\n    \"Convert to Compose\": \"Converteer naar compose\",\n    \"External Networks\": \"Externe netwerken\",\n    \"newUpdate\": \"Nieuwe update\",\n    \"dockgeAgent\": \"Dockge Agent | Dockge Agenten\",\n    \"currentEndpoint\": \"Huidige\",\n    \"dockgeURL\": \"Dockge Adres (bijv. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"Verbinden\",\n    \"connect\": \"Verbind\",\n    \"addAgent\": \"Agent toevoegen\",\n    \"agentAddedSuccessfully\": \"Agent toegevoegd.\",\n    \"agentRemovedSuccessfully\": \"Agent verwijderd.\",\n    \"removeAgent\": \"Verwijder agent\",\n    \"removeAgentMsg\": \"Weet je zeker dat je deze agent wilt verwijderen?\",\n    \"LongSyntaxNotSupported\": \"Lange syntax wordt hier niet ondersteund. Gebruik de YAML editor.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/pl-PL.json",
    "content": "{\n    \"languageName\": \"Polski\",\n    \"Create your admin account\": \"Utwórz konto administratora\",\n    \"authIncorrectCreds\": \"Nieprawidłowa nazwa użytkownika lub hasło.\",\n    \"PasswordsDoNotMatch\": \"Hasła nie pasują do siebie.\",\n    \"Repeat Password\": \"Powtórz hasło\",\n    \"Create\": \"Utwórz\",\n    \"signedInDisp\": \"Zalogowany jako {0}\",\n    \"signedInDispDisabled\": \"Autoryzacja wyłączona.\",\n    \"home\": \"Strona główna\",\n    \"console\": \"Konsola\",\n    \"registry\": \"Rejestr\",\n    \"compose\": \"Stwórz\",\n    \"addFirstStackMsg\": \"Stwórz swój pierwszy stos!\",\n    \"stackName\": \"Nazwa stosu\",\n    \"deployStack\": \"Wdroż\",\n    \"deleteStack\": \"Usuń\",\n    \"stopStack\": \"Zatrzymaj\",\n    \"restartStack\": \"Uruchom ponownie\",\n    \"updateStack\": \"Aktualizuj\",\n    \"startStack\": \"Uruchom\",\n    \"editStack\": \"Edytuj\",\n    \"discardStack\": \"Odrzuć\",\n    \"saveStackDraft\": \"Zapisz\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"Czy na pewno chcesz usunąć ten stos?\",\n    \"stackNotManagedByDockgeMsg\": \"Ten stos nie jest zarządzany przez Dockge.\",\n    \"primaryHostname\": \"Podstawowa nazwa hosta\",\n    \"general\": \"Ogólne\",\n    \"container\": \"Kontener | Kontenery\",\n    \"scanFolder\": \"Skanuj folder ze stosami\",\n    \"dockerImage\": \"Obraz\",\n    \"restartPolicyUnlessStopped\": \"Jeśli nie zatrzymano\",\n    \"restartPolicyAlways\": \"Zawsze\",\n    \"restartPolicyOnFailure\": \"Po awarii\",\n    \"restartPolicyNo\": \"Nie restartuj\",\n    \"environmentVariable\": \"Zmienna środowiskowa | Zmienne środowiskowe\",\n    \"restartPolicy\": \"Polityka restartu\",\n    \"containerName\": \"Nazwa kontenera\",\n    \"port\": \"Port | Porty\",\n    \"volume\": \"Wolumin | Woluminy\",\n    \"network\": \"Sieć | Sieci\",\n    \"dependsOn\": \"Zależność kontenera | Zależności kontenera\",\n    \"addListItem\": \"Dodaj {0}\",\n    \"deleteContainer\": \"Usuń kontener\",\n    \"addContainer\": \"Dodaj kontener\",\n    \"addNetwork\": \"Dodaj sieć\",\n    \"disableauth.message1\": \"Czy na pewno chcesz <strong>wyłączyć uwierzytelnianie</strong>?\",\n    \"disableauth.message2\": \"Przeznaczone dla sytuacji, <strong>w których zamierzasz zaimplementować zewnętrzne mechanizmy uwierzytelniania</strong> przed Dockge, takie jak Cloudflare Access, Authelia lub inne.\",\n    \"passwordNotMatchMsg\": \"Hasła się nie zgadzają.\",\n    \"autoGet\": \"Automatyczne pobieranie\",\n    \"add\": \"Dodaj\",\n    \"Edit\": \"Edytuj\",\n    \"applyToYAML\": \"Zastosuj do YAML\",\n    \"createExternalNetwork\": \"Utwórz\",\n    \"addInternalNetwork\": \"Dodaj\",\n    \"Save\": \"Zapisz\",\n    \"Language\": \"Język\",\n    \"Current User\": \"Aktualny użytkownik\",\n    \"Change Password\": \"Zmień hasło\",\n    \"Current Password\": \"Aktualne hasło\",\n    \"New Password\": \"Nowe hasło\",\n    \"Repeat New Password\": \"Powtórz nowe hasło\",\n    \"Update Password\": \"Aktualizuj hasło\",\n    \"Advanced\": \"Zaawansowane\",\n    \"Please use this option carefully!\": \"Proszę używać tej opcji ostrożnie!\",\n    \"Enable Auth\": \"Włącz autoryzację\",\n    \"Disable Auth\": \"Wyłącz autoryzację\",\n    \"I understand, please disable\": \"Rozumiem, proszę wyłączyć\",\n    \"Leave\": \"Wyjdź\",\n    \"Frontend Version\": \"Wersja interfejsu graficznego\",\n    \"Check Update On GitHub\": \"Sprawdź dostępność aktualizacji na GitHub\",\n    \"Show update if available\": \"Pokaż aktualizacje, jeśli są dostępne\",\n    \"Also check beta release\": \"Sprawdź także wersje beta\",\n    \"Remember me\": \"Zapamiętaj mnie\",\n    \"Login\": \"Zaloguj się\",\n    \"Username\": \"Nazwa użytkownika\",\n    \"Password\": \"Hasło\",\n    \"Settings\": \"Ustawienia\",\n    \"Logout\": \"Wyloguj się\",\n    \"Lowercase only\": \"Tylko małe litery\",\n    \"Convert to Compose\": \"Przekształć na składnię Compose\",\n    \"Docker Run\": \"Uruchom za pomocą Dockera\",\n    \"active\": \"aktywny\",\n    \"exited\": \"wyłączony\",\n    \"inactive\": \"nieaktywny\",\n    \"Appearance\": \"Wygląd\",\n    \"Security\": \"Bezpieczeństwo\",\n    \"About\": \"O programie\",\n    \"Allowed commands:\": \"Dozwolone polecenia:\",\n    \"Internal Networks\": \"Sieci wewnętrzne\",\n    \"External Networks\": \"Sieci zewnętrzne\",\n    \"No External Networks\": \"Brak sieci zewnętrznych\",\n    \"newUpdate\": \"Nowa Aktualizacja\",\n    \"dockgeAgent\": \"Agent Dockge | Agenci Dockge\",\n    \"currentEndpoint\": \"Obecny\",\n    \"connecting\": \"Łączenie\",\n    \"connect\": \"Połącz\",\n    \"addAgent\": \"Dodaj Agenta\",\n    \"agentAddedSuccessfully\": \"Agent pomyślnie dodany.\",\n    \"agentRemovedSuccessfully\": \"Agent pomyślnie usunięty.\",\n    \"removeAgent\": \"Usuń Agenta\",\n    \"removeAgentMsg\": \"Czy na pewno usunąć tego Agenta?\",\n    \"dockgeURL\": \"Dockge URL (e.g. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"downStack\": \"Zatrzymaj i Dezaktywuj\",\n    \"reverseProxyMsg1\": \"Używasz Serwer Proxy?\",\n    \"reverseProxyMsg2\": \"Sprawdź w jaki sposób skonfigurować dla WebSocketów\",\n    \"Cannot connect to the socket server.\": \"Brak połączenia z socket serwera.\",\n    \"connecting...\": \"Łączenie z socketem serwera…\",\n    \"extra\": \"Ekstra\",\n    \"url\": \"URL | URLe\",\n    \"reconnecting...\": \"Wznawianie połączenia…\",\n    \"LongSyntaxNotSupported\": \"Nieobsługiwana składnia. Użyj edytora YAML.\",\n    \"Saved\": \"Zapisano\",\n    \"Switch to sh\": \"Przełącz na sh\",\n    \"terminal\": \"Terminal\",\n    \"Restarted\": \"Zrestartowano\",\n    \"Deployed\": \"Wdrożono\",\n    \"Deleted\": \"Usunięto\",\n    \"Updated\": \"Zaktualizowano\",\n    \"Started\": \"Uruchomiono\",\n    \"Stopped\": \"Zatrzymano\",\n    \"Downed\": \"Położono\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Utracono połączenie z socketem serwera. Ponawiam połączenie...\",\n    \"New Container Name...\": \"Nazwa nowego kontenera...\",\n    \"Network name...\": \"Nazwa sieci...\",\n    \"Select a network...\": \"Wybierz sieć...\",\n    \"NoNetworksAvailable\": \"Brak dostępnych sieci. Musisz najpierw dodać sieć wewnętrzną lub włączyć sieci zewnętrzne po prawej stronie.\",\n    \"CurrentHostname\": \"(Odznacze: Podążaj za aktualnym hostem)\"\n}\n"
  },
  {
    "path": "frontend/src/lang/pt-BR.json",
    "content": "{\n    \"languageName\": \"Português-Brasil\",\n    \"Create your admin account\": \"Crie sua conta de administrador\",\n    \"authIncorrectCreds\": \"Nome de usuário ou senha incorretos.\",\n    \"PasswordsDoNotMatch\": \"As senhas não correspondem.\",\n    \"Repeat Password\": \"Repetir a senha\",\n    \"Create\": \"Criar\",\n    \"signedInDisp\": \"Logado como {0}\",\n    \"signedInDispDisabled\": \"Autenticação desativada.\",\n    \"home\": \"Início\",\n    \"console\": \"Console\",\n    \"registry\": \"Registro\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Crie sua primeira stack!\",\n    \"stackName\": \"Nome da stack\",\n    \"deployStack\": \"Deploy\",\n    \"deleteStack\": \"Excluir\",\n    \"stopStack\": \"Parar\",\n    \"restartStack\": \"Reiniciar\",\n    \"updateStack\": \"Atualizar\",\n    \"startStack\": \"Iniciar\",\n    \"editStack\": \"Editar\",\n    \"discardStack\": \"Descartar\",\n    \"saveStackDraft\": \"Salvar\",\n    \"notAvailableShort\": \"N/D\",\n    \"deleteStackMsg\": \"Tem certeza que deseja excluir esta stack?\",\n    \"stackNotManagedByDockgeMsg\": \"Esta stack não é gerenciada pelo Dockge.\",\n    \"primaryHostname\": \"Nome do Host Primário\",\n    \"general\": \"Geral\",\n    \"container\": \"Contêiner | Contêineres\",\n    \"scanFolder\": \"Pesquisar na pasta de stacks\",\n    \"dockerImage\": \"Imagem\",\n    \"restartPolicyUnlessStopped\": \"A menos que seja parado\",\n    \"restartPolicyAlways\": \"Sempre\",\n    \"restartPolicyOnFailure\": \"Em caso de falha\",\n    \"restartPolicyNo\": \"Não\",\n    \"environmentVariable\": \"Variável de ambiente | Variáveis de ambiente\",\n    \"restartPolicy\": \"Política de reinicialização\",\n    \"containerName\": \"Nome do contêiner\",\n    \"port\": \"Porta | Portas\",\n    \"volume\": \"Volume | Volumes\",\n    \"network\": \"Rede | Redes\",\n    \"dependsOn\": \"Dependência do contêiner | Dependências do contêiner\",\n    \"addListItem\": \"Adicionar {0}\",\n    \"deleteContainer\": \"Excluir\",\n    \"addContainer\": \"Adicionar contêiner\",\n    \"addNetwork\": \"Adicionar rede\",\n    \"disableauth.message1\": \"Tem certeza que deseja <strong>desativar a autenticação</strong>?\",\n    \"disableauth.message2\": \"Isso foi projetado para ambientes <strong>onde você pretende implementar autenticação de terceiros</strong> no Dockge, como Cloudflare Access, Authelia entre outros mecanismos de autenticação.\",\n    \"passwordNotMatchMsg\": \"A senha repetida não corresponde.\",\n    \"autoGet\": \"Obter automaticamente\",\n    \"add\": \"Adicionar\",\n    \"Edit\": \"Editar\",\n    \"applyToYAML\": \"Aplicar ao YAML\",\n    \"createExternalNetwork\": \"Criar\",\n    \"addInternalNetwork\": \"Adicionar\",\n    \"Save\": \"Salvar\",\n    \"Language\": \"Idioma\",\n    \"Current User\": \"Usuário atual\",\n    \"Change Password\": \"Alterar senha\",\n    \"Current Password\": \"Senha atual\",\n    \"New Password\": \"Nova senha\",\n    \"Repeat New Password\": \"Repetir nova senha\",\n    \"Update Password\": \"Atualizar senha\",\n    \"Advanced\": \"Avançado\",\n    \"Please use this option carefully!\": \"Por favor, use esta opção com atenção!\",\n    \"Enable Auth\": \"Habilitar autenticação\",\n    \"Disable Auth\": \"Desabilitar autenticação\",\n    \"I understand, please disable\": \"Entendido, por favor desabilitar\",\n    \"Leave\": \"Sair\",\n    \"Frontend Version\": \"Versão da interface\",\n    \"Check Update On GitHub\": \"Verificar atualização no GitHub\",\n    \"Show update if available\": \"Mostrar atualização se disponível\",\n    \"Also check beta release\": \"Também verificar versão beta\",\n    \"Remember me\": \"Lembrar-me\",\n    \"Login\": \"Entrar\",\n    \"Username\": \"Nome de usuário\",\n    \"Password\": \"Senha\",\n    \"Settings\": \"Configurações\",\n    \"Logout\": \"Sair\",\n    \"Lowercase only\": \"Somente minúsculas\",\n    \"Convert to Compose\": \"Converter para compose\",\n    \"Docker Run\": \"Executar Docker\",\n    \"active\": \"ativo\",\n    \"exited\": \"encerrado\",\n    \"inactive\": \"inativo\",\n    \"Appearance\": \"Aparência\",\n    \"Security\": \"Segurança\",\n    \"About\": \"Sobre\",\n    \"Allowed commands:\": \"Comandos permitidos:\",\n    \"Internal Networks\": \"Redes internas\",\n    \"External Networks\": \"Redes externas\",\n    \"No External Networks\": \"Sem redes externas\",\n    \"reverseProxyMsg2\": \"Veja como configurar para WebSocket\",\n    \"downStack\": \"Parar & Inativar\",\n    \"reverseProxyMsg1\": \"Utiliza proxy reverso?\",\n    \"Cannot connect to the socket server.\": \"Não é possível conectar ao socket server.\",\n    \"connecting...\": \"Conectando ao socket server…\",\n    \"url\": \"URL | URLs\",\n    \"extra\": \"Extra\",\n    \"reconnecting...\": \"Reconectando…\",\n    \"newUpdate\": \"Nova Atualização\",\n    \"dockgeAgent\": \"Agente Dockge | Agentes Dockge\",\n    \"currentEndpoint\": \"Atual\",\n    \"dockgeURL\": \"Dockge URL (ex. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"Conectando\",\n    \"connect\": \"Conectar\",\n    \"addAgent\": \"Adicionar agente\",\n    \"agentAddedSuccessfully\": \"Agente adicionado com sucesso.\",\n    \"agentRemovedSuccessfully\": \"Agente removido com sucesso.\",\n    \"removeAgent\": \"Remover Agente\",\n    \"removeAgentMsg\": \"Tem certeza de que deseja remover este agente?\",\n    \"LongSyntaxNotSupported\": \"Sintaxe longa não é suportada aqui. Por favor, use o editor de YAML.\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Conexão perdida com o servidor de socket. Reconectando...\",\n    \"Saved\": \"Salvo\",\n    \"Deployed\": \"Implantado\",\n    \"Deleted\": \"Excluído\",\n    \"Updated\": \"Alterado\",\n    \"Started\": \"Iniciado\",\n    \"Stopped\": \"Parado\",\n    \"Restarted\": \"Reiniciado\",\n    \"Downed\": \"Finalizado\",\n    \"Switch to sh\": \"Mudar para sh\",\n    \"terminal\": \"Terminal\",\n    \"CurrentHostname\": \"(Não definido: seguir o nome do host atual)\",\n    \"New Container Name...\": \"Nome do novo container...\",\n    \"Network name...\": \"Nome da rede...\",\n    \"Select a network...\": \"Selecione uma rede...\",\n    \"NoNetworksAvailable\": \"Nenhuma rede disponível. Você precisa adicionar redes internas ou habilitar redes externas no lado direito primeiro.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/pt.json",
    "content": "{\n    \"languageName\": \"Português\",\n    \"Create your admin account\": \"Crie sua conta de administrador\",\n    \"authIncorrectCreds\": \"Nome de usuário ou senha incorretos.\",\n    \"PasswordsDoNotMatch\": \"As senhas não coincidem.\",\n    \"Repeat Password\": \"Repetir Senha\",\n    \"Create\": \"Criar\",\n    \"signedInDisp\": \"Logado como {0}\",\n    \"signedInDispDisabled\": \"Autenticação desativada.\",\n    \"home\": \"Início\",\n    \"console\": \"Console\",\n    \"registry\": \"Registro\",\n    \"compose\": \"Compor\",\n    \"addFirstStackMsg\": \"Componha sua primeira pilha!\",\n    \"stackName\": \"Nome da Pilha\",\n    \"deployStack\": \"Implantar\",\n    \"deleteStack\": \"Excluir\",\n    \"stopStack\": \"Parar\",\n    \"restartStack\": \"Reiniciar\",\n    \"updateStack\": \"Atualizar\",\n    \"startStack\": \"Iniciar\",\n    \"editStack\": \"Editar\",\n    \"discardStack\": \"Descartar\",\n    \"saveStackDraft\": \"Salvar\",\n    \"notAvailableShort\": \"N/D\",\n    \"deleteStackMsg\": \"Tem certeza de que deseja excluir esta pilha?\",\n    \"stackNotManagedByDockgeMsg\": \"Esta pilha não é gerenciada pelo Dockge.\",\n    \"primaryHostname\": \"Nome do Host Primário\",\n    \"general\": \"Geral\",\n    \"container\": \"Contêiner | Contêineres\",\n    \"scanFolder\": \"Digitalizar Pasta de Pilhas\",\n    \"dockerImage\": \"Imagem\",\n    \"restartPolicyUnlessStopped\": \"A menos que seja parado\",\n    \"restartPolicyAlways\": \"Sempre\",\n    \"restartPolicyOnFailure\": \"Em caso de falha\",\n    \"restartPolicyNo\": \"Não\",\n    \"environmentVariable\": \"Variável de Ambiente | Variáveis de Ambiente\",\n    \"restartPolicy\": \"Política de Reinicialização\",\n    \"containerName\": \"Nome do Contêiner\",\n    \"port\": \"Porta | Portas\",\n    \"volume\": \"Volume | Volumes\",\n    \"network\": \"Rede | Redes\",\n    \"dependsOn\": \"Dependência do Contêiner | Dependências do Contêiner\",\n    \"addListItem\": \"Adicionar {0}\",\n    \"deleteContainer\": \"Excluir\",\n    \"addContainer\": \"Adicionar Contêiner\",\n    \"addNetwork\": \"Adicionar Rede\",\n    \"disableauth.message1\": \"Tem certeza de que deseja <strong>desativar a autenticação</strong>?\",\n    \"disableauth.message2\": \"Isso é projetado para cenários <strong>onde você pretende implementar autenticação de terceiros</strong> no Dockge, como Cloudflare Access, Authelia ou outros mecanismos de autenticação.\",\n    \"passwordNotMatchMsg\": \"A senha repetida não coincide.\",\n    \"autoGet\": \"Obter Automaticamente\",\n    \"add\": \"Adicionar\",\n    \"Edit\": \"Editar\",\n    \"applyToYAML\": \"Aplicar ao YAML\",\n    \"createExternalNetwork\": \"Criar\",\n    \"addInternalNetwork\": \"Adicionar\",\n    \"Save\": \"Salvar\",\n    \"Language\": \"Idioma\",\n    \"Current User\": \"Usuário Atual\",\n    \"Change Password\": \"Alterar Senha\",\n    \"Current Password\": \"Senha Atual\",\n    \"New Password\": \"Nova Senha\",\n    \"Repeat New Password\": \"Repetir Nova Senha\",\n    \"Update Password\": \"Atualizar Senha\",\n    \"Advanced\": \"Avançado\",\n    \"Please use this option carefully!\": \"Por favor, use esta opção com cuidado!\",\n    \"Enable Auth\": \"Habilitar Autenticação\",\n    \"Disable Auth\": \"Desabilitar Autenticação\",\n    \"I understand, please disable\": \"Entendo, por favor desabilitar\",\n    \"Leave\": \"Sair\",\n    \"Frontend Version\": \"Versão da Interface\",\n    \"Check Update On GitHub\": \"Verificar Atualização no GitHub\",\n    \"Show update if available\": \"Mostrar atualização se disponível\",\n    \"Also check beta release\": \"Também verificar versão beta\",\n    \"Remember me\": \"Lembrar-me\",\n    \"Login\": \"Entrar\",\n    \"Username\": \"Nome de Usuário\",\n    \"Password\": \"Senha\",\n    \"Settings\": \"Configurações\",\n    \"Logout\": \"Sair\",\n    \"Lowercase only\": \"Somente minúsculas\",\n    \"Convert to Compose\": \"Converter para Compose\",\n    \"Docker Run\": \"Executar Docker\",\n    \"active\": \"ativo\",\n    \"exited\": \"encerrado\",\n    \"inactive\": \"inativo\",\n    \"Appearance\": \"Aparência\",\n    \"Security\": \"Segurança\",\n    \"About\": \"Sobre\",\n    \"Allowed commands:\": \"Comandos permitidos:\",\n    \"Internal Networks\": \"Redes Internas\",\n    \"External Networks\": \"Redes Externas\",\n    \"No External Networks\": \"Sem Redes Externas\",\n    \"newUpdate\": \"Nova Atualização\",\n    \"currentEndpoint\": \"Atual\",\n    \"dockgeURL\": \"Dockge URL (e.g. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"Conectando\",\n    \"addAgent\": \"Adicionar Agente\",\n    \"agentAddedSuccessfully\": \"Agente adicionado com sucesso.\",\n    \"agentRemovedSuccessfully\": \"Agente removido com sucesso.\",\n    \"removeAgent\": \"Remover Agente\",\n    \"downStack\": \"Parar & Desativar\",\n    \"dockgeAgent\": \"Dockge Agente | Dockge Agentes\",\n    \"connect\": \"Conectar\",\n    \"removeAgentMsg\": \"Tem certeza de que deseja remover este agente?\",\n    \"reverseProxyMsg1\": \"Usando um Proxy Reverso?\",\n    \"reverseProxyMsg2\": \"Verifique para configurá-lo como WebSocket\",\n    \"Cannot connect to the socket server.\": \"Não é possível se conectar ao servidor socket.\",\n    \"url\": \"URL | URL's\",\n    \"extra\": \"Extra\",\n    \"reconnecting...\": \"Reconectando…\",\n    \"connecting...\": \"Conectando ao servidor de socket…\",\n    \"LongSyntaxNotSupported\": \"Sintaxes longas não são suportadas aqui. Por favor, utilize um editor YAML.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/ro.json",
    "content": "{\n    \"Create your admin account\": \"Creați-vă contul de administrator\",\n    \"PasswordsDoNotMatch\": \"Parolele nu se potrivesc.\",\n    \"Repeat Password\": \"Repetați parola\",\n    \"signedInDisp\": \"Conectat ca {0}\",\n    \"signedInDispDisabled\": \"Autentificare dezactivată.\",\n    \"Create\": \"Creează\",\n    \"home\": \"Acasă\",\n    \"console\": \"Consolă\",\n    \"registry\": \"Registru\",\n    \"compose\": \"Compune\",\n    \"addFirstStackMsg\": \"Compune prima ta stivă!\",\n    \"stackName\": \"Nume stivă\",\n    \"deployStack\": \"Lansează\",\n    \"deleteStack\": \"Șterge\",\n    \"stopStack\": \"Oprește\",\n    \"restartStack\": \"Repornire\",\n    \"updateStack\": \"Actualizare\",\n    \"languageName\": \"Română\",\n    \"authIncorrectCreds\": \"Numele de utilizator sau parola incorectă.\",\n    \"startStack\": \"Pornește\",\n    \"editStack\": \"Editați\",\n    \"discardStack\": \"Renunţa\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"Sigur doriți să ștergeți această stivă?\",\n    \"stackNotManagedByDockgeMsg\": \"Această stivă nu este gestionată de Dockge.\",\n    \"primaryHostname\": \"Numele gazdei principale\",\n    \"general\": \"General\",\n    \"container\": \"Container | Containere\",\n    \"scanFolder\": \"Scanează folderul cu stive\",\n    \"dockerImage\": \"Imagine\",\n    \"restartPolicyOnFailure\": \"La Defecţiune\",\n    \"restartPolicyNo\": \"Nu\",\n    \"restartPolicy\": \"Politica de repornire\",\n    \"restartPolicyAlways\": \"Mereu\",\n    \"containerName\": \"Numele Containerului\",\n    \"port\": \"Port | Porturi\",\n    \"volume\": \"Volum | Volume\",\n    \"network\": \"Reţea | Reţele\",\n    \"dependsOn\": \"Dependența containerului | Dependențele containerelor\",\n    \"addListItem\": \"Adaugă {0}\",\n    \"deleteContainer\": \"Șterge\",\n    \"addContainer\": \"Adaugă Container\",\n    \"addNetwork\": \"Adaugă Rețea\",\n    \"addInternalNetwork\": \"Adaugă\",\n    \"Save\": \"Salvează\",\n    \"Current User\": \"Utilizator Curent\",\n    \"Change Password\": \"Schimbă Parola\",\n    \"Current Password\": \"Parolă Curenta\",\n    \"New Password\": \"Parolă Nouă\",\n    \"Repeat New Password\": \"Repetă Parola Nouă\",\n    \"Update Password\": \"Actualizează Parola\",\n    \"Advanced\": \"Avansat\",\n    \"Enable Auth\": \"Activați Autentificarea\",\n    \"Disable Auth\": \"Dezactivați Autentificarea\",\n    \"I understand, please disable\": \"Am înțeles, vă rog dezactivați\",\n    \"Leave\": \"Părăsiți\",\n    \"Frontend Version\": \"Versiunea Frontend\",\n    \"Check Update On GitHub\": \"Verificați actualizarea pe GitHub\",\n    \"Also check beta release\": \"Verificați și versiunea beta\",\n    \"Remember me\": \"Ține-mă minte\",\n    \"Login\": \"Autentificare\",\n    \"Username\": \"Nume de utilizator\",\n    \"Password\": \"Parolă\",\n    \"passwordNotMatchMsg\": \"Parola repetată nu se potrivește.\",\n    \"autoGet\": \"Obținere automată\",\n    \"add\": \"Adăuga\",\n    \"Edit\": \"Editați\",\n    \"applyToYAML\": \"Aplicați la YAML\",\n    \"createExternalNetwork\": \"Creează\",\n    \"Settings\": \"Setări\",\n    \"Logout\": \"Deconectare\",\n    \"Lowercase only\": \"Doar litere mici\",\n    \"Convert to Compose\": \"Convertiți în Compose\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"activ\",\n    \"exited\": \"ieșit\",\n    \"inactive\": \"inactiv\",\n    \"Appearance\": \"Aspect\",\n    \"Security\": \"Securitate\",\n    \"About\": \"Despre\",\n    \"Allowed commands:\": \"Comenzi permise:\",\n    \"Internal Networks\": \"Rețele interne\",\n    \"External Networks\": \"Rețele externe\",\n    \"No External Networks\": \"Fără rețele externe\",\n    \"reverseProxyMsg1\": \"Folosești un proxy invers?\",\n    \"reverseProxyMsg2\": \"Verificați cum să-l configurați pentru WebSocket\",\n    \"Cannot connect to the socket server.\": \"Nu se poate conecta la serverul socket.\",\n    \"reconnecting...\": \"Reconectare…\",\n    \"connecting...\": \"Se conectează la serverul socket…\",\n    \"url\": \"URL | URLs\",\n    \"extra\": \"Suplimentar\",\n    \"downStack\": \"Opriți & Inactiv\",\n    \"saveStackDraft\": \"Salvați\",\n    \"restartPolicyUnlessStopped\": \"Dacă nu este oprit\",\n    \"environmentVariable\": \"Variabila de mediu | Variabile de mediu\",\n    \"Language\": \"Limbă\",\n    \"Please use this option carefully!\": \"Vă rugăm să utilizați această opțiune cu atenție!\",\n    \"Show update if available\": \"Afișează actualizarea dacă este disponibilă\",\n    \"disableauth.message1\": \"Sigur doriți să <strong>dezactivați autentificarea</strong>?\",\n    \"disableauth.message2\": \"Este conceput pentru scenarii <strong>în care intenționați să implementați autentificarea terță</strong> în fața Dockge-lui, cum ar fi Cloudflare Access, Authelia sau alte mecanisme de autentificare.\",\n    \"newUpdate\": \"Actualizare nouă\",\n    \"dockgeAgent\": \"Agent Dockge | Agenții Dockge\",\n    \"currentEndpoint\": \"Actual\",\n    \"dockgeURL\": \"Dockge URL (de ex. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Online\",\n    \"agentOffline\": \"Offline\",\n    \"connecting\": \"Se conectează\",\n    \"addAgent\": \"Adaugă Agent\",\n    \"connect\": \"Conectează\",\n    \"agentRemovedSuccessfully\": \"Agentul a fost eliminat cu succes.\",\n    \"removeAgent\": \"Șterge Agentul\",\n    \"removeAgentMsg\": \"Ești sigur că vrei să elimini acest agent?\",\n    \"LongSyntaxNotSupported\": \"Sintaxa lungă nu este acceptată aici. Vă rugăm să utilizați editorul YAML.\",\n    \"agentAddedSuccessfully\": \"Agentul a fost adăugat cu succes.\",\n    \"Lost connection to the socket server. Reconnecting...\": \"S-a pierdut conexiunea cu serverul socket. Se reconectează...\",\n    \"Saved\": \"Salvat\",\n    \"Deployed\": \"Lansat\",\n    \"Deleted\": \"Șters\",\n    \"Updated\": \"Actualizat\",\n    \"Started\": \"Pornit\",\n    \"Stopped\": \"Oprit\",\n    \"Restarted\": \"Repornit\",\n    \"Downed\": \"Coborât\",\n    \"Switch to sh\": \"Schimbă la sh\",\n    \"terminal\": \"Terminal\",\n    \"CurrentHostname\": \"(Nesetat: Urmăriți numele de host curent)\",\n    \"New Container Name...\": \"Nume nou de container...\",\n    \"Network name...\": \"Numele rețelei...\",\n    \"Select a network...\": \"Selectați o rețea...\",\n    \"NoNetworksAvailable\": \"Nu există rețele disponibile. Mai întâi trebuie să adăugați rețele interne sau să activați rețele externe în partea dreaptă.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/ru.json",
    "content": "{\n    \"languageName\": \"Русский\",\n    \"Create your admin account\": \"Создайте учетную запись администратора\",\n    \"authIncorrectCreds\": \"Неверный логин или пароль.\",\n    \"PasswordsDoNotMatch\": \"Пароли не совпадают.\",\n    \"Repeat Password\": \"Повторите пароль\",\n    \"Create\": \"Создать\",\n    \"signedInDisp\": \"Авторизован как {0}\",\n    \"signedInDispDisabled\": \"Авторизация выключена.\",\n    \"home\": \"Главная\",\n    \"console\": \"Консоль\",\n    \"registry\": \"Реестр (Registry)\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Создайте свой первый стек!\",\n    \"stackName\": \"Имя стека\",\n    \"deployStack\": \"Развернуть\",\n    \"deleteStack\": \"Удалить\",\n    \"stopStack\": \"Остановить\",\n    \"restartStack\": \"Перезапустить\",\n    \"updateStack\": \"Обновить\",\n    \"startStack\": \"Запустить\",\n    \"editStack\": \"Изменить\",\n    \"discardStack\": \"Отменить\",\n    \"saveStackDraft\": \"Сохранить\",\n    \"notAvailableShort\": \"Н/Д\",\n    \"deleteStackMsg\": \"Вы уверены что хотите удалить этот стек?\",\n    \"stackNotManagedByDockgeMsg\": \"Данный стек не управляется Dockge.\",\n    \"primaryHostname\": \"Имя хоста\",\n    \"general\": \"Основные\",\n    \"container\": \"Контейнер | Контейнеры\",\n    \"scanFolder\": \"Сканировать папку стеков\",\n    \"dockerImage\": \"Образ\",\n    \"restartPolicyUnlessStopped\": \"Пока не будет остановлен\",\n    \"restartPolicyAlways\": \"Всегда\",\n    \"restartPolicyOnFailure\": \"При падении\",\n    \"restartPolicyNo\": \"Никогда\",\n    \"environmentVariable\": \"Переменная окружения | Переменные окружения\",\n    \"restartPolicy\": \"Политика рестарта\",\n    \"containerName\": \"Имя контейнера\",\n    \"port\": \"Порт | Порты\",\n    \"volume\": \"Хранилище | Хранилища\",\n    \"network\": \"Сеть | Сети\",\n    \"dependsOn\": \"Зависимость контейнера | Зависимости контейнера\",\n    \"addListItem\": \"Добавить {0}\",\n    \"deleteContainer\": \"Удалить\",\n    \"addContainer\": \"Добавить контейнер\",\n    \"addNetwork\": \"Добавить сеть\",\n    \"disableauth.message1\": \"Вы уверены что хотите <strong>отключить аутентификацию</strong>?\",\n    \"disableauth.message2\": \"Это предназначено для сценариев, <strong>когда Вы собираетесь использовать стороннюю аутентификацию</strong> перед Dockge, например Cloudflare Access, Authelia или другие механизмы аутентификации.\",\n    \"passwordNotMatchMsg\": \"Повторный пароль не совпадает.\",\n    \"autoGet\": \"Авто\",\n    \"add\": \"Добавить\",\n    \"Edit\": \"Изменить\",\n    \"applyToYAML\": \"Применить к YAML\",\n    \"createExternalNetwork\": \"Создать\",\n    \"addInternalNetwork\": \"Добавить\",\n    \"Save\": \"Сохранить\",\n    \"Language\": \"Язык\",\n    \"Current User\": \"Текущий пользователь\",\n    \"Change Password\": \"Изменить пароль\",\n    \"Current Password\": \"Текущий пароль\",\n    \"New Password\": \"Новый пароль\",\n    \"Repeat New Password\": \"Повторите новый пароль\",\n    \"Update Password\": \"Обновить пароль\",\n    \"Advanced\": \"Расширенные\",\n    \"Please use this option carefully!\": \"Пожалуйста, используйте эту опцию осторожно!\",\n    \"Enable Auth\": \"Включить аутентификацию\",\n    \"Disable Auth\": \"Отключить аутентификацию\",\n    \"I understand, please disable\": \"Я понимаю, пожалуйста, отключите\",\n    \"Leave\": \"Покинуть\",\n    \"Frontend Version\": \"Версия внешнего интерфейса\",\n    \"Check Update On GitHub\": \"Проверить обновления на GitHub\",\n    \"Show update if available\": \"Показать обновление, если оно доступно\",\n    \"Also check beta release\": \"Получать бета-версии\",\n    \"Remember me\": \"Запомнить меня\",\n    \"Login\": \"Логин\",\n    \"Username\": \"Имя пользователя\",\n    \"Password\": \"Пароль\",\n    \"Settings\": \"Настройки\",\n    \"Logout\": \"Выйти\",\n    \"Lowercase only\": \"Только нижний регистр\",\n    \"Convert to Compose\": \"Преобразовать в Compose\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"акт.\",\n    \"exited\": \"ост.\",\n    \"inactive\": \"неакт.\",\n    \"Appearance\": \"Внешний вид\",\n    \"Security\": \"Безопасность\",\n    \"About\": \"О продукте\",\n    \"Allowed commands:\": \"Разрешенные команды:\",\n    \"Internal Networks\": \"Внутренние сети\",\n    \"External Networks\": \"Внешние сети\",\n    \"No External Networks\": \"Нет внешних сетей\",\n    \"downStack\": \"Остановить и деактивировать\",\n    \"reverseProxyMsg1\": \"Используете обратный прокси?\",\n    \"reconnecting...\": \"Переподключение…\",\n    \"Cannot connect to the socket server.\": \"Не удается подключиться к сокет-серверу.\",\n    \"url\": \"URL-адрес | URL-адреса\",\n    \"extra\": \"Дополнительно\",\n    \"reverseProxyMsg2\": \"Проверьте, как настроить его для WebSocket\",\n    \"connecting...\": \"Подключение к сокет-серверу…\",\n    \"newUpdate\": \"Доступно обновление\",\n    \"currentEndpoint\": \"Текущий\",\n    \"agentOnline\": \"В сети\",\n    \"agentOffline\": \"Не в сети\",\n    \"connecting\": \"Подключение\",\n    \"connect\": \"Подключить\",\n    \"addAgent\": \"Добавить Агента\",\n    \"agentAddedSuccessfully\": \"Агент успешно добавлен.\",\n    \"removeAgent\": \"Удалить агента\",\n    \"removeAgentMsg\": \"Вы уверены, что хотите удалить этого агента?\",\n    \"dockgeAgent\": \"Агент Dockge | Агенты Dockge\",\n    \"dockgeURL\": \"URL-адрес Dockge (например: http://127.0.0.1:5001)\",\n    \"agentRemovedSuccessfully\": \"Агент успешно удален.\",\n    \"LongSyntaxNotSupported\": \"Длинный синтаксис здесь не поддерживается. Пожалуйста, используйте редактор YAML.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/sl.json",
    "content": "{\n    \"languageName\": \"Slovenščina\",\n    \"Create your admin account\": \"Ustvarite svoj skrbniški račun\",\n    \"authIncorrectCreds\": \"Napačno uporabniško ime ali geslo.\",\n    \"PasswordsDoNotMatch\": \"Gesli se ne ujemata.\",\n    \"Repeat Password\": \"Ponovi geslo\",\n    \"Create\": \"Ustvari\",\n    \"signedInDisp\": \"Prijavljeni kot {0}\",\n    \"signedInDispDisabled\": \"Preverjanje pristnosti onemogočeno.\",\n    \"home\": \"Domov\",\n    \"console\": \"Konzola\",\n    \"registry\": \"Register\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Ustvarite svoj prvi Stack!\",\n    \"stackName\": \"Ime Stack-a\",\n    \"deployStack\": \"Razporedi\",\n    \"deleteStack\": \"Izbriši\",\n    \"stopStack\": \"Ustavi\",\n    \"restartStack\": \"Ponovni zagon\",\n    \"updateStack\": \"Posodobi\",\n    \"startStack\": \"Zaženi\",\n    \"editStack\": \"Uredi\",\n    \"discardStack\": \"Zavrzi\",\n    \"saveStackDraft\": \"Shrani\",\n    \"notAvailableShort\": \"Ni na voljo\",\n    \"deleteStackMsg\": \"Ste prepričani, da želite izbrisati ta Stack?\",\n    \"stackNotManagedByDockgeMsg\": \"Ta Stack ni upravljan s strani Dockge.\",\n    \"primaryHostname\": \"Osnovno gostiteljsko ime\",\n    \"general\": \"Splošno\",\n    \"container\": \"Kontejner | Kontejnerji\",\n    \"scanFolder\": \"Preglej Stack mapo\",\n    \"dockerImage\": \"Slika\",\n    \"restartPolicyUnlessStopped\": \"Razen ko je zaustavljeno\",\n    \"restartPolicyAlways\": \"Vedno\",\n    \"restartPolicyOnFailure\": \"Ob napaki\",\n    \"restartPolicyNo\": \"Ne\",\n    \"environmentVariable\": \"Okoljska spremenljivka | Okoljske spremenljivke\",\n    \"restartPolicy\": \"Politika ponovnega zagona\",\n    \"containerName\": \"Ime kontejnerja\",\n    \"port\": \"Vrata | Vrata\",\n    \"volume\": \"Zvezek | Zvezki\",\n    \"network\": \"Omrežje | Omrežja\",\n    \"dependsOn\": \"Odvisnost kontejnerja | Odvisnosti kontejnerjev\",\n    \"addListItem\": \"Dodaj {0}\",\n    \"deleteContainer\": \"Izbriši\",\n    \"addContainer\": \"Dodaj kontejner\",\n    \"addNetwork\": \"Dodaj omrežje\",\n    \"disableauth.message1\": \"Ste prepričani, da želite <strong>onemogočiti overjanje</strong>?\",\n    \"disableauth.message2\": \"Namerno je zasnovano za scenarije, <strong>kjer nameravate izvajati avtentikacijo tretjih oseb</strong> pred Dockge, kot so Cloudflare Access, Authelia ali druge avtentikacijske mehanizme.\",\n    \"passwordNotMatchMsg\": \"Ponovljeno geslo se ne ujema.\",\n    \"autoGet\": \"Samodejno pridobi\",\n    \"add\": \"Dodaj\",\n    \"Edit\": \"Uredi\",\n    \"applyToYAML\": \"Uporabi za YAML\",\n    \"createExternalNetwork\": \"Ustvari\",\n    \"addInternalNetwork\": \"Dodaj\",\n    \"Save\": \"Shrani\",\n    \"Language\": \"Jezik\",\n    \"Current User\": \"Trenutni uporabnik\",\n    \"Change Password\": \"Spremeni geslo\",\n    \"Current Password\": \"Trenutno geslo\",\n    \"New Password\": \"Novo geslo\",\n    \"Repeat New Password\": \"Ponovi novo geslo\",\n    \"Update Password\": \"Posodobi geslo\",\n    \"Advanced\": \"Napredno\",\n    \"Please use this option carefully!\": \"Prosimo, uporabite to možnost previdno!\",\n    \"Enable Auth\": \"Omogoči overjanje\",\n    \"Disable Auth\": \"Onemogoči overjanje\",\n    \"I understand, please disable\": \"Razumem, prosim onemogočite\",\n    \"Leave\": \"Zapusti\",\n    \"Frontend Version\": \"Različica vmesnika\",\n    \"Check Update On GitHub\": \"Preveri posodobitve na GitHubu\",\n    \"Show update if available\": \"Prikaži posodobitve, če so na voljo\",\n    \"Also check beta release\": \"Preveri tudi beta izdaje\",\n    \"Remember me\": \"Zapomni si me\",\n    \"Login\": \"Prijava\",\n    \"Username\": \"Uporabniško ime\",\n    \"Password\": \"Geslo\",\n    \"Settings\": \"Nastavitve\",\n    \"Logout\": \"Odjava\",\n    \"Lowercase only\": \"Samo male črke\",\n    \"Convert to Compose\": \"Pretvori v Compose\",\n    \"Docker Run\": \"Zagon Dockerja\",\n    \"active\": \"aktivno\",\n    \"exited\": \"izklopljeno\",\n    \"inactive\": \"neaktivno\",\n    \"Appearance\": \"Videz\",\n    \"Security\": \"Varnost\",\n    \"About\": \"O nas\",\n    \"Allowed commands:\": \"Dovoljeni ukazi:\",\n    \"Internal Networks\": \"Notranja omrežja\",\n    \"External Networks\": \"Zunanja omrežja\",\n    \"No External Networks\": \"Ni zunanjih omrežij\",\n    \"downStack\": \"Ustavi & Deaktiviraj\",\n    \"connecting...\": \"Povezovanje s strežnikom…\",\n    \"reverseProxyMsg1\": \"Uporabljate obratni proxy?\",\n    \"extra\": \"Dodatno\",\n    \"reconnecting...\": \"Ponovna povezava …\",\n    \"newUpdate\": \"Nova posodobitev\",\n    \"reverseProxyMsg2\": \"Preverite, kako ga konfigurirati za WebSocket\",\n    \"Cannot connect to the socket server.\": \"Ni mogoče vzpostaviti povezave s strežnikom vtičnic.\",\n    \"url\": \"URL | URL-ji\",\n    \"currentEndpoint\": \"Trenutni\",\n    \"dockgeURL\": \"Dockge URL (npr. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Aktivno\",\n    \"agentOffline\": \"Neaktivno\",\n    \"connecting\": \"Povezujem\",\n    \"connect\": \"Poveži\",\n    \"addAgent\": \"Dodaj agenta\",\n    \"dockgeAgent\": \"Dockge agent | Dockge agenti\",\n    \"agentAddedSuccessfully\": \"Agent dodan uspešno.\",\n    \"agentRemovedSuccessfully\": \"Agent uspešno odstranjen.\",\n    \"removeAgent\": \"Odstrani agent\",\n    \"removeAgentMsg\": \"Ali ste prepričani, da želite odstraniti agenta?\",\n    \"LongSyntaxNotSupported\": \"Long syntax-a ni podprta tukaj. Prosim uporabite YAML urejevalnik.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/sv-SE.json",
    "content": "{\n    \"languageName\": \"Svenska\",\n    \"Create your admin account\": \"Skapa ditt Admin-konto\",\n    \"authIncorrectCreds\": \"Fel användarnamn eller lösenord.\",\n    \"PasswordsDoNotMatch\": \"Lösenorden matchar inte.\",\n    \"Repeat Password\": \"Repetera lösenord\",\n    \"Create\": \"Skapa\",\n    \"signedInDisp\": \"Inloggad som {0}\",\n    \"signedInDispDisabled\": \"Auth inaktiverad.\",\n    \"home\": \"Hem\",\n    \"console\": \"Konsol\",\n    \"registry\": \"Register\",\n    \"compose\": \"Komponera\",\n    \"addFirstStackMsg\": \"Komponera din första stack!\",\n    \"stackName\": \"Stacknamn\",\n    \"deployStack\": \"Distribuera\",\n    \"deleteStack\": \"Radera\",\n    \"stopStack\": \"Stoppa\",\n    \"restartStack\": \"Starta om\",\n    \"updateStack\": \"Uppdatera\",\n    \"startStack\": \"Starta\",\n    \"downStack\": \"Stoppa & Inaktivera\",\n    \"editStack\": \"Redigera\",\n    \"discardStack\": \"Kasta\",\n    \"saveStackDraft\": \"Spara\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"Är du säker på att du vill radera stacken?\",\n    \"stackNotManagedByDockgeMsg\": \"Denna stacken hanteras inte av Dockge.\",\n    \"primaryHostname\": \"Primärt värdnamn\",\n    \"general\": \"Allmän\",\n    \"container\": \"Container | Containrar\",\n    \"scanFolder\": \"Skanna Stackmapp\",\n    \"dockerImage\": \"Avbild\",\n    \"restartPolicyUnlessStopped\": \"Om inte stoppad\",\n    \"restartPolicyAlways\": \"Alltid\",\n    \"restartPolicyOnFailure\": \"Vid misslyckande\",\n    \"restartPolicyNo\": \"Nej\",\n    \"environmentVariable\": \"Miljövariabel | Miljövariabler\",\n    \"restartPolicy\": \"Omstartspolicy\",\n    \"containerName\": \"Containernamn\",\n    \"port\": \"Port | Portar\",\n    \"volume\": \"Volym | Volymer\",\n    \"network\": \"Nätverk | Nätverk\",\n    \"dependsOn\": \"Containerberoende | Containerberoenden\",\n    \"addListItem\": \"Lägg till {0}\",\n    \"deleteContainer\": \"Radera\",\n    \"addContainer\": \"Lägg till container\",\n    \"addNetwork\": \"Lägg till nätverk\",\n    \"disableauth.message1\": \"Är du säker på att du vill <strong>inaktivera autentisering</strong>?\",\n    \"disableauth.message2\": \"Det är designat för scenarion <strong>där du ska implementera tredjepartsautentisering</strong> framför Dockge som Cloudflare Access, Authelia eller andra autentiseringsmekanismer.\",\n    \"passwordNotMatchMsg\": \"Det upprepade lösenordet matchar inte.\",\n    \"autoGet\": \"Auto-hämta\",\n    \"add\": \"Lägg till\",\n    \"Edit\": \"Redigera\",\n    \"applyToYAML\": \"Lägg till i YAML\",\n    \"createExternalNetwork\": \"Skapa\",\n    \"addInternalNetwork\": \"Lägg till\",\n    \"Save\": \"Spara\",\n    \"Language\": \"Språk\",\n    \"Current User\": \"Nuvarande användare\",\n    \"Change Password\": \"Ändra lösenord\",\n    \"Current Password\": \"Nuvarande lösenord\",\n    \"New Password\": \"Nytt lösenord\",\n    \"Repeat New Password\": \"Upprepa nytt lösenord\",\n    \"Update Password\": \"Uppdatera lösenord\",\n    \"Advanced\": \"Avancerat\",\n    \"Please use this option carefully!\": \"Använd detta alternativ försiktigt!\",\n    \"Enable Auth\": \"Aktivera Auth\",\n    \"Disable Auth\": \"Avaktivera Auth\",\n    \"I understand, please disable\": \"Jag förstår, vänligen inaktivera\",\n    \"Leave\": \"Lämna\",\n    \"Frontend Version\": \"Frontendversion\",\n    \"Check Update On GitHub\": \"Kontrollera uppdatering på GitHub\",\n    \"Show update if available\": \"Visa uppdatering om tillgänglig\",\n    \"Also check beta release\": \"Kontrollera även betaversioner\",\n    \"Remember me\": \"Kom ihåg mig\",\n    \"Login\": \"Logga in\",\n    \"Username\": \"Användarnamn\",\n    \"Password\": \"Lösenord\",\n    \"Settings\": \"Inställningar\",\n    \"Logout\": \"Logga ut\",\n    \"Lowercase only\": \"Endast små tecken\",\n    \"Convert to Compose\": \"Omvandla till compose\",\n    \"Docker Run\": \"Docker kör\",\n    \"active\": \"aktiv\",\n    \"exited\": \"avslutad\",\n    \"inactive\": \"inaktiv\",\n    \"Appearance\": \"Utseende\",\n    \"Security\": \"Säkerhet\",\n    \"About\": \"Om\",\n    \"Allowed commands:\": \"Tillåtna kommandon:\",\n    \"Internal Networks\": \"Interna nätverk\",\n    \"External Networks\": \"Externa nätverk\",\n    \"No External Networks\": \"Inga externa nätverk\",\n    \"reverseProxyMsg1\": \"Används omvänd proxy?\",\n    \"connecting...\": \"Ansluter till socketserver…\",\n    \"Cannot connect to the socket server.\": \"Kan inte ansluta till socketservern.\",\n    \"reverseProxyMsg2\": \"Kontrollera hur man konfigurerar webbsocket\",\n    \"url\": \"URL | URLer\",\n    \"extra\": \"Extra\",\n    \"reconnecting...\": \"Återansluter…\",\n    \"newUpdate\": \"Ny uppdatering\",\n    \"currentEndpoint\": \"Nuvarande\",\n    \"dockgeURL\": \"Dockge URL (ex. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"On-line\",\n    \"agentOffline\": \"Off-line\",\n    \"connecting\": \"Ansluter\",\n    \"connect\": \"Ansluten\",\n    \"addAgent\": \"Lägg till agent\",\n    \"agentRemovedSuccessfully\": \"Agent borttagen.\",\n    \"removeAgent\": \"Ta bort agent\",\n    \"removeAgentMsg\": \"Är du säker att du vill ta bort denna agent?\",\n    \"dockgeAgent\": \"Dockge agenter | Dockge agenter\",\n    \"agentAddedSuccessfully\": \"Agent tillagd.\",\n    \"LongSyntaxNotSupported\": \"Lång syntax stöds inte här. Använd YAML-redigeraren.\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Tappade anslutning till socket-server. Återansluter...\",\n    \"Saved\": \"Sparad\",\n    \"Deployed\": \"Uppsatt\",\n    \"Deleted\": \"Borttagen\",\n    \"Updated\": \"Uppdaterad\",\n    \"Started\": \"Startad\",\n    \"Stopped\": \"Stoppad\",\n    \"Restarted\": \"Omstartad\",\n    \"Downed\": \"Nedstängd\",\n    \"Switch to sh\": \"Byt till sh\",\n    \"terminal\": \"Terminal\",\n    \"CurrentHostname\": \"(Ej angedd: Följ nuvarande värdnamn)\",\n    \"New Container Name...\": \"Nytt kontainernamn...\",\n    \"Network name...\": \"Nätverksnamn...\",\n    \"Select a network...\": \"Välj ett nätverk...\",\n    \"NoNetworksAvailable\": \"Inga nätverk tillgängliga. Du måste lägga till interna nätverk eller aktivera externa nätverk på högra sidan först.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/th.json",
    "content": "{\n    \"languageName\": \"อังกฤษ\",\n    \"Create your admin account\": \"สร้างบัญชีผู้ดูแลระบบของคุณ\",\n    \"authIncorrectCreds\": \"ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง\",\n    \"PasswordsDoNotMatch\": \"รหัสผ่านไม่ตรงกัน\",\n    \"Repeat Password\": \"ยืนยันรหัสผ่าน\",\n    \"Create\": \"สร้าง\",\n    \"signedInDisp\": \"ลงชื่อเข้าใช้ในนาม {0}\",\n    \"signedInDispDisabled\": \"ปิดใช้งาน Auth\",\n    \"home\": \"หน้าหลัก\",\n    \"console\": \"คอนโซล\",\n    \"registry\": \"Registry\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Compose stack แรกของคุณ!\",\n    \"stackName\": \"ชื่อ Stack\",\n    \"deployStack\": \"ปรับใช้\",\n    \"deleteStack\": \"ลบ\",\n    \"stopStack\": \"หยุด\",\n    \"restartStack\": \"เริ่มใหม่\",\n    \"updateStack\": \"อัปเดต\",\n    \"startStack\": \"เริ่มต้น\",\n    \"downStack\": \"หยุดการทำงาน\",\n    \"editStack\": \"แก้ไข\",\n    \"discardStack\": \"ยกเลิก\",\n    \"saveStackDraft\": \"บันทึก\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"คุณแน่ใจหรือไม่ว่าต้องการลบ stack นี้\",\n    \"stackNotManagedByDockgeMsg\": \"stack นี้ไม่ได้รับการจัดการโดย Dockge\",\n    \"primaryHostname\": \"ชื่อโฮสต์หลัก\",\n    \"general\": \"ทั่วไป\",\n    \"container\": \"Container | Containers\",\n    \"scanFolder\": \"สแกนโฟลเดอร์ Stacks\",\n    \"dockerImage\": \"Image\",\n    \"restartPolicyUnlessStopped\": \"Unless Stopped\",\n    \"restartPolicyAlways\": \"Always\",\n    \"restartPolicyOnFailure\": \"On Failure\",\n    \"restartPolicyNo\": \"No\",\n    \"environmentVariable\": \"Environment Variable | Environment Variables\",\n    \"restartPolicy\": \"เริ่มต้น Policy ใหม่\",\n    \"containerName\": \"ชื่อ Container\",\n    \"port\": \"พอร์ต | พอร์ต\",\n    \"volume\": \"ปริมาณ | ปริมาณ\",\n    \"network\": \"เครือข่าย | เครือข่าย\",\n    \"dependsOn\": \"Container Dependency | Container Dependencies\",\n    \"addListItem\": \"เพิ่ม {0}\",\n    \"deleteContainer\": \"ลบ\",\n    \"addContainer\": \"เพิ่ม Container\",\n    \"addNetwork\": \"เพิ่ม เครือข่าย\",\n    \"disableauth.message1\": \"คุณแน่ใจหรือไม่ว่าต้องการ <strong>ปิดใช้งานการตรวจสอบสิทธิ์</strong>?\",\n    \"disableauth.message2\": \"ได้รับการออกแบบมาสำหรับสถานการณ์ <strong>ที่คุณตั้งใจจะใช้การตรวจสอบสิทธิ์ของบุคคลที่สาม</strong> หน้า Dockge เช่น Cloudflare Access, Authelia หรือกลไกการตรวจสอบสิทธิ์อื่นๆ\",\n    \"passwordNotMatchMsg\": \"รหัสผ่านซ้ำไม่ตรงกัน\",\n    \"autoGet\": \"รับอัตโนมัติ\",\n    \"add\": \"เพิ่ม\",\n    \"Edit\": \"แก้ไข\",\n    \"applyToYAML\": \"นำไปใช้เป็น YAML\",\n    \"createExternalNetwork\": \"สร้าง\",\n    \"addInternalNetwork\": \"เพิ่ม\",\n    \"Save\": \"บันทึก\",\n    \"Language\": \"ภาษา\",\n    \"Current User\": \"ผู้ใช้งานปัจจุบัน\",\n    \"Change Password\": \"เปลี่ยนรหัสผ่าน\",\n    \"Current Password\": \"รหัสผ่านปัจจุบัน\",\n    \"New Password\": \"รหัสผ่านใหม่\",\n    \"Repeat New Password\": \"รหัสผ่านใหม่ซ้ำ\",\n    \"Update Password\": \"อัปเดตรหัสผ่าน\",\n    \"Advanced\": \"ขั้นสูง\",\n    \"Please use this option carefully!\": \"โปรดใช้ตัวเลือกนี้อย่างระมัดระวัง!\",\n    \"Enable Auth\": \"เปิดใช้งาน Auth\",\n    \"Disable Auth\": \"ปิดใช้งาน Auth\",\n    \"I understand, please disable\": \"ฉันเข้าใจ กรุณาปิดการใช้งาน\",\n    \"Leave\": \"ออก\",\n    \"Frontend Version\": \"เวอร์ชัน Frontend\",\n    \"Check Update On GitHub\": \"ตรวจสอบการอัปเดตบน GitHub\",\n    \"Show update if available\": \"แสดงการอัปเดตหากมี\",\n    \"Also check beta release\": \"สามารถตรวจสอบรุ่นเบต้าได้\",\n    \"Remember me\": \"จดจำฉัน\",\n    \"Login\": \"เข้าสู่ระบบ\",\n    \"Username\": \"ชื่อผู้ใช้\",\n    \"Password\": \"รหัสผ่าน\",\n    \"Settings\": \"การตั้งค่า\",\n    \"Logout\": \"ออกจากระบบ\",\n    \"Lowercase only\": \"ตัวเล็กทั้งหมด\",\n    \"Convert to Compose\": \"แปลงเป็น Compose\",\n    \"Docker Run\": \"เรียกใช้ Docker\",\n    \"active\": \"ใช้งานอยู่\",\n    \"exited\": \"ปิดลงแล้ว\",\n    \"inactive\": \"ไม่ได้ใช้งาน\",\n    \"Appearance\": \"รูปลักษณ์\",\n    \"Security\": \"ความปลอดภัย\",\n    \"About\": \"เกี่ยวกับ\",\n    \"Allowed commands:\": \"คำสั่งที่อนุญาต:\",\n    \"Internal Networks\": \"เครือข่ายภายใน\",\n    \"External Networks\": \"เครือข่ายภายนอก\",\n    \"No External Networks\": \"ไม่มีเครือข่ายภายนอก\",\n    \"reverseProxyMsg2\": \"ตรวจสอบวิธีกำหนดค่าสำหรับ WebSocket\",\n    \"Cannot connect to the socket server.\": \"ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ socket ได้\",\n    \"reverseProxyMsg1\": \"ใช้ Reverse Proxy หรือไม่?\",\n    \"connecting...\": \"กำลังเชื่อมต่อกับเซิร์ฟเวอร์ socket…\",\n    \"url\": \"URL | URLs\",\n    \"extra\": \"พิเศษ\",\n    \"reconnecting...\": \"กำลังเชื่อมต่อใหม่…\",\n    \"newUpdate\": \"อัปเดตใหม่\",\n    \"dockgeAgent\": \"เอเย่นต์ Dockge | เอเย่นต์ Dockge\",\n    \"currentEndpoint\": \"ปัจุบัน\",\n    \"agentOnline\": \"ออนไลน์\",\n    \"agentOffline\": \"ออฟไลน์\",\n    \"connecting\": \"กำลังเชื่อมต่อ\",\n    \"connect\": \"เชื่อมต่อ\",\n    \"addAgent\": \"เพิ่มเอเย่นต์\",\n    \"agentAddedSuccessfully\": \"เพิ่มเอเย่นต์สำเร็จ\",\n    \"agentRemovedSuccessfully\": \"ลบเอเย่นต์สำเร็จ\",\n    \"removeAgent\": \"ลบเอเย่นต์\",\n    \"removeAgentMsg\": \"คุณแน่ใจหรือไม่ที่จะลบเอเย่นต์นี้?\",\n    \"dockgeURL\": \"ลิ้งก์ Dockge (เช่น http://127.0.0.1:5001)\",\n    \"LongSyntaxNotSupported\": \"Syntax แบบยาสไม่รองรับที่นี่ กรุณาใช้ตัวแก้ไข YAML\"\n}\n"
  },
  {
    "path": "frontend/src/lang/tr.json",
    "content": "{\n    \"languageName\": \"Türkçe\",\n    \"Create your admin account\": \"Yönetici hesabınızı oluşturun\",\n    \"authIncorrectCreds\": \"Yanlış kullanıcı adı veya parola.\",\n    \"PasswordsDoNotMatch\": \"Parolalar eşleşmiyor.\",\n    \"Repeat Password\": \"Parolayı Tekrarla\",\n    \"Create\": \"Oluştur\",\n    \"signedInDisp\": \"{0} olarak oturum açıldı\",\n    \"signedInDispDisabled\": \"Yetkilendirme Devre Dışı.\",\n    \"home\": \"Ana Sayfa\",\n    \"console\": \"Konsol\",\n    \"registry\": \"Kayıt Defteri\",\n    \"compose\": \"Oluştur\",\n    \"addFirstStackMsg\": \"İlk yığınınızı oluşturun!\",\n    \"stackName\": \"Yığın Adı\",\n    \"deployStack\": \"Dağıt\",\n    \"deleteStack\": \"Sil\",\n    \"stopStack\": \"Durdur\",\n    \"restartStack\": \"Yeniden Başlat\",\n    \"updateStack\": \"Güncelle\",\n    \"startStack\": \"Başlat\",\n    \"editStack\": \"Düzenle\",\n    \"discardStack\": \"İptal Et\",\n    \"saveStackDraft\": \"Kaydet\",\n    \"notAvailableShort\": \"YOK\",\n    \"deleteStackMsg\": \"Bu yığını silmek istediğinizden emin misiniz?\",\n    \"stackNotManagedByDockgeMsg\": \"Bu yığın Dockge tarafından yönetilmemektedir.\",\n    \"primaryHostname\": \"Birincil Ana Bilgisayar Adı\",\n    \"general\": \"Genel\",\n    \"container\": \"Konteyner | Konteynerler\",\n    \"scanFolder\": \"Yığınlar Klasörünü Tara\",\n    \"dockerImage\": \"Görüntü\",\n    \"restartPolicyUnlessStopped\": \"Durdurulana Kadar\",\n    \"restartPolicyAlways\": \"Her zaman\",\n    \"restartPolicyOnFailure\": \"Başarısızlıkta\",\n    \"restartPolicyNo\": \"Hayır\",\n    \"environmentVariable\": \"Ortam Değişkeni | Ortam Değişkenleri\",\n    \"restartPolicy\": \"Yeniden Başlatma Politikası\",\n    \"containerName\": \"Konteyner Adı\",\n    \"port\": \"Port | Portlar\",\n    \"volume\": \"Disk Bölümü | Disk Bölümleri\",\n    \"network\": \"Ağ | Ağlar\",\n    \"dependsOn\": \"Konteyner Bağımlılığı | Konteyner Bağımlılıkları\",\n    \"addListItem\": \"{0} Ekle\",\n    \"deleteContainer\": \"Sil\",\n    \"addContainer\": \"Konteyner Ekle\",\n    \"addNetwork\": \"Ağ Ekle\",\n    \"disableauth.message1\": \"<strong>Kimlik doğrulamayı devre dışı bırakmak</strong> istediğinizden emin misiniz?\",\n    \"disableauth.message2\": \"Cloudflare Access, Authelia veya diğer kimlik doğrulama mekanizmaları Dockge önünde <strong>üçüncü taraf kimlik doğrulaması uygulamak</strong> istediğiniz senaryolar için tasarlanmıştır.\",\n    \"passwordNotMatchMsg\": \"Tekrarlanan parola eşleşmiyor.\",\n    \"autoGet\": \"Otomatik Al\",\n    \"add\": \"Ekle\",\n    \"Edit\": \"Düzenle\",\n    \"applyToYAML\": \"YAML dosyasına uygula\",\n    \"createExternalNetwork\": \"Oluştur\",\n    \"addInternalNetwork\": \"Ekle\",\n    \"Save\": \"Kaydet\",\n    \"Language\": \"Dil\",\n    \"Current User\": \"Mevcut Kullanıcı\",\n    \"Change Password\": \"Parolayı Değiştir\",\n    \"Current Password\": \"Mevcut Parola\",\n    \"New Password\": \"Yeni Parola\",\n    \"Repeat New Password\": \"Yeni Parolayı Tekrarla\",\n    \"Update Password\": \"Parolayı Güncelle\",\n    \"Advanced\": \"Gelişmiş\",\n    \"Please use this option carefully!\": \"Lütfen bu seçeneği dikkatli kullanın!\",\n    \"Enable Auth\": \"Kimlik Doğrulamayı Etkinleştir\",\n    \"Disable Auth\": \"Kimlik Doğrulamayı Devre Dışı Bırak\",\n    \"I understand, please disable\": \"Anlıyorum, lütfen devre dışı bırak\",\n    \"Leave\": \"Ayrıl\",\n    \"Frontend Version\": \"Ön Uç Sürümü\",\n    \"Check Update On GitHub\": \"GitHub'da Güncellemeyi Kontrol Edin\",\n    \"Show update if available\": \"Varsa güncellemeyi göster\",\n    \"Also check beta release\": \"Ayrıca beta sürümünü kontrol edin\",\n    \"Remember me\": \"Beni hatırla\",\n    \"Login\": \"Oturum Aç\",\n    \"Username\": \"Kullanıcı Adı\",\n    \"Password\": \"Parola\",\n    \"Settings\": \"Ayarlar\",\n    \"Logout\": \"Oturumu Kapat\",\n    \"Lowercase only\": \"Yalnızca küçük harf\",\n    \"Convert to Compose\": \"Compose dosyasına dönüştür\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"etkin\",\n    \"exited\": \"çıktı\",\n    \"inactive\": \"devre dışı\",\n    \"Appearance\": \"Görünüm\",\n    \"Security\": \"Güvenlik\",\n    \"About\": \"Hakkında\",\n    \"Allowed commands:\": \"İzin verilen komutlar:\",\n    \"Internal Networks\": \"Dahili Ağlar\",\n    \"External Networks\": \"Harici Ağlar\",\n    \"No External Networks\": \"Harici Ağ Yok\",\n    \"extra\": \"Ekstra\",\n    \"reverseProxyMsg1\": \"Ters Proxy mi kullanıyorsunuz?\",\n    \"reverseProxyMsg2\": \"WebSocket için nasıl yapılandırma yapılacağını kontrol edin\",\n    \"reconnecting...\": \"Yeniden bağlanıyor…\",\n    \"connecting...\": \"Soket sunucusuna bağlanıyor…\",\n    \"url\": \"URL | URL’ler\",\n    \"Cannot connect to the socket server.\": \"Soket sunucusuna bağlanılamıyor.\",\n    \"downStack\": \"Durdur ve Devre Dışı Bırak\",\n    \"newUpdate\": \"Yeni Güncelleme\",\n    \"dockgeAgent\": \"Dockge Aracısı | Dockge Aracıları\",\n    \"currentEndpoint\": \"Varsayılan\",\n    \"dockgeURL\": \"Dockge URL (ör. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Çevrimiçi\",\n    \"agentOffline\": \"Çevrimdışı\",\n    \"connecting\": \"Bağlanıyor\",\n    \"connect\": \"Bağlan\",\n    \"addAgent\": \"Aracı Ekle\",\n    \"agentAddedSuccessfully\": \"Aracı başarıyla eklendi.\",\n    \"agentRemovedSuccessfully\": \"Aracı başarıyla kaldırıldı.\",\n    \"removeAgent\": \"Aracıyı Kaldır\",\n    \"removeAgentMsg\": \"Bu aracıyı kaldırmak istediğinize emin misiniz?\",\n    \"LongSyntaxNotSupported\": \"Uzun syntax burada desteklenmiyor. Lütfen YAML editörünü kullanın.\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Soket sunucusuna bağlantı kesildi. Yeniden bağlanılıyor...\",\n    \"NoNetworksAvailable\": \"Kullanılabilir ağ yok. Önce dahili ağları eklemeniz veya sağ tarafta harici ağları etkinleştirmeniz gerekir.\",\n    \"Saved\": \"Kayıtlı\",\n    \"Deployed\": \"Deploy Edildi\",\n    \"Deleted\": \"Silindi\",\n    \"Updated\": \"Güncellendi\",\n    \"Started\": \"Başladı\",\n    \"Stopped\": \"Durdu\",\n    \"Restarted\": \"Yeniden başlatıldı\",\n    \"Downed\": \"Düştü\",\n    \"Switch to sh\": \"sh'ye çevir\",\n    \"terminal\": \"Terminal\",\n    \"CurrentHostname\": \"(Ayarlanmamış: Mevcut hostname'i takip et)\",\n    \"New Container Name...\": \"Yeni Konteyner Adı...\",\n    \"Network name...\": \"Ağ adı...\",\n    \"Select a network...\": \"Bir ağ seçin...\"\n}\n"
  },
  {
    "path": "frontend/src/lang/uk-UA.json",
    "content": "{\n    \"languageName\": \"Українська\",\n    \"Create your admin account\": \"Створити акаунт адміністратора\",\n    \"authIncorrectCreds\": \"Неправильне ім'я користувача або пароль.\",\n    \"PasswordsDoNotMatch\": \"Паролі не збігаються.\",\n    \"Repeat Password\": \"Повторіть пароль\",\n    \"Create\": \"Створити\",\n    \"signedInDisp\": \"Авторизовано як {0}\",\n    \"signedInDispDisabled\": \"Авторизацію вимкнено.\",\n    \"home\": \"Головна\",\n    \"console\": \"Консоль\",\n    \"registry\": \"Registry\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"Додайте свій перший стек!\",\n    \"stackName\": \"Назва стеку\",\n    \"deployStack\": \"Розгорнути\",\n    \"deleteStack\": \"Видалити\",\n    \"stopStack\": \"Зупинити\",\n    \"restartStack\": \"Перезапустити\",\n    \"updateStack\": \"Оновити\",\n    \"startStack\": \"Запустити\",\n    \"editStack\": \"Редагувати\",\n    \"discardStack\": \"Відмінити\",\n    \"saveStackDraft\": \"Зберегти\",\n    \"notAvailableShort\": \"Н/Д\",\n    \"deleteStackMsg\": \"Ви впевнені що хочете видалити цей стек?\",\n    \"stackNotManagedByDockgeMsg\": \"Даний стек не управляється Dockge.\",\n    \"primaryHostname\": \"Назва хосту\",\n    \"general\": \"Загальне\",\n    \"container\": \"Контейнер | Контейнери\",\n    \"scanFolder\": \"Сканувати папку зі стеками\",\n    \"dockerImage\": \"Образ\",\n    \"restartPolicyUnlessStopped\": \"Доки не буде зупинено\",\n    \"restartPolicyAlways\": \"Завжди\",\n    \"restartPolicyOnFailure\": \"При падінні\",\n    \"restartPolicyNo\": \"Ніколи\",\n    \"environmentVariable\": \"Змінна середовища | змінні середовища\",\n    \"restartPolicy\": \"Перезапуск\",\n    \"containerName\": \"Назва контейнеру\",\n    \"port\": \"Порт | Порти\",\n    \"volume\": \"Сховище | Сховища\",\n    \"network\": \"Мережа | Мережі\",\n    \"dependsOn\": \"Залежність контейнера | Залежності контейнеру\",\n    \"addListItem\": \"Додати {0}\",\n    \"deleteContainer\": \"Видалити\",\n    \"addContainer\": \"Додати Контейнер\",\n    \"addNetwork\": \"Додати Мережу\",\n    \"disableauth.message1\": \"Ви впевнені що хочете <strong>вимкнути авторизацію</strong>?\",\n    \"disableauth.message2\": \"Це призначено для сценаріїв, <strong>де ви збираєтесь використати сторонню авторизацію</strong> перед Dockge, наприклад Cloudflare Access, Authelia чи інші.\",\n    \"passwordNotMatchMsg\": \"Повторення паролю не збігається.\",\n    \"autoGet\": \"Отримати\",\n    \"add\": \"Додати\",\n    \"Edit\": \"Змінити\",\n    \"applyToYAML\": \"Застосувати для YAML\",\n    \"createExternalNetwork\": \"Створити\",\n    \"addInternalNetwork\": \"Додати\",\n    \"Save\": \"Зберегти\",\n    \"Language\": \"Мова\",\n    \"Current User\": \"Користувач\",\n    \"Change Password\": \"Змінити пароль\",\n    \"Current Password\": \"Поточний пароль\",\n    \"New Password\": \"Новий пароль\",\n    \"Repeat New Password\": \"Повторіть новий пароль\",\n    \"Update Password\": \"Оновити пароль\",\n    \"Advanced\": \"Розширені опції\",\n    \"Please use this option carefully!\": \"Будь ласка, використовуйте цю опцію з обережністю!\",\n    \"Enable Auth\": \"Увімкнути автентифікацію\",\n    \"Disable Auth\": \"Вимкнути автентифікацію\",\n    \"I understand, please disable\": \"Зрозуміло, все одно вимкнути\",\n    \"Leave\": \"Покинути\",\n    \"Frontend Version\": \"Версія інтерфейсу\",\n    \"Check Update On GitHub\": \"Перевірити оновлення на GitHub\",\n    \"Show update if available\": \"Показати оновлення, якщо доступно\",\n    \"Also check beta release\": \"Перевіряти оновлення до бета-версії\",\n    \"Remember me\": \"Запамʼятати мене\",\n    \"Login\": \"Логін\",\n    \"Username\": \"Імʼя користувача\",\n    \"Password\": \"Пароль\",\n    \"Settings\": \"Налаштування\",\n    \"Logout\": \"Вийти\",\n    \"Lowercase only\": \"Тільки нижній регістр\",\n    \"Convert to Compose\": \"Конвертувати в Compose\",\n    \"Docker Run\": \"Docker Run\",\n    \"active\": \"активно\",\n    \"exited\": \"завершено\",\n    \"inactive\": \"неактивно\",\n    \"Appearance\": \"Зовнішній вигляд\",\n    \"Security\": \"Безпека\",\n    \"About\": \"Про продукт\",\n    \"Allowed commands:\": \"Дозволені команди:\",\n    \"Internal Networks\": \"Внутрішні мережі\",\n    \"External Networks\": \"Зовнішні мережі\",\n    \"No External Networks\": \"Немає зовнішніх мереж\",\n    \"downStack\": \"Зупинити і вимкнути\",\n    \"reverseProxyMsg1\": \"Використовуєте зворотній проксі?\",\n    \"Cannot connect to the socket server.\": \"Не вдається підключитися до сервера сокетів.\",\n    \"reconnecting...\": \"Повторне підключення…\",\n    \"connecting...\": \"Підключення до сервера сокетів…\",\n    \"url\": \"URL-адреса | URL-адреси\",\n    \"reverseProxyMsg2\": \"Перевірте, як налаштувати його для WebSocket\",\n    \"extra\": \"Додатково\",\n    \"newUpdate\": \"Оновлення\",\n    \"currentEndpoint\": \"Поточний\",\n    \"agentOnline\": \"Онлайн\",\n    \"agentOffline\": \"Офлайн\",\n    \"connecting\": \"Підключення\",\n    \"connect\": \"Підключитися\",\n    \"addAgent\": \"Додати агент\",\n    \"removeAgent\": \"Видалити агент\",\n    \"dockgeAgent\": \"Dockge-агент | Dockge-агенти\",\n    \"dockgeURL\": \"Dockge URL (напр. http://127.0.0.1:5001)\",\n    \"agentRemovedSuccessfully\": \"Агент успішно видалено.\",\n    \"agentAddedSuccessfully\": \"Агент успішно додано.\",\n    \"removeAgentMsg\": \"Ви впевнені, що хочете видалити цей агент?\",\n    \"LongSyntaxNotSupported\": \"Довгий синтаксис тут не підтримується. Будь ласка, використовуйте редактор YAML.\",\n    \"Saved\": \"Збережено\",\n    \"Deployed\": \"Розгорнуто\",\n    \"Deleted\": \"Видалено\",\n    \"Updated\": \"Оновлено\",\n    \"Started\": \"Запущено\",\n    \"Stopped\": \"Зупинено\",\n    \"Downed\": \"Вимкнено\",\n    \"Switch to sh\": \"Перемкнути на sh\",\n    \"terminal\": \"Термінал\",\n    \"New Container Name...\": \"Нова назва контейнера...\",\n    \"Network name...\": \"Назва мережі...\",\n    \"Select a network...\": \"Вибрати мережу...\",\n    \"Lost connection to the socket server. Reconnecting...\": \"Втрачено зв'язок з сервером сокетів. Повторне підключення...\",\n    \"Restarted\": \"Перезапущено\",\n    \"CurrentHostname\": \"(Не встановлено: використовувати поточну назву хосту)\",\n    \"NoNetworksAvailable\": \"Немає доступних мереж. Спочатку потрібно додати внутрішні мережі або увімкнути зовнішні мережі в правій частині.\"\n}\n"
  },
  {
    "path": "frontend/src/lang/ur.json",
    "content": "{\n    \"languageName\": \"اردو\",\n    \"Create your admin account\": \"اپنا ایڈمن اکاؤنٹ بنائیں\",\n    \"authIncorrectCreds\": \"غلط صارف نام یا پاس ورڈ.\",\n    \"PasswordsDoNotMatch\": \"پاس ورڈز کوئی مماثل نہیں ہیں۔\",\n    \"Repeat Password\": \"پاس ورڈ دوبارہ لکھیے\",\n    \"Create\": \"بنانا\",\n    \"signedInDisp\": \"بطور {0} سائن ان\",\n    \"signedInDispDisabled\": \"توثیق غیر فعال۔\",\n    \"home\": \"گھر\",\n    \"console\": \"تسلی\",\n    \"registry\": \"رجسٹری\",\n    \"compose\": \"تحریر\",\n    \"addFirstStackMsg\": \"اپنا پہلا اسٹیک کمپوز کریں!\",\n    \"stackName\": \"اسٹیک کا نام\",\n    \"deployStack\": \"تعینات\",\n    \"deleteStack\": \"حذف کریں\",\n    \"stopStack\": \"روکو\",\n    \"restartStack\": \"دوبارہ شروع کریں\",\n    \"updateStack\": \"اپ ڈیٹ\",\n    \"startStack\": \"شروع کریں\",\n    \"editStack\": \"ترمیم\",\n    \"discardStack\": \"رد کر دیں\",\n    \"saveStackDraft\": \"محفوظ کریں۔\",\n    \"notAvailableShort\": \"N / A\",\n    \"deleteStackMsg\": \"کیا آپ واقعی اس اسٹیک کو حذف کرنا چاہتے ہیں؟\",\n    \"stackNotManagedByDockgeMsg\": \"یہ اسٹیک Dockge کے زیر انتظام نہیں ہے۔\",\n    \"primaryHostname\": \"بنیادی میزبان نام\",\n    \"general\": \"جنرل\",\n    \"container\": \"کنٹینر | کنٹینرز\",\n    \"scanFolder\": \"اسٹیک فولڈر کو اسکین کریں\",\n    \"dockerImage\": \"تصویر\",\n    \"restartPolicyUnlessStopped\": \"جب تک روکا نہیں جاتا\",\n    \"restartPolicyAlways\": \"ہمیشہ\",\n    \"restartPolicyOnFailure\": \"ناکامی پر\",\n    \"restartPolicyNo\": \"نہیں\",\n    \"environmentVariable\": \"ماحولیاتی متغیر | ماحولیاتی تغیرات\",\n    \"restartPolicy\": \"پالیسی کو دوبارہ شروع کریں\",\n    \"containerName\": \"کنٹینر کا نام\",\n    \"port\": \"پورٹ | بندرگاہیں\",\n    \"volume\": \"والیوم | جلدیں\",\n    \"network\": \"نیٹ ورک | نیٹ ورکس\",\n    \"dependsOn\": \"کنٹینر انحصار | کنٹینر انحصار\",\n    \"addListItem\": \"شامل کریں {0}\",\n    \"deleteContainer\": \"حذف کریں\",\n    \"addContainer\": \"کنٹینر شامل کریں\",\n    \"addNetwork\": \"نیٹ ورک شامل کریں\",\n    \"disableauth.message1\": \"کیا آپ واقعی <strong>تصدیق کو غیر فعال</strong> کرنا چاہتے ہیں؟\",\n    \"disableauth.message2\": \"یہ ان منظرناموں کے لیے ڈیزائن کیا گیا ہے جہاں <strong>آپ کا ارادہ ہے تیسرے فریق کی توثیق کو لاگو کرنے کا</strong> Dockge کے سامنے جیسے Cloudflare Access، Authelia یا دیگر تصدیقی طریقہ کار۔\",\n    \"passwordNotMatchMsg\": \"دہرانے والا پاس ورڈ مماثل نہیں ہے۔\",\n    \"autoGet\": \"آٹو حاصل کریں\",\n    \"add\": \"شامل کریں\",\n    \"Edit\": \"ترمیم\",\n    \"applyToYAML\": \"YAML پر درخواست دیں\",\n    \"createExternalNetwork\": \"بنانا\",\n    \"addInternalNetwork\": \"شامل کریں\",\n    \"Save\": \"محفوظ کریں\",\n    \"Language\": \"زبان\",\n    \"Current User\": \"موجودہ صارف\",\n    \"Change Password\": \"پاس ورڈ تبدیل کریں\",\n    \"Current Password\": \"موجودہ خفیہ لفظ\",\n    \"New Password\": \"نیا پاس ورڈ\",\n    \"Repeat New Password\": \"نیا پاس ورڈ دہرائیں\",\n    \"Update Password\": \"پاس ورڈ اپ ڈیٹ کریں\",\n    \"Advanced\": \"ترقی یافتہ\",\n    \"Please use this option carefully!\": \"براہ کرم اس اختیار کو احتیاط سے استعمال کریں!\",\n    \"Enable Auth\": \"تصدیق کو فعال کریں\",\n    \"Disable Auth\": \"توثیق کو غیر فعال کریں\",\n    \"I understand, please disable\": \"میں سمجھتا ہوں، براہ کرم غیر فعال کریں\",\n    \"Leave\": \"چھوڑ دو\",\n    \"Frontend Version\": \"فرنٹ اینڈ ورژن\",\n    \"Check Update On GitHub\": \"گیتوب پر اپ ڈیٹ چیک کریں\",\n    \"Show update if available\": \"اگر دستیاب ہو تو اپ ڈیٹ دکھائیں\",\n    \"Also check beta release\": \"بیٹا ریلیز بھی چیک کریں\",\n    \"Remember me\": \"مجھے پہچانتے ہو\",\n    \"Login\": \"لاگ ان کریں\",\n    \"Username\": \"صارف نام\",\n    \"Password\": \"پاس ورڈ\",\n    \"Settings\": \"ترتیبات\",\n    \"Logout\": \"لاگ آوٹ\",\n    \"Lowercase only\": \"صرف لوئر کیس\",\n    \"Convert to Compose\": \"تحریر میں تبدیل کریں\",\n    \"Docker Run\": \"ڈاکر رن\",\n    \"active\": \"فعال\",\n    \"exited\": \"باہر نکلا\",\n    \"inactive\": \"غیر فعال\",\n    \"Appearance\": \"ظہور\",\n    \"Security\": \"سیکورٹی\",\n    \"About\": \"کے بارے میں\",\n    \"Allowed commands:\": \"اجازت شدہ احکامات:\",\n    \"Internal Networks\": \"اندرونی نیٹ ورکس\",\n    \"External Networks\": \"بیرونی نیٹ ورکس\",\n    \"No External Networks\": \"کوئی بیرونی نیٹ ورک نہیں\",\n    \"reverseProxyMsg1\": \"ایک ریورس پراکسی کا استعمال کرتے ہوئے؟\",\n    \"Cannot connect to the socket server.\": \"ساکٹ سرور سے منسلک نہیں ہو سکتا۔\",\n    \"reconnecting...\": \"دوبارہ منسلک ہو رہا ہے…\",\n    \"connecting...\": \"ساکٹ سرور سے منسلک ہو رہا ہے…\",\n    \"url\": \"یو آر ایل | یو آر ایل\",\n    \"extra\": \"اضافی\",\n    \"downStack\": \"روکیں اور غیر فعال\",\n    \"reverseProxyMsg2\": \"اسے WebSocket کے لیے ترتیب دینے کا طریقہ چیک کریں\",\n    \"newUpdate\": \"نئی تازہ کاری\",\n    \"dockgeAgent\": \"ڈاکج ایجنٹ | ڈاکج ایجنٹس\",\n    \"currentEndpoint\": \"کرنٹ\",\n    \"dockgeURL\": \"Dockge URL (جیسے http://127.0.0.1:5001)\",\n    \"agentOnline\": \"آن لائن\",\n    \"agentOffline\": \"آف لائن\",\n    \"connecting\": \"جڑ رہا ہے\",\n    \"connect\": \"جڑیں\",\n    \"addAgent\": \"ایجنٹ شامل کریں\",\n    \"agentAddedSuccessfully\": \"ایجنٹ کامیابی کے ساتھ شامل ہو گیا۔\",\n    \"agentRemovedSuccessfully\": \"ایجنٹ کو کامیابی سے ہٹا دیا گیا۔\",\n    \"removeAgent\": \"ایجنٹ کو ہٹا دیں\",\n    \"removeAgentMsg\": \"کیا آپ واقعی اس ایجنٹ کو ہٹانا چاہتے ہیں؟\",\n    \"LongSyntaxNotSupported\": \"لمبا نحو یہاں تعاون یافتہ نہیں ہے۔ براہ کرم YAML ایڈیٹر استعمال کریں۔\"\n}\n"
  },
  {
    "path": "frontend/src/lang/vi.json",
    "content": "{\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    \"Repeat Password\": \"Lặp Lại Mật Khẩu\",\n    \"Create\": \"Tạo\",\n    \"signedInDisp\": \"Đã đăng nhập với tư cách {0}\",\n    \"home\": \"Trang chủ\",\n    \"console\": \"Console\",\n    \"compose\": \"Compose\",\n    \"registry\": \"Registry\",\n    \"stackName\": \"Tên Stack\",\n    \"deployStack\": \"Triển khai\",\n    \"deleteStack\": \"Xoá\",\n    \"stopStack\": \"Dừng\",\n    \"restartStack\": \"Khởi động lại\",\n    \"signedInDispDisabled\": \"Đã Tắt Xác Thực Đăng Nhập.\",\n    \"startStack\": \"Bắt đầu\",\n    \"downStack\": \"Dừng & Ngưng hoạt động\",\n    \"editStack\": \"Chỉnh sửa\",\n    \"saveStackDraft\": \"Lưu\",\n    \"notAvailableShort\": \"N/A\",\n    \"deleteStackMsg\": \"Bạn có chắc chắn muốn xoá stack này?\",\n    \"primaryHostname\": \"Tên Host Chính\",\n    \"scanFolder\": \"Quét Thư Mục Stack\",\n    \"restartPolicyAlways\": \"Luôn Luôn\",\n    \"restartPolicyOnFailure\": \"Khi Có Lỗi\",\n    \"restartPolicyNo\": \"Không\",\n    \"environmentVariable\": \"Biến Môi Trường | Các Biến Môi Trường\",\n    \"restartPolicy\": \"Chính Sách Khởi Động Lại\",\n    \"containerName\": \"Tên Container\",\n    \"port\": \"Cổng | Cổng\",\n    \"addListItem\": \"Thêm {0}\",\n    \"deleteContainer\": \"Xoá\",\n    \"addContainer\": \"Thêm Container\",\n    \"addNetwork\": \"Thêm Mạng\",\n    \"passwordNotMatchMsg\": \"Mật khẩu nhập lại không khớp.\",\n    \"autoGet\": \"Tự Động Lấy\",\n    \"add\": \"Thêm\",\n    \"Edit\": \"Chỉnh sửa\",\n    \"applyToYAML\": \"Áp dụng cho YAML\",\n    \"createExternalNetwork\": \"Tạo\",\n    \"addInternalNetwork\": \"Thêm\",\n    \"Save\": \"Lưu\",\n    \"Language\": \"Ngôn ngữ\",\n    \"Current User\": \"Người Dùng Hiện Tại\",\n    \"Change Password\": \"Đổi Mật Khẩu\",\n    \"Current Password\": \"Mật Khẩu Hiện Tại\",\n    \"New Password\": \"Mật Khẩu Mới\",\n    \"Repeat New Password\": \"Nhập Lại Mật Khẩu Mới\",\n    \"Update Password\": \"Cập Nhật Mật Khẩu\",\n    \"Advanced\": \"Nâng cao\",\n    \"Please use this option carefully!\": \"Vui lòng sử dụng tuỳ chọn này cẩn thận!\",\n    \"Enable Auth\": \"Kích Hoạt Xác Thực Đăng Nhập\",\n    \"Disable Auth\": \"Vô Hiệu Xác Thực Đăng Nhập\",\n    \"I understand, please disable\": \"Tôi hiểu, vui lòng vô hiệu\",\n    \"Leave\": \"Rời\",\n    \"Frontend Version\": \"Phiên Bản Giao Diện Người Dùng\",\n    \"Check Update On GitHub\": \"Kiểm Tra Cập Nhật Trên Github\",\n    \"Also check beta release\": \"Kiểm tra cả bản phát hành beta\",\n    \"Remember me\": \"Ghi nhớ tôi\",\n    \"Login\": \"Đăng nhập\",\n    \"Username\": \"Tên người dùng\",\n    \"Password\": \"Mật khẩu\",\n    \"Settings\": \"Cài đặt\",\n    \"Logout\": \"Đăng xuất\",\n    \"Lowercase only\": \"Chỉ viết thường\",\n    \"Convert to Compose\": \"Chuyển đổi sang Compose\",\n    \"Docker Run\": \"Chạy Docker\",\n    \"active\": \"hoạt động\",\n    \"exited\": \"đã thoát\",\n    \"inactive\": \"không hoạt động\",\n    \"Security\": \"Bảo Mật\",\n    \"Appearance\": \"Giao Diện\",\n    \"About\": \"Về\",\n    \"Allowed commands:\": \"Các lệnh được cho phép:\",\n    \"Internal Networks\": \"Mạng Nội Bộ\",\n    \"External Networks\": \"Mạng Ngoại Vi\",\n    \"No External Networks\": \"Không Có Mạng Ngoại Vi\",\n    \"reverseProxyMsg1\": \"Đang sử dụng Reverse Proxy?\",\n    \"reverseProxyMsg2\": \"Xem cách để cấu hình nó cho WebSocket\",\n    \"Cannot connect to the socket server.\": \"Không thể kết nối tới máy chủ socket.\",\n    \"reconnecting...\": \"Đang kết nối lại…\",\n    \"connecting...\": \"Đang kết nối tới máy chủ socket…\",\n    \"url\": \"URL\",\n    \"extra\": \"Bổ sung\",\n    \"newUpdate\": \"Cập Nhật Mới\",\n    \"dockgeAgent\": \"Dockge Agent\",\n    \"currentEndpoint\": \"Đang sử dụng\",\n    \"dockgeURL\": \"URL của Dockge (v.d. http://127.0.0.1:5001)\",\n    \"agentOnline\": \"Trực tuyến\",\n    \"agentOffline\": \"Ngoại tuyến\",\n    \"connecting\": \"Đang kết nối\",\n    \"connect\": \"Kết nối\",\n    \"addAgent\": \"Thêm Agent\",\n    \"agentAddedSuccessfully\": \"Agent đã được thêm thành công.\",\n    \"agentRemovedSuccessfully\": \"Agent đã được xoá thành công.\",\n    \"removeAgent\": \"Xoá Agent\",\n    \"removeAgentMsg\": \"Bạn có chắc chắn muốn xoá agent này?\",\n    \"languageName\": \"Tiếng Việt\",\n    \"Create your admin account\": \"Tạo tài khoản admin của bạn\",\n    \"addFirstStackMsg\": \"Tạo stack đầu tiên của bạn!\",\n    \"volume\": \"Volume | Volume\",\n    \"updateStack\": \"Cập nhật\",\n    \"network\": \"Mạng | Mạng\",\n    \"discardStack\": \"Huỷ\",\n    \"stackNotManagedByDockgeMsg\": \"Stack này không được quản lý bởi Dockge.\",\n    \"dependsOn\": \"Container Phụ Thuộc | Các Container Phụ Thuộc\",\n    \"general\": \"Tổng Quan\",\n    \"disableauth.message1\": \"Bạn có chắc chắn muốn <strong>tắt xác thực đăng nhập</strong>?\",\n    \"container\": \"Container\",\n    \"disableauth.message2\": \"Nó được thiết kế trong hoàn cảnh <strong>mà bạn dự định triển khai xác thực đăng nhập bên thứ ba</strong> trước Dockge như là Cloudflare Access, Authelia hay các phương thức xác minh đăng nhập khác.\",\n    \"dockerImage\": \"Image\",\n    \"Show update if available\": \"Hiển thị cập nhật nếu có\",\n    \"restartPolicyUnlessStopped\": \"Trừ Khi Dừng Lại\"\n}\n"
  },
  {
    "path": "frontend/src/lang/zh-CN.json",
    "content": "{\n    \"languageName\": \"简体中文\",\n    \"Create your admin account\": \"创建你的管理员账号\",\n    \"authIncorrectCreds\": \"用户名或密码错误。\",\n    \"PasswordsDoNotMatch\": \"两次输入的密码不一致。\",\n    \"Repeat Password\": \"重复以确认密码\",\n    \"Create\": \"创建\",\n    \"signedInDisp\": \"当前用户： {0}\",\n    \"signedInDispDisabled\": \"已禁用身份验证。\",\n    \"home\": \"主页\",\n    \"console\": \"终端\",\n    \"registry\": \"镜像仓库\",\n    \"compose\": \"Compose\",\n    \"addFirstStackMsg\": \"组合你的第一个堆栈！\",\n    \"stackName\": \"堆栈名称\",\n    \"deployStack\": \"部署\",\n    \"deleteStack\": \"删除\",\n    \"stopStack\": \"停止\",\n    \"restartStack\": \"重启\",\n    \"updateStack\": \"更新\",\n    \"startStack\": \"启动\",\n    \"editStack\": \"编辑\",\n    \"discardStack\": \"放弃\",\n    \"saveStackDraft\": \"保存\",\n    \"notAvailableShort\": \"不可用\",\n    \"deleteStackMsg\": \"你确定要删除这个堆栈吗?\",\n    \"stackNotManagedByDockgeMsg\": \"这个堆栈不由Dockge管理。\",\n    \"primaryHostname\": \"主机名\",\n    \"general\": \"常规\",\n    \"container\": \"容器 | 容器组\",\n    \"scanFolder\": \"扫描堆栈文件夹\",\n    \"dockerImage\": \"镜像\",\n    \"restartPolicyUnlessStopped\": \"除非手动停止\",\n    \"restartPolicyAlways\": \"始终\",\n    \"restartPolicyOnFailure\": \"在失败时\",\n    \"restartPolicyNo\": \"不重启\",\n    \"environmentVariable\": \"环境变量 | 环境变量组\",\n    \"restartPolicy\": \"重启策略\",\n    \"containerName\": \"容器名\",\n    \"port\": \"端口 | 端口组\",\n    \"volume\": \"数据卷 | 数据卷组\",\n    \"network\": \"网络 | 网络组\",\n    \"dependsOn\": \"容器依赖 | 容器依赖关系\",\n    \"addListItem\": \"添加 {0}\",\n    \"deleteContainer\": \"删除\",\n    \"addContainer\": \"添加容器\",\n    \"addNetwork\": \"添加网络\",\n    \"disableauth.message1\": \"你确定要<strong>禁用身份验证</strong>吗?\",\n    \"disableauth.message2\": \"该选项设计用于某些场景，<strong>例如在Dockge之上接入第三方认证</strong>，比如Cloudflare Access、Authelia或其他认证机制，如果你不清楚这个选项的作用，不要禁用验证！\",\n    \"passwordNotMatchMsg\": \"两次输入的密码不一致。\",\n    \"autoGet\": \"自动获取\",\n    \"add\": \"添加\",\n    \"Edit\": \"编辑\",\n    \"applyToYAML\": \"应用到YAML\",\n    \"createExternalNetwork\": \"创建\",\n    \"addInternalNetwork\": \"添加\",\n    \"Save\": \"保存\",\n    \"Language\": \"语言\",\n    \"Current User\": \"当前用户\",\n    \"Change Password\": \"更换密码\",\n    \"Current Password\": \"当前密码\",\n    \"New Password\": \"新密码\",\n    \"Repeat New Password\": \"重复以确认新密码\",\n    \"Update Password\": \"更新密码\",\n    \"Advanced\": \"进阶\",\n    \"Please use this option carefully!\": \"请谨慎使用该选项!\",\n    \"Enable Auth\": \"启用验证\",\n    \"Disable Auth\": \"禁用验证\",\n    \"I understand, please disable\": \"我已了解风险，确认禁用\",\n    \"Leave\": \"离开\",\n    \"Frontend Version\": \"前端版本\",\n    \"Check Update On GitHub\": \"在GitHub上检查更新\",\n    \"Show update if available\": \"有更新时提醒我\",\n    \"Also check beta release\": \"同时检查Beta渠道更新\",\n    \"Remember me\": \"记住我\",\n    \"Login\": \"登录\",\n    \"Username\": \"用户名\",\n    \"Password\": \"密码\",\n    \"Settings\": \"设置\",\n    \"Logout\": \"登出\",\n    \"Lowercase only\": \"仅小写字母\",\n    \"Convert to Compose\": \"转换为Compose格式\",\n    \"Docker Run\": \"Docker启动\",\n    \"active\": \"已启动\",\n    \"exited\": \"已退出\",\n    \"inactive\": \"未启动\",\n    \"Appearance\": \"外观\",\n    \"Security\": \"安全\",\n    \"About\": \"关于\",\n    \"Allowed commands:\": \"允许使用的指令:\",\n    \"Internal Networks\": \"内部网络\",\n    \"External Networks\": \"外部网络\",\n    \"No External Networks\": \"无外部网络\",\n    \"reconnecting...\": \"重连中…\",\n    \"reverseProxyMsg2\": \"检查如何配置WebSocket\",\n    \"reverseProxyMsg1\": \"正在使用反向代理？\",\n    \"connecting...\": \"正在连接到socket服务器…\",\n    \"Cannot connect to the socket server.\": \"无法连接到socket服务器。\",\n    \"url\": \"网址 | 网址\",\n    \"extra\": \"额外\",\n    \"downStack\": \"停止并置于非活动状态\",\n    \"newUpdate\": \"新版本\",\n    \"dockgeURL\": \"Dockge地址 (例如 http://127.0.0.1:5001)\",\n    \"agentOnline\": \"在线\",\n    \"agentOffline\": \"离线\",\n    \"connecting\": \"连接中\",\n    \"connect\": \"连接\",\n    \"dockgeAgent\": \"Dockge代理\",\n    \"currentEndpoint\": \"当前\",\n    \"addAgent\": \"添加代理\",\n    \"agentRemovedSuccessfully\": \"代理移除成功。\",\n    \"removeAgent\": \"移除代理\",\n    \"removeAgentMsg\": \"您确定要移除此代理？\",\n    \"agentAddedSuccessfully\": \"代理添加成功。\",\n    \"LongSyntaxNotSupported\": \"此处不支持Long syntax，请使用YAML编辑器。\",\n    \"Lost connection to the socket server. Reconnecting...\": \"已断开socket服务器连接，重新连接中...\",\n    \"Saved\": \"已保存\",\n    \"Deployed\": \"已部署\",\n    \"Deleted\": \"已删除\",\n    \"Updated\": \"已更新\",\n    \"Started\": \"已启动\",\n    \"Stopped\": \"已停止\",\n    \"Restarted\": \"已重启\",\n    \"Switch to sh\": \"切换至sh\",\n    \"terminal\": \"终端\",\n    \"CurrentHostname\": \"未设置:沿用当前主机名\",\n    \"New Container Name...\": \"新的容器名称...\",\n    \"Network name...\": \"网络名称...\",\n    \"Select a network...\": \"选择网络...\",\n    \"NoNetworksAvailable\": \"网络不可用.你需要在正确的方向先添加内部网络或者启用外部网络.\",\n    \"Downed\": \"已宕机\"\n}\n"
  },
  {
    "path": "frontend/src/lang/zh-TW.json",
    "content": "{\n    \"languageName\": \"繁體中文 (台灣)\",\n    \"Create your admin account\": \"建立您的管理員帳號\",\n    \"authIncorrectCreds\": \"使用者名稱或密碼錯誤。\",\n    \"PasswordsDoNotMatch\": \"兩次輸入的密碼不一致。\",\n    \"Repeat Password\": \"重複以確認密碼\",\n    \"Create\": \"建立\",\n    \"signedInDisp\": \"目前使用者：{0}\",\n    \"signedInDispDisabled\": \"已停用身份驗證。\",\n    \"home\": \"首頁\",\n    \"console\": \"主控台\",\n    \"registry\": \"映像倉庫\",\n    \"compose\": \"撰寫\",\n    \"addFirstStackMsg\": \"組合您的第一個堆疊！\",\n    \"stackName\": \"堆疊名稱\",\n    \"deployStack\": \"部署\",\n    \"deleteStack\": \"刪除\",\n    \"stopStack\": \"停止\",\n    \"restartStack\": \"重啟\",\n    \"updateStack\": \"更新\",\n    \"startStack\": \"啟動\",\n    \"editStack\": \"編輯\",\n    \"discardStack\": \"丟棄\",\n    \"saveStackDraft\": \"儲存\",\n    \"notAvailableShort\": \"不可用\",\n    \"deleteStackMsg\": \"您確定要刪除這個堆疊嗎？\",\n    \"stackNotManagedByDockgeMsg\": \"這個堆疊不由 Dockge 管理。\",\n    \"primaryHostname\": \"主機名稱\",\n    \"general\": \"一般\",\n    \"container\": \"容器 | 容器群組\",\n    \"scanFolder\": \"掃描堆疊資料夾\",\n    \"dockerImage\": \"映像\",\n    \"restartPolicyUnlessStopped\": \"除非手動停止\",\n    \"restartPolicyAlways\": \"始終\",\n    \"restartPolicyOnFailure\": \"在失敗時\",\n    \"restartPolicyNo\": \"不重啟\",\n    \"environmentVariable\": \"環境變數 | 環境變數群組\",\n    \"restartPolicy\": \"重啟策略\",\n    \"containerName\": \"容器名稱\",\n    \"port\": \"連接埠 | 連接埠群組\",\n    \"volume\": \"資料卷 | 資料卷群組\",\n    \"network\": \"網路 | 網路群組\",\n    \"dependsOn\": \"容器依賴 | 容器依賴關係\",\n    \"addListItem\": \"新增 {0}\",\n    \"deleteContainer\": \"刪除容器\",\n    \"addContainer\": \"新增容器\",\n    \"addNetwork\": \"新增網路\",\n    \"disableauth.message1\": \"您確定要<strong>停用身份驗證</strong>嗎？\",\n    \"disableauth.message2\": \"該選項設計用於某些場景，<strong>例如在 Dockge 之介接接第三方身份驗證</strong>，例如 Cloudflare Access、Authelia 或其他身份驗證機制。\",\n    \"passwordNotMatchMsg\": \"兩次輸入的密碼不一致。\",\n    \"autoGet\": \"自動取得\",\n    \"add\": \"新增\",\n    \"Edit\": \"編輯\",\n    \"applyToYAML\": \"套用到 YAML\",\n    \"createExternalNetwork\": \"建立\",\n    \"addInternalNetwork\": \"新增\",\n    \"Save\": \"儲存\",\n    \"Language\": \"語言\",\n    \"Current User\": \"目前使用者\",\n    \"Change Password\": \"更換密碼\",\n    \"Current Password\": \"目前密碼\",\n    \"New Password\": \"新密碼\",\n    \"Repeat New Password\": \"重複以確認新密碼\",\n    \"Update Password\": \"更新密碼\",\n    \"Advanced\": \"進階\",\n    \"Please use this option carefully!\": \"請謹慎使用該選項！\",\n    \"Enable Auth\": \"啟用驗證\",\n    \"Disable Auth\": \"停用驗證\",\n    \"I understand, please disable\": \"我已了解風險，確認停用\",\n    \"Leave\": \"離開\",\n    \"Frontend Version\": \"前端版本\",\n    \"Check Update On GitHub\": \"在 GitHub 上檢查更新\",\n    \"Show update if available\": \"有更新時提醒我\",\n    \"Also check beta release\": \"同時檢查 Beta 版更新\",\n    \"Remember me\": \"記住我\",\n    \"Login\": \"登入\",\n    \"Username\": \"使用者名稱\",\n    \"Password\": \"密碼\",\n    \"Settings\": \"設定\",\n    \"Logout\": \"登出\",\n    \"Lowercase only\": \"僅小寫字母\",\n    \"Convert to Compose\": \"轉換為 Compose 格式\",\n    \"Docker Run\": \"Docker 啟動\",\n    \"active\": \"已啟動\",\n    \"exited\": \"已退出\",\n    \"inactive\": \"未啟動\",\n    \"Appearance\": \"外觀\",\n    \"Security\": \"安全\",\n    \"About\": \"關於\",\n    \"Allowed commands:\": \"允許使用的指令:\",\n    \"Internal Networks\": \"內部網路\",\n    \"External Networks\": \"外部網路\",\n    \"No External Networks\": \"無外部網路\",\n    \"downStack\": \"停止及未啟動化\",\n    \"reverseProxyMsg1\": \"在使用反向代理嗎？\",\n    \"reverseProxyMsg2\": \"點擊這裡了解如何為 WebSocket 配置反向代理\",\n    \"Cannot connect to the socket server.\": \"無法連接到 Socket 伺服器。\",\n    \"reconnecting...\": \"重新連線中…\",\n    \"connecting...\": \"連線至 Socket 伺服器中…\",\n    \"url\": \"網址 | 網址\",\n    \"extra\": \"額外\",\n    \"newUpdate\": \"新版本\",\n    \"currentEndpoint\": \"目前\",\n    \"dockgeURL\": \"Dockge URL（例如：http://127.0.0.1:5001）\",\n    \"agentOnline\": \"線上\",\n    \"connecting\": \"正在連線\",\n    \"agentOffline\": \"離線\",\n    \"Lost connection to the socket server. Reconnecting...\": \"與伺服器斷線。正在重新連線...\",\n    \"dockgeAgent\": \"Dockge代理 | Dockge代理\",\n    \"Saved\": \"已儲存\",\n    \"Switch to sh\": \"切換到 sh\",\n    \"NoNetworksAvailable\": \"沒有可以使用的網路。您需要先在右側新增內部網路或啟用外部網路。\",\n    \"LongSyntaxNotSupported\": \"這裡不支援長語法。請使用 YAML 編輯器。\",\n    \"connect\": \"連接\",\n    \"addAgent\": \"新增代理\",\n    \"agentAddedSuccessfully\": \"代理新增成功。\",\n    \"agentRemovedSuccessfully\": \"代理刪除成功。\",\n    \"Deployed\": \"已佈署\",\n    \"Deleted\": \"已刪除\",\n    \"Updated\": \"已更新\",\n    \"Started\": \"開始\",\n    \"Stopped\": \"已停止\",\n    \"Restarted\": \"重新啟動\",\n    \"Downed\": \"斷線\",\n    \"terminal\": \"終端\",\n    \"CurrentHostname\": \"（取消設定：依據目前主機名稱）\",\n    \"New Container Name...\": \"新容器名稱...\",\n    \"Network name...\": \"網路名稱...\",\n    \"Select a network...\": \"選擇網路...\",\n    \"removeAgent\": \"刪除代理\",\n    \"removeAgentMsg\": \"您確定要刪除這個代理嗎？\"\n}\n"
  },
  {
    "path": "frontend/src/layouts/EmptyLayout.vue",
    "content": "<template>\n    <router-view />\n</template>\n\n<script>\nexport default {};\n</script>\n\n"
  },
  {
    "path": "frontend/src/layouts/Layout.vue",
    "content": "<template>\n    <div :class=\"classes\">\n        <div v-if=\"! $root.socketIO.connected && ! $root.socketIO.firstConnect\" class=\"lost-connection\">\n            <div class=\"container-fluid\">\n                {{ $root.socketIO.connectionErrorMsg }}\n                <div v-if=\"$root.socketIO.showReverseProxyGuide\">\n                    {{ $t(\"reverseProxyMsg1\") }} <a href=\"https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy\" target=\"_blank\">{{ $t(\"reverseProxyMsg2\") }}</a>\n                </div>\n            </div>\n        </div>\n\n        <!-- Desktop header -->\n        <header v-if=\"! $root.isMobile\" class=\"d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom\">\n            <router-link to=\"/\" class=\"d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none\">\n                <object class=\"bi me-2 ms-4\" width=\"40\" height=\"40\" data=\"/icon.svg\" />\n                <span class=\"fs-4 title\">Dockge</span>\n            </router-link>\n\n            <a v-if=\"hasNewVersion\" target=\"_blank\" href=\"https://github.com/louislam/dockge/releases\" class=\"btn btn-warning me-3\">\n                <font-awesome-icon icon=\"arrow-alt-circle-up\" /> {{ $t(\"newUpdate\") }}\n            </a>\n\n            <ul class=\"nav nav-pills\">\n                <li v-if=\"$root.loggedIn\" class=\"nav-item me-2\">\n                    <router-link to=\"/\" class=\"nav-link\">\n                        <font-awesome-icon icon=\"home\" /> {{ $t(\"home\") }}\n                    </router-link>\n                </li>\n\n                <li v-if=\"$root.loggedIn\" class=\"nav-item me-2\">\n                    <router-link to=\"/console\" class=\"nav-link\">\n                        <font-awesome-icon icon=\"terminal\" /> {{ $t(\"console\") }}\n                    </router-link>\n                </li>\n\n                <li v-if=\"$root.loggedIn\" class=\"nav-item\">\n                    <div class=\"dropdown dropdown-profile-pic\">\n                        <div class=\"nav-link\" data-bs-toggle=\"dropdown\">\n                            <div class=\"profile-pic\">{{ $root.usernameFirstChar }}</div>\n                            <font-awesome-icon icon=\"angle-down\" />\n                        </div>\n\n                        <!-- Header's Dropdown Menu -->\n                        <ul class=\"dropdown-menu\">\n                            <!-- Username -->\n                            <li>\n                                <i18n-t v-if=\"$root.username != null\" tag=\"span\" keypath=\"signedInDisp\" class=\"dropdown-item-text\">\n                                    <strong>{{ $root.username }}</strong>\n                                </i18n-t>\n                                <span v-if=\"$root.username == null\" class=\"dropdown-item-text\">{{ $t(\"signedInDispDisabled\") }}</span>\n                            </li>\n\n                            <li><hr class=\"dropdown-divider\"></li>\n\n                            <!-- Functions -->\n\n                            <!--<li>\n                                <router-link to=\"/registry\" class=\"dropdown-item\" :class=\"{ active: $route.path.includes('settings') }\">\n                                    <font-awesome-icon icon=\"warehouse\" /> {{ $t(\"registry\") }}\n                                </router-link>\n                            </li>-->\n\n                            <li>\n                                <button class=\"dropdown-item\" @click=\"scanFolder\">\n                                    <font-awesome-icon icon=\"arrows-rotate\" /> {{ $t(\"scanFolder\") }}\n                                </button>\n                            </li>\n\n                            <li>\n                                <router-link to=\"/settings/general\" class=\"dropdown-item\" :class=\"{ active: $route.path.includes('settings') }\">\n                                    <font-awesome-icon icon=\"cog\" /> {{ $t(\"Settings\") }}\n                                </router-link>\n                            </li>\n\n                            <li>\n                                <button class=\"dropdown-item\" @click=\"$root.logout\">\n                                    <font-awesome-icon icon=\"sign-out-alt\" />\n                                    {{ $t(\"Logout\") }}\n                                </button>\n                            </li>\n                        </ul>\n                    </div>\n                </li>\n            </ul>\n        </header>\n\n        <main>\n            <div v-if=\"$root.socketIO.connecting\" class=\"container mt-5\">\n                <h4>{{ $t(\"connecting...\") }}</h4>\n            </div>\n\n            <router-view v-if=\"$root.loggedIn\" />\n            <Login v-if=\"! $root.loggedIn && $root.allowLoginDialog\" />\n        </main>\n    </div>\n</template>\n\n<script>\nimport Login from \"../components/Login.vue\";\nimport { compareVersions } from \"compare-versions\";\nimport { ALL_ENDPOINTS } from \"../../../common/util-common\";\n\nexport default {\n\n    components: {\n        Login,\n    },\n\n    data() {\n        return {\n\n        };\n    },\n\n    computed: {\n\n        // Theme or Mobile\n        classes() {\n            const classes = {};\n            classes[this.$root.theme] = true;\n            classes[\"mobile\"] = this.$root.isMobile;\n            return classes;\n        },\n\n        hasNewVersion() {\n            if (this.$root.info.latestVersion && this.$root.info.version) {\n                return compareVersions(this.$root.info.latestVersion, this.$root.info.version) >= 1;\n            } else {\n                return false;\n            }\n        },\n\n    },\n\n    watch: {\n\n    },\n\n    mounted() {\n\n    },\n\n    beforeUnmount() {\n\n    },\n\n    methods: {\n        scanFolder() {\n            this.$root.emitAgent(ALL_ENDPOINTS, \"requestStackList\", (res) => {\n                this.$root.toastRes(res);\n            });\n        },\n    },\n\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../styles/vars.scss\";\n\n.nav-link {\n    &.status-page {\n        background-color: rgba(255, 255, 255, 0.1);\n    }\n}\n\n.bottom-nav {\n    z-index: 1000;\n    position: fixed;\n    bottom: 0;\n    height: calc(60px + env(safe-area-inset-bottom));\n    width: 100%;\n    left: 0;\n    background-color: #fff;\n    box-shadow: 0 15px 47px 0 rgba(0, 0, 0, 0.05), 0 5px 14px 0 rgba(0, 0, 0, 0.05);\n    text-align: center;\n    white-space: nowrap;\n    padding: 0 10px env(safe-area-inset-bottom);\n\n    a {\n        text-align: center;\n        width: 25%;\n        display: inline-block;\n        height: 100%;\n        padding: 8px 10px 0;\n        font-size: 13px;\n        color: #c1c1c1;\n        overflow: hidden;\n        text-decoration: none;\n\n        &.router-link-exact-active, &.active {\n            color: $primary;\n            font-weight: bold;\n        }\n\n        div {\n            font-size: 20px;\n        }\n    }\n}\n\nmain {\n    min-height: calc(100vh - 160px);\n}\n\n.title {\n    font-weight: bold;\n}\n\n.nav {\n    margin-right: 25px;\n}\n\n.lost-connection {\n    padding: 5px;\n    background-color: crimson;\n    color: white;\n    position: fixed;\n    width: 100%;\n    z-index: 99999;\n}\n\n// Profile Pic Button with Dropdown\n.dropdown-profile-pic {\n    user-select: none;\n\n    .nav-link {\n        cursor: pointer;\n        display: flex;\n        gap: 6px;\n        align-items: center;\n        background-color: rgba(200, 200, 200, 0.2);\n        padding: 0.5rem 0.8rem;\n\n        &:hover {\n            background-color: rgba(255, 255, 255, 0.2);\n        }\n    }\n\n    .dropdown-menu {\n        transition: all 0.2s;\n        padding-left: 0;\n        padding-bottom: 0;\n        margin-top: 8px !important;\n        border-radius: 16px;\n        overflow: hidden;\n\n        .dropdown-divider {\n            margin: 0;\n            border-top: 1px solid rgba(0, 0, 0, 0.4);\n            background-color: transparent;\n        }\n\n        .dropdown-item-text {\n            font-size: 14px;\n            padding-bottom: 0.7rem;\n        }\n\n        .dropdown-item {\n            padding: 0.7rem 1rem;\n        }\n\n        .dark & {\n            background-color: $dark-bg;\n            color: $dark-font-color;\n            border-color: $dark-border-color;\n\n            .dropdown-item {\n                color: $dark-font-color;\n\n                &.active {\n                    color: $dark-font-color2;\n                    background-color: $highlight !important;\n                }\n\n                &:hover {\n                    background-color: $dark-bg2;\n                }\n            }\n        }\n    }\n\n    .profile-pic {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        color: white;\n        background-color: $primary;\n        width: 24px;\n        height: 24px;\n        margin-right: 5px;\n        border-radius: 50rem;\n        font-weight: bold;\n        font-size: 10px;\n    }\n}\n\n.dark {\n    header {\n        background-color: $dark-header-bg;\n        border-bottom-color: $dark-header-bg !important;\n\n        span {\n            color: #f0f6fc;\n        }\n    }\n\n    .bottom-nav {\n        background-color: $dark-bg;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/main.ts",
    "content": "// Dayjs init inside this, so it has to be the first import\nimport \"../../common/util-common\";\n\nimport { createApp, defineComponent, h } from \"vue\";\nimport App from \"./App.vue\";\nimport { router } from \"./router\";\nimport { FontAwesomeIcon } from \"./icon.js\";\nimport { i18n } from \"./i18n\";\n\n// Dependencies\nimport \"bootstrap\";\nimport Toast, { POSITION, useToast } from \"vue-toastification\";\nimport \"@xterm/xterm/lib/xterm.js\";\n\n// CSS\nimport \"@fontsource/jetbrains-mono\";\nimport \"vue-toastification/dist/index.css\";\nimport \"@xterm/xterm/css/xterm.css\";\nimport \"./styles/main.scss\";\n\n// Minxins\nimport socket from \"./mixins/socket\";\nimport lang from \"./mixins/lang\";\nimport theme from \"./mixins/theme\";\n\n// Set Title\ndocument.title = document.title + \" - \" + location.host;\n\nconst app = createApp(rootApp());\n\napp.use(Toast, {\n    position: POSITION.BOTTOM_RIGHT,\n    showCloseButtonOnHover: true,\n});\napp.use(router);\napp.use(i18n);\napp.component(\"FontAwesomeIcon\", FontAwesomeIcon);\napp.mount(\"#app\");\n\n/**\n * Root Vue component\n */\nfunction rootApp() {\n    const toast = useToast();\n\n    return defineComponent({\n        mixins: [\n            socket,\n            lang,\n            theme,\n        ],\n        data() {\n            return {\n                loggedIn: false,\n                allowLoginDialog: false,\n                username: null,\n            };\n        },\n        computed: {\n\n        },\n        methods: {\n\n            /**\n             * Show success or error toast dependant on response status code\n             * @param {object} res Response object\n             * @returns {void}\n             */\n            toastRes(res) {\n                let msg = res.msg;\n                if (res.msgi18n) {\n                    if (msg != null && typeof msg === \"object\") {\n                        msg = this.$t(msg.key, msg.values);\n                    } else {\n                        msg = this.$t(msg);\n                    }\n                }\n\n                if (res.ok) {\n                    toast.success(msg);\n                } else {\n                    toast.error(msg);\n                }\n            },\n            /**\n             * Show a success toast\n             * @param {string} msg Message to show\n             * @returns {void}\n             */\n            toastSuccess(msg : string) {\n                toast.success(this.$t(msg));\n            },\n\n            /**\n             * Show an error toast\n             * @param {string} msg Message to show\n             * @returns {void}\n             */\n            toastError(msg : string) {\n                toast.error(this.$t(msg));\n            },\n        },\n        render: () => h(App),\n    });\n}\n"
  },
  {
    "path": "frontend/src/mixins/lang.ts",
    "content": "import { currentLocale } from \"../i18n\";\nimport { setPageLocale } from \"../util-frontend\";\nimport { defineComponent } from \"vue\";\nconst langModules = import.meta.glob(\"../lang/*.json\");\n\nexport default defineComponent({\n    data() {\n        return {\n            language: currentLocale(),\n        };\n    },\n\n    watch: {\n        async language(lang) {\n            await this.changeLang(lang);\n        },\n    },\n\n    async created() {\n        if (this.language !== \"en\") {\n            await this.changeLang(this.language);\n        }\n    },\n\n    methods: {\n        /**\n         * Change the application language\n         * @param {string} lang Language code to switch to\n         * @returns {Promise<void>}\n         */\n        async changeLang(lang : string) {\n            const message = (await langModules[\"../lang/\" + lang + \".json\"]()).default;\n            this.$i18n.setLocaleMessage(lang, message);\n            this.$i18n.locale = lang;\n            localStorage.locale = lang;\n            setPageLocale();\n        }\n    }\n});\n"
  },
  {
    "path": "frontend/src/mixins/socket.ts",
    "content": "import { io } from \"socket.io-client\";\nimport { Socket } from \"socket.io-client\";\nimport { defineComponent } from \"vue\";\nimport jwtDecode from \"jwt-decode\";\nimport { Terminal } from \"@xterm/xterm\";\nimport { AgentSocket } from \"../../../common/agent-socket\";\n\nlet socket : Socket;\n\nlet terminalMap : Map<string, Terminal> = new Map();\n\nexport default defineComponent({\n    data() {\n        return {\n            socketIO: {\n                token: null,\n                firstConnect: true,\n                connected: false,\n                connectCount: 0,\n                initedSocketIO: false,\n                connectionErrorMsg: `${this.$t(\"Cannot connect to the socket server.\")} ${this.$t(\"Reconnecting...\")}`,\n                showReverseProxyGuide: true,\n                connecting: false,\n            },\n            info: {\n\n            },\n            remember: (localStorage.remember !== \"0\"),\n            loggedIn: false,\n            allowLoginDialog: false,\n            username: null,\n            composeTemplate: \"\",\n\n            stackList: {},\n\n            // All stack list from all agents\n            allAgentStackList: {} as Record<string, object>,\n\n            // online / offline / connecting\n            agentStatusList: {\n\n            },\n\n            // Agent List\n            agentList: {\n\n            },\n        };\n    },\n    computed: {\n\n        agentCount() {\n            return Object.keys(this.agentList).length;\n        },\n\n        completeStackList() {\n            let list : Record<string, object> = {};\n\n            for (let stackName in this.stackList) {\n                list[stackName + \"_\"] = this.stackList[stackName];\n            }\n\n            for (let endpoint in this.allAgentStackList) {\n                let instance = this.allAgentStackList[endpoint];\n                for (let stackName in instance.stackList) {\n                    list[stackName + \"_\" + endpoint] = instance.stackList[stackName];\n                }\n            }\n            return list;\n        },\n\n        usernameFirstChar() {\n            if (typeof this.username == \"string\" && this.username.length >= 1) {\n                return this.username.charAt(0).toUpperCase();\n            } else {\n                return \"🐬\";\n            }\n        },\n\n        /**\n         *  Frontend Version\n         *  It should be compiled to a static value while building the frontend.\n         *  Please see ./frontend/vite.config.ts, it is defined via vite.js\n         * @returns {string}\n         */\n        frontendVersion() {\n            // eslint-disable-next-line no-undef\n            return FRONTEND_VERSION;\n        },\n\n        /**\n         * Are both frontend and backend in the same version?\n         * @returns {boolean}\n         */\n        isFrontendBackendVersionMatched() {\n            if (!this.info.version) {\n                return true;\n            }\n            return this.info.version === this.frontendVersion;\n        },\n\n    },\n    watch: {\n\n        \"socketIO.connected\"() {\n            if (this.socketIO.connected) {\n                this.agentStatusList[\"\"] = \"online\";\n            } else {\n                this.agentStatusList[\"\"] = \"offline\";\n            }\n        },\n\n        remember() {\n            localStorage.remember = (this.remember) ? \"1\" : \"0\";\n        },\n\n        // Reload the SPA if the server version is changed.\n        \"info.version\"(to, from) {\n            if (from && from !== to) {\n                window.location.reload();\n            }\n        },\n    },\n    created() {\n        this.initSocketIO();\n    },\n    mounted() {\n        return;\n\n    },\n    methods: {\n\n        endpointDisplayFunction(endpoint : string) {\n            if (endpoint) {\n                return endpoint;\n            } else {\n                return this.$t(\"currentEndpoint\");\n            }\n        },\n\n        /**\n         * Initialize connection to socket server\n         * @param bypass Should the check for if we\n         * are on a status page be bypassed?\n         */\n        initSocketIO(bypass = false) {\n            // No need to re-init\n            if (this.socketIO.initedSocketIO) {\n                return;\n            }\n\n            this.socketIO.initedSocketIO = true;\n            let url : string;\n            const env = process.env.NODE_ENV || \"production\";\n            if (env === \"development\" || localStorage.dev === \"dev\") {\n                url = location.protocol + \"//\" + location.hostname + \":5001\";\n            } else {\n                url = location.protocol + \"//\" + location.host;\n            }\n\n            let connectingMsgTimeout = setTimeout(() => {\n                this.socketIO.connecting = true;\n            }, 1500);\n\n            socket = io(url);\n\n            // Handling events from agents\n            let agentSocket = new AgentSocket();\n            socket.on(\"agent\", (eventName : unknown, ...args : unknown[]) => {\n                agentSocket.call(eventName, ...args);\n            });\n\n            socket.on(\"connect\", () => {\n                console.log(\"Connected to the socket server\");\n\n                clearTimeout(connectingMsgTimeout);\n                this.socketIO.connecting = false;\n\n                this.socketIO.connectCount++;\n                this.socketIO.connected = true;\n                this.socketIO.showReverseProxyGuide = false;\n                const token = this.storage().token;\n\n                if (token) {\n                    if (token !== \"autoLogin\") {\n                        console.log(\"Logging in by token\");\n                        this.loginByToken(token);\n                    } else {\n                        // Timeout if it is not actually auto login\n                        setTimeout(() => {\n                            if (! this.loggedIn) {\n                                this.allowLoginDialog = true;\n                                this.storage().removeItem(\"token\");\n                            }\n                        }, 5000);\n                    }\n                } else {\n                    this.allowLoginDialog = true;\n                }\n\n                this.socketIO.firstConnect = false;\n            });\n\n            socket.on(\"disconnect\", () => {\n                console.log(\"disconnect\");\n                this.socketIO.connectionErrorMsg = `${this.$t(\"Lost connection to the socket server. Reconnecting...\")}`;\n                this.socketIO.connected = false;\n            });\n\n            socket.on(\"connect_error\", (err) => {\n                console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);\n                this.socketIO.connectionErrorMsg = `${this.$t(\"Cannot connect to the socket server.\")} [${err}] ${this.$t(\"reconnecting...\")}`;\n                this.socketIO.showReverseProxyGuide = true;\n                this.socketIO.connected = false;\n                this.socketIO.firstConnect = false;\n                this.socketIO.connecting = false;\n            });\n\n            // Custom Events\n\n            socket.on(\"info\", (info) => {\n                this.info = info;\n            });\n\n            socket.on(\"autoLogin\", () => {\n                this.loggedIn = true;\n                this.storage().token = \"autoLogin\";\n                this.socketIO.token = \"autoLogin\";\n                this.allowLoginDialog = false;\n                this.afterLogin();\n            });\n\n            socket.on(\"setup\", () => {\n                console.log(\"setup\");\n                this.$router.push(\"/setup\");\n            });\n\n            agentSocket.on(\"terminalWrite\", (terminalName, data) => {\n                const terminal = terminalMap.get(terminalName);\n                if (!terminal) {\n                    //console.error(\"Terminal not found: \" + terminalName);\n                    return;\n                }\n                terminal.write(data);\n            });\n\n            agentSocket.on(\"stackList\", (res) => {\n                if (res.ok) {\n                    if (!res.endpoint) {\n                        this.stackList = res.stackList;\n                    } else {\n                        if (!this.allAgentStackList[res.endpoint]) {\n                            this.allAgentStackList[res.endpoint] = {\n                                stackList: {},\n                            };\n                        }\n                        this.allAgentStackList[res.endpoint].stackList = res.stackList;\n                    }\n                }\n            });\n\n            socket.on(\"stackStatusList\", (res) => {\n                if (res.ok) {\n                    for (let stackName in res.stackStatusList) {\n                        const stackObj = this.stackList[stackName];\n                        if (stackObj) {\n                            stackObj.status = res.stackStatusList[stackName];\n                        }\n                    }\n                }\n            });\n\n            socket.on(\"agentStatus\", (res) => {\n                this.agentStatusList[res.endpoint] = res.status;\n\n                if (res.msg) {\n                    this.toastError(res.msg);\n                }\n            });\n\n            socket.on(\"agentList\", (res) => {\n                if (res.ok) {\n                    this.agentList = res.agentList;\n                }\n            });\n\n            socket.on(\"refresh\", () => {\n                location.reload();\n            });\n        },\n\n        /**\n         * The storage currently in use\n         * @returns Current storage\n         */\n        storage() : Storage {\n            return (this.remember) ? localStorage : sessionStorage;\n        },\n\n        getSocket() : Socket {\n            return socket;\n        },\n\n        emitAgent(endpoint : string, eventName : string, ...args : unknown[]) {\n            this.getSocket().emit(\"agent\", endpoint, eventName, ...args);\n        },\n\n        /**\n         * Get payload of JWT cookie\n         * @returns {(object | undefined)} JWT payload\n         */\n        getJWTPayload() {\n            const jwtToken = this.storage().token;\n\n            if (jwtToken && jwtToken !== \"autoLogin\") {\n                return jwtDecode(jwtToken);\n            }\n            return undefined;\n        },\n\n        /**\n         * Send request to log user in\n         * @param {string} username Username to log in with\n         * @param {string} password Password to log in with\n         * @param {string} token User token\n         * @param {loginCB} callback Callback to call with result\n         * @returns {void}\n         */\n        login(username : string, password : string, token : string, callback) {\n            this.getSocket().emit(\"login\", {\n                username,\n                password,\n                token,\n            }, (res) => {\n                if (res.tokenRequired) {\n                    callback(res);\n                }\n\n                if (res.ok) {\n                    this.storage().token = res.token;\n                    this.socketIO.token = res.token;\n                    this.loggedIn = true;\n                    this.username = this.getJWTPayload()?.username;\n\n                    this.afterLogin();\n\n                    // Trigger Chrome Save Password\n                    history.pushState({}, \"\");\n                }\n\n                callback(res);\n            });\n        },\n\n        /**\n         * Log in using a token\n         * @param {string} token Token to log in with\n         * @returns {void}\n         */\n        loginByToken(token : string) {\n            socket.emit(\"loginByToken\", token, (res) => {\n                this.allowLoginDialog = true;\n\n                if (! res.ok) {\n                    this.logout();\n                } else {\n                    this.loggedIn = true;\n                    this.username = this.getJWTPayload()?.username;\n                    this.afterLogin();\n                }\n            });\n        },\n\n        /**\n         * Log out of the web application\n         * @returns {void}\n         */\n        logout() {\n            socket.emit(\"logout\", () => { });\n            this.storage().removeItem(\"token\");\n            this.socketIO.token = null;\n            this.loggedIn = false;\n            this.username = null;\n            this.clearData();\n        },\n\n        /**\n         * @returns {void}\n         */\n        clearData() {\n\n        },\n\n        afterLogin() {\n\n        },\n\n        bindTerminal(endpoint : string, terminalName : string, terminal : Terminal) {\n            // Load terminal, get terminal screen\n            this.emitAgent(endpoint, \"terminalJoin\", terminalName, (res) => {\n                if (res.ok) {\n                    terminal.write(res.buffer);\n                    terminalMap.set(terminalName, terminal);\n                } else {\n                    this.toastRes(res);\n                }\n            });\n        },\n\n        unbindTerminal(terminalName : string) {\n            terminalMap.delete(terminalName);\n        },\n\n    }\n});\n"
  },
  {
    "path": "frontend/src/mixins/theme.ts",
    "content": "import { defineComponent } from \"vue\";\n\nexport default defineComponent({\n    data() {\n        return {\n            system: (window.matchMedia(\"(prefers-color-scheme: dark)\").matches) ? \"dark\" : \"light\",\n            userTheme: localStorage.theme,\n            statusPageTheme: \"light\",\n            forceStatusPageTheme: false,\n            path: \"\",\n        };\n    },\n\n    computed: {\n        theme() {\n            if (this.userTheme === \"auto\") {\n                return this.system;\n            }\n            return this.userTheme;\n        },\n\n        isDark() {\n            return this.theme === \"dark\";\n        }\n    },\n\n    watch: {\n        \"$route.fullPath\"(path) {\n            this.path = path;\n        },\n\n        userTheme(to, from) {\n            localStorage.theme = to;\n        },\n\n        styleElapsedTime(to, from) {\n            localStorage.styleElapsedTime = to;\n        },\n\n        theme(to, from) {\n            document.body.classList.remove(from);\n            document.body.classList.add(this.theme);\n            this.updateThemeColorMeta();\n        },\n\n        userHeartbeatBar(to, from) {\n            localStorage.heartbeatBarTheme = to;\n        },\n\n        heartbeatBarTheme(to, from) {\n            document.body.classList.remove(from);\n            document.body.classList.add(this.heartbeatBarTheme);\n        }\n    },\n\n    mounted() {\n        // Default Dark\n        if (! this.userTheme) {\n            this.userTheme = \"dark\";\n        }\n\n        document.body.classList.add(this.theme);\n        this.updateThemeColorMeta();\n    },\n\n    methods: {\n        /**\n         * Update the theme color meta tag\n         * @returns {void}\n         */\n        updateThemeColorMeta() {\n            if (this.theme === \"dark\") {\n                document.querySelector(\"#theme-color\").setAttribute(\"content\", \"#161B22\");\n            } else {\n                document.querySelector(\"#theme-color\").setAttribute(\"content\", \"#5cdd8b\");\n            }\n        }\n    }\n});\n\n"
  },
  {
    "path": "frontend/src/pages/Compose.vue",
    "content": "<template>\n    <transition name=\"slide-fade\" appear>\n        <div>\n            <h1 v-if=\"isAdd\" class=\"mb-3\">{{ $t(\"compose\") }}</h1>\n            <h1 v-else class=\"mb-3\">\n                <Uptime :stack=\"globalStack\" :pill=\"true\" /> {{ stack.name }}\n                <span v-if=\"$root.agentCount > 1\" class=\"agent-name\">\n                    ({{ endpointDisplay }})\n                </span>\n            </h1>\n\n            <div v-if=\"stack.isManagedByDockge\" class=\"mb-3\">\n                <div class=\"btn-group me-2\" role=\"group\">\n                    <button v-if=\"isEditMode\" class=\"btn btn-primary\" :disabled=\"processing\" @click=\"deployStack\">\n                        <font-awesome-icon icon=\"rocket\" class=\"me-1\" />\n                        {{ $t(\"deployStack\") }}\n                    </button>\n\n                    <button v-if=\"isEditMode\" class=\"btn btn-normal\" :disabled=\"processing\" @click=\"saveStack\">\n                        <font-awesome-icon icon=\"save\" class=\"me-1\" />\n                        {{ $t(\"saveStackDraft\") }}\n                    </button>\n\n                    <button v-if=\"!isEditMode\" class=\"btn btn-secondary\" :disabled=\"processing\" @click=\"enableEditMode\">\n                        <font-awesome-icon icon=\"pen\" class=\"me-1\" />\n                        {{ $t(\"editStack\") }}\n                    </button>\n\n                    <button v-if=\"!isEditMode && !active\" class=\"btn btn-primary\" :disabled=\"processing\" @click=\"startStack\">\n                        <font-awesome-icon icon=\"play\" class=\"me-1\" />\n                        {{ $t(\"startStack\") }}\n                    </button>\n\n                    <button v-if=\"!isEditMode && active\" class=\"btn btn-normal \" :disabled=\"processing\" @click=\"restartStack\">\n                        <font-awesome-icon icon=\"rotate\" class=\"me-1\" />\n                        {{ $t(\"restartStack\") }}\n                    </button>\n\n                    <button v-if=\"!isEditMode\" class=\"btn btn-normal\" :disabled=\"processing\" @click=\"updateStack\">\n                        <font-awesome-icon icon=\"cloud-arrow-down\" class=\"me-1\" />\n                        {{ $t(\"updateStack\") }}\n                    </button>\n\n                    <button v-if=\"!isEditMode && active\" class=\"btn btn-normal\" :disabled=\"processing\" @click=\"stopStack\">\n                        <font-awesome-icon icon=\"stop\" class=\"me-1\" />\n                        {{ $t(\"stopStack\") }}\n                    </button>\n\n                    <BDropdown right text=\"\" variant=\"normal\">\n                        <BDropdownItem @click=\"downStack\">\n                            <font-awesome-icon icon=\"stop\" class=\"me-1\" />\n                            {{ $t(\"downStack\") }}\n                        </BDropdownItem>\n                    </BDropdown>\n                </div>\n\n                <button v-if=\"isEditMode && !isAdd\" class=\"btn btn-normal\" :disabled=\"processing\" @click=\"discardStack\">{{ $t(\"discardStack\") }}</button>\n                <button v-if=\"!isEditMode\" class=\"btn btn-danger\" :disabled=\"processing\" @click=\"showDeleteDialog = !showDeleteDialog\">\n                    <font-awesome-icon icon=\"trash\" class=\"me-1\" />\n                    {{ $t(\"deleteStack\") }}\n                </button>\n            </div>\n\n            <!-- URLs -->\n            <div v-if=\"urls.length > 0\" class=\"mb-3\">\n                <a v-for=\"(url, index) in urls\" :key=\"index\" target=\"_blank\" :href=\"url.url\">\n                    <span class=\"badge bg-secondary me-2\">{{ url.display }}</span>\n                </a>\n            </div>\n\n            <!-- Progress Terminal -->\n            <transition name=\"slide-fade\" appear>\n                <Terminal\n                    v-show=\"showProgressTerminal\"\n                    ref=\"progressTerminal\"\n                    class=\"mb-3 terminal\"\n                    :name=\"terminalName\"\n                    :endpoint=\"endpoint\"\n                    :rows=\"progressTerminalRows\"\n                    @has-data=\"showProgressTerminal = true; submitted = true;\"\n                ></Terminal>\n            </transition>\n\n            <div v-if=\"stack.isManagedByDockge\" class=\"row\">\n                <div class=\"col-lg-6\">\n                    <!-- General -->\n                    <div v-if=\"isAdd\">\n                        <h4 class=\"mb-3\">{{ $t(\"general\") }}</h4>\n                        <div class=\"shadow-box big-padding mb-3\">\n                            <!-- Stack Name -->\n                            <div>\n                                <label for=\"name\" class=\"form-label\">{{ $t(\"stackName\") }}</label>\n                                <input id=\"name\" v-model=\"stack.name\" type=\"text\" class=\"form-control\" required @blur=\"stackNameToLowercase\">\n                                <div class=\"form-text\">{{ $t(\"Lowercase only\") }}</div>\n                            </div>\n\n                            <!-- Endpoint -->\n                            <div class=\"mt-3\">\n                                <label for=\"name\" class=\"form-label\">{{ $t(\"dockgeAgent\") }}</label>\n                                <select v-model=\"stack.endpoint\" class=\"form-select\">\n                                    <option v-for=\"(agent, endpoint) in $root.agentList\" :key=\"endpoint\" :value=\"endpoint\" :disabled=\"$root.agentStatusList[endpoint] != 'online'\">\n                                        ({{ $root.agentStatusList[endpoint] }}) {{ (endpoint) ? endpoint : $t(\"currentEndpoint\") }}\n                                    </option>\n                                </select>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Containers -->\n                    <h4 class=\"mb-3\">{{ $tc(\"container\", 2) }}</h4>\n\n                    <div v-if=\"isEditMode\" class=\"input-group mb-3\">\n                        <input\n                            v-model=\"newContainerName\"\n                            :placeholder=\"$t(`New Container Name...`)\"\n                            class=\"form-control\"\n                            @keyup.enter=\"addContainer\"\n                        />\n                        <button class=\"btn btn-primary\" @click=\"addContainer\">\n                            {{ $t(\"addContainer\") }}\n                        </button>\n                    </div>\n\n                    <div ref=\"containerList\">\n                        <Container\n                            v-for=\"(service, name) in jsonConfig.services\"\n                            :key=\"name\"\n                            :name=\"name\"\n                            :is-edit-mode=\"isEditMode\"\n                            :first=\"name === Object.keys(jsonConfig.services)[0]\"\n                            :status=\"serviceStatusList[name]?.state\"\n                            :ports=\"serviceStatusList[name]?.ports\"\n                        />\n                    </div>\n\n                    <button v-if=\"false && isEditMode && jsonConfig.services && Object.keys(jsonConfig.services).length > 0\" class=\"btn btn-normal mb-3\" @click=\"addContainer\">{{ $t(\"addContainer\") }}</button>\n\n                    <!-- General -->\n                    <div v-if=\"isEditMode\">\n                        <h4 class=\"mb-3\">{{ $t(\"extra\") }}</h4>\n                        <div class=\"shadow-box big-padding mb-3\">\n                            <!-- URLs -->\n                            <div class=\"mb-4\">\n                                <label class=\"form-label\">\n                                    {{ $tc(\"url\", 2) }}\n                                </label>\n                                <ArrayInput name=\"urls\" :display-name=\"$t('url')\" placeholder=\"https://\" object-type=\"x-dockge\" />\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Combined Terminal Output -->\n                    <div v-show=\"!isEditMode\">\n                        <h4 class=\"mb-3\">{{ $t(\"terminal\") }}</h4>\n                        <Terminal\n                            ref=\"combinedTerminal\"\n                            class=\"mb-3 terminal\"\n                            :name=\"combinedTerminalName\"\n                            :endpoint=\"endpoint\"\n                            :rows=\"combinedTerminalRows\"\n                            :cols=\"combinedTerminalCols\"\n                            style=\"height: 315px;\"\n                        ></Terminal>\n                    </div>\n                </div>\n                <div class=\"col-lg-6\">\n                    <h4 class=\"mb-3\">{{ stack.composeFileName }}</h4>\n\n                    <!-- YAML editor -->\n                    <div class=\"shadow-box mb-3 editor-box\" :class=\"{'edit-mode' : isEditMode}\">\n                        <code-mirror\n                            ref=\"editor\"\n                            v-model=\"stack.composeYAML\"\n                            :extensions=\"extensions\"\n                            minimal\n                            wrap=\"true\"\n                            dark=\"true\"\n                            tab=\"true\"\n                            :disabled=\"!isEditMode\"\n                            :hasFocus=\"editorFocus\"\n                            @change=\"yamlCodeChange\"\n                        />\n                    </div>\n                    <div v-if=\"isEditMode\" class=\"mb-3\">\n                        {{ yamlError }}\n                    </div>\n\n                    <!-- ENV editor -->\n                    <div v-if=\"isEditMode\">\n                        <h4 class=\"mb-3\">.env</h4>\n                        <div class=\"shadow-box mb-3 editor-box\" :class=\"{'edit-mode' : isEditMode}\">\n                            <code-mirror\n                                ref=\"editor\"\n                                v-model=\"stack.composeENV\"\n                                :extensions=\"extensionsEnv\"\n                                minimal\n                                wrap=\"true\"\n                                dark=\"true\"\n                                tab=\"true\"\n                                :disabled=\"!isEditMode\"\n                                :hasFocus=\"editorFocus\"\n                                @change=\"yamlCodeChange\"\n                            />\n                        </div>\n                    </div>\n\n                    <div v-if=\"isEditMode\">\n                        <!-- Volumes -->\n                        <div v-if=\"false\">\n                            <h4 class=\"mb-3\">{{ $tc(\"volume\", 2) }}</h4>\n                            <div class=\"shadow-box big-padding mb-3\">\n                            </div>\n                        </div>\n\n                        <!-- Networks -->\n                        <h4 class=\"mb-3\">{{ $tc(\"network\", 2) }}</h4>\n                        <div class=\"shadow-box big-padding mb-3\">\n                            <NetworkInput />\n                        </div>\n                    </div>\n\n                    <!-- <div class=\"shadow-box big-padding mb-3\">\n                        <div class=\"mb-3\">\n                            <label for=\"name\" class=\"form-label\"> Search Templates</label>\n                            <input id=\"name\" v-model=\"name\" type=\"text\" class=\"form-control\" placeholder=\"Search...\" required>\n                        </div>\n\n                        <prism-editor v-if=\"false\" v-model=\"yamlConfig\" class=\"yaml-editor\" :highlight=\"highlighter\" line-numbers @input=\"yamlCodeChange\"></prism-editor>\n                    </div>-->\n                </div>\n            </div>\n\n            <div v-if=\"!stack.isManagedByDockge && !processing\">\n                {{ $t(\"stackNotManagedByDockgeMsg\") }}\n            </div>\n\n            <!-- Delete Dialog -->\n            <BModal v-model=\"showDeleteDialog\" :cancelTitle=\"$t('cancel')\" :okTitle=\"$t('deleteStack')\" okVariant=\"danger\" @ok=\"deleteDialog\">\n                {{ $t(\"deleteStackMsg\") }}\n            </BModal>\n        </div>\n    </transition>\n</template>\n\n<script>\nimport CodeMirror from \"vue-codemirror6\";\nimport { yaml } from \"@codemirror/lang-yaml\";\nimport { python } from \"@codemirror/lang-python\";\nimport { dracula as editorTheme } from \"thememirror\";\nimport { lineNumbers, EditorView } from \"@codemirror/view\";\nimport { parseDocument, Document } from \"yaml\";\n\nimport { FontAwesomeIcon } from \"@fortawesome/vue-fontawesome\";\nimport {\n    COMBINED_TERMINAL_COLS,\n    COMBINED_TERMINAL_ROWS,\n    copyYAMLComments, envsubstYAML,\n    getCombinedTerminalName,\n    getComposeTerminalName,\n    PROGRESS_TERMINAL_ROWS,\n    RUNNING\n} from \"../../../common/util-common\";\nimport { BModal } from \"bootstrap-vue-next\";\nimport NetworkInput from \"../components/NetworkInput.vue\";\nimport dotenv from \"dotenv\";\nimport { ref } from \"vue\";\n\nconst template = `\nservices:\n  nginx:\n    image: nginx:latest\n    restart: unless-stopped\n    ports:\n      - \"8080:80\"\n`;\nconst envDefault = \"# VARIABLE=value #comment\";\n\nlet yamlErrorTimeout = null;\n\nlet serviceStatusTimeout = null;\n\nexport default {\n    components: {\n        NetworkInput,\n        FontAwesomeIcon,\n        CodeMirror,\n        BModal,\n    },\n    beforeRouteUpdate(to, from, next) {\n        this.exitConfirm(next);\n    },\n    beforeRouteLeave(to, from, next) {\n        this.exitConfirm(next);\n    },\n    setup() {\n        const editorFocus = ref(false);\n\n        const focusEffectHandler = (state, focusing) => {\n            editorFocus.value = focusing;\n            return null;\n        };\n\n        const extensions = [\n            editorTheme,\n            yaml(),\n            lineNumbers(),\n            EditorView.focusChangeEffect.of(focusEffectHandler)\n        ];\n\n        const extensionsEnv = [\n            editorTheme,\n            python(),\n            lineNumbers(),\n            EditorView.focusChangeEffect.of(focusEffectHandler)\n        ];\n\n        return { extensions,\n            extensionsEnv,\n            editorFocus };\n    },\n    yamlDoc: null,  // For keeping the yaml comments\n    data() {\n        return {\n            jsonConfig: {},\n            envsubstJSONConfig: {},\n            yamlError: \"\",\n            processing: true,\n            showProgressTerminal: false,\n            progressTerminalRows: PROGRESS_TERMINAL_ROWS,\n            combinedTerminalRows: COMBINED_TERMINAL_ROWS,\n            combinedTerminalCols: COMBINED_TERMINAL_COLS,\n            stack: {\n\n            },\n            serviceStatusList: {},\n            isEditMode: false,\n            submitted: false,\n            showDeleteDialog: false,\n            newContainerName: \"\",\n            stopServiceStatusTimeout: false,\n        };\n    },\n    computed: {\n        endpointDisplay() {\n            return this.$root.endpointDisplayFunction(this.endpoint);\n        },\n\n        urls() {\n            if (!this.envsubstJSONConfig[\"x-dockge\"] || !this.envsubstJSONConfig[\"x-dockge\"].urls || !Array.isArray(this.envsubstJSONConfig[\"x-dockge\"].urls)) {\n                return [];\n            }\n\n            let urls = [];\n            for (const url of this.envsubstJSONConfig[\"x-dockge\"].urls) {\n                let display;\n                try {\n                    let obj = new URL(url);\n                    let pathname = obj.pathname;\n                    if (pathname === \"/\") {\n                        pathname = \"\";\n                    }\n                    display = obj.host + pathname + obj.search;\n                } catch (e) {\n                    display = url;\n                }\n\n                urls.push({\n                    display,\n                    url,\n                });\n            }\n            return urls;\n        },\n\n        isAdd() {\n            return this.$route.path === \"/compose\" && !this.submitted;\n        },\n\n        /**\n         * Get the stack from the global stack list, because it may contain more real-time data like status\n         * @return {*}\n         */\n        globalStack() {\n            return this.$root.completeStackList[this.stack.name + \"_\" + this.endpoint];\n        },\n\n        status() {\n            return this.globalStack?.status;\n        },\n\n        active() {\n            return this.status === RUNNING;\n        },\n\n        terminalName() {\n            if (!this.stack.name) {\n                return \"\";\n            }\n            return getComposeTerminalName(this.endpoint, this.stack.name);\n        },\n\n        combinedTerminalName() {\n            if (!this.stack.name) {\n                return \"\";\n            }\n            return getCombinedTerminalName(this.endpoint, this.stack.name);\n        },\n\n        networks() {\n            return this.jsonConfig.networks;\n        },\n\n        endpoint() {\n            return this.stack.endpoint || this.$route.params.endpoint || \"\";\n        },\n\n        url() {\n            if (this.stack.endpoint) {\n                return `/compose/${this.stack.name}/${this.stack.endpoint}`;\n            } else {\n                return `/compose/${this.stack.name}`;\n            }\n        },\n    },\n    watch: {\n        \"stack.composeYAML\": {\n            handler() {\n                if (this.editorFocus) {\n                    console.debug(\"yaml code changed\");\n                    this.yamlCodeChange();\n                }\n            },\n            deep: true,\n        },\n\n        \"stack.composeENV\": {\n            handler() {\n                if (this.editorFocus) {\n                    console.debug(\"env code changed\");\n                    this.yamlCodeChange();\n                }\n            },\n            deep: true,\n        },\n\n        jsonConfig: {\n            handler() {\n                if (!this.editorFocus) {\n                    console.debug(\"jsonConfig changed\");\n\n                    let doc = new Document(this.jsonConfig);\n\n                    // Stick back the yaml comments\n                    if (this.yamlDoc) {\n                        copyYAMLComments(doc, this.yamlDoc);\n                    }\n\n                    this.stack.composeYAML = doc.toString();\n                    this.yamlDoc = doc;\n                }\n            },\n            deep: true,\n        },\n\n        $route(to, from) {\n\n        }\n    },\n    mounted() {\n        if (this.isAdd) {\n            this.processing = false;\n            this.isEditMode = true;\n\n            let composeYAML;\n            let composeENV;\n\n            if (this.$root.composeTemplate) {\n                composeYAML = this.$root.composeTemplate;\n                this.$root.composeTemplate = \"\";\n            } else {\n                composeYAML = template;\n            }\n            if (this.$root.envTemplate) {\n                composeENV = this.$root.envTemplate;\n                this.$root.envTemplate = \"\";\n            } else {\n                composeENV = envDefault;\n            }\n\n            // Default Values\n            this.stack = {\n                name: \"\",\n                composeYAML,\n                composeENV,\n                isManagedByDockge: true,\n                endpoint: \"\",\n            };\n\n            this.yamlCodeChange();\n\n        } else {\n            this.stack.name = this.$route.params.stackName;\n            this.loadStack();\n        }\n\n        this.requestServiceStatus();\n    },\n    unmounted() {\n\n    },\n    methods: {\n        startServiceStatusTimeout() {\n            clearTimeout(serviceStatusTimeout);\n            serviceStatusTimeout = setTimeout(async () => {\n                this.requestServiceStatus();\n            }, 5000);\n        },\n\n        requestServiceStatus() {\n            // Do not request if it is add mode\n            if (this.isAdd) {\n                return;\n            }\n\n            this.$root.emitAgent(this.endpoint, \"serviceStatusList\", this.stack.name, (res) => {\n                if (res.ok) {\n                    this.serviceStatusList = res.serviceStatusList;\n                }\n                if (!this.stopServiceStatusTimeout) {\n                    this.startServiceStatusTimeout();\n                }\n            });\n        },\n\n        exitConfirm(next) {\n            if (this.isEditMode) {\n                if (confirm(this.$t(\"confirmLeaveStack\"))) {\n                    this.exitAction();\n                    next();\n                } else {\n                    next(false);\n                }\n            } else {\n                this.exitAction();\n                next();\n            }\n        },\n\n        exitAction() {\n            console.log(\"exitAction\");\n            this.stopServiceStatusTimeout = true;\n            clearTimeout(serviceStatusTimeout);\n\n            // Leave Combined Terminal\n            console.debug(\"leaveCombinedTerminal\", this.endpoint, this.stack.name);\n            this.$root.emitAgent(this.endpoint, \"leaveCombinedTerminal\", this.stack.name, () => {});\n        },\n\n        bindTerminal() {\n            this.$refs.progressTerminal?.bind(this.endpoint, this.terminalName);\n        },\n\n        loadStack() {\n            this.processing = true;\n            this.$root.emitAgent(this.endpoint, \"getStack\", this.stack.name, (res) => {\n                if (res.ok) {\n                    this.stack = res.stack;\n                    this.yamlCodeChange();\n                    this.processing = false;\n                    this.bindTerminal();\n                } else {\n                    this.$root.toastRes(res);\n                }\n            });\n        },\n\n        deployStack() {\n            this.processing = true;\n\n            if (!this.jsonConfig.services) {\n                this.$root.toastError(\"No services found in compose.yaml\");\n                this.processing = false;\n                return;\n            }\n\n            // Check if services is object\n            if (typeof this.jsonConfig.services !== \"object\") {\n                this.$root.toastError(\"Services must be an object\");\n                this.processing = false;\n                return;\n            }\n\n            let serviceNameList = Object.keys(this.jsonConfig.services);\n\n            // Set the stack name if empty, use the first container name\n            if (!this.stack.name && serviceNameList.length > 0) {\n                let serviceName = serviceNameList[0];\n                let service = this.jsonConfig.services[serviceName];\n\n                if (service && service.container_name) {\n                    this.stack.name = service.container_name;\n                } else {\n                    this.stack.name = serviceName;\n                }\n            }\n\n            this.bindTerminal();\n\n            this.$root.emitAgent(this.stack.endpoint, \"deployStack\", this.stack.name, this.stack.composeYAML, this.stack.composeENV, this.isAdd, (res) => {\n                this.processing = false;\n                this.$root.toastRes(res);\n\n                if (res.ok) {\n                    this.isEditMode = false;\n                    this.$router.push(this.url);\n                }\n            });\n        },\n\n        saveStack() {\n            this.processing = true;\n\n            this.$root.emitAgent(this.stack.endpoint, \"saveStack\", this.stack.name, this.stack.composeYAML, this.stack.composeENV, this.isAdd, (res) => {\n                this.processing = false;\n                this.$root.toastRes(res);\n\n                if (res.ok) {\n                    this.isEditMode = false;\n                    this.$router.push(this.url);\n                }\n            });\n        },\n\n        startStack() {\n            this.processing = true;\n\n            this.$root.emitAgent(this.endpoint, \"startStack\", this.stack.name, (res) => {\n                this.processing = false;\n                this.$root.toastRes(res);\n            });\n        },\n\n        stopStack() {\n            this.processing = true;\n\n            this.$root.emitAgent(this.endpoint, \"stopStack\", this.stack.name, (res) => {\n                this.processing = false;\n                this.$root.toastRes(res);\n            });\n        },\n\n        downStack() {\n            this.processing = true;\n\n            this.$root.emitAgent(this.endpoint, \"downStack\", this.stack.name, (res) => {\n                this.processing = false;\n                this.$root.toastRes(res);\n            });\n        },\n\n        restartStack() {\n            this.processing = true;\n\n            this.$root.emitAgent(this.endpoint, \"restartStack\", this.stack.name, (res) => {\n                this.processing = false;\n                this.$root.toastRes(res);\n            });\n        },\n\n        updateStack() {\n            this.processing = true;\n\n            this.$root.emitAgent(this.endpoint, \"updateStack\", this.stack.name, (res) => {\n                this.processing = false;\n                this.$root.toastRes(res);\n            });\n        },\n\n        deleteDialog() {\n            this.$root.emitAgent(this.endpoint, \"deleteStack\", this.stack.name, (res) => {\n                this.$root.toastRes(res);\n                if (res.ok) {\n                    this.$router.push(\"/\");\n                }\n            });\n        },\n\n        discardStack() {\n            this.loadStack();\n            this.isEditMode = false;\n        },\n\n        yamlToJSON(yaml) {\n            let doc = parseDocument(yaml);\n            if (doc.errors.length > 0) {\n                throw doc.errors[0];\n            }\n\n            const config = doc.toJS() ?? {};\n\n            // Check data types\n            // \"services\" must be an object\n            if (!config.services) {\n                config.services = {};\n            }\n\n            if (Array.isArray(config.services) || typeof config.services !== \"object\") {\n                throw new Error(\"Services must be an object\");\n            }\n\n            return {\n                config,\n                doc,\n            };\n        },\n\n        yamlCodeChange() {\n            try {\n                let { config, doc } = this.yamlToJSON(this.stack.composeYAML);\n\n                this.yamlDoc = doc;\n                this.jsonConfig = config;\n\n                let env = dotenv.parse(this.stack.composeENV);\n                let envYAML = envsubstYAML(this.stack.composeYAML, env);\n                this.envsubstJSONConfig = this.yamlToJSON(envYAML).config;\n\n                clearTimeout(yamlErrorTimeout);\n                this.yamlError = \"\";\n            } catch (e) {\n                clearTimeout(yamlErrorTimeout);\n\n                if (this.yamlError) {\n                    this.yamlError = e.message;\n\n                } else {\n                    yamlErrorTimeout = setTimeout(() => {\n                        this.yamlError = e.message;\n                    }, 3000);\n                }\n            }\n        },\n\n        enableEditMode() {\n            this.isEditMode = true;\n        },\n\n        checkYAML() {\n\n        },\n\n        addContainer() {\n            this.checkYAML();\n\n            if (this.jsonConfig.services[this.newContainerName]) {\n                this.$root.toastError(\"Container name already exists\");\n                return;\n            }\n\n            if (!this.newContainerName) {\n                this.$root.toastError(\"Container name cannot be empty\");\n                return;\n            }\n\n            this.jsonConfig.services[this.newContainerName] = {\n                restart: \"unless-stopped\",\n            };\n            this.newContainerName = \"\";\n            let element = this.$refs.containerList.lastElementChild;\n            element.scrollIntoView({\n                block: \"start\",\n                behavior: \"smooth\"\n            });\n        },\n\n        stackNameToLowercase() {\n            this.stack.name = this.stack?.name?.toLowerCase();\n        },\n\n    }\n};\n</script>\n\n<style scoped lang=\"scss\">\n@import \"../styles/vars.scss\";\n\n.terminal {\n    height: 200px;\n}\n\n.editor-box {\n    font-family: 'JetBrains Mono', monospace;\n    font-size: 14px;\n}\n\n.agent-name {\n    font-size: 13px;\n    color: $dark-font-color3;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/pages/Console.vue",
    "content": "<template>\n    <transition name=\"slide-fade\" appear>\n        <div v-if=\"!processing\">\n            <h1 class=\"mb-3\">{{ $t(\"console\") }}</h1>\n\n            <Terminal v-if=\"enableConsole\" class=\"terminal\" :rows=\"20\" mode=\"mainTerminal\" name=\"console\" :endpoint=\"endpoint\"></Terminal>\n\n            <div v-else class=\"alert alert-warning shadow-box\" role=\"alert\">\n                <h4 class=\"alert-heading\">{{ $t(\"Console is not enabled\") }}</h4>\n                <p v-html=\"$t('ConsoleNotEnabledMSG1')\"></p>\n                <p v-html=\"$t('ConsoleNotEnabledMSG2')\"></p>\n                <p v-html=\"$t('ConsoleNotEnabledMSG3')\"></p>\n            </div>\n        </div>\n    </transition>\n</template>\n\n<script>\nexport default {\n    components: {\n    },\n    data() {\n        return {\n            processing: true,\n            enableConsole: false,\n        };\n    },\n    computed: {\n        endpoint() {\n            return this.$route.params.endpoint || \"\";\n        },\n    },\n    mounted() {\n        this.$root.emitAgent(this.endpoint, \"checkMainTerminal\", (res) => {\n            this.enableConsole = res.ok;\n            this.processing = false;\n        });\n    },\n    methods: {\n        \n    }\n};\n</script>\n\n<style scoped lang=\"scss\">\n.terminal {\n    height: 410px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/pages/ContainerTerminal.vue",
    "content": "<template>\n    <transition name=\"slide-fade\" appear>\n        <div>\n            <h1 class=\"mb-3\">{{$t(\"terminal\")}} - {{ serviceName }} ({{ stackName }})</h1>\n\n            <div class=\"mb-3\">\n                <router-link :to=\"sh\" class=\"btn btn-normal me-2\">{{ $t(\"Switch to sh\") }}</router-link>\n            </div>\n\n            <Terminal class=\"terminal\" :rows=\"20\" mode=\"interactive\" :name=\"terminalName\" :stack-name=\"stackName\" :service-name=\"serviceName\" :shell=\"shell\" :endpoint=\"endpoint\"></Terminal>\n        </div>\n    </transition>\n</template>\n\n<script>\nimport { getContainerExecTerminalName } from \"../../../common/util-common\";\n\nexport default {\n    components: {\n    },\n    data() {\n        return {\n\n        };\n    },\n    computed: {\n        stackName() {\n            return this.$route.params.stackName;\n        },\n        endpoint() {\n            return this.$route.params.endpoint || \"\";\n        },\n        shell() {\n            return this.$route.params.type;\n        },\n        serviceName() {\n            return this.$route.params.serviceName;\n        },\n        terminalName() {\n            return getContainerExecTerminalName(this.endpoint, this.stackName, this.serviceName, 0);\n        },\n        sh() {\n            let endpoint = this.$route.params.endpoint;\n\n            let data = {\n                name: \"containerTerminal\",\n                params: {\n                    stackName: this.stackName,\n                    serviceName: this.serviceName,\n                    type: \"sh\",\n                },\n            };\n\n            if (endpoint) {\n                data.name = \"containerTerminalEndpoint\";\n                data.params.endpoint = endpoint;\n            }\n\n            return data;\n        },\n    },\n    mounted() {\n\n    },\n    methods: {\n\n    }\n};\n</script>\n\n<style scoped lang=\"scss\">\n.terminal {\n    height: 410px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/pages/Dashboard.vue",
    "content": "<template>\n    <div class=\"container-fluid\">\n        <div class=\"row\">\n            <div v-if=\"!$root.isMobile\" class=\"col-12 col-md-4 col-xl-3\">\n                <div>\n                    <router-link to=\"/compose\" class=\"btn btn-primary mb-3\"><font-awesome-icon icon=\"plus\" /> {{ $t(\"compose\") }}</router-link>\n                </div>\n                <StackList :scrollbar=\"true\" />\n            </div>\n\n            <div ref=\"container\" class=\"col-12 col-md-8 col-xl-9 mb-3\">\n                <!-- Add :key to disable vue router re-use the same component -->\n                <router-view :key=\"$route.fullPath\" :calculatedHeight=\"height\" />\n            </div>\n        </div>\n    </div>\n</template>\n\n<script>\n\nimport StackList from \"../components/StackList.vue\";\n\nexport default {\n    components: {\n        StackList,\n    },\n    data() {\n        return {\n            height: 0\n        };\n    },\n    mounted() {\n        this.height = this.$refs.container.offsetHeight;\n    },\n};\n</script>\n\n<style lang=\"scss\" scoped>\n.container-fluid {\n    width: 98%;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/pages/DashboardHome.vue",
    "content": "<template>\n    <transition ref=\"tableContainer\" name=\"slide-fade\" appear>\n        <div v-if=\"$route.name === 'DashboardHome'\">\n            <h1 class=\"mb-3\">\n                {{ $t(\"home\") }}\n            </h1>\n\n            <div class=\"row first-row\">\n                <!-- Left -->\n                <div class=\"col-md-7\">\n                    <!-- Stats -->\n                    <div class=\"shadow-box big-padding text-center mb-4\">\n                        <div class=\"row\">\n                            <div class=\"col\">\n                                <h3>{{ $t(\"active\") }}</h3>\n                                <span class=\"num active\">{{ activeNum }}</span>\n                            </div>\n                            <div class=\"col\">\n                                <h3>{{ $t(\"exited\") }}</h3>\n                                <span class=\"num exited\">{{ exitedNum }}</span>\n                            </div>\n                            <div class=\"col\">\n                                <h3>{{ $t(\"inactive\") }}</h3>\n                                <span class=\"num inactive\">{{ inactiveNum }}</span>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Docker Run -->\n                    <h2 class=\"mb-3\">{{ $t(\"Docker Run\") }}</h2>\n                    <div class=\"mb-3\">\n                        <textarea id=\"name\" v-model=\"dockerRunCommand\" type=\"text\" class=\"form-control docker-run shadow-box\" required placeholder=\"docker run ...\"></textarea>\n                    </div>\n\n                    <button class=\"btn-normal btn mb-4\" @click=\"convertDockerRun\">{{ $t(\"Convert to Compose\") }}</button>\n                </div>\n                <!-- Right -->\n                <div class=\"col-md-5\">\n                    <!-- Agent List -->\n                    <div class=\"shadow-box big-padding\">\n                        <h4 class=\"mb-3\">{{ $tc(\"dockgeAgent\", 2) }} <span class=\"badge bg-warning\" style=\"font-size: 12px;\">beta</span></h4>\n\n                        <div v-for=\"(agent, endpoint) in $root.agentList\" :key=\"endpoint\" class=\"mb-3 agent\">\n                            <!-- Agent Status -->\n                            <template v-if=\"$root.agentStatusList[endpoint]\">\n                                <span v-if=\"$root.agentStatusList[endpoint] === 'online'\" class=\"badge bg-primary me-2\">{{ $t(\"agentOnline\") }}</span>\n                                <span v-else-if=\"$root.agentStatusList[endpoint] === 'offline'\" class=\"badge bg-danger me-2\">{{ $t(\"agentOffline\") }}</span>\n                                <span v-else class=\"badge bg-secondary me-2\">{{ $t($root.agentStatusList[endpoint]) }}</span>\n                            </template>\n\n                            <!-- Agent Display Name -->\n                            <span v-if=\"endpoint === ''\">{{ $t(\"currentEndpoint\") }}</span>\n                            <a v-else :href=\"agent.url\" target=\"_blank\">{{ endpoint }}</a>\n\n                            <!-- Remove Button -->\n                            <font-awesome-icon v-if=\"endpoint !== ''\" class=\"ms-2 remove-agent\" icon=\"trash\" @click=\"showRemoveAgentDialog[agent.url] = !showRemoveAgentDialog[agent.url]\" />\n\n                            <!-- Remoe Agent Dialog -->\n                            <BModal v-model=\"showRemoveAgentDialog[agent.url]\" :okTitle=\"$t('removeAgent')\" okVariant=\"danger\" @ok=\"removeAgent(agent.url)\">\n                                <p>{{ agent.url }}</p>\n                                {{ $t(\"removeAgentMsg\") }}\n                            </BModal>\n                        </div>\n\n                        <button v-if=\"!showAgentForm\" class=\"btn btn-normal\" @click=\"showAgentForm = !showAgentForm\">{{ $t(\"addAgent\") }}</button>\n\n                        <!-- Add Agent Form -->\n                        <form v-if=\"showAgentForm\" @submit.prevent=\"addAgent\">\n                            <div class=\"mb-3\">\n                                <label for=\"url\" class=\"form-label\">{{ $t(\"dockgeURL\") }}</label>\n                                <input id=\"url\" v-model=\"agent.url\" type=\"url\" class=\"form-control\" required placeholder=\"http://\">\n                            </div>\n\n                            <div class=\"mb-3\">\n                                <label for=\"username\" class=\"form-label\">{{ $t(\"Username\") }}</label>\n                                <input id=\"username\" v-model=\"agent.username\" type=\"text\" class=\"form-control\" required>\n                            </div>\n\n                            <div class=\"mb-3\">\n                                <label for=\"password\" class=\"form-label\">{{ $t(\"Password\") }}</label>\n                                <input id=\"password\" v-model=\"agent.password\" type=\"password\" class=\"form-control\" required autocomplete=\"new-password\">\n                            </div>\n\n                            <button type=\"submit\" class=\"btn btn-primary\" :disabled=\"connectingAgent\">\n                                <template v-if=\"connectingAgent\">{{ $t(\"connecting\") }}</template>\n                                <template v-else>{{ $t(\"connect\") }}</template>\n                            </button>\n                        </form>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </transition>\n    <router-view ref=\"child\" />\n</template>\n\n<script>\nimport { statusNameShort } from \"../../../common/util-common\";\n\nexport default {\n    components: {\n\n    },\n    props: {\n        calculatedHeight: {\n            type: Number,\n            default: 0\n        }\n    },\n    data() {\n        return {\n            page: 1,\n            perPage: 25,\n            initialPerPage: 25,\n            paginationConfig: {\n                hideCount: true,\n                chunksNavigation: \"scroll\",\n            },\n            importantHeartBeatListLength: 0,\n            displayedRecords: [],\n            dockerRunCommand: \"\",\n            showAgentForm: false,\n            showRemoveAgentDialog: {},\n            connectingAgent: false,\n            agent: {\n                url: \"http://\",\n                username: \"\",\n                password: \"\",\n            }\n        };\n    },\n\n    computed: {\n        activeNum() {\n            return this.getStatusNum(\"active\");\n        },\n        inactiveNum() {\n            return this.getStatusNum(\"inactive\");\n        },\n        exitedNum() {\n            return this.getStatusNum(\"exited\");\n        },\n    },\n\n    watch: {\n        perPage() {\n            this.$nextTick(() => {\n                this.getImportantHeartbeatListPaged();\n            });\n        },\n\n        page() {\n            this.getImportantHeartbeatListPaged();\n        },\n    },\n\n    mounted() {\n        this.initialPerPage = this.perPage;\n\n        window.addEventListener(\"resize\", this.updatePerPage);\n        this.updatePerPage();\n    },\n\n    beforeUnmount() {\n        window.removeEventListener(\"resize\", this.updatePerPage);\n    },\n\n    methods: {\n\n        addAgent() {\n            this.connectingAgent = true;\n            this.$root.getSocket().emit(\"addAgent\", this.agent, (res) => {\n                this.$root.toastRes(res);\n\n                if (res.ok) {\n                    this.showAgentForm = false;\n                    this.agent = {\n                        url: \"http://\",\n                        username: \"\",\n                        password: \"\",\n                    };\n                }\n\n                this.connectingAgent = false;\n            });\n        },\n\n        removeAgent(url) {\n            this.$root.getSocket().emit(\"removeAgent\", url, (res) => {\n                if (res.ok) {\n                    this.$root.toastRes(res);\n\n                    let urlObj = new URL(url);\n                    let endpoint = urlObj.host;\n\n                    // Remove the stack list and status list of the removed agent\n                    delete this.$root.allAgentStackList[endpoint];\n                }\n            });\n        },\n\n        getStatusNum(statusName) {\n            let num = 0;\n\n            for (let stackName in this.$root.completeStackList) {\n                const stack = this.$root.completeStackList[stackName];\n                if (statusNameShort(stack.status) === statusName) {\n                    num += 1;\n                }\n            }\n            return num;\n        },\n\n        convertDockerRun() {\n            if (this.dockerRunCommand.trim() === \"docker run\") {\n                throw new Error(\"Please enter a docker run command\");\n            }\n\n            // composerize is working in dev, but after \"vite build\", it is not working\n            // So pass to backend to do the conversion\n            this.$root.getSocket().emit(\"composerize\", this.dockerRunCommand, (res) => {\n                if (res.ok) {\n                    this.$root.composeTemplate = res.composeTemplate;\n                    this.$router.push(\"/compose\");\n                } else {\n                    this.$root.toastRes(res);\n                }\n            });\n        },\n\n        /**\n         * Updates the displayed records when a new important heartbeat arrives.\n         * @param {object} heartbeat - The heartbeat object received.\n         * @returns {void}\n         */\n        onNewImportantHeartbeat(heartbeat) {\n            if (this.page === 1) {\n                this.displayedRecords.unshift(heartbeat);\n                if (this.displayedRecords.length > this.perPage) {\n                    this.displayedRecords.pop();\n                }\n                this.importantHeartBeatListLength += 1;\n            }\n        },\n\n        /**\n         * Retrieves the length of the important heartbeat list for all monitors.\n         * @returns {void}\n         */\n        getImportantHeartbeatListLength() {\n            this.$root.getSocket().emit(\"monitorImportantHeartbeatListCount\", null, (res) => {\n                if (res.ok) {\n                    this.importantHeartBeatListLength = res.count;\n                    this.getImportantHeartbeatListPaged();\n                }\n            });\n        },\n\n        /**\n         * Retrieves the important heartbeat list for the current page.\n         * @returns {void}\n         */\n        getImportantHeartbeatListPaged() {\n            const offset = (this.page - 1) * this.perPage;\n            this.$root.getSocket().emit(\"monitorImportantHeartbeatListPaged\", null, offset, this.perPage, (res) => {\n                if (res.ok) {\n                    this.displayedRecords = res.data;\n                }\n            });\n        },\n\n        /**\n         * Updates the number of items shown per page based on the available height.\n         * @returns {void}\n         */\n        updatePerPage() {\n            const tableContainer = this.$refs.tableContainer;\n            const tableContainerHeight = tableContainer.offsetHeight;\n            const availableHeight = window.innerHeight - tableContainerHeight;\n            const additionalPerPage = Math.floor(availableHeight / 58);\n\n            if (additionalPerPage > 0) {\n                this.perPage = Math.max(this.initialPerPage, this.perPage + additionalPerPage);\n            } else {\n                this.perPage = this.initialPerPage;\n            }\n\n        },\n    },\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../styles/vars\";\n\n.num {\n    font-size: 30px;\n\n    font-weight: bold;\n    display: block;\n\n    &.active {\n        color: $primary;\n    }\n\n    &.exited {\n        color: $danger;\n    }\n}\n\n.shadow-box {\n    padding: 20px;\n}\n\ntable {\n    font-size: 14px;\n\n    tr {\n        transition: all ease-in-out 0.2ms;\n    }\n\n    @media (max-width: 550px) {\n        table-layout: fixed;\n        overflow-wrap: break-word;\n    }\n}\n\n.docker-run {\n    border: none;\n    font-family: 'JetBrains Mono', monospace;\n    font-size: 15px;\n}\n\n.first-row .shadow-box {\n\n}\n\n.remove-agent {\n    cursor: pointer;\n    color: rgba(255, 255, 255, 0.3);\n}\n\n.agent {\n    a {\n        text-decoration: none;\n    }\n}\n\n</style>\n"
  },
  {
    "path": "frontend/src/pages/Settings.vue",
    "content": "<template>\n    <div>\n        <h1 v-show=\"show\" class=\"mb-3\">\n            {{ $t(\"Settings\") }}\n        </h1>\n\n        <div class=\"shadow-box shadow-box-settings\">\n            <div class=\"row\">\n                <div v-if=\"showSubMenu\" class=\"settings-menu col-lg-3 col-md-5\">\n                    <router-link\n                        v-for=\"(item, key) in subMenus\"\n                        :key=\"key\"\n                        :to=\"`/settings/${key}`\"\n                    >\n                        <div class=\"menu-item\">\n                            {{ item.title }}\n                        </div>\n                    </router-link>\n\n                    <!-- Logout Button -->\n                    <a v-if=\"$root.isMobile && $root.loggedIn && $root.socket.token !== 'autoLogin'\" class=\"logout\" @click.prevent=\"$root.logout\">\n                        <div class=\"menu-item\">\n                            <font-awesome-icon icon=\"sign-out-alt\" />\n                            {{ $t(\"Logout\") }}\n                        </div>\n                    </a>\n                </div>\n                <div class=\"settings-content col-lg-9 col-md-7\">\n                    <div v-if=\"currentPage\" class=\"settings-content-header\">\n                        {{ subMenus[currentPage].title }}\n                    </div>\n                    <div class=\"mx-3\">\n                        <router-view v-slot=\"{ Component }\">\n                            <transition name=\"slide-fade\" appear>\n                                <component :is=\"Component\" />\n                            </transition>\n                        </router-view>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</template>\n\n<script>\nimport { useRoute } from \"vue-router\";\n\nexport default {\n    data() {\n        return {\n            show: true,\n            settings: {},\n            settingsLoaded: false,\n        };\n    },\n\n    computed: {\n        currentPage() {\n            let pathSplit = useRoute().path.split(\"/\");\n            let pathEnd = pathSplit[pathSplit.length - 1];\n            if (!pathEnd || pathEnd === \"settings\") {\n                return null;\n            }\n            return pathEnd;\n        },\n\n        showSubMenu() {\n            if (this.$root.isMobile) {\n                return !this.currentPage;\n            } else {\n                return true;\n            }\n        },\n\n        subMenus() {\n            return {\n                general: {\n                    title: this.$t(\"general\"),\n                },\n                appearance: {\n                    title: this.$t(\"Appearance\"),\n                },\n                security: {\n                    title: this.$t(\"Security\"),\n                },\n                globalEnv: {\n                    title: this.$t(\"GlobalEnv\"),\n                },\n                about: {\n                    title: this.$t(\"About\"),\n                },\n            };\n        },\n    },\n\n    watch: {\n        \"$root.isMobile\"() {\n            this.loadGeneralPage();\n        }\n    },\n\n    mounted() {\n        this.loadSettings();\n        this.loadGeneralPage();\n    },\n\n    methods: {\n\n        /**\n         * Load the general settings page\n         * For desktop only, on mobile do nothing\n         */\n        loadGeneralPage() {\n            if (!this.currentPage && !this.$root.isMobile) {\n                this.$router.push(\"/settings/appearance\");\n            }\n        },\n\n        /** Load settings from server */\n        loadSettings() {\n            this.$root.getSocket().emit(\"getSettings\", (res) => {\n                this.settings = res.data;\n                if (this.settings.checkUpdate === undefined) {\n                    this.settings.checkUpdate = true;\n                }\n                this.settingsLoaded = true;\n            });\n        },\n\n        /**\n         * Callback for saving settings\n         * @callback saveSettingsCB\n         * @param {Object} res Result of operation\n         */\n\n        /**\n         * Save Settings\n         * @param {saveSettingsCB} [callback]\n         * @param {string} [currentPassword] Only need for disableAuth to true\n         */\n        saveSettings(callback, currentPassword) {\n            let valid = this.validateSettings();\n            if (valid.success) {\n                this.$root.getSocket().emit(\"setSettings\", this.settings, currentPassword, (res) => {\n                    this.$root.toastRes(res);\n                    this.loadSettings();\n\n                    if (callback) {\n                        callback();\n                    }\n                });\n            } else {\n                this.$root.toastError(valid.msg);\n            }\n        },\n\n        /**\n         * Ensure settings are valid\n         * @returns {Object} Contains success state and error msg\n         */\n        validateSettings() {\n            if (this.settings.keepDataPeriodDays < 0) {\n                return {\n                    success: false,\n                    msg: this.$t(\"dataRetentionTimeError\"),\n                };\n            }\n            return {\n                success: true,\n                msg: \"\",\n            };\n        },\n    }\n};\n</script>\n\n<style lang=\"scss\" scoped>\n@import \"../styles/vars.scss\";\n\n.shadow-box-settings {\n    padding: 20px;\n    min-height: calc(100vh - 155px);\n}\n\nfooter {\n    color: #aaa;\n    font-size: 13px;\n    margin-top: 20px;\n    padding-bottom: 30px;\n    text-align: center;\n}\n\n.settings-menu {\n    a {\n        text-decoration: none !important;\n    }\n\n    .menu-item {\n        border-radius: 10px;\n        margin: 0.5em;\n        padding: 0.7em 1em;\n        cursor: pointer;\n        border-left-width: 0;\n        transition: all ease-in-out 0.1s;\n    }\n\n    .menu-item:hover {\n        background: $highlight-white;\n\n        .dark & {\n            background: $dark-header-bg;\n        }\n    }\n\n    .active .menu-item {\n        background: $highlight-white;\n        border-left: 4px solid $primary;\n        border-top-left-radius: 0;\n        border-bottom-left-radius: 0;\n\n        .dark & {\n            background: $dark-header-bg;\n        }\n    }\n}\n\n.settings-content {\n    .settings-content-header {\n        width: calc(100% + 20px);\n        border-bottom: 1px solid #dee2e6;\n        border-radius: 0 10px 0 0;\n        margin-top: -20px;\n        margin-right: -20px;\n        padding: 12.5px 1em;\n        font-size: 26px;\n\n        .dark & {\n            background: $dark-header-bg;\n            border-bottom: 0;\n        }\n\n        .mobile & {\n            padding: 15px 0 0 0;\n\n            .dark & {\n                background-color: transparent;\n            }\n        }\n    }\n}\n\n.logout {\n    color: $danger !important;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/pages/Setup.vue",
    "content": "<template>\n    <div class=\"form-container\" data-cy=\"setup-form\">\n        <div class=\"form\">\n            <form @submit.prevent=\"submit\">\n                <div>\n                    <object width=\"64\" height=\"64\" data=\"/icon.svg\" />\n                    <div style=\"font-size: 28px; font-weight: bold; margin-top: 5px;\">\n                        Dockge\n                    </div>\n                </div>\n\n                <p class=\"mt-3\">\n                    {{ $t(\"Create your admin account\") }}\n                </p>\n\n                <div class=\"form-floating\">\n                    <select id=\"language\" v-model=\"$root.language\" class=\"form-select\">\n                        <option v-for=\"(lang, i) in $i18n.availableLocales\" :key=\"`Lang${i}`\" :value=\"lang\">\n                            {{ $i18n.messages[lang].languageName }}\n                        </option>\n                    </select>\n                    <label for=\"language\" class=\"form-label\">{{ $t(\"Language\") }}</label>\n                </div>\n\n                <div class=\"form-floating mt-3\">\n                    <input id=\"floatingInput\" v-model=\"username\" type=\"text\" class=\"form-control\" :placeholder=\"$t('Username')\" required data-cy=\"username-input\">\n                    <label for=\"floatingInput\">{{ $t(\"Username\") }}</label>\n                </div>\n\n                <div class=\"form-floating mt-3\">\n                    <input id=\"floatingPassword\" v-model=\"password\" type=\"password\" class=\"form-control\" :placeholder=\"$t('Password')\" required data-cy=\"password-input\">\n                    <label for=\"floatingPassword\">{{ $t(\"Password\") }}</label>\n                </div>\n\n                <div class=\"form-floating mt-3\">\n                    <input id=\"repeat\" v-model=\"repeatPassword\" type=\"password\" class=\"form-control\" :placeholder=\"$t('Repeat Password')\" required data-cy=\"password-repeat-input\">\n                    <label for=\"repeat\">{{ $t(\"Repeat Password\") }}</label>\n                </div>\n\n                <button class=\"w-100 btn btn-primary mt-3\" type=\"submit\" :disabled=\"processing\" data-cy=\"submit-setup-form\">\n                    {{ $t(\"Create\") }}\n                </button>\n            </form>\n        </div>\n    </div>\n</template>\n\n<script>\nexport default {\n    data() {\n        return {\n            processing: false,\n            username: \"\",\n            password: \"\",\n            repeatPassword: \"\",\n        };\n    },\n    watch: {\n\n    },\n    mounted() {\n        // TODO: Check if it is a database setup\n\n        this.$root.getSocket().emit(\"needSetup\", (needSetup) => {\n            if (! needSetup) {\n                this.$router.push(\"/\");\n            }\n        });\n    },\n    methods: {\n        /**\n         * Submit form data for processing\n         * @returns {void}\n         */\n        submit() {\n            this.processing = true;\n\n            if (this.password !== this.repeatPassword) {\n                this.$root.toastError(\"PasswordsDoNotMatch\");\n                this.processing = false;\n                return;\n            }\n\n            this.$root.getSocket().emit(\"setup\", this.username, this.password, (res) => {\n                this.processing = false;\n                this.$root.toastRes(res);\n\n                if (res.ok) {\n                    this.processing = true;\n\n                    this.$root.login(this.username, this.password, \"\", () => {\n                        this.processing = false;\n                        this.$router.push(\"/\");\n                    });\n                }\n            });\n        },\n    },\n};\n</script>\n\n<style lang=\"scss\" scoped>\n.form-container {\n    display: flex;\n    align-items: center;\n    padding-top: 40px;\n    padding-bottom: 40px;\n}\n\n.form-floating {\n    > .form-select {\n        padding-left: 1.3rem;\n        padding-top: 1.525rem;\n        line-height: 1.35;\n\n        ~ label {\n            padding-left: 1.3rem;\n        }\n    }\n\n    > label {\n        padding-left: 1.3rem;\n    }\n\n    > .form-control {\n        padding-left: 1.3rem;\n    }\n}\n\n.form {\n\n    width: 100%;\n    max-width: 330px;\n    padding: 15px;\n    margin: auto;\n    text-align: center;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/router.ts",
    "content": "import { createRouter, createWebHistory } from \"vue-router\";\n\nimport Layout from \"./layouts/Layout.vue\";\nimport Setup from \"./pages/Setup.vue\";\nimport Dashboard from \"./pages/Dashboard.vue\";\nimport DashboardHome from \"./pages/DashboardHome.vue\";\nimport Console from \"./pages/Console.vue\";\nimport Compose from \"./pages/Compose.vue\";\nimport ContainerTerminal from \"./pages/ContainerTerminal.vue\";\n\nconst Settings = () => import(\"./pages/Settings.vue\");\n\n// Settings - Sub Pages\nimport Appearance from \"./components/settings/Appearance.vue\";\nimport General from \"./components/settings/General.vue\";\nconst Security = () => import(\"./components/settings/Security.vue\");\nconst GlobalEnv = () => import(\"./components/settings/GlobalEnv.vue\");\nimport About from \"./components/settings/About.vue\";\n\nconst routes = [\n    {\n        path: \"/empty\",\n        component: Layout,\n        children: [\n            {\n                path: \"\",\n                component: Dashboard,\n                children: [\n                    {\n                        name: \"DashboardHome\",\n                        path: \"/\",\n                        component: DashboardHome,\n                        children: [\n                            {\n                                path: \"/compose\",\n                                component: Compose,\n                            },\n                            {\n                                path: \"/compose/:stackName/:endpoint\",\n                                component: Compose,\n                            },\n                            {\n                                path: \"/compose/:stackName\",\n                                component: Compose,\n                            },\n                            {\n                                path: \"/terminal/:stackName/:serviceName/:type\",\n                                component: ContainerTerminal,\n                                name: \"containerTerminal\",\n                            },\n                            {\n                                path: \"/terminal/:stackName/:serviceName/:type/:endpoint\",\n                                component: ContainerTerminal,\n                                name: \"containerTerminalEndpoint\",\n                            },\n                        ]\n                    },\n                    {\n                        path: \"/console\",\n                        component: Console,\n                    },\n                    {\n                        path: \"/console/:endpoint\",\n                        component: Console,\n                    },\n                    {\n                        path: \"/settings\",\n                        component: Settings,\n                        children: [\n                            {\n                                path: \"general\",\n                                component: General,\n                            },\n                            {\n                                path: \"appearance\",\n                                component: Appearance,\n                            },\n                            {\n                                path: \"security\",\n                                component: Security,\n                            },\n                            {\n                                path: \"globalEnv\",\n                                component: GlobalEnv,\n                            },\n                            {\n                                path: \"about\",\n                                component: About,\n                            },\n                        ]\n                    },\n                ]\n            },\n        ]\n    },\n    {\n        path: \"/setup\",\n        component: Setup,\n    },\n];\n\nexport const router = createRouter({\n    linkActiveClass: \"active\",\n    history: createWebHistory(),\n    routes,\n});\n"
  },
  {
    "path": "frontend/src/styles/localization.scss",
    "content": "html[lang='fa'] {\n    #app {\n        font-family: 'IRANSans', 'Iranian Sans','B Nazanin', 'Tahoma', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji;\n    }\n}\n\nul.multiselect__content {\n    padding-left: 0 !important;\n}\n"
  },
  {
    "path": "frontend/src/styles/main.scss",
    "content": "@import \"vars.scss\";\n@import \"bootstrap/scss/bootstrap\";\n@import \"bootstrap-vue-next/dist/bootstrap-vue-next.css\";\n\n#app {\n    font-family: BlinkMacSystemFont, segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji;\n}\n\nh1 {\n    font-size: 32px;\n}\n\nh2 {\n    font-size: 26px;\n}\n\ntextarea.form-control {\n    border-radius: 19px;\n}\n\n::-webkit-scrollbar {\n    width: 10px;\n}\n\n.bg-maintenance {\n    color: white !important;\n    background-color: $maintenance !important;\n}\n\n.bg-dark {\n    color: white;\n}\n\n.text-maintenance {\n    color: $maintenance !important;\n}\n\n::placeholder {\n    color: $dark-font-color3;\n}\n\n.incident a,\n.bg-maintenance a {\n    color: inherit;\n}\n\n.list-group {\n    border-radius: 0.75rem;\n\n    .dark & {\n        .list-group-item {\n            background-color: $dark-bg2;\n            color: $dark-font-color;\n            border-color: $dark-border-color;\n        }\n    }\n}\n\n\n// optgroup\noptgroup {\n    color: #b1b1b1;\n    option {\n        color: #212529;\n    }\n}\n\n.dark {\n    optgroup {\n        color: #535864;\n        option {\n            color: $dark-font-color;\n        }\n    }\n}\n\n// Scrollbar\n::-webkit-scrollbar-thumb {\n    background: #ccc;\n    border-radius: 20px;\n}\n\n.modal {\n    backdrop-filter: blur(3px);\n}\n\n.modal-content {\n    border-radius: 1rem;\n    box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);\n\n    .dark & {\n        box-shadow: 0 15px 70px rgb(0 0 0);\n        background-color: $dark-bg;\n    }\n}\n\n.VuePagination__count {\n    font-size: 13px;\n    text-align: center;\n}\n\n.shadow-box {\n    box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);\n    padding: 10px;\n    border-radius: 10px;\n\n    &.big-padding {\n        padding: 20px;\n    }\n}\n\n.btn {\n    padding-left: 20px;\n    padding-right: 20px;\n}\n\n.btn-sm {\n    border-radius: 25px;\n}\n\n.btn-primary {\n    color: white;\n    background: $primary-gradient;\n\n    &:hover, &:active, &:focus, &.active {\n        color: white;\n        background: $primary-gradient-active;\n        border-color: $highlight;\n    }\n\n    .dark & {\n        color: $dark-font-color2;\n    }\n}\n\n.btn-normal {\n    $bg-color: #F5F5F5;\n\n    background-color: $bg-color;\n    border-color: $bg-color;\n\n    &:hover {\n        $hover-color: darken($bg-color, 3%);\n        background-color: $hover-color;\n        border-color: $hover-color;\n    }\n}\n\n.btn-warning {\n    color: white;\n\n    &:hover, &:active, &:focus, &.active {\n        color: white;\n    }\n}\n\n.btn-info {\n    color: white;\n\n    &:hover, &:active, &:focus, &.active {\n        color: white;\n    }\n}\n\n.btn-dark {\n    background-color: #161B22;\n}\n\n.btn-outline-normal {\n    padding: 4px 10px;\n    border: 1px solid #ced4da;\n    border-radius: 25px;\n    background-color: transparent;\n\n    .dark & {\n        color: $dark-font-color;\n        border: 1px solid $dark-font-color2;\n    }\n\n    &.active {\n        background-color: $highlight-white;\n\n        .dark & {\n            background-color: $dark-font-color2;\n        }\n    }\n}\n\n@media (max-width: 550px) {\n    .table-shadow-box {\n        padding: 10px !important;\n\n        thead {\n            display: none;\n        }\n\n        tbody {\n            .shadow-box {\n                background-color: white;\n            }\n        }\n\n        tr {\n            margin-top: 0 !important;\n            padding: 4px 10px !important;\n            display: block;\n            margin-bottom: 6px;\n\n            td:first-child {\n                font-weight: bold;\n            }\n\n            td:nth-child(-n+3) {\n                text-align: center;\n            }\n\n            td:last-child {\n                text-align: left;\n            }\n\n            td {\n                border-bottom: 1px solid $dark-font-color;\n                display: block;\n                padding: 4px;\n\n                .badge {\n                    margin: auto;\n                    display: block;\n                    width: 30%;\n                }\n            }\n        }\n    }\n}\n\n// Dark Theme override here\n.dark {\n    background-color: #090c10;\n    color: $dark-font-color;\n\n    mark, .mark {\n        background-color: #b6ad86;\n    }\n\n    &::-webkit-scrollbar-thumb, ::-webkit-scrollbar-thumb {\n        background: $dark-border-color;\n    }\n\n    .shadow-box {\n        &:not(.alert) {\n            background-color: $dark-bg;\n        }\n    }\n\n    .form-check-input {\n        background-color: $dark-bg2;\n        border-color: $dark-border-color;\n    }\n\n    .input-group-text {\n        background-color: #282f39;\n        border-color: $dark-border-color;\n        color: $dark-font-color;\n    }\n\n    .form-check-input:checked {\n        border-color: $primary; // Re-apply bootstrap border\n    }\n\n    .form-switch .form-check-input {\n        background-color: #232f3b;\n    }\n\n    a:not(.btn),\n    .table,\n    .nav-link {\n        color: $dark-font-color;\n\n        &.btn-info {\n            color: white;\n        }\n    }\n\n    .incident a,\n    .bg-maintenance a {\n        color: inherit;\n    }\n\n    .form-control,\n    .form-control:focus,\n    .form-select,\n    .form-select:focus {\n        color: $dark-font-color;\n        background-color: $dark-bg2;\n    }\n\n    .form-select:disabled {\n        color: rgba($dark-font-color, 0.7);\n        background-color: $dark-bg;\n    }\n\n    .form-control, .form-select {\n        border-color: $dark-border-color;\n    }\n\n    .form-control:disabled, .form-control[readonly] {\n        background-color: #232f3b;\n        opacity: 1;\n    }\n\n    .table-hover > tbody > tr:hover > * {\n        --bs-table-accent-bg: #070a10;\n        color: $dark-font-color;\n    }\n\n    .nav-pills .nav-link.active, .nav-pills .show > .nav-link {\n        color: $dark-font-color2;\n        background: $primary-gradient;\n\n        &:hover {\n            background: $primary-gradient-active;\n        }\n    }\n\n    .bg-primary {\n        color: $dark-font-color2;\n        background: $primary-gradient;\n    }\n\n    .btn-secondary {\n        color: white;\n    }\n\n    .btn-normal {\n        $bg-color: $dark-header-bg;\n\n        color: $dark-font-color;\n        background-color: $bg-color;\n        border-color: $bg-color;\n\n        &:hover {\n            $hover-color: darken($bg-color, 3%);\n            background-color: $hover-color;\n            border-color: $hover-color;\n        }\n    }\n\n    .btn-warning {\n        color: $dark-font-color2;\n\n        &:hover, &:active, &:focus, &.active {\n            color: $dark-font-color2;\n        }\n    }\n\n    .btn-close {\n        box-shadow: none;\n        filter: invert(1);\n\n        &:hover {\n            opacity: 0.6;\n        }\n    }\n\n    .modal-header {\n        border-color: $dark-bg;\n    }\n\n    .modal-footer {\n        border-color: $dark-bg;\n    }\n\n    // Pagination\n    .page-item.disabled .page-link {\n        background-color: $dark-bg;\n        border-color: $dark-border-color;\n    }\n\n    .page-link {\n        background-color: $dark-bg;\n        border-color: $dark-border-color;\n        color: $dark-font-color;\n    }\n\n    .stack-list {\n        .item {\n            &:hover {\n                background-color: $dark-bg2;\n            }\n\n            &.active {\n                background-color: $dark-bg2;\n            }\n        }\n    }\n\n    @media (max-width: 550px) {\n        .table-shadow-box {\n            tbody {\n                .shadow-box {\n                    background-color: $dark-bg2;\n\n                    td {\n                        border-bottom: 1px solid $dark-border-color;\n                    }\n                }\n            }\n        }\n    }\n\n    .alert {\n        &.bg-info,\n        &.bg-warning,\n        &.bg-danger,\n        &.bg-maintenance,\n        &.bg-light {\n            color: $dark-font-color2;\n        }\n    }\n}\n\n// Floating Label\n.form-floating > .form-control:focus ~ label::after, .form-floating > .form-control:not(:placeholder-shown) ~ label::after, .form-floating > .form-control-plaintext ~ label::after, .form-floating > .form-select ~ label::after {\n    background-color: transparent;\n}\n\n.form-floating > label {\n    .dark & {\n        color: $dark-font-color3 !important;\n    }\n}\n\n\n/*\n * Transitions\n */\n\n// page-change\n.slide-fade-enter-active {\n    transition: all 0.2s $easing-in;\n}\n\n.slide-fade-leave-active {\n    transition: all 0.2s $easing-in;\n}\n\n.slide-fade-enter-from,\n.slide-fade-leave-to {\n    transform: translateY(50px);\n    opacity: 0;\n}\n\n.slide-fade-right-enter-active {\n    transition: all 0.2s $easing-in;\n}\n\n.slide-fade-right-leave-active {\n    transition: all 0.2s $easing-in;\n}\n\n.slide-fade-right-enter-from,\n.slide-fade-right-leave-to {\n    transform: translateX(50px);\n    opacity: 0;\n}\n\n.slide-fade-up-enter-active {\n    transition: all 0.2s $easing-in;\n}\n\n.slide-fade-up-leave-active {\n    transition: all 0.2s $easing-in;\n}\n\n.slide-fade-up-enter-from,\n.slide-fade-up-leave-to {\n    transform: translateY(-50px);\n    opacity: 0;\n}\n\n.stack-list {\n    &.scrollbar {\n        overflow-y: auto;\n    }\n\n    @media (max-width: 770px) {\n        &.scrollbar {\n            height: calc(100% - 97px);\n        }\n    }\n\n    .item {\n        display: flex;\n        align-items: center;\n        height: 52px;\n        text-decoration: none;\n        border-radius: 10px;\n        transition: all ease-in-out 0.15s;\n        width: 100%;\n        padding: 0 8px;\n\n        &.disabled {\n            opacity: 0.3;\n        }\n\n        &:hover {\n            background-color: $highlight-white;\n        }\n\n        &.active {\n            background-color: #cdf8f4;\n        }\n\n        .title {\n            display: inline-block;\n            margin-top: -4px;\n        }\n    }\n}\n\n.alert-success {\n    color: #122f21;\n    background-color: $primary;\n    border-color: $primary;\n}\n\n.alert-info {\n    color: #055160;\n    background-color: #cff4fc;\n    border-color: #cff4fc;\n}\n\n.alert-danger {\n    color: #842029;\n    background-color: #f8d7da;\n    border-color: #f8d7da;\n}\n\n.btn-success {\n    color: #fff;\n    background-color: #4caf50;\n    border-color: #4caf50;\n}\n\n\n[contenteditable=true] {\n    transition: all $easing-in 0.2s;\n    background-color: rgba(239, 239, 239, 0.7);\n    border-radius: 8px;\n\n    &.no-bg {\n        background-color: transparent !important;\n    }\n\n    &:focus {\n        outline: 0 solid #eee;\n        background-color: rgba(245, 245, 245, 0.9);\n    }\n\n    &:hover {\n        background-color: rgba(239, 239, 239, 0.8);\n    }\n\n    .dark & {\n        background-color: rgba(239, 239, 239, 0.2);\n    }\n\n    /*\n    &::after {\n        margin-left: 5px;\n        content: \"🖊️\";\n        font-size: 13px;\n        color: #eee;\n    }\n    */\n\n}\n\n.action {\n    transition: all $easing-in 0.2s;\n\n    &:hover {\n        cursor: pointer;\n        transform: scale(1.2);\n    }\n}\n\n.vue-image-crop-upload .vicp-wrap {\n    border-radius: 10px !important;\n}\n\n.spinner {\n    color: $primary;\n}\n\n\nh5.settings-subheading::after {\n    content: \"\";\n    display: block;\n    width: 50%;\n    padding-top: 8px;\n    border-bottom: 1px solid $dark-border-color;\n}\n\n/* required class */\n.code-editor, .css-editor {\n    /* we dont use `language-` classes anymore so thats why we need to add background and text color manually */\n\n    border-radius: 1rem;\n    padding: 10px 5px;\n    border: 1px solid #ced4da;\n\n    .dark & {\n        background: $dark-bg2;\n        border: 1px solid $dark-border-color;\n    }\n}\n\n\n$shadow-box-padding: 20px;\n\n.shadow-box-with-fixed-bottom-bar {\n    padding-top: $shadow-box-padding;\n    padding-bottom: 0;\n    padding-right: $shadow-box-padding;\n    padding-left: $shadow-box-padding;\n}\n\n.fixed-bottom-bar {\n    position: sticky;\n    bottom: 0;\n    margin-left: -$shadow-box-padding;\n    margin-right: -$shadow-box-padding;\n    z-index: 100;\n    background-color: rgba(white, 0.2);\n    backdrop-filter: blur(2px);\n    border-radius: 0 0 10px 10px;\n\n    .dark & {\n        background-color: rgba($dark-header-bg, 0.9);\n    }\n}\n\n@media (max-width: 770px) {\n    .toast-container {\n        margin-bottom: 100px !important;\n    }\n}\n\n@media (max-width: 550px) {\n    .toast-container {\n        margin-bottom: 126px !important;\n    }\n}\n\ncode {\n    padding: .2em .4em;\n    margin: 0;\n    font-size: 85%;\n    white-space: break-spaces;\n    background-color: rgba(239, 239, 239, 0.15);\n\n    border-radius: 6px;\n    font-family: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n    color: black;\n\n    .dark & {\n        color: $dark-font-color;\n    }\n}\n\n.form-text {\n    color: $dark-font-color3;\n}\n\n\n.cm-gutters {\n\tbackground-color: transparent !important;\n}\n.dark [contenteditable=\"true\"] {\n\tbackground-color: transparent !important;\n}\n.cm-editor {\n\tbackground-color: transparent !important;\n}\n.cm-activeLine, .cm-activeLineGutter {\n\tbackground-color: transparent !important;\n}\n.cm-selectionBackground {\n    background-color: #74c2ff3d !important;\n}\n.cm-focused {\n    outline: none !important;\n}\n\n// Localization\n@import \"localization.scss\";\n"
  },
  {
    "path": "frontend/src/styles/vars.scss",
    "content": "$primary: #74c2ff;\n$danger: #dc3545;\n$warning: #f8a306;\n$maintenance: #1747f5;\n$link-color: #111;\n$border-radius: 50rem;\n\n$highlight: #9dd1ff;\n$highlight-white: #e7faec;\n\n$dark-font-color: #b1b8c0;\n$dark-font-color2: #020b05;\n$dark-font-color3: #575c62;\n$dark-bg: #0d1117;\n$dark-bg2: #070a10;\n$dark-border-color: #1d2634;\n$dark-header-bg: #161b22;\n\n$easing-in: cubic-bezier(0.54, 0.78, 0.55, 0.97);\n$easing-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n$easing-in-out: cubic-bezier(0.79, 0.14, 0.15, 0.86);\n\n$dropdown-border-radius: 0.5rem;\n\n$primary-gradient: linear-gradient(135deg, #74c2ff 0%, #74c2ff 75%, #86e6a9);\n$primary-gradient-active: linear-gradient(135deg, #74c2ff 0%, #74c2ff 50%, #86e6a9);\n"
  },
  {
    "path": "frontend/src/util-frontend.ts",
    "content": "import dayjs from \"dayjs\";\nimport timezones from \"timezones-list\";\nimport { localeDirection, currentLocale } from \"./i18n\";\nimport { POSITION } from \"vue-toastification\";\n\n/**\n * Returns the offset from UTC in hours for the current locale.\n * @param {string} timeZone Timezone to get offset for\n * @returns {number} The offset from UTC in hours.\n *\n * Generated by Trelent\n */\nfunction getTimezoneOffset(timeZone : string) {\n    const now = new Date();\n    const tzString = now.toLocaleString(\"en-US\", {\n        timeZone,\n    });\n    const localString = now.toLocaleString(\"en-US\");\n    const diff = (Date.parse(localString) - Date.parse(tzString)) / 3600000;\n    const offset = diff + now.getTimezoneOffset() / 60;\n    return -offset;\n}\n\n/**\n * Returns a list of timezones sorted by their offset from UTC.\n * @returns {object[]} A list of the given timezones sorted by their offset from UTC.\n *\n * Generated by Trelent\n */\nexport function timezoneList() {\n    let result = [];\n\n    for (let timezone of timezones) {\n        try {\n            let display = dayjs().tz(timezone.tzCode).format(\"Z\");\n\n            result.push({\n                name: `(UTC${display}) ${timezone.tzCode}`,\n                value: timezone.tzCode,\n                time: getTimezoneOffset(timezone.tzCode),\n            });\n        } catch (e) {\n            // Skipping not supported timezone.tzCode by dayjs\n        }\n    }\n\n    result.sort((a, b) => {\n        if (a.time > b.time) {\n            return 1;\n        }\n\n        if (b.time > a.time) {\n            return -1;\n        }\n\n        return 0;\n    });\n\n    return result;\n}\n\n/**\n * Set the locale of the HTML page\n * @returns {void}\n */\nexport function setPageLocale() {\n    const html = document.documentElement;\n    html.setAttribute(\"lang\", currentLocale() );\n    html.setAttribute(\"dir\", localeDirection() );\n}\n\n/**\n * Get the base URL\n * Mainly used for dev, because the backend and the frontend are in different ports.\n * @returns {string} Base URL\n */\nexport function getResBaseURL() {\n    const env = process.env.NODE_ENV;\n    if (env === \"development\" && isDevContainer()) {\n        return location.protocol + \"//\" + getDevContainerServerHostname();\n    } else if (env === \"development\" || localStorage.dev === \"dev\") {\n        return location.protocol + \"//\" + location.hostname + \":3001\";\n    } else {\n        return \"\";\n    }\n}\n\n/**\n * Are we currently running in a dev container?\n * @returns {boolean} Running in dev container?\n */\nexport function isDevContainer() {\n    // eslint-disable-next-line no-undef\n    return (typeof DEVCONTAINER === \"string\" && DEVCONTAINER === \"1\");\n}\n\n/**\n * Supports GitHub Codespaces only currently\n * @returns {string} Dev container server hostname\n */\nexport function getDevContainerServerHostname() {\n    if (!isDevContainer()) {\n        return \"\";\n    }\n\n    // eslint-disable-next-line no-undef\n    return CODESPACE_NAME + \"-3001.\" + GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN;\n}\n\n/**\n * Regex pattern fr identifying hostnames and IP addresses\n * @param {boolean} mqtt whether or not the regex should take into\n * account the fact that it is an mqtt uri\n * @returns {RegExp} The requested regex\n */\nexport function hostNameRegexPattern(mqtt = false) {\n    // mqtt, mqtts, ws and wss schemes accepted by mqtt.js (https://github.com/mqttjs/MQTT.js/#connect)\n    const mqttSchemeRegexPattern = \"((mqtt|ws)s?:\\\\/\\\\/)?\";\n    // Source: https://digitalfortress.tech/tips/top-15-commonly-used-regex/\n    const ipRegexPattern = `((^${mqtt ? mqttSchemeRegexPattern : \"\"}((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))$)|(^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)(\\\\.(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)(\\\\.(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)(\\\\.(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)(\\\\.(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)(\\\\.(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)(\\\\.(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)(\\\\.(25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)){3}))|:)))(%.+)?$))`;\n    // Source: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address\n    const hostNameRegexPattern = `^${mqtt ? mqttSchemeRegexPattern : \"\"}([a-zA-Z0-9])?(([a-zA-Z0-9_]|[a-zA-Z0-9_][a-zA-Z0-9\\\\-_]*[a-zA-Z0-9_])\\\\.)*([A-Za-z0-9_]|[A-Za-z0-9_][A-Za-z0-9\\\\-_]*[A-Za-z0-9_])(\\\\.)?$`;\n\n    return `${ipRegexPattern}|${hostNameRegexPattern}`;\n}\n\n/**\n * Loads the toast timeout settings from storage.\n * @returns {object} The toast plugin options object.\n */\nexport function loadToastSettings() {\n    return {\n        position: POSITION.BOTTOM_RIGHT,\n        containerClassName: \"toast-container\",\n        showCloseButtonOnHover: true,\n\n        filterBeforeCreate: (toast, toasts) => {\n            if (toast.timeout === 0) {\n                return false;\n            } else {\n                return toast;\n            }\n        },\n    };\n}\n\n/**\n * Get timeout for success toasts\n * @returns {(number|boolean)} Timeout in ms. If false timeout disabled.\n */\nexport function getToastSuccessTimeout() {\n    let successTimeout = 20000;\n\n    if (localStorage.toastSuccessTimeout !== undefined) {\n        const parsedTimeout = parseInt(localStorage.toastSuccessTimeout);\n        if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {\n            successTimeout = parsedTimeout;\n        }\n    }\n\n    if (successTimeout === -1) {\n        successTimeout = false;\n    }\n\n    return successTimeout;\n}\n\n/**\n * Get timeout for error toasts\n * @returns {(number|boolean)} Timeout in ms. If false timeout disabled.\n */\nexport function getToastErrorTimeout() {\n    let errorTimeout = -1;\n\n    if (localStorage.toastErrorTimeout !== undefined) {\n        const parsedTimeout = parseInt(localStorage.toastErrorTimeout);\n        if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {\n            errorTimeout = parsedTimeout;\n        }\n    }\n\n    if (errorTimeout === -1) {\n        errorTimeout = false;\n    }\n\n    return errorTimeout;\n}\n\n"
  },
  {
    "path": "frontend/src/vite-env.d.ts",
    "content": "/* eslint-disable */\n/// <reference types=\"vite/client\" />\n\ndeclare module \"*.vue\" {\n    import type { DefineComponent } from \"vue\";\n    const component: DefineComponent<{}, {}, any>;\n    export default component;\n}\n"
  },
  {
    "path": "frontend/vite.config.ts",
    "content": "import { defineConfig } from \"vite\";\nimport vue from \"@vitejs/plugin-vue\";\nimport Components from \"unplugin-vue-components/vite\";\nimport { BootstrapVueNextResolver } from \"unplugin-vue-components/resolvers\";\nimport viteCompression from \"vite-plugin-compression\";\nimport \"vue\";\n\nconst viteCompressionFilter = /\\.(js|mjs|json|css|html|svg)$/i;\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n    server: {\n        port: 5000,\n    },\n    define: {\n        \"FRONTEND_VERSION\": JSON.stringify(process.env.npm_package_version),\n    },\n    root: \"./frontend\",\n    build: {\n        outDir: \"../frontend-dist\",\n    },\n    plugins: [\n        vue(),\n        Components({\n            resolvers: [ BootstrapVueNextResolver() ],\n        }),\n        viteCompression({\n            algorithm: \"gzip\",\n            filter: viteCompressionFilter,\n        }),\n        viteCompression({\n            algorithm: \"brotliCompress\",\n            filter: viteCompressionFilter,\n        }),\n    ],\n});\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"dockge\",\n    \"version\": \"1.5.0\",\n    \"type\": \"module\",\n    \"engines\": {\n        \"node\": \">= 22.14.0\"\n    },\n    \"scripts\": {\n        \"fmt\": \"eslint \\\"**/*.{ts,vue}\\\" --fix\",\n        \"lint\": \"eslint \\\"**/*.{ts,vue}\\\"\",\n        \"check-ts\": \"tsc --noEmit\",\n        \"start\": \"tsx ./backend/index.ts\",\n        \"dev\": \"concurrently -k -r \\\"wait-on tcp:5000 && npm run dev:backend \\\" \\\"npm run dev:frontend\\\"\",\n        \"dev:backend\": \"cross-env NODE_ENV=development tsx watch --inspect ./backend/index.ts\",\n        \"dev:frontend\": \"cross-env NODE_ENV=development vite --host --config ./frontend/vite.config.ts\",\n        \"release-final\": \"tsx ./extra/test-docker.ts && tsx extra/update-version.ts && npm run build:frontend && npm run build:docker\",\n        \"release-beta\": \"tsx ./extra/test-docker.ts && tsx extra/update-version.ts && npm run build:frontend && npm run build:docker-beta\",\n        \"build:frontend\": \"vite build --config ./frontend/vite.config.ts\",\n        \"build:docker-base\": \"docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:base -f ./docker/Base.Dockerfile . --push\",\n        \"build:docker\": \"node ./extra/env2arg.js docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:latest -t louislam/dockge:1 -t louislam/dockge:$VERSION -t louislam/dockge:beta -t louislam/dockge:nightly --target release -f ./docker/Dockerfile . --push\",\n        \"build:docker-beta\": \"node ./extra/env2arg.js docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:beta -t louislam/dockge:$VERSION --target release -f ./docker/Dockerfile . --push\",\n        \"build:healthcheck\": \"docker buildx build -f docker/BuildHealthCheck.Dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:build-healthcheck . --push\",\n        \"release-nightly\": \"npm run build:frontend && docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:nightly -t ghcr.io/louislam/dockge:nightly --target nightly -f ./docker/Dockerfile . --push\",\n        \"start-docker\": \"docker run --rm -p 5001:5001 --name dockge louislam/dockge:latest\",\n        \"mark-as-nightly\": \"tsx ./extra/mark-as-nightly.ts\",\n        \"reformat-changelog\": \"tsx ./extra/reformat-changelog.ts\",\n        \"reset-password\": \"tsx ./extra/reset-password.ts\"\n    },\n    \"dependencies\": {\n        \"@homebridge/node-pty-prebuilt-multiarch\": \"0.11.14\",\n        \"@inventage/envsubst\": \"^0.16.0\",\n        \"@louislam/sqlite3\": \"~15.1.6\",\n        \"bcryptjs\": \"~2.4.3\",\n        \"check-password-strength\": \"~2.0.10\",\n        \"command-exists\": \"~1.2.9\",\n        \"compare-versions\": \"~6.1.1\",\n        \"composerize\": \"~1.7.1\",\n        \"croner\": \"~8.1.2\",\n        \"dayjs\": \"~1.11.13\",\n        \"dotenv\": \"~16.3.2\",\n        \"express\": \"~4.21.2\",\n        \"express-static-gzip\": \"~2.1.8\",\n        \"http-graceful-shutdown\": \"~3.1.14\",\n        \"jsonwebtoken\": \"~9.0.2\",\n        \"jwt-decode\": \"~3.1.2\",\n        \"knex\": \"~2.5.1\",\n        \"limiter-es6-compat\": \"~2.1.2\",\n        \"mysql2\": \"~3.12.0\",\n        \"promisify-child-process\": \"~4.1.2\",\n        \"redbean-node\": \"~0.3.3\",\n        \"semver\": \"^7.7.1\",\n        \"socket.io\": \"~4.8.1\",\n        \"socket.io-client\": \"~4.8.1\",\n        \"timezones-list\": \"~3.0.3\",\n        \"ts-command-line-args\": \"~2.5.1\",\n        \"tsx\": \"~4.19.3\",\n        \"type-fest\": \"~4.3.3\",\n        \"yaml\": \"~2.3.4\"\n    },\n    \"devDependencies\": {\n        \"@actions/github\": \"^6.0.0\",\n        \"@codemirror/lang-python\": \"^6.1.7\",\n        \"@codemirror/lang-yaml\": \"^6.1.2\",\n        \"@fontsource/jetbrains-mono\": \"^5.2.5\",\n        \"@fortawesome/fontawesome-svg-core\": \"6.4.2\",\n        \"@fortawesome/free-regular-svg-icons\": \"6.4.2\",\n        \"@fortawesome/free-solid-svg-icons\": \"6.4.2\",\n        \"@fortawesome/vue-fontawesome\": \"3.0.3\",\n        \"@types/bcryptjs\": \"^2.4.6\",\n        \"@types/bootstrap\": \"~5.2.10\",\n        \"@types/command-exists\": \"~1.2.3\",\n        \"@types/express\": \"~4.17.21\",\n        \"@types/jsonwebtoken\": \"~9.0.9\",\n        \"@types/semver\": \"^7.7.0\",\n        \"@typescript-eslint/eslint-plugin\": \"~6.8.0\",\n        \"@typescript-eslint/parser\": \"~6.8.0\",\n        \"@vitejs/plugin-vue\": \"~5.2.3\",\n        \"@xterm/addon-fit\": \"beta\",\n        \"@xterm/xterm\": \"beta\",\n        \"bootstrap\": \"5.3.2\",\n        \"bootstrap-vue-next\": \"~0.14.10\",\n        \"codemirror\": \"^6.0.1\",\n        \"concurrently\": \"^8.2.2\",\n        \"cross-env\": \"~7.0.3\",\n        \"eslint\": \"~8.50.0\",\n        \"eslint-plugin-jsdoc\": \"~46.8.2\",\n        \"eslint-plugin-vue\": \"~9.32.0\",\n        \"sass\": \"~1.68.0\",\n        \"thememirror\": \"^2.0.1\",\n        \"typescript\": \"~5.2.2\",\n        \"unplugin-vue-components\": \"~0.25.2\",\n        \"vite\": \"~5.4.15\",\n        \"vite-plugin-compression\": \"~0.5.1\",\n        \"vue\": \"~3.5.13\",\n        \"vue-codemirror6\": \"^1.3.13\",\n        \"vue-eslint-parser\": \"~9.3.2\",\n        \"vue-i18n\": \"~10.0.6\",\n        \"vue-qrcode\": \"~2.2.2\",\n        \"vue-router\": \"~4.5.0\",\n        \"vue-toastification\": \"2.0.0-rc.5\",\n        \"wait-on\": \"^7.2.0\",\n        \"xterm-addon-web-links\": \"~0.9.0\"\n    }\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"module\": \"ESNext\",\n        \"target\": \"ESNext\",\n        \"strict\": true,\n        \"moduleResolution\": \"bundler\",\n        \"skipLibCheck\": true\n    },\n    \"include\": [\n        \"backend/**/*\",\n        \"common/**/*\"\n    ]\n}\n"
  }
]