forked from extern/dockge
Compare commits
97 Commits
Author | SHA1 | Date | |
---|---|---|---|
69e0f77830 | |||
16cdaa8ed5 | |||
5d33c474ec | |||
7385d216a3 | |||
631bc60cb2 | |||
d23e2d8aa1 | |||
457f038108 | |||
f862bbc7cd | |||
3d56846cd6 | |||
cff929c69d | |||
766e751522 | |||
45ab36db98 | |||
47435d41cd | |||
ee8f39699a | |||
204c776b0d | |||
724b5d6d7e | |||
766ecb070d | |||
d1d3a54377 | |||
d17a63fcab | |||
5454b44a1c | |||
9e8bccbf2f | |||
866fa380dd | |||
49b28d0e36 | |||
1e7dd0504b | |||
05191b14de | |||
81cacbdddd | |||
0279d431e0 | |||
0e768abfb4 | |||
62e952f1e6 | |||
f044c2e328 | |||
dc8787d204 | |||
d92ea2dac9 | |||
67819ecd73 | |||
73d23373cf | |||
891a217682 | |||
31726315c3 | |||
e95ef66ca1 | |||
b7c6bbba67 | |||
dc8c3a7568 | |||
b50b1cc6e1 | |||
2e26178d2d | |||
6ef861c989 | |||
853b43a876 | |||
16a4dd63ac | |||
0847a4a0c0 | |||
889f0c133f | |||
7cff52f614 | |||
01398aa498 | |||
afe0bc561f | |||
c8770a9605 | |||
0208684b50 | |||
a007ec56f7 | |||
7bb0a1cb08 | |||
4df799b5b6 | |||
03bc2b6a34 | |||
53b052c1e5 | |||
13c3dac44d | |||
5ce6b90546 | |||
a488518f6e | |||
8c4004f32d | |||
393bbcae79 | |||
9fbf94586b | |||
0a46a7df1a | |||
d1732af529 | |||
87a6436f28 | |||
ac75283b0f | |||
8d6160ec5b | |||
ecb16ae007 | |||
c296069a8d | |||
d76442434f | |||
54e8484efd | |||
2cd10ad16d | |||
96a4f2fd0c | |||
700a24171b | |||
6ce75a2df3 | |||
317c97650d | |||
9295583727 | |||
6dc998bedf | |||
f5552b3344 | |||
b90fd35348 | |||
3dca9e735a | |||
200ba0ca07 | |||
dd58a9cbc4 | |||
7f41cc099c | |||
4ce696181b | |||
959dbba776 | |||
ffa978eea1 | |||
9fd0c6416a | |||
e6fc623758 | |||
209dedf682 | |||
cf49a2ef2a | |||
c5d3b23af2 | |||
b12056aa83 | |||
6ad11277e0 | |||
ab48866ae6 | |||
d55d7c62a2 | |||
6749e343ba |
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Should be identical to .gitignore
|
||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
data
|
||||||
|
stacks
|
||||||
|
tmp
|
||||||
|
/private
|
||||||
|
|
||||||
|
# Docker extra
|
||||||
|
docker
|
||||||
|
frontend
|
||||||
|
.editorconfig
|
||||||
|
.eslintrc.cjs
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
24
.editorconfig
Normal file
24
.editorconfig
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.yaml]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.yml]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.vue]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
100
.eslintrc.cjs
Normal file
100
.eslintrc.cjs
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:vue/vue3-recommended",
|
||||||
|
],
|
||||||
|
parser: "vue-eslint-parser",
|
||||||
|
parserOptions: {
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
"@typescript-eslint",
|
||||||
|
"jsdoc"
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"yoda": "error",
|
||||||
|
"linebreak-style": [ "error", "unix" ],
|
||||||
|
"camelcase": [ "warn", {
|
||||||
|
"properties": "never",
|
||||||
|
"ignoreImports": true
|
||||||
|
}],
|
||||||
|
"no-unused-vars": [ "warn", {
|
||||||
|
"args": "none"
|
||||||
|
}],
|
||||||
|
indent: [
|
||||||
|
"error",
|
||||||
|
4,
|
||||||
|
{
|
||||||
|
ignoredNodes: [ "TemplateLiteral" ],
|
||||||
|
SwitchCase: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quotes: [ "error", "double" ],
|
||||||
|
semi: "error",
|
||||||
|
"vue/html-indent": [ "error", 4 ], // default: 2
|
||||||
|
"vue/max-attributes-per-line": "off",
|
||||||
|
"vue/singleline-html-element-content-newline": "off",
|
||||||
|
"vue/html-self-closing": "off",
|
||||||
|
"vue/require-component-is": "off", // not allow is="style" https://github.com/vuejs/eslint-plugin-vue/issues/462#issuecomment-430234675
|
||||||
|
"vue/attribute-hyphenation": "off", // This change noNL to "no-n-l" unexpectedly
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"no-multi-spaces": [ "error", {
|
||||||
|
ignoreEOLComments: true,
|
||||||
|
}],
|
||||||
|
"array-bracket-spacing": [ "warn", "always", {
|
||||||
|
"singleValue": true,
|
||||||
|
"objectsInArrays": false,
|
||||||
|
"arraysInArrays": false
|
||||||
|
}],
|
||||||
|
"space-before-function-paren": [ "error", {
|
||||||
|
"anonymous": "always",
|
||||||
|
"named": "never",
|
||||||
|
"asyncArrow": "always"
|
||||||
|
}],
|
||||||
|
"curly": "error",
|
||||||
|
"object-curly-spacing": [ "error", "always" ],
|
||||||
|
"object-curly-newline": "off",
|
||||||
|
"object-property-newline": "error",
|
||||||
|
"comma-spacing": "error",
|
||||||
|
"brace-style": "error",
|
||||||
|
"no-var": "error",
|
||||||
|
"key-spacing": "warn",
|
||||||
|
"keyword-spacing": "warn",
|
||||||
|
"space-infix-ops": "error",
|
||||||
|
"arrow-spacing": "warn",
|
||||||
|
"no-trailing-spaces": "error",
|
||||||
|
"no-constant-condition": [ "error", {
|
||||||
|
"checkLoops": false,
|
||||||
|
}],
|
||||||
|
"space-before-blocks": "warn",
|
||||||
|
"no-extra-boolean-cast": "off",
|
||||||
|
"no-multiple-empty-lines": [ "warn", {
|
||||||
|
"max": 1,
|
||||||
|
"maxBOF": 0,
|
||||||
|
}],
|
||||||
|
"lines-between-class-members": [ "warn", "always", {
|
||||||
|
exceptAfterSingleLine: true,
|
||||||
|
}],
|
||||||
|
"no-unneeded-ternary": "error",
|
||||||
|
"array-bracket-newline": [ "error", "consistent" ],
|
||||||
|
"eol-last": [ "error", "always" ],
|
||||||
|
"comma-dangle": [ "warn", "only-multiline" ],
|
||||||
|
"no-empty": [ "error", {
|
||||||
|
"allowEmptyCatch": true
|
||||||
|
}],
|
||||||
|
"no-control-regex": "off",
|
||||||
|
"one-var": [ "error", "never" ],
|
||||||
|
"max-statements-per-line": [ "error", { "max": 1 }],
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [ "warn", {
|
||||||
|
"args": "none"
|
||||||
|
}],
|
||||||
|
"prefer-const" : "off",
|
||||||
|
},
|
||||||
|
};
|
72
.github/DISCUSSION_TEMPLATE/ask-for-help.yml
vendored
Normal file
72
.github/DISCUSSION_TEMPLATE/ask-for-help.yml
vendored
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
title: "❓ Ask for help"
|
||||||
|
labels: [help]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: no-duplicate-issues
|
||||||
|
attributes:
|
||||||
|
label: "⚠️ Please verify that this bug has NOT been raised before."
|
||||||
|
description: "Search in the issues sections by clicking [HERE](https://github.com/louislam/dockge/discussions/categories/ask-for-help)"
|
||||||
|
options:
|
||||||
|
- label: "I checked and didn't find similar issue"
|
||||||
|
required: true
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: "🛡️ Security Policy"
|
||||||
|
description: Please review the security policy before reporting security related issues/bugs.
|
||||||
|
options:
|
||||||
|
- label: I agree to have read this project [Security Policy](https://github.com/louislam/dockge/security/policy)
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "📝 Describe your problem"
|
||||||
|
description: "Please walk us through it step by step."
|
||||||
|
placeholder: "Describe what are you asking for..."
|
||||||
|
- type: textarea
|
||||||
|
id: error-msg
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
attributes:
|
||||||
|
label: "📝 Error Message(s) or Log"
|
||||||
|
- type: input
|
||||||
|
id: dockge-version
|
||||||
|
attributes:
|
||||||
|
label: "🐻 Dockge Version"
|
||||||
|
description: "Which version of Dockge are you running? Please do NOT provide the docker tag such as latest or 1"
|
||||||
|
placeholder: "Ex. 1.10.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: operating-system
|
||||||
|
attributes:
|
||||||
|
label: "💻 Operating System and Arch"
|
||||||
|
description: "Which OS is your server/device running on? (For Replit, please do not report this bug)"
|
||||||
|
placeholder: "Ex. Ubuntu 20.04 x86"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: browser-vendor
|
||||||
|
attributes:
|
||||||
|
label: "🌐 Browser"
|
||||||
|
description: "Which browser are you running on? (For Replit, please do not report this bug)"
|
||||||
|
placeholder: "Ex. Google Chrome 95.0.4638.69"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: docker-version
|
||||||
|
attributes:
|
||||||
|
label: "🐋 Docker Version"
|
||||||
|
description: "If running with Docker, which version are you running?"
|
||||||
|
placeholder: "Ex. Docker 20.10.9 / K8S / Podman"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: nodejs-version
|
||||||
|
attributes:
|
||||||
|
label: "🟩 NodeJS Version"
|
||||||
|
description: "If running with Node.js? which version are you running?"
|
||||||
|
placeholder: "Ex. 14.18.0"
|
||||||
|
validations:
|
||||||
|
required: false
|
55
.github/DISCUSSION_TEMPLATE/feature-request.yml
vendored
Normal file
55
.github/DISCUSSION_TEMPLATE/feature-request.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
title: 🚀 Feature Request
|
||||||
|
labels: [feature-request]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: no-duplicate-issues
|
||||||
|
attributes:
|
||||||
|
label: "⚠️ Please verify that this feature request has NOT been suggested before."
|
||||||
|
description: "Search in the issues sections by clicking [HERE](https://github.com/louislam/dockge/discussions/categories/feature-request)"
|
||||||
|
options:
|
||||||
|
- label: "I checked and didn't find similar feature request"
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: feature-area
|
||||||
|
attributes:
|
||||||
|
label: "🏷️ Feature Request Type"
|
||||||
|
description: "What kind of feature request is this?"
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- API
|
||||||
|
- UI Feature
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: feature-description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "🔖 Feature description"
|
||||||
|
description: "A clear and concise description of what the feature request is."
|
||||||
|
placeholder: "You should add ..."
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "✔️ Solution"
|
||||||
|
description: "A clear and concise description of what you want to happen."
|
||||||
|
placeholder: "In my use-case, ..."
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
attributes:
|
||||||
|
label: "❓ Alternatives"
|
||||||
|
description: "A clear and concise description of any alternative solutions or features you've considered."
|
||||||
|
placeholder: "I have considered ..."
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
attributes:
|
||||||
|
label: "📝 Additional Context"
|
||||||
|
description: "Add any other context or screenshots about the feature request here."
|
||||||
|
placeholder: "..."
|
12
.github/FUNDING.yml
vendored
Normal file
12
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: louislam # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
#patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: uptime-kuma # Replace with a single Open Collective username
|
||||||
|
#ko_fi: # Replace with a single Ko-fi username
|
||||||
|
#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
#liberapay: # Replace with a single Liberapay username
|
||||||
|
#issuehunt: # Replace with a single IssueHunt username
|
||||||
|
#otechie: # Replace with a single Otechie username
|
||||||
|
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
14
.github/ISSUE_TEMPLATE/ask-for-help.yaml
vendored
Normal file
14
.github/ISSUE_TEMPLATE/ask-for-help.yaml
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
name: "❓ Ask for help"
|
||||||
|
description: "Please go to the Discussions tab to submit a Help Request"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please go to https://github.com/louislam/dockge/discussions/new?category=ask-for-help
|
||||||
|
- type: checkboxes
|
||||||
|
id: no-duplicate-issues
|
||||||
|
attributes:
|
||||||
|
label: "Issues are for bug reports only"
|
||||||
|
options:
|
||||||
|
- label: "I understand"
|
||||||
|
required: true
|
99
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
99
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
name: "🐛 Bug Report"
|
||||||
|
description: "Submit a bug report to help us improve"
|
||||||
|
#title: "[Bug] "
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: no-duplicate-issues
|
||||||
|
attributes:
|
||||||
|
label: "⚠️ Please verify that this bug has NOT been reported before."
|
||||||
|
description: "Search in the issues sections by clicking [HERE](https://github.com/louislam/dockge/issues?q=)"
|
||||||
|
options:
|
||||||
|
- label: "I checked and didn't find similar issue"
|
||||||
|
required: true
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: "🛡️ Security Policy"
|
||||||
|
description: Please review the security policy before reporting security related issues/bugs.
|
||||||
|
options:
|
||||||
|
- label: I agree to have read this project [Security Policy](https://github.com/louislam/dockge/security/policy)
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
attributes:
|
||||||
|
label: "Description"
|
||||||
|
description: "You could also upload screenshots"
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "👟 Reproduction steps"
|
||||||
|
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||||
|
placeholder: "..."
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "👀 Expected behavior"
|
||||||
|
description: "What did you think would happen?"
|
||||||
|
placeholder: "..."
|
||||||
|
- type: textarea
|
||||||
|
id: actual-behavior
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "😓 Actual Behavior"
|
||||||
|
description: "What actually happen?"
|
||||||
|
placeholder: "..."
|
||||||
|
- type: input
|
||||||
|
id: dockge-version
|
||||||
|
attributes:
|
||||||
|
label: "Dockge Version"
|
||||||
|
description: "Which version of Dockge are you running? Please do NOT provide the docker tag such as latest or 1"
|
||||||
|
placeholder: "Ex. 1.1.1"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: operating-system
|
||||||
|
attributes:
|
||||||
|
label: "💻 Operating System and Arch"
|
||||||
|
description: "Which OS is your server/device running on?"
|
||||||
|
placeholder: "Ex. Ubuntu 20.04 x64 "
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: browser-vendor
|
||||||
|
attributes:
|
||||||
|
label: "🌐 Browser"
|
||||||
|
description: "Which browser are you running on?"
|
||||||
|
placeholder: "Ex. Google Chrome 95.0.4638.69"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: docker-version
|
||||||
|
attributes:
|
||||||
|
label: "🐋 Docker Version"
|
||||||
|
description: "If running with Docker, which version are you running?"
|
||||||
|
placeholder: "Ex. Docker 20.10.9 / K8S / Podman"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: nodejs-version
|
||||||
|
attributes:
|
||||||
|
label: "🟩 NodeJS Version"
|
||||||
|
description: "If running with Node.js? which version are you running?"
|
||||||
|
placeholder: "Ex. 14.18.0"
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: "📝 Relevant log output"
|
||||||
|
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
14
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
14
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
name: 🚀 Feature Request
|
||||||
|
description: "Please go to the Discussions tab to submit a Feature Request"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please go to https://github.com/louislam/dockge/discussions/new?category=feature-request
|
||||||
|
- type: checkboxes
|
||||||
|
id: no-duplicate-issues
|
||||||
|
attributes:
|
||||||
|
label: "Issues are for bug reports only"
|
||||||
|
options:
|
||||||
|
- label: "I understand"
|
||||||
|
required: true
|
19
.github/ISSUE_TEMPLATE/security.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/security.md
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
name: "Security Issue"
|
||||||
|
about: "Just for alerting @louislam, do not provide any details here"
|
||||||
|
title: "Security Issue"
|
||||||
|
ref: "main"
|
||||||
|
labels:
|
||||||
|
|
||||||
|
- security
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
DO NOT PROVIDE ANY DETAILS HERE. Please privately report to https://github.com/louislam/dockge/security/advisories/new.
|
||||||
|
|
||||||
|
|
||||||
|
Why need this issue? It is because GitHub Advisory do not send a notification to @louislam, it is a workaround to do so.
|
||||||
|
|
||||||
|
Your GitHub Advisory URL:
|
||||||
|
|
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
⚠️⚠️⚠️ Since we do not accept all types of pull requests and do not want to waste your time. Please be sure that you have read pull request rules:
|
||||||
|
https://github.com/louislam/dockge/blob/master/CONTRIBUTING.md
|
||||||
|
|
||||||
|
Tick the checkbox if you understand [x]:
|
||||||
|
- [ ] I have read and understand the pull request rules.
|
||||||
|
|
||||||
|
# Description
|
||||||
|
|
||||||
|
Fixes #(issue)
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
Please delete any options that are not relevant.
|
||||||
|
|
||||||
|
- Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- User interface (UI)
|
||||||
|
- New feature (non-breaking change which adds functionality)
|
||||||
|
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
|
- Other
|
||||||
|
- This change requires a documentation update
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] My code follows the style guidelines of this project
|
||||||
|
- [ ] I ran ESLint and other linters for modified files
|
||||||
|
- [ ] I have performed a self-review of my own code and tested it
|
||||||
|
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||||
|
(including JSDoc for methods)
|
||||||
|
- [ ] My changes generate no new warnings
|
||||||
|
- [ ] My code needed automated testing. I have added them (this is optional task)
|
||||||
|
|
||||||
|
## Screenshots (if any)
|
||||||
|
|
||||||
|
Please do not use any external image service. Instead, just paste in or drag and drop the image here, and it will be uploaded automatically.
|
1
.github/config/exclude.txt
vendored
Normal file
1
.github/config/exclude.txt
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
# This is a .gitignore style file for 'GrantBirki/json-yaml-validate' Action workflow
|
63
.github/workflows/ci.yml
vendored
Normal file
63
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
name: Node.js CI - Dockge
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
node: [20.x] # Can be changed
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- run: git config --global core.autocrlf false # Mainly for Windows
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{matrix.node}}
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
name: Install pnpm
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- uses: actions/cache@v3
|
||||||
|
name: Setup pnpm cache
|
||||||
|
with:
|
||||||
|
path: ${{ env.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm run lint
|
||||||
|
|
||||||
|
- name: Check Typescript
|
||||||
|
run: pnpm run check-ts
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build:frontend
|
||||||
|
# more things can be add later like tests etc..
|
||||||
|
|
42
.github/workflows/close-incorrect-issue.yml
vendored
Normal file
42
.github/workflows/close-incorrect-issue.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
name: Close Incorrect Issue
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-incorrect-issue:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
node-version: [16]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
name: Install pnpm
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- uses: actions/cache@v3
|
||||||
|
name: Setup pnpm cache
|
||||||
|
with:
|
||||||
|
path: ${{ env.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Close Incorrect Issue
|
||||||
|
run: node extra/close-incorrect-issue.js ${{ secrets.GITHUB_TOKEN }} ${{ github.event.issue.number }} ${{ github.event.issue.user.login }}
|
27
.github/workflows/json-yaml-validate.yml
vendored
Normal file
27
.github/workflows/json-yaml-validate.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
name: json-yaml-validate
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- 2.0.X
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write # enable write permissions for pull request comments
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
json-yaml-validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: json-yaml-validate
|
||||||
|
id: json-yaml-validate
|
||||||
|
uses: GrantBirki/json-yaml-validate@v1.3.0
|
||||||
|
with:
|
||||||
|
comment: "false" # enable comment mode
|
||||||
|
exclude_file: ".github/config/exclude.txt" # gitignore style file for exclusions
|
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Should update .dockerignore as well
|
||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
data
|
||||||
|
stacks
|
||||||
|
tmp
|
||||||
|
/private
|
||||||
|
|
||||||
|
# Git only
|
||||||
|
frontend-dist
|
||||||
|
|
139
CONTRIBUTING.md
Normal file
139
CONTRIBUTING.md
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
## Can I create a pull request for Dockge?
|
||||||
|
|
||||||
|
Yes or no, it depends on what you will try to do. Since I don't want to waste your time, be sure to **create open a discussion, so we can have a discussion first**. Especially for a large pull request or you don't know if it will be merged or not.
|
||||||
|
|
||||||
|
Here are some references:
|
||||||
|
|
||||||
|
### ✅ Usually accepted:
|
||||||
|
- Bug fix
|
||||||
|
- Security fix
|
||||||
|
- Translation
|
||||||
|
|
||||||
|
### ⚠️ Discussion required:
|
||||||
|
- Large pull requests
|
||||||
|
- New features
|
||||||
|
|
||||||
|
### ❌ Won't be merged:
|
||||||
|
- Do not pass the auto-test
|
||||||
|
- Any breaking changes
|
||||||
|
- Duplicated pull requests
|
||||||
|
- Buggy
|
||||||
|
- UI/UX is not close to Dockge
|
||||||
|
- Modifications or deletions of existing logic without a valid reason.
|
||||||
|
- Adding functions that is completely out of scope
|
||||||
|
- Converting existing code into other programming languages
|
||||||
|
- Unnecessarily large code changes that are hard to review and cause conflicts with other PRs.
|
||||||
|
|
||||||
|
The above cases may not cover all possible situations.
|
||||||
|
|
||||||
|
I (@louislam) have the final say. If your pull request does not meet my expectations, I will reject it, no matter how much time you spend on it. Therefore, it is essential to have a discussion beforehand.
|
||||||
|
|
||||||
|
I will assign your pull request to a [milestone](https://github.com/louislam/dockge/milestones), if I plan to review and merge it.
|
||||||
|
|
||||||
|
Also, please don't rush or ask for an ETA, because I have to understand the pull request, make sure it is no breaking changes and stick to my vision of this project, especially for large pull requests.
|
||||||
|
|
||||||
|
## Project Styles
|
||||||
|
|
||||||
|
I personally do not like something that requires so many configurations before you can finally start the app.
|
||||||
|
|
||||||
|
- Settings should be configurable in the frontend. Environment variables are discouraged, unless it is related to startup such as `DOCKGE_STACKS_DIR`
|
||||||
|
- Easy to use
|
||||||
|
- The web UI styling should be consistent and nice
|
||||||
|
- No native build dependency
|
||||||
|
|
||||||
|
## Coding Styles
|
||||||
|
|
||||||
|
- 4 spaces indentation
|
||||||
|
- Follow `.editorconfig`
|
||||||
|
- Follow ESLint
|
||||||
|
- Methods and functions should be documented with JSDoc
|
||||||
|
|
||||||
|
## Name Conventions
|
||||||
|
|
||||||
|
- Javascript/Typescript: camelCaseType
|
||||||
|
- SQLite: snake_case (Underscore)
|
||||||
|
- CSS/SCSS: kebab-case (Dash)
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- [`Node.js`](https://nodejs.org/) >= 20
|
||||||
|
- [`pnpm`](https://pnpm.io/)
|
||||||
|
- [`git`](https://git-scm.com/)
|
||||||
|
- IDE that supports [`ESLint`](https://eslint.org/) and EditorConfig (I am using [`IntelliJ IDEA`](https://www.jetbrains.com/idea/))
|
||||||
|
- A SQLite GUI tool (f.ex. [`SQLite Expert Personal`](https://www.sqliteexpert.com/download.html) or [`DBeaver Community`](https://dbeaver.io/download/))
|
||||||
|
|
||||||
|
## Install Dependencies for Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev Server
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm run dev:frontend
|
||||||
|
pnpm run dev:backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Dev Server
|
||||||
|
|
||||||
|
It binds to `0.0.0.0:5001` by default.
|
||||||
|
|
||||||
|
It is mainly a socket.io app + express.js.
|
||||||
|
|
||||||
|
## Frontend Dev Server
|
||||||
|
|
||||||
|
It binds to `0.0.0.0:5000` by default. The frontend dev server is used for development only.
|
||||||
|
|
||||||
|
For production, it is not used. It will be compiled to `frontend-dist` directory instead.
|
||||||
|
|
||||||
|
You can use Vue.js devtools Chrome extension for debugging.
|
||||||
|
|
||||||
|
### Build the frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Both frontend and backend share the same package.json. However, the frontend dependencies are eventually not used in the production environment, because it is usually also baked into dist files. So:
|
||||||
|
|
||||||
|
- Frontend dependencies = "devDependencies"
|
||||||
|
- Examples: vue, chart.js
|
||||||
|
- Backend dependencies = "dependencies"
|
||||||
|
- Examples: socket.io, sqlite3
|
||||||
|
- Development dependencies = "devDependencies"
|
||||||
|
- Examples: eslint, sass
|
||||||
|
|
||||||
|
### Update Dependencies
|
||||||
|
|
||||||
|
Should only be done by the maintainer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm update
|
||||||
|
````
|
||||||
|
|
||||||
|
It should update the patch release version only.
|
||||||
|
|
||||||
|
Patch release = the third digit ([Semantic Versioning](https://semver.org/))
|
||||||
|
|
||||||
|
If for security / bug / other reasons, a library must be updated, breaking changes need to be checked by the person proposing the change.
|
||||||
|
|
||||||
|
## Translations
|
||||||
|
|
||||||
|
Please add **all** the strings which are translatable to `src/lang/en.json` (If translation keys are omitted, they can not be translated).
|
||||||
|
|
||||||
|
**Don't include any other languages in your initial Pull-Request** (even if this is your mother tongue), to avoid merge-conflicts between weblate and `master`.
|
||||||
|
The translations can then (after merging a PR into `master`) be translated by awesome people donating their language skills.
|
||||||
|
|
||||||
|
If you want to help by translating Uptime Kuma into your language, please visit the [instructions on how to translate using weblate](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md).
|
||||||
|
|
||||||
|
## Spelling & Grammar
|
||||||
|
|
||||||
|
Feel free to correct the grammar in the documentation or code.
|
||||||
|
My mother language is not English and my grammar is not that great.
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 Louis Lam
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
170
README.md
170
README.md
@ -1 +1,171 @@
|
|||||||
|
<div align="center" width="100%">
|
||||||
|
<img src="./frontend/public/icon.svg" width="128" alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
# Dockge
|
# Dockge
|
||||||
|
|
||||||
|
A fancy, easy-to-use and reactive self-hosted docker compose.yaml stack-oriented manager.
|
||||||
|
|
||||||
|
      
|
||||||
|
|
||||||
|
<img src="https://github.com/louislam/dockge/assets/1336778/26a583e1-ecb1-4a8d-aedf-76157d714ad7" width="900" alt="" />
|
||||||
|
|
||||||
|
View Video: https://youtu.be/AWAlOQeNpgU?t=48
|
||||||
|
|
||||||
|
## ⭐ Features
|
||||||
|
|
||||||
|
- Manage `compose.yaml`
|
||||||
|
- Create/Edit/Start/Stop/Restart/Delete
|
||||||
|
- Update Docker Images
|
||||||
|
- Interactive Editor for `compose.yaml`
|
||||||
|
- Interactive Web Terminal
|
||||||
|
- Reactive
|
||||||
|
- Everything is just responsive. Progress (Pull/Up/Down) and terminal output are in real-time
|
||||||
|
- Easy-to-use & fancy UI
|
||||||
|
- If you love Uptime Kuma's UI/UX, you will love this one too
|
||||||
|
- Convert `docker run ...` commands into `compose.yaml`
|
||||||
|
- File based structure
|
||||||
|
- Dockge won't kidnap your compose files, they are stored on your drive as usual. You can interact with them using normal `docker compose` commands
|
||||||
|
<img src="https://github.com/louislam/dockge/assets/1336778/cc071864-592e-4909-b73a-343a57494002" width=300 />
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 🔧 How to Install
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- [Docker CE](https://docs.docker.com/engine/install/) 20+ is recommended / Podman
|
||||||
|
- (Docker only) [Docker Compose Plugin](https://docs.docker.com/compose/install/linux/)
|
||||||
|
- (Podman only) podman-docker (Debian: `apt install podman-docker`)
|
||||||
|
- OS:
|
||||||
|
- As long as you can run Docker CE / Podman, it should be fine, but:
|
||||||
|
- Debian/Raspbian Buster or lower is not supported, please upgrade to Bullseye or higher
|
||||||
|
- Arch: armv7, arm64, amd64 (a.k.a x86_64)
|
||||||
|
|
||||||
|
### Basic
|
||||||
|
|
||||||
|
- Default Stacks Directory: `/opt/stacks`
|
||||||
|
- Default Port: 5001
|
||||||
|
|
||||||
|
```
|
||||||
|
# Create a directory that stores your stacks and stores dockge's compose.yaml
|
||||||
|
mkdir -p /opt/stacks /opt/dockge
|
||||||
|
cd /opt/dockge
|
||||||
|
|
||||||
|
# Download the compose.yaml
|
||||||
|
curl https://raw.githubusercontent.com/louislam/dockge/master/compose.yaml --output compose.yaml
|
||||||
|
|
||||||
|
# Start the Server
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# If you are using docker-compose V1 or Podman
|
||||||
|
# docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockge is now running on http://localhost:5001
|
||||||
|
|
||||||
|
### Advanced
|
||||||
|
|
||||||
|
If you want to store your stacks in another directory, you can change the `DOCKGE_STACKS_DIR` environment variable and volumes.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
dockge:
|
||||||
|
image: louislam/dockge:1
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# Host Port : Container Port
|
||||||
|
- 5001:5001
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
# If you want to use private registries, you need to share the auth file with Dockge:
|
||||||
|
# - /root/.docker/:/root/.docker
|
||||||
|
|
||||||
|
# Your stacks directory in the host (The paths inside container must be the same as the host)
|
||||||
|
# ⚠️⚠️ If you did it wrong, your data could end up be written into a wrong path.
|
||||||
|
# ✔️✔️✔️✔️ CORRECT: - /my-stacks:/my-stacks (Both paths match)
|
||||||
|
# ❌❌❌❌ WRONG: - /docker:/my-stacks (Both paths do not match)
|
||||||
|
- /opt/stacks:/opt/stacks
|
||||||
|
environment:
|
||||||
|
# Tell Dockge where is your stacks directory
|
||||||
|
- DOCKGE_STACKS_DIR=/opt/stacks
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/dockge
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## Motivations
|
||||||
|
|
||||||
|
- I have been using Portainer for some time, but for the stack management, I am sometimes not satisfied with it. For example, sometimes when I try to deploy a stack, the loading icon keeps spinning for a few minutes without progress. And sometimes error messages are not clear.
|
||||||
|
- Try to develop with ES Module + TypeScript (Originally, I planned to use Deno or Bun.js, but they don't have support for arm64, so I stepped back to Node.js)
|
||||||
|
|
||||||
|
If you love this project, please consider giving it a ⭐.
|
||||||
|
|
||||||
|
|
||||||
|
## 🗣️
|
||||||
|
|
||||||
|
### Bug Report
|
||||||
|
https://github.com/louislam/dockge/issues
|
||||||
|
|
||||||
|
### Ask for Help / Discussions
|
||||||
|
https://github.com/louislam/dockge/discussions
|
||||||
|
|
||||||
|
## Translation
|
||||||
|
|
||||||
|
If you want to translate Dockge into your language, please read [Translation Guide](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
#### "Dockge"?
|
||||||
|
|
||||||
|
"Dockge" is a coinage word which is created by myself. I hope it sounds like `Dodge`.
|
||||||
|
|
||||||
|
The naming idea came from Twitch emotes like `sadge`, `bedge` or `wokege`. They all end in `-ge`.
|
||||||
|
|
||||||
|
#### Can I manage a single container without `compose.yaml`?
|
||||||
|
|
||||||
|
The main objective of Dockge is to try to use the docker `compose.yaml` for everything. If you want to manage a single container, you can just use Portainer or Docker CLI.
|
||||||
|
|
||||||
|
#### Can I manage existing stacks?
|
||||||
|
|
||||||
|
Yes, you can. However, you need to move your compose file into the stacks directory:
|
||||||
|
|
||||||
|
1. Stop your stack
|
||||||
|
2. Move your compose file into `/opt/stacks/<stackName>/compose.yaml`
|
||||||
|
3. In Dockge, click the " Scan Stacks Folder" button in the top-right corner's dropdown menu
|
||||||
|
4. Now you should see your stack in the list
|
||||||
|
|
||||||
|
## More Ideas?
|
||||||
|
|
||||||
|
- Stats
|
||||||
|
- File manager
|
||||||
|
- App store for yaml templates
|
||||||
|
- Get app icons
|
||||||
|
- Switch Docker context
|
||||||
|
- Support Dockerfile and build
|
||||||
|
- Support Docker swarm
|
||||||
|
|
||||||
|
|
||||||
|
# Others
|
||||||
|
|
||||||
|
Dockge is built on top of [Compose V2](https://docs.docker.com/compose/migrate/). `compose.yaml` also known as `docker-compose.yml`.
|
||||||
|
12
SECURITY.md
Normal file
12
SECURITY.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
1. Please report security issues to https://github.com/louislam/dockge/security/advisories/new.
|
||||||
|
1. Please also create an empty security issue to alert me, as GitHub Advisories do not send a notification, I probably will miss it without this. https://github.com/louislam/dockge/issues/new?assignees=&labels=help&template=security.md
|
||||||
|
|
||||||
|
Do not use the public issue tracker or discuss it in public as it will cause more damage.
|
||||||
|
|
||||||
|
## Do you accept other 3rd-party bug bounty platforms?
|
||||||
|
|
||||||
|
At this moment, I DO NOT accept other bug bounty platforms, because I am not familiar with these platforms and someone has tried to send a phishing link to me by doing this already. To minimize my own risk, please report through GitHub Advisories only. I will ignore all 3rd-party bug bounty platforms emails.
|
71
backend/check-version.ts
Normal file
71
backend/check-version.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { log } from "./log";
|
||||||
|
import compareVersions from "compare-versions";
|
||||||
|
import packageJSON from "../package.json";
|
||||||
|
import { Settings } from "./settings";
|
||||||
|
|
||||||
|
export const obj = {
|
||||||
|
version: packageJSON.version,
|
||||||
|
latestVersion: null,
|
||||||
|
};
|
||||||
|
export default obj;
|
||||||
|
|
||||||
|
// How much time in ms to wait between update checks
|
||||||
|
const UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 60 * 48;
|
||||||
|
const CHECK_URL = "https://dockge.kuma.pet/version";
|
||||||
|
|
||||||
|
let interval : NodeJS.Timeout;
|
||||||
|
|
||||||
|
export function startInterval() {
|
||||||
|
const check = async () => {
|
||||||
|
if (await Settings.get("checkUpdate") === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("update-checker", "Retrieving latest versions");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(CHECK_URL);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// For debug
|
||||||
|
if (process.env.TEST_CHECK_VERSION === "1") {
|
||||||
|
data.slow = "1000.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkBeta = await Settings.get("checkBeta");
|
||||||
|
|
||||||
|
if (checkBeta && data.beta) {
|
||||||
|
if (compareVersions.compare(data.beta, data.slow, ">")) {
|
||||||
|
obj.latestVersion = data.beta;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.slow) {
|
||||||
|
obj.latestVersion = data.slow;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (_) {
|
||||||
|
log.info("update-checker", "Failed to check for new versions");
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
interval = setInterval(check, UPDATE_CHECKER_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the check update feature
|
||||||
|
* @param value Should the check update feature be enabled?
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export async function enableCheckUpdate(value : boolean) {
|
||||||
|
await Settings.set("checkUpdate", value);
|
||||||
|
|
||||||
|
clearInterval(interval);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
startInterval();
|
||||||
|
}
|
||||||
|
}
|
258
backend/database.ts
Normal file
258
backend/database.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import { log } from "./log";
|
||||||
|
import { R } from "redbean-node";
|
||||||
|
import { DockgeServer } from "./dockge-server";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import knex from "knex";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import Dialect from "knex/lib/dialects/sqlite3/index.js";
|
||||||
|
|
||||||
|
import sqlite from "@louislam/sqlite3";
|
||||||
|
import { sleep } from "./util-common";
|
||||||
|
|
||||||
|
interface DBConfig {
|
||||||
|
type?: "sqlite" | "mysql";
|
||||||
|
hostname?: string;
|
||||||
|
port?: string;
|
||||||
|
database?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Database {
|
||||||
|
/**
|
||||||
|
* SQLite file path (Default: ./data/dockge.db)
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
static sqlitePath : string;
|
||||||
|
|
||||||
|
static noReject = true;
|
||||||
|
|
||||||
|
static dbConfig: DBConfig = {};
|
||||||
|
|
||||||
|
static knexMigrationsPath = "./backend/migrations";
|
||||||
|
|
||||||
|
private static server : DockgeServer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use for decode the auth object
|
||||||
|
*/
|
||||||
|
jwtSecret? : string;
|
||||||
|
|
||||||
|
static async init(server : DockgeServer) {
|
||||||
|
this.server = server;
|
||||||
|
|
||||||
|
log.debug("server", "Connecting to the database");
|
||||||
|
await Database.connect();
|
||||||
|
log.info("server", "Connected to the database");
|
||||||
|
|
||||||
|
// Patch the database
|
||||||
|
await Database.patch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the database config
|
||||||
|
* @throws {Error} If the config is invalid
|
||||||
|
* @typedef {string|undefined} envString
|
||||||
|
* @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config
|
||||||
|
*/
|
||||||
|
static readDBConfig() : DBConfig {
|
||||||
|
const dbConfigString = fs.readFileSync(path.join(this.server.config.dataDir, "db-config.json")).toString("utf-8");
|
||||||
|
const dbConfig = JSON.parse(dbConfigString);
|
||||||
|
|
||||||
|
if (typeof dbConfig !== "object") {
|
||||||
|
throw new Error("Invalid db-config.json, it must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof dbConfig.type !== "string") {
|
||||||
|
throw new Error("Invalid db-config.json, type must be a string");
|
||||||
|
}
|
||||||
|
return dbConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {string|undefined} envString
|
||||||
|
* @param dbConfig the database configuration that should be written
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
static writeDBConfig(dbConfig : DBConfig) {
|
||||||
|
fs.writeFileSync(path.join(this.server.config.dataDir, "db-config.json"), JSON.stringify(dbConfig, null, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the database
|
||||||
|
* @param {boolean} autoloadModels Should models be automatically loaded?
|
||||||
|
* @param {boolean} noLog Should logs not be output?
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async connect(autoloadModels = true) {
|
||||||
|
const acquireConnectionTimeout = 120 * 1000;
|
||||||
|
let dbConfig : DBConfig;
|
||||||
|
try {
|
||||||
|
dbConfig = this.readDBConfig();
|
||||||
|
Database.dbConfig = dbConfig;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
log.warn("db", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbConfig = {
|
||||||
|
type: "sqlite",
|
||||||
|
};
|
||||||
|
this.writeDBConfig(dbConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = {};
|
||||||
|
|
||||||
|
log.info("db", `Database Type: ${dbConfig.type}`);
|
||||||
|
|
||||||
|
if (dbConfig.type === "sqlite") {
|
||||||
|
this.sqlitePath = path.join(this.server.config.dataDir, "dockge.db");
|
||||||
|
Dialect.prototype._driver = () => sqlite;
|
||||||
|
|
||||||
|
config = {
|
||||||
|
client: Dialect,
|
||||||
|
connection: {
|
||||||
|
filename: Database.sqlitePath,
|
||||||
|
acquireConnectionTimeout: acquireConnectionTimeout,
|
||||||
|
},
|
||||||
|
useNullAsDefault: true,
|
||||||
|
pool: {
|
||||||
|
min: 1,
|
||||||
|
max: 1,
|
||||||
|
idleTimeoutMillis: 120 * 1000,
|
||||||
|
propagateCreateError: false,
|
||||||
|
acquireTimeoutMillis: acquireConnectionTimeout,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error("Unknown Database type: " + dbConfig.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const knexInstance = knex(config);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
R.setup(knexInstance);
|
||||||
|
|
||||||
|
if (process.env.SQL_LOG === "1") {
|
||||||
|
R.debug(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto map the model to a bean object
|
||||||
|
R.freeze(true);
|
||||||
|
|
||||||
|
if (autoloadModels) {
|
||||||
|
R.autoloadModels("./backend/models", "ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbConfig.type === "sqlite") {
|
||||||
|
await this.initSQLite();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
@returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async initSQLite() {
|
||||||
|
await R.exec("PRAGMA foreign_keys = ON");
|
||||||
|
// Change to WAL
|
||||||
|
await R.exec("PRAGMA journal_mode = WAL");
|
||||||
|
await R.exec("PRAGMA cache_size = -12000");
|
||||||
|
await R.exec("PRAGMA auto_vacuum = INCREMENTAL");
|
||||||
|
|
||||||
|
// This ensures that an operating system crash or power failure will not corrupt the database.
|
||||||
|
// FULL synchronous is very safe, but it is also slower.
|
||||||
|
// Read more: https://sqlite.org/pragma.html#pragma_synchronous
|
||||||
|
await R.exec("PRAGMA synchronous = NORMAL");
|
||||||
|
|
||||||
|
log.debug("db", "SQLite config:");
|
||||||
|
log.debug("db", await R.getAll("PRAGMA journal_mode"));
|
||||||
|
log.debug("db", await R.getAll("PRAGMA cache_size"));
|
||||||
|
log.debug("db", "SQLite Version: " + await R.getCell("SELECT sqlite_version()"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch the database
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
static async patch() {
|
||||||
|
// Using knex migrations
|
||||||
|
// https://knexjs.org/guide/migrations.html
|
||||||
|
// https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261
|
||||||
|
try {
|
||||||
|
await R.knex.migrate.latest({
|
||||||
|
directory: Database.knexMigrationsPath,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
// Allow missing patch files for downgrade or testing pr.
|
||||||
|
if (e.message.includes("the following files are missing:")) {
|
||||||
|
log.warn("db", e.message);
|
||||||
|
log.warn("db", "Database migration failed, you may be downgrading Dockge.");
|
||||||
|
} else {
|
||||||
|
log.error("db", "Database migration failed");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special handle, because tarn.js throw a promise reject that cannot be caught
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async close() {
|
||||||
|
const listener = () => {
|
||||||
|
Database.noReject = false;
|
||||||
|
};
|
||||||
|
process.addListener("unhandledRejection", listener);
|
||||||
|
|
||||||
|
log.info("db", "Closing the database");
|
||||||
|
|
||||||
|
// Flush WAL to main database
|
||||||
|
if (Database.dbConfig.type === "sqlite") {
|
||||||
|
await R.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
Database.noReject = true;
|
||||||
|
await R.close();
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
if (Database.noReject) {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
log.info("db", "Waiting to close the database");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("db", "Database closed");
|
||||||
|
|
||||||
|
process.removeListener("unhandledRejection", listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the size of the database (SQLite only)
|
||||||
|
* @returns {number} Size of database
|
||||||
|
*/
|
||||||
|
static getSize() {
|
||||||
|
if (Database.dbConfig.type === "sqlite") {
|
||||||
|
log.debug("db", "Database.getSize()");
|
||||||
|
const stats = fs.statSync(Database.sqlitePath);
|
||||||
|
log.debug("db", stats);
|
||||||
|
return stats.size;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shrink the database
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async shrink() {
|
||||||
|
if (Database.dbConfig.type === "sqlite") {
|
||||||
|
await R.exec("VACUUM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
3
backend/docker.ts
Normal file
3
backend/docker.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export class Docker {
|
||||||
|
|
||||||
|
}
|
571
backend/dockge-server.ts
Normal file
571
backend/dockge-server.ts
Normal file
@ -0,0 +1,571 @@
|
|||||||
|
import { MainRouter } from "./routers/main-router";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import { PackageJson } from "type-fest";
|
||||||
|
import { Database } from "./database";
|
||||||
|
import packageJSON from "../package.json";
|
||||||
|
import { log } from "./log";
|
||||||
|
import * as socketIO from "socket.io";
|
||||||
|
import express, { Express } from "express";
|
||||||
|
import { parse } from "ts-command-line-args";
|
||||||
|
import https from "https";
|
||||||
|
import http from "http";
|
||||||
|
import { Router } from "./router";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { MainSocketHandler } from "./socket-handlers/main-socket-handler";
|
||||||
|
import { SocketHandler } from "./socket-handler";
|
||||||
|
import { Settings } from "./settings";
|
||||||
|
import checkVersion from "./check-version";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { R } from "redbean-node";
|
||||||
|
import { genSecret, isDev } from "./util-common";
|
||||||
|
import { generatePasswordHash } from "./password-hash";
|
||||||
|
import { Bean } from "redbean-node/dist/bean";
|
||||||
|
import { Arguments, Config, DockgeSocket } from "./util-server";
|
||||||
|
import { DockerSocketHandler } from "./socket-handlers/docker-socket-handler";
|
||||||
|
import expressStaticGzip from "express-static-gzip";
|
||||||
|
import path from "path";
|
||||||
|
import { TerminalSocketHandler } from "./socket-handlers/terminal-socket-handler";
|
||||||
|
import { Stack } from "./stack";
|
||||||
|
import { Cron } from "croner";
|
||||||
|
import gracefulShutdown from "http-graceful-shutdown";
|
||||||
|
import User from "./models/user";
|
||||||
|
import childProcess from "child_process";
|
||||||
|
import { Terminal } from "./terminal";
|
||||||
|
|
||||||
|
export class DockgeServer {
|
||||||
|
app : Express;
|
||||||
|
httpServer : http.Server;
|
||||||
|
packageJSON : PackageJson;
|
||||||
|
io : socketIO.Server;
|
||||||
|
config : Config;
|
||||||
|
indexHTML : string = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of express routers
|
||||||
|
*/
|
||||||
|
routerList : Router[] = [
|
||||||
|
new MainRouter(),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of socket handlers
|
||||||
|
*/
|
||||||
|
socketHandlerList : SocketHandler[] = [
|
||||||
|
new MainSocketHandler(),
|
||||||
|
new DockerSocketHandler(),
|
||||||
|
new TerminalSocketHandler(),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show Setup Page
|
||||||
|
*/
|
||||||
|
needSetup = false;
|
||||||
|
|
||||||
|
jwtSecret : string = "";
|
||||||
|
|
||||||
|
stacksDir : string = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
// Catch unexpected errors here
|
||||||
|
let unexpectedErrorHandler = (error : unknown) => {
|
||||||
|
console.trace(error);
|
||||||
|
console.error("If you keep encountering errors, please report to https://github.com/louislam/dockge");
|
||||||
|
};
|
||||||
|
process.addListener("unhandledRejection", unexpectedErrorHandler);
|
||||||
|
process.addListener("uncaughtException", unexpectedErrorHandler);
|
||||||
|
|
||||||
|
if (!process.env.NODE_ENV) {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log NODE ENV
|
||||||
|
log.info("server", "NODE_ENV: " + process.env.NODE_ENV);
|
||||||
|
|
||||||
|
// Default stacks directory
|
||||||
|
let defaultStacksDir;
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
defaultStacksDir = "./stacks";
|
||||||
|
} else {
|
||||||
|
defaultStacksDir = "/opt/stacks";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define all possible arguments
|
||||||
|
let args = parse<Arguments>({
|
||||||
|
sslKey: {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
sslCert: {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
sslKeyPassphrase: {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
port: {
|
||||||
|
type: Number,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
hostname: {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
dataDir: {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
stacksDir: {
|
||||||
|
type: String,
|
||||||
|
optional: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.config = args as Config;
|
||||||
|
|
||||||
|
// Load from environment variables or default values if args are not set
|
||||||
|
this.config.sslKey = args.sslKey || process.env.DOCKGE_SSL_KEY || undefined;
|
||||||
|
this.config.sslCert = args.sslCert || process.env.DOCKGE_SSL_CERT || undefined;
|
||||||
|
this.config.sslKeyPassphrase = args.sslKeyPassphrase || process.env.DOCKGE_SSL_KEY_PASSPHRASE || undefined;
|
||||||
|
this.config.port = args.port || Number(process.env.DOCKGE_PORT) || 5001;
|
||||||
|
this.config.hostname = args.hostname || process.env.DOCKGE_HOSTNAME || undefined;
|
||||||
|
this.config.dataDir = args.dataDir || process.env.DOCKGE_DATA_DIR || "./data/";
|
||||||
|
this.config.stacksDir = args.stacksDir || process.env.DOCKGE_STACKS_DIR || defaultStacksDir;
|
||||||
|
this.stacksDir = this.config.stacksDir;
|
||||||
|
|
||||||
|
log.debug("server", this.config);
|
||||||
|
|
||||||
|
this.packageJSON = packageJSON as PackageJson;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.indexHTML = fs.readFileSync("./frontend-dist/index.html").toString();
|
||||||
|
} catch (e) {
|
||||||
|
// "dist/index.html" is not necessary for development
|
||||||
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
log.error("server", "Error: Cannot find 'frontend-dist/index.html', did you install correctly?");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all the necessary directories
|
||||||
|
this.initDataDir();
|
||||||
|
|
||||||
|
// Create express
|
||||||
|
this.app = express();
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
if (this.config.sslKey && this.config.sslCert) {
|
||||||
|
log.info("server", "Server Type: HTTPS");
|
||||||
|
this.httpServer = https.createServer({
|
||||||
|
key: fs.readFileSync(this.config.sslKey),
|
||||||
|
cert: fs.readFileSync(this.config.sslCert),
|
||||||
|
passphrase: this.config.sslKeyPassphrase,
|
||||||
|
}, this.app);
|
||||||
|
} else {
|
||||||
|
log.info("server", "Server Type: HTTP");
|
||||||
|
this.httpServer = http.createServer(this.app);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binding Routers
|
||||||
|
for (const router of this.routerList) {
|
||||||
|
this.app.use(router.create(this.app, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
this.app.use("/", expressStaticGzip("frontend-dist", {
|
||||||
|
enableBrotli: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Universal Route Handler, must be at the end of all express routes.
|
||||||
|
this.app.get("*", async (_request, response) => {
|
||||||
|
response.send(this.indexHTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow all CORS origins in development
|
||||||
|
let cors = undefined;
|
||||||
|
if (isDev) {
|
||||||
|
cors = {
|
||||||
|
origin: "*",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Socket.io
|
||||||
|
this.io = new socketIO.Server(this.httpServer, {
|
||||||
|
cors,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.io.on("connection", async (socket: Socket) => {
|
||||||
|
log.info("server", "Socket connected!");
|
||||||
|
|
||||||
|
this.sendInfo(socket, true);
|
||||||
|
|
||||||
|
if (this.needSetup) {
|
||||||
|
log.info("server", "Redirect to setup page");
|
||||||
|
socket.emit("setup");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create socket handlers
|
||||||
|
for (const socketHandler of this.socketHandlerList) {
|
||||||
|
socketHandler.create(socket as DockgeSocket, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ***************************
|
||||||
|
// Better do anything after added all socket handlers here
|
||||||
|
// ***************************
|
||||||
|
|
||||||
|
log.debug("auth", "check auto login");
|
||||||
|
if (await Settings.get("disableAuth")) {
|
||||||
|
log.info("auth", "Disabled Auth: auto login to admin");
|
||||||
|
this.afterLogin(socket as DockgeSocket, await R.findOne("user") as User);
|
||||||
|
socket.emit("autoLogin");
|
||||||
|
} else {
|
||||||
|
log.debug("auth", "need auth");
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
this.io.on("disconnect", () => {
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
setInterval(() => {
|
||||||
|
log.debug("terminal", "Terminal count: " + Terminal.getTerminalCount());
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async afterLogin(socket : DockgeSocket, user : User) {
|
||||||
|
socket.userID = user.id;
|
||||||
|
socket.join(user.id.toString());
|
||||||
|
|
||||||
|
this.sendInfo(socket);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.sendStackList();
|
||||||
|
} catch (e) {
|
||||||
|
log.error("server", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async serve() {
|
||||||
|
// Connect to database
|
||||||
|
try {
|
||||||
|
await Database.init(this);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
log.error("server", "Failed to prepare your database: " + e.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First time setup if needed
|
||||||
|
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
|
||||||
|
"jwtSecret",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! jwtSecretBean) {
|
||||||
|
log.info("server", "JWT secret is not found, generate one.");
|
||||||
|
jwtSecretBean = await this.initJWTSecret();
|
||||||
|
log.info("server", "Stored JWT secret into database");
|
||||||
|
} else {
|
||||||
|
log.debug("server", "Load JWT secret from database.");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jwtSecret = jwtSecretBean.value;
|
||||||
|
|
||||||
|
const userCount = (await R.knex("user").count("id as count").first()).count;
|
||||||
|
|
||||||
|
log.debug("server", "User count: " + userCount);
|
||||||
|
|
||||||
|
// If there is no record in user table, it is a new Dockge instance, need to setup
|
||||||
|
if (userCount == 0) {
|
||||||
|
log.info("server", "No user, need setup");
|
||||||
|
this.needSetup = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen
|
||||||
|
this.httpServer.listen(this.config.port, this.config.hostname, () => {
|
||||||
|
if (this.config.hostname) {
|
||||||
|
log.info( "server", `Listening on ${this.config.hostname}:${this.config.port}`);
|
||||||
|
} else {
|
||||||
|
log.info("server", `Listening on ${this.config.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run every 10 seconds
|
||||||
|
Cron("*/10 * * * * *", {
|
||||||
|
protect: true, // Enabled over-run protection.
|
||||||
|
}, () => {
|
||||||
|
//log.debug("server", "Cron job running");
|
||||||
|
this.sendStackList(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
gracefulShutdown(this.httpServer, {
|
||||||
|
signals: "SIGINT SIGTERM",
|
||||||
|
timeout: 30000, // timeout: 30 secs
|
||||||
|
development: false, // not in dev mode
|
||||||
|
forceExit: true, // triggers process.exit() at the end of shutdown process
|
||||||
|
onShutdown: this.shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ...
|
||||||
|
finally: this.finalFunction, // finally function (sync) - e.g. for logging
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the version information to the client.
|
||||||
|
* @param socket Socket.io socket instance
|
||||||
|
* @param hideVersion Should we hide the version information in the response?
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async sendInfo(socket : Socket, hideVersion = false) {
|
||||||
|
let versionProperty;
|
||||||
|
let latestVersionProperty;
|
||||||
|
let isContainer;
|
||||||
|
|
||||||
|
if (!hideVersion) {
|
||||||
|
versionProperty = packageJSON.version;
|
||||||
|
latestVersionProperty = checkVersion.latestVersion;
|
||||||
|
isContainer = (process.env.DOCKGE_IS_CONTAINER === "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit("info", {
|
||||||
|
version: versionProperty,
|
||||||
|
latestVersion: latestVersionProperty,
|
||||||
|
isContainer,
|
||||||
|
primaryHostname: await Settings.get("primaryHostname"),
|
||||||
|
//serverTimezone: await this.getTimezone(),
|
||||||
|
//serverTimezoneOffset: this.getTimezoneOffset(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the IP of the client connected to the socket
|
||||||
|
* @param {Socket} socket Socket to query
|
||||||
|
* @returns IP of client
|
||||||
|
*/
|
||||||
|
async getClientIP(socket : Socket) : Promise<string> {
|
||||||
|
let clientIP = socket.client.conn.remoteAddress;
|
||||||
|
|
||||||
|
if (clientIP === undefined) {
|
||||||
|
clientIP = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Settings.get("trustProxy")) {
|
||||||
|
const forwardedFor = socket.client.conn.request.headers["x-forwarded-for"];
|
||||||
|
|
||||||
|
if (typeof forwardedFor === "string") {
|
||||||
|
return forwardedFor.split(",")[0].trim();
|
||||||
|
} else if (typeof socket.client.conn.request.headers["x-real-ip"] === "string") {
|
||||||
|
return socket.client.conn.request.headers["x-real-ip"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clientIP.replace(/^::ffff:/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to get the current server timezone
|
||||||
|
* If this fails, fall back to environment variables and then make a
|
||||||
|
* guess.
|
||||||
|
* @returns {Promise<string>} Current timezone
|
||||||
|
*/
|
||||||
|
async getTimezone() {
|
||||||
|
// From process.env.TZ
|
||||||
|
try {
|
||||||
|
if (process.env.TZ) {
|
||||||
|
this.checkTimezone(process.env.TZ);
|
||||||
|
return process.env.TZ;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
log.warn("timezone", e.message + " in process.env.TZ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timezone = await Settings.get("serverTimezone");
|
||||||
|
|
||||||
|
// From Settings
|
||||||
|
try {
|
||||||
|
log.debug("timezone", "Using timezone from settings: " + timezone);
|
||||||
|
if (timezone) {
|
||||||
|
this.checkTimezone(timezone);
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
log.warn("timezone", e.message + " in settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guess
|
||||||
|
try {
|
||||||
|
const guess = dayjs.tz.guess();
|
||||||
|
log.debug("timezone", "Guessing timezone: " + guess);
|
||||||
|
if (guess) {
|
||||||
|
this.checkTimezone(guess);
|
||||||
|
return guess;
|
||||||
|
} else {
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Guess failed, fall back to UTC
|
||||||
|
log.debug("timezone", "Guessed an invalid timezone. Use UTC as fallback");
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current offset
|
||||||
|
* @returns {string} Time offset
|
||||||
|
*/
|
||||||
|
getTimezoneOffset() {
|
||||||
|
return dayjs().format("Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw an error if the timezone is invalid
|
||||||
|
* @param {string} timezone Timezone to test
|
||||||
|
* @returns {void}
|
||||||
|
* @throws The timezone is invalid
|
||||||
|
*/
|
||||||
|
checkTimezone(timezone : string) {
|
||||||
|
try {
|
||||||
|
dayjs.utc("2013-11-18 11:55").tz(timezone).format();
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("Invalid timezone:" + timezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the data directory
|
||||||
|
*/
|
||||||
|
initDataDir() {
|
||||||
|
if (! fs.existsSync(this.config.dataDir)) {
|
||||||
|
fs.mkdirSync(this.config.dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a directory
|
||||||
|
if (!fs.lstatSync(this.config.dataDir).isDirectory()) {
|
||||||
|
throw new Error(`Fatal error: ${this.config.dataDir} is not a directory`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create data/stacks directory
|
||||||
|
if (!fs.existsSync(this.stacksDir)) {
|
||||||
|
fs.mkdirSync(this.stacksDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("server", `Data Dir: ${this.config.dataDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init or reset JWT secret
|
||||||
|
* @returns JWT secret
|
||||||
|
*/
|
||||||
|
async initJWTSecret() : Promise<Bean> {
|
||||||
|
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
|
||||||
|
"jwtSecret",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!jwtSecretBean) {
|
||||||
|
jwtSecretBean = R.dispense("setting");
|
||||||
|
jwtSecretBean.key = "jwtSecret";
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtSecretBean.value = generatePasswordHash(genSecret());
|
||||||
|
await R.store(jwtSecretBean);
|
||||||
|
return jwtSecretBean;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStackList(useCache = false) {
|
||||||
|
let roomList = this.io.sockets.adapter.rooms.keys();
|
||||||
|
let map : Map<string, object> | undefined;
|
||||||
|
|
||||||
|
for (let room of roomList) {
|
||||||
|
// Check if the room is a number (user id)
|
||||||
|
if (Number(room)) {
|
||||||
|
|
||||||
|
// Get the list only if there is a room
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
let stackList = Stack.getStackList(this, useCache);
|
||||||
|
|
||||||
|
for (let [ stackName, stack ] of stackList) {
|
||||||
|
map.set(stackName, stack.toSimpleJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("server", "Send stack list to room " + room);
|
||||||
|
this.io.to(room).emit("stackList", {
|
||||||
|
ok: true,
|
||||||
|
stackList: Object.fromEntries(map),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStackStatusList() {
|
||||||
|
let statusList = Stack.getStatusList();
|
||||||
|
|
||||||
|
let roomList = this.io.sockets.adapter.rooms.keys();
|
||||||
|
|
||||||
|
for (let room of roomList) {
|
||||||
|
// Check if the room is a number (user id)
|
||||||
|
if (Number(room)) {
|
||||||
|
log.debug("server", "Send stack status list to room " + room);
|
||||||
|
this.io.to(room).emit("stackStatusList", {
|
||||||
|
ok: true,
|
||||||
|
stackStatusList: Object.fromEntries(statusList),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.debug("server", "Skip sending stack status list to room " + room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDockerNetworkList() : string[] {
|
||||||
|
let res = childProcess.spawnSync("docker", [ "network", "ls", "--format", "{{.Name}}" ]);
|
||||||
|
let list = res.stdout.toString().split("\n");
|
||||||
|
|
||||||
|
// Remove empty string item
|
||||||
|
list = list.filter((item) => {
|
||||||
|
return item !== "";
|
||||||
|
}).sort((a, b) => {
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stackDirFullPath() {
|
||||||
|
return path.resolve(this.stacksDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown the application
|
||||||
|
* Stops all monitors and closes the database connection.
|
||||||
|
* @param signal The signal that triggered this function to be called.
|
||||||
|
*/
|
||||||
|
async shutdownFunction(signal : string | undefined) {
|
||||||
|
log.info("server", "Shutdown requested");
|
||||||
|
log.info("server", "Called signal: " + signal);
|
||||||
|
|
||||||
|
// TODO: Close all terminals?
|
||||||
|
|
||||||
|
await Database.close();
|
||||||
|
Settings.stopCacheCleaner();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final function called before application exits
|
||||||
|
*/
|
||||||
|
finalFunction() {
|
||||||
|
log.info("server", "Graceful shutdown successful!");
|
||||||
|
}
|
||||||
|
}
|
6
backend/index.ts
Normal file
6
backend/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { DockgeServer } from "./dockge-server";
|
||||||
|
import { log } from "./log";
|
||||||
|
|
||||||
|
log.info("server", "Welcome to dockge!");
|
||||||
|
const server = new DockgeServer();
|
||||||
|
await server.serve();
|
212
backend/log.ts
Normal file
212
backend/log.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
// Console colors
|
||||||
|
// https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color
|
||||||
|
import { intHash, isDev } from "./util-common";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export const CONSOLE_STYLE_Reset = "\x1b[0m";
|
||||||
|
export const CONSOLE_STYLE_Bright = "\x1b[1m";
|
||||||
|
export const CONSOLE_STYLE_Dim = "\x1b[2m";
|
||||||
|
export const CONSOLE_STYLE_Underscore = "\x1b[4m";
|
||||||
|
export const CONSOLE_STYLE_Blink = "\x1b[5m";
|
||||||
|
export const CONSOLE_STYLE_Reverse = "\x1b[7m";
|
||||||
|
export const CONSOLE_STYLE_Hidden = "\x1b[8m";
|
||||||
|
|
||||||
|
export const CONSOLE_STYLE_FgBlack = "\x1b[30m";
|
||||||
|
export const CONSOLE_STYLE_FgRed = "\x1b[31m";
|
||||||
|
export const CONSOLE_STYLE_FgGreen = "\x1b[32m";
|
||||||
|
export const CONSOLE_STYLE_FgYellow = "\x1b[33m";
|
||||||
|
export const CONSOLE_STYLE_FgBlue = "\x1b[34m";
|
||||||
|
export const CONSOLE_STYLE_FgMagenta = "\x1b[35m";
|
||||||
|
export const CONSOLE_STYLE_FgCyan = "\x1b[36m";
|
||||||
|
export const CONSOLE_STYLE_FgWhite = "\x1b[37m";
|
||||||
|
export const CONSOLE_STYLE_FgGray = "\x1b[90m";
|
||||||
|
export const CONSOLE_STYLE_FgOrange = "\x1b[38;5;208m";
|
||||||
|
export const CONSOLE_STYLE_FgLightGreen = "\x1b[38;5;119m";
|
||||||
|
export const CONSOLE_STYLE_FgLightBlue = "\x1b[38;5;117m";
|
||||||
|
export const CONSOLE_STYLE_FgViolet = "\x1b[38;5;141m";
|
||||||
|
export const CONSOLE_STYLE_FgBrown = "\x1b[38;5;130m";
|
||||||
|
export const CONSOLE_STYLE_FgPink = "\x1b[38;5;219m";
|
||||||
|
|
||||||
|
export const CONSOLE_STYLE_BgBlack = "\x1b[40m";
|
||||||
|
export const CONSOLE_STYLE_BgRed = "\x1b[41m";
|
||||||
|
export const CONSOLE_STYLE_BgGreen = "\x1b[42m";
|
||||||
|
export const CONSOLE_STYLE_BgYellow = "\x1b[43m";
|
||||||
|
export const CONSOLE_STYLE_BgBlue = "\x1b[44m";
|
||||||
|
export const CONSOLE_STYLE_BgMagenta = "\x1b[45m";
|
||||||
|
export const CONSOLE_STYLE_BgCyan = "\x1b[46m";
|
||||||
|
export const CONSOLE_STYLE_BgWhite = "\x1b[47m";
|
||||||
|
export const CONSOLE_STYLE_BgGray = "\x1b[100m";
|
||||||
|
|
||||||
|
const consoleModuleColors = [
|
||||||
|
CONSOLE_STYLE_FgCyan,
|
||||||
|
CONSOLE_STYLE_FgGreen,
|
||||||
|
CONSOLE_STYLE_FgLightGreen,
|
||||||
|
CONSOLE_STYLE_FgBlue,
|
||||||
|
CONSOLE_STYLE_FgLightBlue,
|
||||||
|
CONSOLE_STYLE_FgMagenta,
|
||||||
|
CONSOLE_STYLE_FgOrange,
|
||||||
|
CONSOLE_STYLE_FgViolet,
|
||||||
|
CONSOLE_STYLE_FgBrown,
|
||||||
|
CONSOLE_STYLE_FgPink,
|
||||||
|
];
|
||||||
|
|
||||||
|
const consoleLevelColors : Record<string, string> = {
|
||||||
|
"INFO": CONSOLE_STYLE_FgCyan,
|
||||||
|
"WARN": CONSOLE_STYLE_FgYellow,
|
||||||
|
"ERROR": CONSOLE_STYLE_FgRed,
|
||||||
|
"DEBUG": CONSOLE_STYLE_FgGray,
|
||||||
|
};
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOCKGE_HIDE_LOG=debug_monitor,info_monitor
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* [
|
||||||
|
* "debug_monitor", // Hide all logs that level is debug and the module is monitor
|
||||||
|
* "info_monitor",
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
hideLog : Record<string, string[]> = {
|
||||||
|
info: [],
|
||||||
|
warn: [],
|
||||||
|
error: [],
|
||||||
|
debug: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
if (typeof process !== "undefined" && process.env.DOCKGE_HIDE_LOG) {
|
||||||
|
const list = process.env.DOCKGE_HIDE_LOG.split(",").map(v => v.toLowerCase());
|
||||||
|
|
||||||
|
for (const pair of list) {
|
||||||
|
// split first "_" only
|
||||||
|
const values = pair.split(/_(.*)/s);
|
||||||
|
|
||||||
|
if (values.length >= 2) {
|
||||||
|
this.hideLog[values[0]].push(values[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debug("server", "DOCKGE_HIDE_LOG is set");
|
||||||
|
this.debug("server", this.hideLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a message to the log
|
||||||
|
* @param module The module the log comes from
|
||||||
|
* @param msg Message to write
|
||||||
|
* @param level Log level. One of INFO, WARN, ERROR, DEBUG or can be customized.
|
||||||
|
*/
|
||||||
|
log(module: string, msg: unknown, level: string) {
|
||||||
|
if (level === "DEBUG" && !isDev) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hideLog[level] && this.hideLog[level].includes(module.toLowerCase())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
module = module.toUpperCase();
|
||||||
|
level = level.toUpperCase();
|
||||||
|
|
||||||
|
let now;
|
||||||
|
if (dayjs.tz) {
|
||||||
|
now = dayjs.tz(new Date()).format();
|
||||||
|
} else {
|
||||||
|
now = dayjs().format();
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelColor = consoleLevelColors[level];
|
||||||
|
const moduleColor = consoleModuleColors[intHash(module, consoleModuleColors.length)];
|
||||||
|
|
||||||
|
let timePart = CONSOLE_STYLE_FgCyan + now + CONSOLE_STYLE_Reset;
|
||||||
|
const modulePart = "[" + moduleColor + module + CONSOLE_STYLE_Reset + "]";
|
||||||
|
const levelPart = levelColor + `${level}:` + CONSOLE_STYLE_Reset;
|
||||||
|
|
||||||
|
if (level === "INFO") {
|
||||||
|
console.info(timePart, modulePart, levelPart, msg);
|
||||||
|
} else if (level === "WARN") {
|
||||||
|
console.warn(timePart, modulePart, levelPart, msg);
|
||||||
|
} else if (level === "ERROR") {
|
||||||
|
let msgPart : unknown;
|
||||||
|
if (typeof msg === "string") {
|
||||||
|
msgPart = CONSOLE_STYLE_FgRed + msg + CONSOLE_STYLE_Reset;
|
||||||
|
} else {
|
||||||
|
msgPart = msg;
|
||||||
|
}
|
||||||
|
console.error(timePart, modulePart, levelPart, msgPart);
|
||||||
|
} else if (level === "DEBUG") {
|
||||||
|
if (isDev) {
|
||||||
|
timePart = CONSOLE_STYLE_FgGray + now + CONSOLE_STYLE_Reset;
|
||||||
|
let msgPart : unknown;
|
||||||
|
if (typeof msg === "string") {
|
||||||
|
msgPart = CONSOLE_STYLE_FgGray + msg + CONSOLE_STYLE_Reset;
|
||||||
|
} else {
|
||||||
|
msgPart = msg;
|
||||||
|
}
|
||||||
|
console.debug(timePart, modulePart, levelPart, msgPart);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(timePart, modulePart, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an INFO message
|
||||||
|
* @param module Module log comes from
|
||||||
|
* @param msg Message to write
|
||||||
|
*/
|
||||||
|
info(module: string, msg: unknown) {
|
||||||
|
this.log(module, msg, "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a WARN message
|
||||||
|
* @param module Module log comes from
|
||||||
|
* @param msg Message to write
|
||||||
|
*/
|
||||||
|
warn(module: string, msg: unknown) {
|
||||||
|
this.log(module, msg, "warn");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an ERROR message
|
||||||
|
* @param module Module log comes from
|
||||||
|
* @param msg Message to write
|
||||||
|
*/
|
||||||
|
error(module: string, msg: unknown) {
|
||||||
|
this.log(module, msg, "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a DEBUG message
|
||||||
|
* @param module Module log comes from
|
||||||
|
* @param msg Message to write
|
||||||
|
*/
|
||||||
|
debug(module: string, msg: unknown) {
|
||||||
|
this.log(module, msg, "debug");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an exception as an ERROR
|
||||||
|
* @param module Module log comes from
|
||||||
|
* @param exception The exception to include
|
||||||
|
* @param msg The message to write
|
||||||
|
*/
|
||||||
|
exception(module: string, exception: unknown, msg: unknown) {
|
||||||
|
let finalMessage = exception;
|
||||||
|
|
||||||
|
if (msg) {
|
||||||
|
finalMessage = `${msg}: ${exception}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(module, finalMessage, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const log = new Logger();
|
14
backend/migrations/2023-10-20-0829-setting-table.ts
Normal file
14
backend/migrations/2023-10-20-0829-setting-table.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
return knex.schema.createTable("setting", (table) => {
|
||||||
|
table.increments("id");
|
||||||
|
table.string("key", 200).notNullable().unique().collate("utf8_general_ci");
|
||||||
|
table.text("value");
|
||||||
|
table.string("type", 20);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
return knex.schema.dropTable("setting");
|
||||||
|
}
|
19
backend/migrations/2023-10-20-0829-user-table.ts
Normal file
19
backend/migrations/2023-10-20-0829-user-table.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
// Create the user table
|
||||||
|
return knex.schema.createTable("user", (table) => {
|
||||||
|
table.increments("id");
|
||||||
|
table.string("username", 255).notNullable().unique().collate("utf8_general_ci");
|
||||||
|
table.string("password", 255);
|
||||||
|
table.boolean("active").notNullable().defaultTo(true);
|
||||||
|
table.string("timezone", 150);
|
||||||
|
table.string("twofa_secret", 64);
|
||||||
|
table.boolean("twofa_status").notNullable().defaultTo(false);
|
||||||
|
table.string("twofa_last_token", 6);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
return knex.schema.dropTable("user");
|
||||||
|
}
|
46
backend/models/user.ts
Normal file
46
backend/models/user.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { R } from "redbean-node";
|
||||||
|
import { BeanModel } from "redbean-node/dist/bean-model";
|
||||||
|
import { generatePasswordHash, shake256, SHAKE256_LENGTH } from "../password-hash";
|
||||||
|
|
||||||
|
export class User extends BeanModel {
|
||||||
|
/**
|
||||||
|
* Reset user password
|
||||||
|
* Fix #1510, as in the context reset-password.js, there is no auto model mapping. Call this static function instead.
|
||||||
|
* @param {number} userID ID of user to update
|
||||||
|
* @param {string} newPassword Users new password
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async resetPassword(userID : number, newPassword : string) {
|
||||||
|
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
|
||||||
|
generatePasswordHash(newPassword),
|
||||||
|
userID
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset this users password
|
||||||
|
* @param {string} newPassword
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async resetPassword(newPassword : string) {
|
||||||
|
await User.resetPassword(this.id, newPassword);
|
||||||
|
this.password = newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new JWT for a user
|
||||||
|
* @param {User} user The User to create a JsonWebToken for
|
||||||
|
* @param {string} jwtSecret The key used to sign the JsonWebToken
|
||||||
|
* @returns {string} the JsonWebToken as a string
|
||||||
|
*/
|
||||||
|
static createJWT(user : User, jwtSecret : string) {
|
||||||
|
return jwt.sign({
|
||||||
|
username: user.username,
|
||||||
|
h: shake256(user.password, SHAKE256_LENGTH),
|
||||||
|
}, jwtSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default User;
|
47
backend/password-hash.ts
Normal file
47
backend/password-hash.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
const saltRounds = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a password
|
||||||
|
* @param {string} password Password to hash
|
||||||
|
* @returns {string} Hash
|
||||||
|
*/
|
||||||
|
export function generatePasswordHash(password : string) {
|
||||||
|
return bcrypt.hashSync(password, saltRounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a password against a hash
|
||||||
|
* @param {string} password Password to verify
|
||||||
|
* @param {string} hash Hash to verify against
|
||||||
|
* @returns {boolean} Does the password match the hash?
|
||||||
|
*/
|
||||||
|
export function verifyPassword(password : string, hash : string) {
|
||||||
|
return bcrypt.compareSync(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the hash need to be rehashed?
|
||||||
|
* @param {string} hash Hash to check
|
||||||
|
* @returns {boolean} Needs to be rehashed?
|
||||||
|
*/
|
||||||
|
export function needRehashPassword(hash : string) : boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHAKE256_LENGTH = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} data The data to be hashed
|
||||||
|
* @param {number} len Output length of the hash
|
||||||
|
* @returns {string} The hashed data in hex format
|
||||||
|
*/
|
||||||
|
export function shake256(data : string, len : number) {
|
||||||
|
if (!data) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return crypto.createHash("shake256", { outputLength: len })
|
||||||
|
.update(data)
|
||||||
|
.digest("hex");
|
||||||
|
}
|
81
backend/rate-limiter.ts
Normal file
81
backend/rate-limiter.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
// "limit" is bugged in Typescript, use "limiter-es6-compat" instead
|
||||||
|
// See https://github.com/jhurliman/node-rate-limiter/issues/80
|
||||||
|
import { RateLimiter, RateLimiterOpts } from "limiter-es6-compat";
|
||||||
|
import { log } from "./log";
|
||||||
|
|
||||||
|
export interface KumaRateLimiterOpts extends RateLimiterOpts {
|
||||||
|
errorMessage : string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KumaRateLimiterCallback = (err : object) => void;
|
||||||
|
|
||||||
|
class KumaRateLimiter {
|
||||||
|
|
||||||
|
errorMessage : string;
|
||||||
|
rateLimiter : RateLimiter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} config Rate limiter configuration object
|
||||||
|
*/
|
||||||
|
constructor(config : KumaRateLimiterOpts) {
|
||||||
|
this.errorMessage = config.errorMessage;
|
||||||
|
this.rateLimiter = new RateLimiter(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for pass
|
||||||
|
* @callback passCB
|
||||||
|
* @param {object} err Too many requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the request be passed through
|
||||||
|
* @param callback Callback function to call with decision
|
||||||
|
* @param {number} num Number of tokens to remove
|
||||||
|
* @returns {Promise<boolean>} Should the request be allowed?
|
||||||
|
*/
|
||||||
|
async pass(callback : KumaRateLimiterCallback, num = 1) {
|
||||||
|
const remainingRequests = await this.removeTokens(num);
|
||||||
|
log.info("rate-limit", "remaining requests: " + remainingRequests);
|
||||||
|
if (remainingRequests < 0) {
|
||||||
|
if (callback) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: this.errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a given number of tokens
|
||||||
|
* @param {number} num Number of tokens to remove
|
||||||
|
* @returns {Promise<number>} Number of remaining tokens
|
||||||
|
*/
|
||||||
|
async removeTokens(num = 1) {
|
||||||
|
return await this.rateLimiter.removeTokens(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loginRateLimiter = new KumaRateLimiter({
|
||||||
|
tokensPerInterval: 20,
|
||||||
|
interval: "minute",
|
||||||
|
fireImmediately: true,
|
||||||
|
errorMessage: "Too frequently, try again later."
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiRateLimiter = new KumaRateLimiter({
|
||||||
|
tokensPerInterval: 60,
|
||||||
|
interval: "minute",
|
||||||
|
fireImmediately: true,
|
||||||
|
errorMessage: "Too frequently, try again later."
|
||||||
|
});
|
||||||
|
|
||||||
|
export const twoFaRateLimiter = new KumaRateLimiter({
|
||||||
|
tokensPerInterval: 30,
|
||||||
|
interval: "minute",
|
||||||
|
fireImmediately: true,
|
||||||
|
errorMessage: "Too frequently, try again later."
|
||||||
|
});
|
6
backend/router.ts
Normal file
6
backend/router.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { DockgeServer } from "./dockge-server";
|
||||||
|
import { Express, Router as ExpressRouter } from "express";
|
||||||
|
|
||||||
|
export abstract class Router {
|
||||||
|
abstract create(app : Express, server : DockgeServer): ExpressRouter;
|
||||||
|
}
|
23
backend/routers/main-router.ts
Normal file
23
backend/routers/main-router.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { DockgeServer } from "../dockge-server";
|
||||||
|
import { Router } from "../router";
|
||||||
|
import express, { Express, Router as ExpressRouter } from "express";
|
||||||
|
|
||||||
|
export class MainRouter extends Router {
|
||||||
|
create(app: Express, server: DockgeServer): ExpressRouter {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
res.send(server.indexHTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Robots.txt
|
||||||
|
router.get("/robots.txt", async (_request, response) => {
|
||||||
|
let txt = "User-agent: *\nDisallow: /";
|
||||||
|
response.setHeader("Content-Type", "text/plain");
|
||||||
|
response.send(txt);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
174
backend/settings.ts
Normal file
174
backend/settings.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { R } from "redbean-node";
|
||||||
|
import { log } from "./log";
|
||||||
|
import { LooseObject } from "./util-common";
|
||||||
|
|
||||||
|
export class Settings {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example:
|
||||||
|
* {
|
||||||
|
* key1: {
|
||||||
|
* value: "value2",
|
||||||
|
* timestamp: 12345678
|
||||||
|
* },
|
||||||
|
* key2: {
|
||||||
|
* value: 2,
|
||||||
|
* timestamp: 12345678
|
||||||
|
* },
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
static cacheList : LooseObject = {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
static cacheCleaner? : NodeJS.Timeout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve value of setting based on key
|
||||||
|
* @param key Key of setting to retrieve
|
||||||
|
* @returns Value
|
||||||
|
*/
|
||||||
|
static async get(key : string) {
|
||||||
|
|
||||||
|
// Start cache clear if not started yet
|
||||||
|
if (!Settings.cacheCleaner) {
|
||||||
|
Settings.cacheCleaner = setInterval(() => {
|
||||||
|
log.debug("settings", "Cache Cleaner is just started.");
|
||||||
|
for (key in Settings.cacheList) {
|
||||||
|
if (Date.now() - Settings.cacheList[key].timestamp > 60 * 1000) {
|
||||||
|
log.debug("settings", "Cache Cleaner deleted: " + key);
|
||||||
|
delete Settings.cacheList[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query from cache
|
||||||
|
if (key in Settings.cacheList) {
|
||||||
|
const v = Settings.cacheList[key].value;
|
||||||
|
log.debug("settings", `Get Setting (cache): ${key}: ${v}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [
|
||||||
|
key,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(value);
|
||||||
|
log.debug("settings", `Get Setting: ${key}: ${v}`);
|
||||||
|
|
||||||
|
Settings.cacheList[key] = {
|
||||||
|
value: v,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
return v;
|
||||||
|
} catch (e) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the specified setting to specified value
|
||||||
|
* @param key Key of setting to set
|
||||||
|
* @param value Value to set to
|
||||||
|
* @param {?string} type Type of setting
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async set(key : string, value : object | string | number | boolean, type : string | null = null) {
|
||||||
|
|
||||||
|
let bean = await R.findOne("setting", " `key` = ? ", [
|
||||||
|
key,
|
||||||
|
]);
|
||||||
|
if (!bean) {
|
||||||
|
bean = R.dispense("setting");
|
||||||
|
bean.key = key;
|
||||||
|
}
|
||||||
|
bean.type = type;
|
||||||
|
bean.value = JSON.stringify(value);
|
||||||
|
await R.store(bean);
|
||||||
|
|
||||||
|
Settings.deleteCache([ key ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get settings based on type
|
||||||
|
* @param type The type of setting
|
||||||
|
* @returns Settings
|
||||||
|
*/
|
||||||
|
static async getSettings(type : string) {
|
||||||
|
const list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [
|
||||||
|
type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result : LooseObject = {};
|
||||||
|
|
||||||
|
for (const row of list) {
|
||||||
|
try {
|
||||||
|
result[row.key] = JSON.parse(row.value);
|
||||||
|
} catch (e) {
|
||||||
|
result[row.key] = row.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set settings based on type
|
||||||
|
* @param type Type of settings to set
|
||||||
|
* @param data Values of settings
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async setSettings(type : string, data : LooseObject) {
|
||||||
|
const keyList = Object.keys(data);
|
||||||
|
|
||||||
|
const promiseList = [];
|
||||||
|
|
||||||
|
for (const key of keyList) {
|
||||||
|
let bean = await R.findOne("setting", " `key` = ? ", [
|
||||||
|
key
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (bean == null) {
|
||||||
|
bean = R.dispense("setting");
|
||||||
|
bean.type = type;
|
||||||
|
bean.key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bean.type === type) {
|
||||||
|
bean.value = JSON.stringify(data[key]);
|
||||||
|
promiseList.push(R.store(bean));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promiseList);
|
||||||
|
|
||||||
|
Settings.deleteCache(keyList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete selected keys from settings cache
|
||||||
|
* @param {string[]} keyList Keys to remove
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
static deleteCache(keyList : string[]) {
|
||||||
|
for (const key of keyList) {
|
||||||
|
delete Settings.cacheList[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the cache cleaner if running
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
static stopCacheCleaner() {
|
||||||
|
if (Settings.cacheCleaner) {
|
||||||
|
clearInterval(Settings.cacheCleaner);
|
||||||
|
Settings.cacheCleaner = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
6
backend/socket-handler.ts
Normal file
6
backend/socket-handler.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { DockgeServer } from "./dockge-server";
|
||||||
|
import { DockgeSocket } from "./util-server";
|
||||||
|
|
||||||
|
export abstract class SocketHandler {
|
||||||
|
abstract create(socket : DockgeSocket, server : DockgeServer): void;
|
||||||
|
}
|
285
backend/socket-handlers/docker-socket-handler.ts
Normal file
285
backend/socket-handlers/docker-socket-handler.ts
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
import { SocketHandler } from "../socket-handler.js";
|
||||||
|
import { DockgeServer } from "../dockge-server";
|
||||||
|
import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server";
|
||||||
|
import { Stack } from "../stack";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import composerize from "composerize";
|
||||||
|
|
||||||
|
export class DockerSocketHandler extends SocketHandler {
|
||||||
|
create(socket : DockgeSocket, server : DockgeServer) {
|
||||||
|
|
||||||
|
socket.on("deployStack", async (name : unknown, composeYAML : unknown, isAdd : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
const stack = this.saveStack(socket, server, name, composeYAML, isAdd);
|
||||||
|
await stack.deploy(socket);
|
||||||
|
server.sendStackList();
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Deployed",
|
||||||
|
});
|
||||||
|
stack.joinCombinedTerminal(socket);
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("saveStack", async (name : unknown, composeYAML : unknown, isAdd : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
this.saveStack(socket, server, name, composeYAML, isAdd);
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
"msg": "Saved"
|
||||||
|
});
|
||||||
|
server.sendStackList();
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("deleteStack", async (name : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
if (typeof(name) !== "string") {
|
||||||
|
throw new ValidationError("Name must be a string");
|
||||||
|
}
|
||||||
|
const stack = Stack.getStack(server, name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stack.delete(socket);
|
||||||
|
} catch (e) {
|
||||||
|
server.sendStackList();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
server.sendStackList();
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Deleted"
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("getStack", (stackName : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(stackName) !== "string") {
|
||||||
|
throw new ValidationError("Stack name must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = Stack.getStack(server, stackName);
|
||||||
|
|
||||||
|
if (stack.isManagedByDockge) {
|
||||||
|
stack.joinCombinedTerminal(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
stack: stack.toJSON(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// requestStackList
|
||||||
|
socket.on("requestStackList", async (callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
server.sendStackList();
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Updated"
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// startStack
|
||||||
|
socket.on("startStack", async (stackName : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(stackName) !== "string") {
|
||||||
|
throw new ValidationError("Stack name must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = Stack.getStack(server, stackName);
|
||||||
|
await stack.start(socket);
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Started"
|
||||||
|
});
|
||||||
|
server.sendStackList();
|
||||||
|
|
||||||
|
stack.joinCombinedTerminal(socket);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// stopStack
|
||||||
|
socket.on("stopStack", async (stackName : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(stackName) !== "string") {
|
||||||
|
throw new ValidationError("Stack name must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = Stack.getStack(server, stackName);
|
||||||
|
await stack.stop(socket);
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Stopped"
|
||||||
|
});
|
||||||
|
server.sendStackList();
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// restartStack
|
||||||
|
socket.on("restartStack", async (stackName : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(stackName) !== "string") {
|
||||||
|
throw new ValidationError("Stack name must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = Stack.getStack(server, stackName);
|
||||||
|
await stack.restart(socket);
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Restarted"
|
||||||
|
});
|
||||||
|
server.sendStackList();
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// updateStack
|
||||||
|
socket.on("updateStack", async (stackName : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(stackName) !== "string") {
|
||||||
|
throw new ValidationError("Stack name must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = Stack.getStack(server, stackName);
|
||||||
|
await stack.update(socket);
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Updated"
|
||||||
|
});
|
||||||
|
server.sendStackList();
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// down stack
|
||||||
|
socket.on("downStack", async (stackName : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(stackName) !== "string") {
|
||||||
|
throw new ValidationError("Stack name must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = Stack.getStack(server, stackName);
|
||||||
|
await stack.down(socket);
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Downed"
|
||||||
|
});
|
||||||
|
server.sendStackList();
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Services status
|
||||||
|
socket.on("serviceStatusList", async (stackName : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(stackName) !== "string") {
|
||||||
|
throw new ValidationError("Stack name must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = Stack.getStack(server, stackName, true);
|
||||||
|
const serviceStatusList = Object.fromEntries(await stack.getServiceStatusList());
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
serviceStatusList,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// getExternalNetworkList
|
||||||
|
socket.on("getDockerNetworkList", async (callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
const dockerNetworkList = server.getDockerNetworkList();
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
dockerNetworkList,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// composerize
|
||||||
|
socket.on("composerize", async (dockerRunCommand : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(dockerRunCommand) !== "string") {
|
||||||
|
throw new ValidationError("dockerRunCommand must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const composeTemplate = composerize(dockerRunCommand);
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
composeTemplate,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStack(socket : DockgeSocket, server : DockgeServer, name : unknown, composeYAML : unknown, isAdd : unknown) : Stack {
|
||||||
|
// Check types
|
||||||
|
if (typeof(name) !== "string") {
|
||||||
|
throw new ValidationError("Name must be a string");
|
||||||
|
}
|
||||||
|
if (typeof(composeYAML) !== "string") {
|
||||||
|
throw new ValidationError("Compose YAML must be a string");
|
||||||
|
}
|
||||||
|
if (typeof(isAdd) !== "boolean") {
|
||||||
|
throw new ValidationError("isAdd must be a boolean");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = new Stack(server, name, composeYAML);
|
||||||
|
stack.save(isAdd);
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
307
backend/socket-handlers/main-socket-handler.ts
Normal file
307
backend/socket-handlers/main-socket-handler.ts
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import { SocketHandler } from "../socket-handler.js";
|
||||||
|
import { DockgeServer } from "../dockge-server";
|
||||||
|
import { log } from "../log";
|
||||||
|
import { R } from "redbean-node";
|
||||||
|
import { loginRateLimiter, twoFaRateLimiter } from "../rate-limiter";
|
||||||
|
import { generatePasswordHash, needRehashPassword, shake256, SHAKE256_LENGTH, verifyPassword } from "../password-hash";
|
||||||
|
import { User } from "../models/user";
|
||||||
|
import { checkLogin, DockgeSocket, doubleCheckPassword, JWTDecoded } from "../util-server";
|
||||||
|
import { passwordStrength } from "check-password-strength";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { Settings } from "../settings";
|
||||||
|
|
||||||
|
export class MainSocketHandler extends SocketHandler {
|
||||||
|
create(socket : DockgeSocket, server : DockgeServer) {
|
||||||
|
|
||||||
|
// ***************************
|
||||||
|
// Public Socket API
|
||||||
|
// ***************************
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
socket.on("setup", async (username, password, callback) => {
|
||||||
|
try {
|
||||||
|
if (passwordStrength(password).value === "Too weak") {
|
||||||
|
throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await R.knex("user").count("id as count").first()).count !== 0) {
|
||||||
|
throw new Error("Dockge has been initialized. If you want to run setup again, please delete the database.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = R.dispense("user");
|
||||||
|
user.username = username;
|
||||||
|
user.password = generatePasswordHash(password);
|
||||||
|
await R.store(user);
|
||||||
|
|
||||||
|
server.needSetup = false;
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "successAdded",
|
||||||
|
msgi18n: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login by token
|
||||||
|
socket.on("loginByToken", async (token, callback) => {
|
||||||
|
const clientIP = await server.getClientIP(socket);
|
||||||
|
|
||||||
|
log.info("auth", `Login by token. IP=${clientIP}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, server.jwtSecret) as JWTDecoded;
|
||||||
|
|
||||||
|
log.info("auth", "Username from JWT: " + decoded.username);
|
||||||
|
|
||||||
|
const user = await R.findOne("user", " username = ? AND active = 1 ", [
|
||||||
|
decoded.username,
|
||||||
|
]) as User;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Check if the password changed
|
||||||
|
if (decoded.h !== shake256(user.password, SHAKE256_LENGTH)) {
|
||||||
|
throw new Error("The token is invalid due to password change or old token");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("auth", "afterLogin");
|
||||||
|
await server.afterLogin(socket, user);
|
||||||
|
log.debug("auth", "afterLogin ok");
|
||||||
|
|
||||||
|
log.info("auth", `Successfully logged in user ${decoded.username}. IP=${clientIP}`);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
|
||||||
|
log.info("auth", `Inactive or deleted user ${decoded.username}. IP=${clientIP}`);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: "authUserInactiveOrDeleted",
|
||||||
|
msgi18n: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
console.error("Unknown error:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.error("auth", `Invalid token. IP=${clientIP}`);
|
||||||
|
if (error.message) {
|
||||||
|
log.error("auth", error.message + ` IP=${clientIP}`);
|
||||||
|
}
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: "authInvalidToken",
|
||||||
|
msgi18n: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
socket.on("login", async (data, callback) => {
|
||||||
|
const clientIP = await server.getClientIP(socket);
|
||||||
|
|
||||||
|
log.info("auth", `Login by username + password. IP=${clientIP}`);
|
||||||
|
|
||||||
|
// Checking
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login Rate Limit
|
||||||
|
if (!await loginRateLimiter.pass(callback)) {
|
||||||
|
log.info("auth", `Too many failed requests for user ${data.username}. IP=${clientIP}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.login(data.username, data.password);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
if (user.twofa_status === 0) {
|
||||||
|
server.afterLogin(socket, user);
|
||||||
|
|
||||||
|
log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
token: User.createJWT(user, server.jwtSecret),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.twofa_status === 1 && !data.token) {
|
||||||
|
|
||||||
|
log.info("auth", `2FA token required for user ${data.username}. IP=${clientIP}`);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
tokenRequired: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.token) {
|
||||||
|
// @ts-ignore
|
||||||
|
const verify = notp.totp.verify(data.token, user.twofa_secret, twoFAVerifyOptions);
|
||||||
|
|
||||||
|
if (user.twofa_last_token !== data.token && verify) {
|
||||||
|
server.afterLogin(socket, user);
|
||||||
|
|
||||||
|
await R.exec("UPDATE `user` SET twofa_last_token = ? WHERE id = ? ", [
|
||||||
|
data.token,
|
||||||
|
socket.userID,
|
||||||
|
]);
|
||||||
|
|
||||||
|
log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
token: User.createJWT(user, server.jwtSecret),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
|
||||||
|
log.warn("auth", `Invalid token provided for user ${data.username}. IP=${clientIP}`);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: "authInvalidToken",
|
||||||
|
msgi18n: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
log.warn("auth", `Incorrect username or password for user ${data.username}. IP=${clientIP}`);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: "authIncorrectCreds",
|
||||||
|
msgi18n: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change Password
|
||||||
|
socket.on("changePassword", async (password, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (! password.newPassword) {
|
||||||
|
throw new Error("Invalid new password");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordStrength(password.newPassword).value === "Too weak") {
|
||||||
|
throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await doubleCheckPassword(socket, password.currentPassword);
|
||||||
|
await user.resetPassword(password.newPassword);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Password has been updated successfully.",
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("getSettings", async (callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
const data = await Settings.getSettings("general");
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("setSettings", async (data, currentPassword, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
// If currently is disabled auth, don't need to check
|
||||||
|
// Disabled Auth + Want to Disable Auth => No Check
|
||||||
|
// Disabled Auth + Want to Enable Auth => No Check
|
||||||
|
// Enabled Auth + Want to Disable Auth => Check!!
|
||||||
|
// Enabled Auth + Want to Enable Auth => No Check
|
||||||
|
const currentDisabledAuth = await Settings.get("disableAuth");
|
||||||
|
if (!currentDisabledAuth && data.disableAuth) {
|
||||||
|
await doubleCheckPassword(socket, currentPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
await Settings.setSettings("general", data);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
msg: "Saved"
|
||||||
|
});
|
||||||
|
|
||||||
|
server.sendInfo(socket);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username : string, password : string) : Promise<User | null> {
|
||||||
|
if (typeof username !== "string" || typeof password !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await R.findOne("user", " username = ? AND active = 1 ", [
|
||||||
|
username,
|
||||||
|
]) as User;
|
||||||
|
|
||||||
|
if (user && verifyPassword(password, user.password)) {
|
||||||
|
// Upgrade the hash to bcrypt
|
||||||
|
if (needRehashPassword(user.password)) {
|
||||||
|
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
|
||||||
|
generatePasswordHash(password),
|
||||||
|
user.id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
170
backend/socket-handlers/terminal-socket-handler.ts
Normal file
170
backend/socket-handlers/terminal-socket-handler.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { SocketHandler } from "../socket-handler.js";
|
||||||
|
import { DockgeServer } from "../dockge-server";
|
||||||
|
import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server";
|
||||||
|
import { log } from "../log";
|
||||||
|
import yaml from "yaml";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import {
|
||||||
|
allowedCommandList,
|
||||||
|
allowedRawKeys,
|
||||||
|
getComposeTerminalName, getContainerExecTerminalName,
|
||||||
|
isDev,
|
||||||
|
PROGRESS_TERMINAL_ROWS
|
||||||
|
} from "../util-common";
|
||||||
|
import { InteractiveTerminal, MainTerminal, Terminal } from "../terminal";
|
||||||
|
import { Stack } from "../stack";
|
||||||
|
|
||||||
|
export class TerminalSocketHandler extends SocketHandler {
|
||||||
|
create(socket : DockgeSocket, server : DockgeServer) {
|
||||||
|
|
||||||
|
socket.on("terminalInput", async (terminalName : unknown, cmd : unknown, errorCallback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(terminalName) !== "string") {
|
||||||
|
throw new Error("Terminal name must be a string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof(cmd) !== "string") {
|
||||||
|
throw new Error("Command must be a string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let terminal = Terminal.getTerminal(terminalName);
|
||||||
|
if (terminal instanceof InteractiveTerminal) {
|
||||||
|
//log.debug("terminalInput", "Terminal found, writing to terminal.");
|
||||||
|
terminal.write(cmd);
|
||||||
|
} else {
|
||||||
|
throw new Error("Terminal not found or it is not a Interactive Terminal.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
errorCallback({
|
||||||
|
ok: false,
|
||||||
|
msg: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main Terminal
|
||||||
|
socket.on("mainTerminal", async (terminalName : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
// TODO: Reset the name here, force one main terminal for now
|
||||||
|
terminalName = "console";
|
||||||
|
|
||||||
|
if (typeof(terminalName) !== "string") {
|
||||||
|
throw new ValidationError("Terminal name must be a string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("deployStack", "Terminal name: " + terminalName);
|
||||||
|
|
||||||
|
let terminal = Terminal.getTerminal(terminalName);
|
||||||
|
|
||||||
|
if (!terminal) {
|
||||||
|
terminal = new MainTerminal(server, terminalName);
|
||||||
|
terminal.rows = 50;
|
||||||
|
log.debug("deployStack", "Terminal created");
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.join(socket);
|
||||||
|
terminal.start();
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interactive Terminal for containers
|
||||||
|
socket.on("interactiveTerminal", async (stackName : unknown, serviceName : unknown, shell : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
if (typeof(stackName) !== "string") {
|
||||||
|
throw new ValidationError("Stack name must be a string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof(serviceName) !== "string") {
|
||||||
|
throw new ValidationError("Service name must be a string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof(shell) !== "string") {
|
||||||
|
throw new ValidationError("Shell must be a string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("interactiveTerminal", "Stack name: " + stackName);
|
||||||
|
log.debug("interactiveTerminal", "Service name: " + serviceName);
|
||||||
|
|
||||||
|
// Get stack
|
||||||
|
const stack = Stack.getStack(server, stackName);
|
||||||
|
stack.joinContainerTerminal(socket, serviceName, shell);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Join Output Terminal
|
||||||
|
socket.on("terminalJoin", async (terminalName : unknown, callback) => {
|
||||||
|
if (typeof(callback) !== "function") {
|
||||||
|
log.debug("console", "Callback is not a function.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
if (typeof(terminalName) !== "string") {
|
||||||
|
throw new ValidationError("Terminal name must be a string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer : string = Terminal.getTerminal(terminalName)?.getBuffer() ?? "";
|
||||||
|
|
||||||
|
if (!buffer) {
|
||||||
|
log.debug("console", "No buffer found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
buffer,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave Combined Terminal
|
||||||
|
socket.on("leaveCombinedTerminal", async (stackName : unknown, callback) => {
|
||||||
|
try {
|
||||||
|
checkLogin(socket);
|
||||||
|
|
||||||
|
log.debug("leaveCombinedTerminal", "Stack name: " + stackName);
|
||||||
|
|
||||||
|
if (typeof(stackName) !== "string") {
|
||||||
|
throw new ValidationError("Stack name must be a string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = Stack.getStack(server, stackName);
|
||||||
|
await stack.leaveCombinedTerminal(socket);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callbackError(e, callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Resize Terminal
|
||||||
|
socket.on("terminalResize", async (rows : unknown) => {
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
429
backend/stack.ts
Normal file
429
backend/stack.ts
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
import { DockgeServer } from "./dockge-server";
|
||||||
|
import fs from "fs";
|
||||||
|
import { log } from "./log";
|
||||||
|
import yaml from "yaml";
|
||||||
|
import { DockgeSocket, ValidationError } from "./util-server";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
COMBINED_TERMINAL_COLS,
|
||||||
|
COMBINED_TERMINAL_ROWS,
|
||||||
|
CREATED_FILE,
|
||||||
|
CREATED_STACK,
|
||||||
|
EXITED, getCombinedTerminalName,
|
||||||
|
getComposeTerminalName, getContainerExecTerminalName,
|
||||||
|
PROGRESS_TERMINAL_ROWS,
|
||||||
|
RUNNING, TERMINAL_ROWS,
|
||||||
|
UNKNOWN
|
||||||
|
} from "./util-common";
|
||||||
|
import { InteractiveTerminal, Terminal } from "./terminal";
|
||||||
|
import childProcess from "child_process";
|
||||||
|
|
||||||
|
export class Stack {
|
||||||
|
|
||||||
|
name: string;
|
||||||
|
protected _status: number = UNKNOWN;
|
||||||
|
protected _composeYAML?: string;
|
||||||
|
protected _configFilePath?: string;
|
||||||
|
protected _composeFileName: string = "compose.yaml";
|
||||||
|
protected server: DockgeServer;
|
||||||
|
|
||||||
|
protected combinedTerminal? : Terminal;
|
||||||
|
|
||||||
|
protected static managedStackList: Map<string, Stack> = new Map();
|
||||||
|
|
||||||
|
constructor(server : DockgeServer, name : string, composeYAML? : string, skipFSOperations = false) {
|
||||||
|
this.name = name;
|
||||||
|
this.server = server;
|
||||||
|
this._composeYAML = composeYAML;
|
||||||
|
|
||||||
|
if (!skipFSOperations) {
|
||||||
|
// Check if compose file name is different from compose.yaml
|
||||||
|
const supportedFileNames = [ "compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml" ];
|
||||||
|
for (const filename of supportedFileNames) {
|
||||||
|
if (fs.existsSync(path.join(this.path, filename))) {
|
||||||
|
this._composeFileName = filename;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() : object {
|
||||||
|
let obj = this.toSimpleJSON();
|
||||||
|
return {
|
||||||
|
...obj,
|
||||||
|
composeYAML: this.composeYAML,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toSimpleJSON() : object {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
status: this._status,
|
||||||
|
tags: [],
|
||||||
|
isManagedByDockge: this.isManagedByDockge,
|
||||||
|
composeFileName: this._composeFileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status of the stack from `docker compose ps --format json`
|
||||||
|
*/
|
||||||
|
ps() : object {
|
||||||
|
let res = childProcess.execSync("docker compose ps --format json", {
|
||||||
|
cwd: this.path
|
||||||
|
});
|
||||||
|
return JSON.parse(res.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
get isManagedByDockge() : boolean {
|
||||||
|
return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
get status() : number {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
|
validate() {
|
||||||
|
// Check name, allows [a-z][0-9] _ - only
|
||||||
|
if (!this.name.match(/^[a-z0-9_-]+$/)) {
|
||||||
|
throw new ValidationError("Stack name can only contain [a-z][0-9] _ - only");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check YAML format
|
||||||
|
yaml.parse(this.composeYAML);
|
||||||
|
}
|
||||||
|
|
||||||
|
get composeYAML() : string {
|
||||||
|
if (this._composeYAML === undefined) {
|
||||||
|
try {
|
||||||
|
this._composeYAML = fs.readFileSync(path.join(this.path, this._composeFileName), "utf-8");
|
||||||
|
} catch (e) {
|
||||||
|
this._composeYAML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this._composeYAML;
|
||||||
|
}
|
||||||
|
|
||||||
|
get path() : string {
|
||||||
|
return path.join(this.server.stacksDir, this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
get fullPath() : string {
|
||||||
|
let dir = this.path;
|
||||||
|
|
||||||
|
// Compose up via node-pty
|
||||||
|
let fullPathDir;
|
||||||
|
|
||||||
|
// if dir is relative, make it absolute
|
||||||
|
if (!path.isAbsolute(dir)) {
|
||||||
|
fullPathDir = path.join(process.cwd(), dir);
|
||||||
|
} else {
|
||||||
|
fullPathDir = dir;
|
||||||
|
}
|
||||||
|
return fullPathDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the stack to the disk
|
||||||
|
* @param isAdd
|
||||||
|
*/
|
||||||
|
save(isAdd : boolean) {
|
||||||
|
this.validate();
|
||||||
|
|
||||||
|
let dir = this.path;
|
||||||
|
|
||||||
|
// Check if the name is used if isAdd
|
||||||
|
if (isAdd) {
|
||||||
|
if (fs.existsSync(dir)) {
|
||||||
|
throw new ValidationError("Stack name already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the stack folder
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
} else {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
throw new ValidationError("Stack not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write or overwrite the compose.yaml
|
||||||
|
fs.writeFileSync(path.join(dir, this._composeFileName), this.composeYAML);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deploy(socket? : DockgeSocket) : Promise<number> {
|
||||||
|
const terminalName = getComposeTerminalName(this.name);
|
||||||
|
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error("Failed to deploy, please check the terminal output for more information.");
|
||||||
|
}
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(socket?: DockgeSocket) : Promise<number> {
|
||||||
|
const terminalName = getComposeTerminalName(this.name);
|
||||||
|
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans" ], this.path);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error("Failed to delete, please check the terminal output for more information.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the stack folder
|
||||||
|
fs.rmSync(this.path, {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus() {
|
||||||
|
let statusList = Stack.getStatusList();
|
||||||
|
let status = statusList.get(this.name);
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
this._status = status;
|
||||||
|
} else {
|
||||||
|
this._status = UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getStackList(server : DockgeServer, useCacheForManaged = false) : Map<string, Stack> {
|
||||||
|
let stacksDir = server.stacksDir;
|
||||||
|
let stackList : Map<string, Stack>;
|
||||||
|
|
||||||
|
// Use cached stack list?
|
||||||
|
if (useCacheForManaged && this.managedStackList.size > 0) {
|
||||||
|
stackList = this.managedStackList;
|
||||||
|
} else {
|
||||||
|
stackList = new Map<string, Stack>();
|
||||||
|
|
||||||
|
// Scan the stacks directory, and get the stack list
|
||||||
|
let filenameList = fs.readdirSync(stacksDir);
|
||||||
|
|
||||||
|
for (let filename of filenameList) {
|
||||||
|
try {
|
||||||
|
// Check if it is a directory
|
||||||
|
let stat = fs.statSync(path.join(stacksDir, filename));
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stack = this.getStack(server, filename);
|
||||||
|
stack._status = CREATED_FILE;
|
||||||
|
stackList.set(filename, stack);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache by copying
|
||||||
|
this.managedStackList = new Map(stackList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status from docker compose ls
|
||||||
|
let res = childProcess.execSync("docker compose ls --all --format json");
|
||||||
|
let composeList = JSON.parse(res.toString());
|
||||||
|
|
||||||
|
for (let composeStack of composeList) {
|
||||||
|
let stack = stackList.get(composeStack.Name);
|
||||||
|
|
||||||
|
// This stack probably is not managed by Dockge, but we still want to show it
|
||||||
|
if (!stack) {
|
||||||
|
// Skip the dockge stack if it is not managed by Dockge
|
||||||
|
if (composeStack.Name === "dockge") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stack = new Stack(server, composeStack.Name);
|
||||||
|
stackList.set(composeStack.Name, stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
stack._status = this.statusConvert(composeStack.Status);
|
||||||
|
stack._configFilePath = composeStack.ConfigFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stackList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status list, it will be used to update the status of the stacks
|
||||||
|
* Not all status will be returned, only the stack that is deployed or created to `docker compose` will be returned
|
||||||
|
*/
|
||||||
|
static getStatusList() : Map<string, number> {
|
||||||
|
let statusList = new Map<string, number>();
|
||||||
|
|
||||||
|
let res = childProcess.execSync("docker compose ls --all --format json");
|
||||||
|
let composeList = JSON.parse(res.toString());
|
||||||
|
|
||||||
|
for (let composeStack of composeList) {
|
||||||
|
statusList.set(composeStack.Name, this.statusConvert(composeStack.Status));
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the status string from `docker compose ls` to the status number
|
||||||
|
* Input Example: "exited(1), running(1)"
|
||||||
|
* @param status
|
||||||
|
*/
|
||||||
|
static statusConvert(status : string) : number {
|
||||||
|
if (status.startsWith("created")) {
|
||||||
|
return CREATED_STACK;
|
||||||
|
} else if (status.includes("exited")) {
|
||||||
|
// If one of the service is exited, we consider the stack is exited
|
||||||
|
return EXITED;
|
||||||
|
} else if (status.startsWith("running")) {
|
||||||
|
// If there is no exited services, there should be only running services
|
||||||
|
return RUNNING;
|
||||||
|
} else {
|
||||||
|
return UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getStack(server: DockgeServer, stackName: string, skipFSOperations = false) : Stack {
|
||||||
|
let dir = path.join(server.stacksDir, stackName);
|
||||||
|
|
||||||
|
if (!skipFSOperations) {
|
||||||
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||||
|
// Maybe it is a stack managed by docker compose directly
|
||||||
|
let stackList = this.getStackList(server, true);
|
||||||
|
let stack = stackList.get(stackName);
|
||||||
|
|
||||||
|
if (stack) {
|
||||||
|
return stack;
|
||||||
|
} else {
|
||||||
|
// Really not found
|
||||||
|
throw new ValidationError("Stack not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
//log.debug("getStack", "Skip FS operations");
|
||||||
|
}
|
||||||
|
|
||||||
|
let stack : Stack;
|
||||||
|
|
||||||
|
if (!skipFSOperations) {
|
||||||
|
stack = new Stack(server, stackName);
|
||||||
|
} else {
|
||||||
|
stack = new Stack(server, stackName, undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
stack._status = UNKNOWN;
|
||||||
|
stack._configFilePath = path.resolve(dir);
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(socket: DockgeSocket) {
|
||||||
|
const terminalName = getComposeTerminalName(this.name);
|
||||||
|
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error("Failed to start, please check the terminal output for more information.");
|
||||||
|
}
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(socket: DockgeSocket) : Promise<number> {
|
||||||
|
const terminalName = getComposeTerminalName(this.name);
|
||||||
|
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "stop" ], this.path);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error("Failed to stop, please check the terminal output for more information.");
|
||||||
|
}
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restart(socket: DockgeSocket) : Promise<number> {
|
||||||
|
const terminalName = getComposeTerminalName(this.name);
|
||||||
|
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "restart" ], this.path);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error("Failed to restart, please check the terminal output for more information.");
|
||||||
|
}
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(socket: DockgeSocket) : Promise<number> {
|
||||||
|
const terminalName = getComposeTerminalName(this.name);
|
||||||
|
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down" ], this.path);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error("Failed to down, please check the terminal output for more information.");
|
||||||
|
}
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(socket: DockgeSocket) {
|
||||||
|
const terminalName = getComposeTerminalName(this.name);
|
||||||
|
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "pull" ], this.path);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error("Failed to pull, please check the terminal output for more information.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the stack is not running, we don't need to restart it
|
||||||
|
this.updateStatus();
|
||||||
|
log.debug("update", "Status: " + this.status);
|
||||||
|
if (this.status !== RUNNING) {
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error("Failed to restart, please check the terminal output for more information.");
|
||||||
|
}
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinCombinedTerminal(socket: DockgeSocket) {
|
||||||
|
const terminalName = getCombinedTerminalName(this.name);
|
||||||
|
const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", [ "compose", "logs", "-f", "--tail", "100" ], this.path);
|
||||||
|
terminal.enableKeepAlive = true;
|
||||||
|
terminal.rows = COMBINED_TERMINAL_ROWS;
|
||||||
|
terminal.cols = COMBINED_TERMINAL_COLS;
|
||||||
|
terminal.join(socket);
|
||||||
|
terminal.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveCombinedTerminal(socket: DockgeSocket) {
|
||||||
|
const terminalName = getCombinedTerminalName(this.name);
|
||||||
|
const terminal = Terminal.getTerminal(terminalName);
|
||||||
|
if (terminal) {
|
||||||
|
terminal.leave(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinContainerTerminal(socket: DockgeSocket, serviceName: string, shell : string = "sh", index: number = 0) {
|
||||||
|
const terminalName = getContainerExecTerminalName(this.name, serviceName, index);
|
||||||
|
let terminal = Terminal.getTerminal(terminalName);
|
||||||
|
|
||||||
|
if (!terminal) {
|
||||||
|
terminal = new InteractiveTerminal(this.server, terminalName, "docker", [ "compose", "exec", serviceName, shell ], this.path);
|
||||||
|
terminal.rows = TERMINAL_ROWS;
|
||||||
|
log.debug("joinContainerTerminal", "Terminal created");
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.join(socket);
|
||||||
|
terminal.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServiceStatusList() {
|
||||||
|
let statusList = new Map<string, number>();
|
||||||
|
|
||||||
|
let res = childProcess.spawnSync("docker", [ "compose", "ps", "--format", "json" ], {
|
||||||
|
cwd: this.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
let lines = res.stdout.toString().split("\n");
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
try {
|
||||||
|
let obj = JSON.parse(line);
|
||||||
|
if (obj.Health === "") {
|
||||||
|
statusList.set(obj.Service, obj.State);
|
||||||
|
} else {
|
||||||
|
statusList.set(obj.Service, obj.Health);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusList;
|
||||||
|
}
|
||||||
|
}
|
286
backend/terminal.ts
Normal file
286
backend/terminal.ts
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import { DockgeServer } from "./dockge-server";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as pty from "@homebridge/node-pty-prebuilt-multiarch";
|
||||||
|
import { LimitQueue } from "./utils/limit-queue";
|
||||||
|
import { DockgeSocket } from "./util-server";
|
||||||
|
import {
|
||||||
|
allowedCommandList, allowedRawKeys,
|
||||||
|
PROGRESS_TERMINAL_ROWS,
|
||||||
|
TERMINAL_COLS,
|
||||||
|
TERMINAL_ROWS
|
||||||
|
} from "./util-common";
|
||||||
|
import { sync as commandExistsSync } from "command-exists";
|
||||||
|
import { log } from "./log";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal for running commands, no user interaction
|
||||||
|
*/
|
||||||
|
export class Terminal {
|
||||||
|
|
||||||
|
protected static terminalMap : Map<string, Terminal> = new Map();
|
||||||
|
|
||||||
|
protected _ptyProcess? : pty.IPty;
|
||||||
|
protected server : DockgeServer;
|
||||||
|
protected buffer : LimitQueue<string> = new LimitQueue(100);
|
||||||
|
protected _name : string;
|
||||||
|
|
||||||
|
protected file : string;
|
||||||
|
protected args : string | string[];
|
||||||
|
protected cwd : string;
|
||||||
|
protected callback? : (exitCode : number) => void;
|
||||||
|
|
||||||
|
protected _rows : number = TERMINAL_ROWS;
|
||||||
|
protected _cols : number = TERMINAL_COLS;
|
||||||
|
|
||||||
|
public enableKeepAlive : boolean = false;
|
||||||
|
protected keepAliveInterval? : NodeJS.Timeout;
|
||||||
|
|
||||||
|
constructor(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) {
|
||||||
|
this.server = server;
|
||||||
|
this._name = name;
|
||||||
|
//this._name = "terminal-" + Date.now() + "-" + getCryptoRandomInt(0, 1000000);
|
||||||
|
this.file = file;
|
||||||
|
this.args = args;
|
||||||
|
this.cwd = cwd;
|
||||||
|
|
||||||
|
Terminal.terminalMap.set(this.name, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
get rows() {
|
||||||
|
return this._rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
set rows(rows : number) {
|
||||||
|
this._rows = rows;
|
||||||
|
try {
|
||||||
|
this.ptyProcess?.resize(this.cols, this.rows);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
log.debug("Terminal", "Failed to resize terminal: " + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get cols() {
|
||||||
|
return this._cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
set cols(cols : number) {
|
||||||
|
this._cols = cols;
|
||||||
|
try {
|
||||||
|
this.ptyProcess?.resize(this.cols, this.rows);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
log.debug("Terminal", "Failed to resize terminal: " + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public start() {
|
||||||
|
if (this._ptyProcess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.enableKeepAlive) {
|
||||||
|
log.debug("Terminal", "Keep alive enabled for terminal " + this.name);
|
||||||
|
|
||||||
|
// Close if there is no clients
|
||||||
|
this.keepAliveInterval = setInterval(() => {
|
||||||
|
const clients = this.server.io.sockets.adapter.rooms.get(this.name);
|
||||||
|
const numClients = clients ? clients.size : 0;
|
||||||
|
|
||||||
|
if (numClients === 0) {
|
||||||
|
log.debug("Terminal", "Terminal " + this.name + " has no client, closing...");
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
log.debug("Terminal", "Terminal " + this.name + " has " + numClients + " client(s)");
|
||||||
|
}
|
||||||
|
}, 60 * 1000);
|
||||||
|
} else {
|
||||||
|
log.debug("Terminal", "Keep alive disabled for terminal " + this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._ptyProcess = pty.spawn(this.file, this.args, {
|
||||||
|
name: this.name,
|
||||||
|
cwd: this.cwd,
|
||||||
|
cols: TERMINAL_COLS,
|
||||||
|
rows: this.rows,
|
||||||
|
});
|
||||||
|
|
||||||
|
// On Data
|
||||||
|
this._ptyProcess.onData((data) => {
|
||||||
|
this.buffer.pushItem(data);
|
||||||
|
if (this.server.io) {
|
||||||
|
this.server.io.to(this.name).emit("terminalWrite", this.name, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// On Exit
|
||||||
|
this._ptyProcess.onExit(this.exit);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
clearInterval(this.keepAliveInterval);
|
||||||
|
|
||||||
|
log.error("Terminal", "Failed to start terminal: " + error.message);
|
||||||
|
const exitCode = Number(error.message.split(" ").pop());
|
||||||
|
this.exit({
|
||||||
|
exitCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit event handler
|
||||||
|
* @param res
|
||||||
|
*/
|
||||||
|
protected exit = (res : {exitCode: number, signal?: number | undefined}) => {
|
||||||
|
this.server.io.to(this.name).emit("terminalExit", this.name, res.exitCode);
|
||||||
|
|
||||||
|
// Remove room
|
||||||
|
this.server.io.in(this.name).socketsLeave(this.name);
|
||||||
|
|
||||||
|
Terminal.terminalMap.delete(this.name);
|
||||||
|
log.debug("Terminal", "Terminal " + this.name + " exited with code " + res.exitCode);
|
||||||
|
|
||||||
|
clearInterval(this.keepAliveInterval);
|
||||||
|
|
||||||
|
if (this.callback) {
|
||||||
|
this.callback(res.exitCode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public onExit(callback : (exitCode : number) => void) {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public join(socket : DockgeSocket) {
|
||||||
|
socket.join(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public leave(socket : DockgeSocket) {
|
||||||
|
socket.leave(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get ptyProcess() {
|
||||||
|
return this._ptyProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get name() {
|
||||||
|
return this._name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the terminal output string
|
||||||
|
*/
|
||||||
|
getBuffer() : string {
|
||||||
|
if (this.buffer.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return this.buffer.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
clearInterval(this.keepAliveInterval);
|
||||||
|
// Send Ctrl+C to the terminal
|
||||||
|
this.ptyProcess?.write("\x03");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a running and non-exited terminal
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
public static getTerminal(name : string) : Terminal | undefined {
|
||||||
|
return Terminal.terminalMap.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getOrCreateTerminal(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) : Terminal {
|
||||||
|
// Since exited terminal will be removed from the map, it is safe to get the terminal from the map
|
||||||
|
let terminal = Terminal.getTerminal(name);
|
||||||
|
if (!terminal) {
|
||||||
|
terminal = new Terminal(server, name, file, args, cwd);
|
||||||
|
}
|
||||||
|
return terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static exec(server : DockgeServer, socket : DockgeSocket | undefined, terminalName : string, file : string, args : string | string[], cwd : string) : Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// check if terminal exists
|
||||||
|
if (Terminal.terminalMap.has(terminalName)) {
|
||||||
|
reject("Another operation is already running, please try again later.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let terminal = new Terminal(server, terminalName, file, args, cwd);
|
||||||
|
terminal.rows = PROGRESS_TERMINAL_ROWS;
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
terminal.join(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.onExit((exitCode : number) => {
|
||||||
|
resolve(exitCode);
|
||||||
|
});
|
||||||
|
terminal.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getTerminalCount() {
|
||||||
|
return Terminal.terminalMap.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive terminal
|
||||||
|
* Mainly used for container exec
|
||||||
|
*/
|
||||||
|
export class InteractiveTerminal extends Terminal {
|
||||||
|
public write(input : string) {
|
||||||
|
this.ptyProcess?.write(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCWD() {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
this.ptyProcess?.write(`cd "${cwd}"\r`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User interactive terminal that use bash or powershell with limited commands such as docker, ls, cd, dir
|
||||||
|
*/
|
||||||
|
export class MainTerminal extends InteractiveTerminal {
|
||||||
|
constructor(server : DockgeServer, name : string) {
|
||||||
|
let shell;
|
||||||
|
|
||||||
|
if (os.platform() === "win32") {
|
||||||
|
if (commandExistsSync("pwsh.exe")) {
|
||||||
|
shell = "pwsh.exe";
|
||||||
|
} else {
|
||||||
|
shell = "powershell.exe";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shell = "bash";
|
||||||
|
}
|
||||||
|
super(server, name, shell, [], server.stacksDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public write(input : string) {
|
||||||
|
// For like Ctrl + C
|
||||||
|
if (allowedRawKeys.includes(input)) {
|
||||||
|
super.write(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the command is allowed
|
||||||
|
const cmdParts = input.split(" ");
|
||||||
|
const executable = cmdParts[0].trim();
|
||||||
|
log.debug("console", "Executable: " + executable);
|
||||||
|
log.debug("console", "Executable length: " + executable.length);
|
||||||
|
|
||||||
|
if (!allowedCommandList.includes(executable)) {
|
||||||
|
throw new Error("Command not allowed.");
|
||||||
|
}
|
||||||
|
super.write(input);
|
||||||
|
}
|
||||||
|
}
|
342
backend/util-common.ts
Normal file
342
backend/util-common.ts
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
/*
|
||||||
|
* Common utilities for backend and frontend
|
||||||
|
*/
|
||||||
|
import { Document } from "yaml";
|
||||||
|
|
||||||
|
// Init dayjs
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
export interface LooseObject {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
let randomBytes : (numBytes: number) => Uint8Array;
|
||||||
|
initRandomBytes();
|
||||||
|
|
||||||
|
async function initRandomBytes() {
|
||||||
|
if (typeof window !== "undefined" && window.crypto) {
|
||||||
|
randomBytes = function randomBytes(numBytes: number) {
|
||||||
|
const bytes = new Uint8Array(numBytes);
|
||||||
|
for (let i = 0; i < numBytes; i += 65536) {
|
||||||
|
window.crypto.getRandomValues(bytes.subarray(i, i + Math.min(numBytes - i, 65536)));
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
randomBytes = (await import("node:crypto")).randomBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stack Status
|
||||||
|
export const UNKNOWN = 0;
|
||||||
|
export const CREATED_FILE = 1;
|
||||||
|
export const CREATED_STACK = 2;
|
||||||
|
export const RUNNING = 3;
|
||||||
|
export const EXITED = 4;
|
||||||
|
|
||||||
|
export function statusName(status : number) : string {
|
||||||
|
switch (status) {
|
||||||
|
case CREATED_FILE:
|
||||||
|
return "draft";
|
||||||
|
case CREATED_STACK:
|
||||||
|
return "created_stack";
|
||||||
|
case RUNNING:
|
||||||
|
return "running";
|
||||||
|
case EXITED:
|
||||||
|
return "exited";
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusNameShort(status : number) : string {
|
||||||
|
switch (status) {
|
||||||
|
case CREATED_FILE:
|
||||||
|
return "inactive";
|
||||||
|
case CREATED_STACK:
|
||||||
|
return "inactive";
|
||||||
|
case RUNNING:
|
||||||
|
return "active";
|
||||||
|
case EXITED:
|
||||||
|
return "exited";
|
||||||
|
default:
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusColor(status : number) : string {
|
||||||
|
switch (status) {
|
||||||
|
case CREATED_FILE:
|
||||||
|
return "dark";
|
||||||
|
case CREATED_STACK:
|
||||||
|
return "dark";
|
||||||
|
case RUNNING:
|
||||||
|
return "primary";
|
||||||
|
case EXITED:
|
||||||
|
return "danger";
|
||||||
|
default:
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isDev = process.env.NODE_ENV === "development";
|
||||||
|
export const TERMINAL_COLS = 105;
|
||||||
|
export const TERMINAL_ROWS = 10;
|
||||||
|
export const PROGRESS_TERMINAL_ROWS = 8;
|
||||||
|
|
||||||
|
export const COMBINED_TERMINAL_COLS = 58;
|
||||||
|
export const COMBINED_TERMINAL_ROWS = 20;
|
||||||
|
|
||||||
|
export const ERROR_TYPE_VALIDATION = 1;
|
||||||
|
|
||||||
|
export const allowedCommandList : string[] = [
|
||||||
|
"docker",
|
||||||
|
"ls",
|
||||||
|
"cd",
|
||||||
|
"dir",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const allowedRawKeys = [
|
||||||
|
"\u0003", // Ctrl + C
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a decimal integer number from a string
|
||||||
|
* @param str Input
|
||||||
|
* @param length Default is 10 which means 0 - 9
|
||||||
|
*/
|
||||||
|
export function intHash(str : string, length = 10) : number {
|
||||||
|
// A simple hashing function (you can use more complex hash functions if needed)
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash += str.charCodeAt(i);
|
||||||
|
}
|
||||||
|
// Normalize the hash to the range [0, 10]
|
||||||
|
return (hash % length + length) % length; // Ensure the result is non-negative
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delays for specified number of seconds
|
||||||
|
* @param ms Number of milliseconds to sleep for
|
||||||
|
*/
|
||||||
|
export function sleep(ms: number) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random alphanumeric string of fixed length
|
||||||
|
* @param length Length of string to generate
|
||||||
|
* @returns string
|
||||||
|
*/
|
||||||
|
export function genSecret(length = 64) {
|
||||||
|
let secret = "";
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
const charsLength = chars.length;
|
||||||
|
for ( let i = 0; i < length; i++ ) {
|
||||||
|
secret += chars.charAt(getCryptoRandomInt(0, charsLength - 1));
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a random integer suitable for use in cryptography between upper
|
||||||
|
* and lower bounds.
|
||||||
|
* @param min Minimum value of integer
|
||||||
|
* @param max Maximum value of integer
|
||||||
|
* @returns Cryptographically suitable random integer
|
||||||
|
*/
|
||||||
|
export function getCryptoRandomInt(min: number, max: number):number {
|
||||||
|
// synchronous version of: https://github.com/joepie91/node-random-number-csprng
|
||||||
|
|
||||||
|
const range = max - min;
|
||||||
|
if (range >= Math.pow(2, 32)) {
|
||||||
|
console.log("Warning! Range is too large.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmpRange = range;
|
||||||
|
let bitsNeeded = 0;
|
||||||
|
let bytesNeeded = 0;
|
||||||
|
let mask = 1;
|
||||||
|
|
||||||
|
while (tmpRange > 0) {
|
||||||
|
if (bitsNeeded % 8 === 0) {
|
||||||
|
bytesNeeded += 1;
|
||||||
|
}
|
||||||
|
bitsNeeded += 1;
|
||||||
|
mask = mask << 1 | 1;
|
||||||
|
tmpRange = tmpRange >>> 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = randomBytes(bytesNeeded);
|
||||||
|
let randomValue = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < bytesNeeded; i++) {
|
||||||
|
randomValue |= bytes[i] << 8 * i;
|
||||||
|
}
|
||||||
|
|
||||||
|
randomValue = randomValue & mask;
|
||||||
|
|
||||||
|
if (randomValue <= range) {
|
||||||
|
return min + randomValue;
|
||||||
|
} else {
|
||||||
|
return getCryptoRandomInt(min, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComposeTerminalName(stack : string) {
|
||||||
|
return "compose-" + stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCombinedTerminalName(stack : string) {
|
||||||
|
return "combined-" + stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContainerTerminalName(container : string) {
|
||||||
|
return "container-" + container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContainerExecTerminalName(stackName : string, container : string, index : number) {
|
||||||
|
return "container-exec-" + container + "-" + index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyYAMLComments(doc : Document, src : Document) {
|
||||||
|
doc.comment = src.comment;
|
||||||
|
doc.commentBefore = src.commentBefore;
|
||||||
|
|
||||||
|
if (doc && doc.contents && src && src.contents) {
|
||||||
|
// @ts-ignore
|
||||||
|
copyYAMLCommentsItems(doc.contents.items, src.contents.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy yaml comments from srcItems to items
|
||||||
|
* Typescript is super annoying here, so I have to use any here
|
||||||
|
* TODO: Since comments are belong to the array index, the comments will be lost if the order of the items is changed or removed or added.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function copyYAMLCommentsItems(items : any, srcItems : any) {
|
||||||
|
if (!items || !srcItems) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const item : any = items[i];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const srcItem : any = srcItems[i];
|
||||||
|
|
||||||
|
if (!srcItem) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.key && srcItem.key) {
|
||||||
|
item.key.comment = srcItem.key.comment;
|
||||||
|
item.key.commentBefore = srcItem.key.commentBefore;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (srcItem.comment) {
|
||||||
|
item.comment = srcItem.comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.value && srcItem.value) {
|
||||||
|
if (typeof item.value === "object" && typeof srcItem.value === "object") {
|
||||||
|
item.value.comment = srcItem.value.comment;
|
||||||
|
item.value.commentBefore = srcItem.value.commentBefore;
|
||||||
|
|
||||||
|
if (item.value.items && srcItem.value.items) {
|
||||||
|
copyYAMLCommentsItems(item.value.items, srcItem.value.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Possible Inputs:
|
||||||
|
* ports:
|
||||||
|
* - "3000"
|
||||||
|
* - "3000-3005"
|
||||||
|
* - "8000:8000"
|
||||||
|
* - "9090-9091:8080-8081"
|
||||||
|
* - "49100:22"
|
||||||
|
* - "8000-9000:80"
|
||||||
|
* - "127.0.0.1:8001:8001"
|
||||||
|
* - "127.0.0.1:5000-5010:5000-5010"
|
||||||
|
* - "6060:6060/udp"
|
||||||
|
* @param input
|
||||||
|
* @param defaultHostname
|
||||||
|
*/
|
||||||
|
export function parseDockerPort(input : string, defaultHostname : string = "localhost") {
|
||||||
|
let hostname = defaultHostname;
|
||||||
|
let port;
|
||||||
|
let display;
|
||||||
|
|
||||||
|
const parts = input.split("/");
|
||||||
|
const part1 = parts[0];
|
||||||
|
let protocol = parts[1] || "tcp";
|
||||||
|
|
||||||
|
// Split the last ":"
|
||||||
|
const lastColon = part1.lastIndexOf(":");
|
||||||
|
|
||||||
|
if (lastColon === -1) {
|
||||||
|
// No colon, so it's just a port or port range
|
||||||
|
// Check if it's a port range
|
||||||
|
const dash = part1.indexOf("-");
|
||||||
|
if (dash === -1) {
|
||||||
|
// No dash, so it's just a port
|
||||||
|
port = part1;
|
||||||
|
} else {
|
||||||
|
// Has dash, so it's a port range, use the first port
|
||||||
|
port = part1.substring(0, dash);
|
||||||
|
}
|
||||||
|
|
||||||
|
display = part1;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Has colon, so it's a port mapping
|
||||||
|
let hostPart = part1.substring(0, lastColon);
|
||||||
|
display = hostPart;
|
||||||
|
|
||||||
|
// Check if it's a port range
|
||||||
|
const dash = part1.indexOf("-");
|
||||||
|
|
||||||
|
if (dash !== -1) {
|
||||||
|
// Has dash, so it's a port range, use the first port
|
||||||
|
hostPart = part1.substring(0, dash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it has a ip (ip:port)
|
||||||
|
const colon = hostPart.indexOf(":");
|
||||||
|
|
||||||
|
if (colon !== -1) {
|
||||||
|
// Has colon, so it's a ip:port
|
||||||
|
hostname = hostPart.substring(0, colon);
|
||||||
|
port = hostPart.substring(colon + 1);
|
||||||
|
} else {
|
||||||
|
// No colon, so it's just a port
|
||||||
|
port = hostPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let portInt = parseInt(port);
|
||||||
|
|
||||||
|
if (portInt == 443) {
|
||||||
|
protocol = "https";
|
||||||
|
} else if (protocol === "tcp") {
|
||||||
|
protocol = "http";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: protocol + "://" + hostname + ":" + portInt,
|
||||||
|
display: display,
|
||||||
|
};
|
||||||
|
}
|
84
backend/util-server.ts
Normal file
84
backend/util-server.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { Terminal } from "./terminal";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { log } from "./log";
|
||||||
|
import { ERROR_TYPE_VALIDATION } from "./util-common";
|
||||||
|
import { R } from "redbean-node";
|
||||||
|
import { verifyPassword } from "./password-hash";
|
||||||
|
|
||||||
|
export interface JWTDecoded {
|
||||||
|
username : string;
|
||||||
|
h? : string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockgeSocket extends Socket {
|
||||||
|
userID: number;
|
||||||
|
consoleTerminal? : Terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For command line arguments, so they are nullable
|
||||||
|
export interface Arguments {
|
||||||
|
sslKey? : string;
|
||||||
|
sslCert? : string;
|
||||||
|
sslKeyPassphrase? : string;
|
||||||
|
port? : number;
|
||||||
|
hostname? : string;
|
||||||
|
dataDir? : string;
|
||||||
|
stacksDir? : string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some config values are required
|
||||||
|
export interface Config extends Arguments {
|
||||||
|
dataDir : string;
|
||||||
|
stacksDir : string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkLogin(socket : DockgeSocket) {
|
||||||
|
if (!socket.userID) {
|
||||||
|
throw new Error("You are not logged in.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
constructor(message : string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function callbackError(error : unknown, callback : unknown) {
|
||||||
|
if (typeof(callback) !== "function") {
|
||||||
|
log.error("console", "Callback is not a function");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
msg: error.message,
|
||||||
|
});
|
||||||
|
} else if (error instanceof ValidationError) {
|
||||||
|
callback({
|
||||||
|
ok: false,
|
||||||
|
type: ERROR_TYPE_VALIDATION,
|
||||||
|
msg: error.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.debug("console", "Unknown error: " + error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doubleCheckPassword(socket : DockgeSocket, currentPassword : unknown) {
|
||||||
|
if (typeof currentPassword !== "string") {
|
||||||
|
throw new Error("Wrong data type?");
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await R.findOne("user", " id = ? AND active = 1 ", [
|
||||||
|
socket.userID,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!user || !verifyPassword(currentPassword, user.password)) {
|
||||||
|
throw new Error("Incorrect current password");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
24
backend/utils/limit-queue.ts
Normal file
24
backend/utils/limit-queue.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Limit Queue
|
||||||
|
* The first element will be removed when the length exceeds the limit
|
||||||
|
*/
|
||||||
|
export class LimitQueue<T> extends Array<T> {
|
||||||
|
__limit;
|
||||||
|
__onExceed? : (item : T | undefined) => void;
|
||||||
|
|
||||||
|
constructor(limit: number) {
|
||||||
|
super();
|
||||||
|
this.__limit = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushItem(value : T) {
|
||||||
|
super.push(value);
|
||||||
|
if (this.length > this.__limit) {
|
||||||
|
const item = this.shift();
|
||||||
|
if (this.__onExceed) {
|
||||||
|
this.__onExceed(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
23
compose.yaml
Normal file
23
compose.yaml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
dockge:
|
||||||
|
image: louislam/dockge:1
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# Host Port : Container Port
|
||||||
|
- 5001:5001
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
# If you want to use private registries, you need to share the auth file with Dockge:
|
||||||
|
# - /root/.docker/:/root/.docker
|
||||||
|
|
||||||
|
# Your stacks directory in the host (The paths inside container must be the same as the host)
|
||||||
|
# ⚠️⚠️ If you did it wrong, your data could end up be written into a wrong path.
|
||||||
|
# ✔️✔️✔️✔️ CORRECT: - /my-stacks:/my-stacks (Both paths match)
|
||||||
|
# ❌❌❌❌ WRONG: - /docker:/my-stacks (Both paths do not match)
|
||||||
|
- /opt/stacks:/opt/stacks
|
||||||
|
environment:
|
||||||
|
# Tell Dockge where is your stacks directory
|
||||||
|
- DOCKGE_STACKS_DIR=/opt/stacks
|
26
docker/Base.Dockerfile
Normal file
26
docker/Base.Dockerfile
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
FROM node:18.17.1-bookworm-slim
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|
||||||
|
# COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
|
||||||
|
|
||||||
|
RUN apt update && apt install --yes --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
gnupg \
|
||||||
|
unzip \
|
||||||
|
dumb-init \
|
||||||
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& echo \
|
||||||
|
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
|
||||||
|
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
|
||||||
|
tee /etc/apt/sources.list.d/docker.list > /dev/null \
|
||||||
|
&& apt update \
|
||||||
|
&& apt --yes --no-install-recommends install \
|
||||||
|
docker-ce-cli \
|
||||||
|
docker-compose-plugin \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& npm install pnpm -g \
|
||||||
|
&& pnpm install -g tsx
|
29
docker/Dockerfile
Normal file
29
docker/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
############################################
|
||||||
|
# Build
|
||||||
|
############################################
|
||||||
|
FROM louislam/dockge:base AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --chown=node:node ./package.json ./package.json
|
||||||
|
COPY --chown=node:node ./pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# ⭐ Main Image
|
||||||
|
############################################
|
||||||
|
FROM louislam/dockge:base AS release
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --chown=node:node . .
|
||||||
|
COPY --from=build /app/node_modules /app/node_modules
|
||||||
|
RUN mkdir ./data
|
||||||
|
|
||||||
|
VOLUME /app/data
|
||||||
|
EXPOSE 5001
|
||||||
|
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||||
|
CMD ["tsx", "./backend/index.ts"]
|
||||||
|
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Mark as Nightly
|
||||||
|
############################################
|
||||||
|
FROM release AS nightly
|
||||||
|
RUN pnpm run mark-as-nightly
|
57
extra/close-incorrect-issue.js
Normal file
57
extra/close-incorrect-issue.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import github from "@actions/github";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const token = process.argv[2];
|
||||||
|
const issueNumber = process.argv[3];
|
||||||
|
const username = process.argv[4];
|
||||||
|
|
||||||
|
const client = github.getOctokit(token).rest;
|
||||||
|
|
||||||
|
const issue = {
|
||||||
|
owner: "louislam",
|
||||||
|
repo: "dockge",
|
||||||
|
number: issueNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = (
|
||||||
|
await client.issues.listLabelsOnIssue({
|
||||||
|
owner: issue.owner,
|
||||||
|
repo: issue.repo,
|
||||||
|
issue_number: issue.number
|
||||||
|
})
|
||||||
|
).data.map(({ name }) => name);
|
||||||
|
|
||||||
|
if (labels.length === 0) {
|
||||||
|
console.log("Bad format here");
|
||||||
|
|
||||||
|
await client.issues.addLabels({
|
||||||
|
owner: issue.owner,
|
||||||
|
repo: issue.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
labels: [ "invalid-format" ]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the issue closing comment
|
||||||
|
await client.issues.createComment({
|
||||||
|
owner: issue.owner,
|
||||||
|
repo: issue.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
body: `@${username}: Hello! :wave:\n\nThis issue is being automatically closed because it does not follow the issue template. Please DO NOT open a blank issue.`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close the issue
|
||||||
|
await client.issues.update({
|
||||||
|
owner: issue.owner,
|
||||||
|
repo: issue.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
state: "closed"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("Pass!");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
20
extra/env2arg.js
Normal file
20
extra/env2arg.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import childProcess from "child_process";
|
||||||
|
|
||||||
|
let env = process.env;
|
||||||
|
|
||||||
|
let cmd = process.argv[2];
|
||||||
|
let args = process.argv.slice(3);
|
||||||
|
let replacedArgs = [];
|
||||||
|
|
||||||
|
for (let arg of args) {
|
||||||
|
for (let key in env) {
|
||||||
|
arg = arg.replaceAll(`$${key}`, env[key]);
|
||||||
|
}
|
||||||
|
replacedArgs.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let child = childProcess.spawn(cmd, replacedArgs);
|
||||||
|
child.stdout.pipe(process.stdout);
|
||||||
|
child.stderr.pipe(process.stderr);
|
22
extra/mark-as-nightly.ts
Normal file
22
extra/mark-as-nightly.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import pkg from "../package.json";
|
||||||
|
import fs from "fs";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
const oldVersion = pkg.version;
|
||||||
|
const newVersion = oldVersion + "-nightly-" + dayjs().format("YYYYMMDDHHmmss");
|
||||||
|
|
||||||
|
console.log("Old Version: " + oldVersion);
|
||||||
|
console.log("New Version: " + newVersion);
|
||||||
|
|
||||||
|
if (newVersion) {
|
||||||
|
// Process package.json
|
||||||
|
pkg.version = newVersion;
|
||||||
|
//pkg.scripts.setup = pkg.scripts.setup.replaceAll(oldVersion, newVersion);
|
||||||
|
//pkg.scripts["build-docker"] = pkg.scripts["build-docker"].replaceAll(oldVersion, newVersion);
|
||||||
|
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n");
|
||||||
|
|
||||||
|
// Process README.md
|
||||||
|
if (fs.existsSync("README.md")) {
|
||||||
|
fs.writeFileSync("README.md", fs.readFileSync("README.md", "utf8").replaceAll(oldVersion, newVersion));
|
||||||
|
}
|
||||||
|
}
|
42
extra/reformat-changelog.ts
Normal file
42
extra/reformat-changelog.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// Generate on GitHub
|
||||||
|
const input = `
|
||||||
|
* Add Korean translation by @Alanimdeo in https://github.com/louislam/dockge/pull/86
|
||||||
|
`;
|
||||||
|
|
||||||
|
const template = `
|
||||||
|
### 🆕 New Features
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
### 🐞 Bug Fixes
|
||||||
|
|
||||||
|
### 🦎 Translation Contributions
|
||||||
|
|
||||||
|
### Others
|
||||||
|
- Other small changes, code refactoring and comment/doc updates in this repo:
|
||||||
|
`;
|
||||||
|
|
||||||
|
const lines = input.split("\n").filter((line) => line.trim() !== "");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Split the last " by "
|
||||||
|
const usernamePullRequesURL = line.split(" by ").pop();
|
||||||
|
|
||||||
|
if (!usernamePullRequesURL) {
|
||||||
|
console.log("Unable to parse", line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ username, pullRequestURL ] = usernamePullRequesURL.split(" in ");
|
||||||
|
const pullRequestID = "#" + pullRequestURL.split("/").pop();
|
||||||
|
let message = line.split(" by ").shift();
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
console.log("Unable to parse", line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
message = message.split("* ").pop();
|
||||||
|
console.log("-", pullRequestID, message, `(Thanks ${username})`);
|
||||||
|
}
|
||||||
|
console.log(template);
|
9
extra/templates/mariadb/compose.yaml
Normal file
9
extra/templates/mariadb/compose.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
mariadb:
|
||||||
|
image: mariadb:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
environment:
|
||||||
|
- MARIADB_ROOT_PASSWORD=123456
|
12
extra/templates/nginx-proxy-manager/compose.yaml
Normal file
12
extra/templates/nginx-proxy-manager/compose.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
nginx-proxy-manager:
|
||||||
|
image: 'jc21/nginx-proxy-manager:latest'
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '81:81'
|
||||||
|
- '443:443'
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- ./letsencrypt:/etc/letsencrypt
|
9
extra/templates/uptime-kuma/compose.yaml
Normal file
9
extra/templates/uptime-kuma/compose.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:1
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
restart: always
|
9
extra/test-docker.ts
Normal file
9
extra/test-docker.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// Check if docker is running
|
||||||
|
import { exec } from "child_process";
|
||||||
|
|
||||||
|
exec("docker ps", (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Docker is not running. Please start docker and try again.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
64
extra/update-version.ts
Normal file
64
extra/update-version.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import pkg from "../package.json";
|
||||||
|
import childProcess from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const newVersion = process.env.VERSION;
|
||||||
|
|
||||||
|
console.log("New Version: " + newVersion);
|
||||||
|
|
||||||
|
if (! newVersion) {
|
||||||
|
console.error("invalid version");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = tagExists(newVersion);
|
||||||
|
|
||||||
|
if (! exists) {
|
||||||
|
// Process package.json
|
||||||
|
pkg.version = newVersion;
|
||||||
|
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n");
|
||||||
|
commit(newVersion);
|
||||||
|
tag(newVersion);
|
||||||
|
} else {
|
||||||
|
console.log("version exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit updated files
|
||||||
|
* @param {string} version Version to update to
|
||||||
|
*/
|
||||||
|
function commit(version) {
|
||||||
|
let msg = "Update to " + version;
|
||||||
|
|
||||||
|
let res = childProcess.spawnSync("git", [ "commit", "-m", msg, "-a" ]);
|
||||||
|
let stdout = res.stdout.toString().trim();
|
||||||
|
console.log(stdout);
|
||||||
|
|
||||||
|
if (stdout.includes("no changes added to commit")) {
|
||||||
|
throw new Error("commit error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tag with the specified version
|
||||||
|
* @param {string} version Tag to create
|
||||||
|
*/
|
||||||
|
function tag(version) {
|
||||||
|
let res = childProcess.spawnSync("git", [ "tag", version ]);
|
||||||
|
console.log(res.stdout.toString().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tag exists for the specified version
|
||||||
|
* @param {string} version Version to check
|
||||||
|
* @returns {boolean} Does the tag already exist
|
||||||
|
*/
|
||||||
|
function tagExists(version) {
|
||||||
|
if (! version) {
|
||||||
|
throw new Error("invalid version");
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = childProcess.spawnSync("git", [ "tag", "-l", version ]);
|
||||||
|
|
||||||
|
return res.stdout.toString().trim() === version;
|
||||||
|
}
|
32
frontend/components.d.ts
vendored
Normal file
32
frontend/components.d.ts
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// Generated by unplugin-vue-components
|
||||||
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
About: typeof import('./src/components/settings/About.vue')['default']
|
||||||
|
Appearance: typeof import('./src/components/settings/Appearance.vue')['default']
|
||||||
|
ArrayInput: typeof import('./src/components/ArrayInput.vue')['default']
|
||||||
|
ArraySelect: typeof import('./src/components/ArraySelect.vue')['default']
|
||||||
|
BDropdown: typeof import('bootstrap-vue-next')['BDropdown']
|
||||||
|
BDropdownItem: typeof import('bootstrap-vue-next')['BDropdownItem']
|
||||||
|
BModal: typeof import('bootstrap-vue-next')['BModal']
|
||||||
|
Confirm: typeof import('./src/components/Confirm.vue')['default']
|
||||||
|
Container: typeof import('./src/components/Container.vue')['default']
|
||||||
|
General: typeof import('./src/components/settings/General.vue')['default']
|
||||||
|
HiddenInput: typeof import('./src/components/HiddenInput.vue')['default']
|
||||||
|
Login: typeof import('./src/components/Login.vue')['default']
|
||||||
|
NetworkInput: typeof import('./src/components/NetworkInput.vue')['default']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
Security: typeof import('./src/components/settings/Security.vue')['default']
|
||||||
|
StackList: typeof import('./src/components/StackList.vue')['default']
|
||||||
|
StackListItem: typeof import('./src/components/StackListItem.vue')['default']
|
||||||
|
Terminal: typeof import('./src/components/Terminal.vue')['default']
|
||||||
|
TwoFADialog: typeof import('./src/components/TwoFADialog.vue')['default']
|
||||||
|
Uptime: typeof import('./src/components/Uptime.vue')['default']
|
||||||
|
}
|
||||||
|
}
|
33
frontend/index.html
Normal file
33
frontend/index.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" id="theme-color" content="" />
|
||||||
|
<meta name="description" content="" />
|
||||||
|
<title>Dockge</title>
|
||||||
|
<style>
|
||||||
|
.noscript-message {
|
||||||
|
font-size: 20px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<div class="noscript-message">
|
||||||
|
Sorry, you don't seem to have JavaScript enabled or your browser
|
||||||
|
doesn't support it.<br />This website requires JavaScript to function.
|
||||||
|
Please enable JavaScript in your browser settings to continue.
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
BIN
frontend/public/icon-192x192.png
Normal file
BIN
frontend/public/icon-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/icon-512x512.png
Normal file
BIN
frontend/public/icon-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
14
frontend/public/icon.svg
Normal file
14
frontend/public/icon.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="640" height="640" viewBox="0 0 640 640" xml:space="preserve">
|
||||||
|
<desc>Created with Fabric.js 5.3.0</desc>
|
||||||
|
<defs>
|
||||||
|
</defs>
|
||||||
|
<g transform="matrix(0.9544918218 0 0 0.9544918218 320 325.5657767239)" id="0UAuLmXgnot4bJxVEVJCQ" >
|
||||||
|
<linearGradient id="SVGID_136_0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1 0 0 1 -236.6470440833 -213.9441386034)" x1="259.78" y1="261.15" x2="463.85" y2="456.49">
|
||||||
|
<stop offset="0%" style="stop-color:#74C2FF;stop-opacity: 1"/>
|
||||||
|
<stop offset="100%" style="stop-color:rgb(134,230,169);stop-opacity: 1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<path style="stroke: rgb(242,242,242); stroke-opacity: 0.51; stroke-width: 190; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: url(#SVGID_136_0); fill-rule: nonzero; opacity: 1;" transform=" translate(0, 0)" d="M 131.8665 -139.04883 C 159.01022 -111.20969000000001 170.12421 -99.45396000000001 203.11849999999998 -51.72057000000001 C 236.1128 -3.9871800000000093 264.44147999999996 83.98416999999998 187.33995 144.05073 C 177.72728999999998 151.53955 166.73827 158.81189999999998 154.65932999999998 165.65812999999997 C 69.85514999999998 213.72433999999998 -68.67309000000003 240.78578 -161.79279 174.28328999999997 C -268.17583 98.30862999999997 -260.10282 -68.66557000000003 -144.35093 -170.50579000000005 C -28.599040000000002 -272.34602000000007 104.72278 -166.88797000000005 131.86649999999997 -139.04883000000004 z" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
19
frontend/public/manifest.json
Normal file
19
frontend/public/manifest.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "Dockge",
|
||||||
|
"short_name": "Dockge",
|
||||||
|
"start_url": "/",
|
||||||
|
"background_color": "#fff",
|
||||||
|
"display": "standalone",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
9
frontend/src/App.vue
Normal file
9
frontend/src/App.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
|
||||||
|
};
|
||||||
|
</script>
|
149
frontend/src/components/ArrayInput.vue
Normal file
149
frontend/src/components/ArrayInput.vue
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="valid">
|
||||||
|
<ul v-if="isArrayInited" class="list-group">
|
||||||
|
<li v-for="(value, index) in array" :key="index" class="list-group-item">
|
||||||
|
<input v-model="array[index]" type="text" class="no-bg domain-input" :placeholder="placeholder" />
|
||||||
|
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" @click="remove(index)" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button class="btn btn-normal btn-sm mt-3" @click="addField">{{ $t("addListItem", [ displayName ]) }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
Long syntax is not supported here. Please use the YAML editor.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
objectType: {
|
||||||
|
type: String,
|
||||||
|
default: "service",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
array() {
|
||||||
|
// Create the array if not exists, it should be safe.
|
||||||
|
if (!this.service[this.name]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.service[this.name];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the array is inited before called v-for.
|
||||||
|
* Prevent empty arrays inserted to the YAML file.
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
isArrayInited() {
|
||||||
|
return this.service[this.name] !== undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not a good name, but it is used to get the object.
|
||||||
|
*/
|
||||||
|
service() {
|
||||||
|
if (this.objectType === "service") {
|
||||||
|
// Used in Container.vue
|
||||||
|
return this.$parent.$parent.service;
|
||||||
|
} else if (this.objectType === "x-dockge") {
|
||||||
|
|
||||||
|
if (!this.$parent.$parent.jsonConfig["x-dockge"]) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used in Compose.vue
|
||||||
|
return this.$parent.$parent.jsonConfig["x-dockge"];
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
valid() {
|
||||||
|
// Check if the array is actually an array
|
||||||
|
if (!Array.isArray(this.array)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the array contains non-object only.
|
||||||
|
for (let item of this.array) {
|
||||||
|
if (typeof item === "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addField() {
|
||||||
|
|
||||||
|
// Create the object if not exists.
|
||||||
|
if (this.objectType === "x-dockge") {
|
||||||
|
if (!this.$parent.$parent.jsonConfig["x-dockge"]) {
|
||||||
|
this.$parent.$parent.jsonConfig["x-dockge"] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the array if not exists.
|
||||||
|
if (!this.service[this.name]) {
|
||||||
|
this.service[this.name] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.array.push("");
|
||||||
|
},
|
||||||
|
remove(index) {
|
||||||
|
this.array.splice(index, 1);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../styles/vars.scss";
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
background-color: $dark-bg2;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0 10px 10px;
|
||||||
|
|
||||||
|
.domain-input {
|
||||||
|
flex-grow: 1;
|
||||||
|
background-color: $dark-bg2;
|
||||||
|
border: none;
|
||||||
|
color: $dark-font-color;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #1d2634;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
128
frontend/src/components/ArraySelect.vue
Normal file
128
frontend/src/components/ArraySelect.vue
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="valid">
|
||||||
|
<ul v-if="isArrayInited" class="list-group">
|
||||||
|
<li v-for="(value, index) in array" :key="index" class="list-group-item">
|
||||||
|
<select v-model="array[index]" class="no-bg domain-input">
|
||||||
|
<option value="">Select a network...</option>
|
||||||
|
<option v-for="option in options" :key="option" :value="option">{{ option }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" @click="remove(index)" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button class="btn btn-normal btn-sm mt-3" @click="addField">{{ $t("addListItem", [ displayName ]) }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
Long syntax is not supported here. Please use the YAML editor.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
array() {
|
||||||
|
// Create the array if not exists, it should be safe.
|
||||||
|
if (!this.service[this.name]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.service[this.name];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the array is inited before called v-for.
|
||||||
|
* Prevent empty arrays inserted to the YAML file.
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
isArrayInited() {
|
||||||
|
return this.service[this.name] !== undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
service() {
|
||||||
|
return this.$parent.$parent.service;
|
||||||
|
},
|
||||||
|
|
||||||
|
valid() {
|
||||||
|
// Check if the array is actually an array
|
||||||
|
if (!Array.isArray(this.array)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the array contains non-object only.
|
||||||
|
for (let item of this.array) {
|
||||||
|
if (typeof item === "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addField() {
|
||||||
|
// Create the array if not exists.
|
||||||
|
if (!this.service[this.name]) {
|
||||||
|
this.service[this.name] = [];
|
||||||
|
}
|
||||||
|
this.array.push("");
|
||||||
|
},
|
||||||
|
remove(index) {
|
||||||
|
this.array.splice(index, 1);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../styles/vars.scss";
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
background-color: $dark-bg2;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0 10px 10px;
|
||||||
|
|
||||||
|
.domain-input {
|
||||||
|
flex-grow: 1;
|
||||||
|
background-color: $dark-bg2;
|
||||||
|
border: none;
|
||||||
|
color: $dark-font-color;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #1d2634;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
84
frontend/src/components/Confirm.vue
Normal file
84
frontend/src/components/Confirm.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="modal" class="modal fade" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 id="exampleModalLabel" class="modal-title">
|
||||||
|
{{ title || $t("Confirm") }}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" :class="btnStyle" data-bs-dismiss="modal" @click="yes">
|
||||||
|
{{ yesText }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="no">
|
||||||
|
{{ noText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Modal } from "bootstrap";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
/** Style of button */
|
||||||
|
btnStyle: {
|
||||||
|
type: String,
|
||||||
|
default: "btn-primary",
|
||||||
|
},
|
||||||
|
/** Text to use as yes */
|
||||||
|
yesText: {
|
||||||
|
type: String,
|
||||||
|
default: "Yes", // TODO: No idea what to translate this
|
||||||
|
},
|
||||||
|
/** Text to use as no */
|
||||||
|
noText: {
|
||||||
|
type: String,
|
||||||
|
default: "No",
|
||||||
|
},
|
||||||
|
/** Title to show on modal. Defaults to translated version of "Config" */
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: [ "yes", "no" ],
|
||||||
|
data: () => ({
|
||||||
|
modal: null,
|
||||||
|
}),
|
||||||
|
mounted() {
|
||||||
|
this.modal = new Modal(this.$refs.modal);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Show the confirm dialog
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
show() {
|
||||||
|
this.modal.show();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @fires string "yes" Notify the parent when Yes is pressed
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
yes() {
|
||||||
|
this.$emit("yes");
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @fires string "no" Notify the parent when No is pressed
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
no() {
|
||||||
|
this.$emit("no");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
275
frontend/src/components/Container.vue
Normal file
275
frontend/src/components/Container.vue
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shadow-box big-padding mb-3 container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-7">
|
||||||
|
<h4>{{ name }}</h4>
|
||||||
|
<div class="image mb-2">
|
||||||
|
<span class="me-1">{{ imageName }}:</span><span class="tag">{{ imageTag }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isEditMode">
|
||||||
|
<span class="badge me-1" :class="bgStyle">{{ status }}</span>
|
||||||
|
|
||||||
|
<a v-for="port in service.ports" :key="port" :href="parsePort(port).url" target="_blank">
|
||||||
|
<span class="badge me-1 bg-secondary">{{ parsePort(port).display }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<div class="function">
|
||||||
|
<router-link v-if="!isEditMode" class="btn btn-normal" :to="terminalRouteLink" disabled="">
|
||||||
|
<font-awesome-icon icon="terminal" />
|
||||||
|
Bash
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isEditMode" class="mt-2">
|
||||||
|
<button class="btn btn-normal me-2" @click="showConfig = !showConfig">
|
||||||
|
<font-awesome-icon icon="edit" />
|
||||||
|
{{ $t("Edit") }}
|
||||||
|
</button>
|
||||||
|
<button v-if="false" class="btn btn-normal me-2">Rename</button>
|
||||||
|
<button class="btn btn-danger me-2" @click="remove">
|
||||||
|
<font-awesome-icon icon="trash" />
|
||||||
|
{{ $t("deleteContainer") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="slide-fade" appear>
|
||||||
|
<div v-if="isEditMode && showConfig" class="config mt-3">
|
||||||
|
<!-- Image -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">
|
||||||
|
{{ $t("dockerImage") }}
|
||||||
|
</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input
|
||||||
|
v-model="service.image"
|
||||||
|
class="form-control"
|
||||||
|
list="image-datalist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO: Search online: https://hub.docker.com/api/content/v1/products/search?q=louislam%2Fuptime&source=community&page=1&page_size=4 -->
|
||||||
|
<datalist id="image-datalist">
|
||||||
|
<option value="louislam/uptime-kuma:1" />
|
||||||
|
</datalist>
|
||||||
|
<div class="form-text"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ports -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">
|
||||||
|
{{ $tc("port", 2) }}
|
||||||
|
</label>
|
||||||
|
<ArrayInput name="ports" :display-name="$t('port')" placeholder="HOST:CONTAINER" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volumes -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">
|
||||||
|
{{ $tc("volume", 2) }}
|
||||||
|
</label>
|
||||||
|
<ArrayInput name="volumes" :display-name="$t('volume')" placeholder="HOST:CONTAINER" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart Policy -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">
|
||||||
|
{{ $t("restartPolicy") }}
|
||||||
|
</label>
|
||||||
|
<select v-model="service.restart" class="form-select">
|
||||||
|
<option value="always">{{ $t("restartPolicyAlways") }}</option>
|
||||||
|
<option value="unless-stopped">{{ $t("restartPolicyUnlessStopped") }}</option>
|
||||||
|
<option value="on-failure">{{ $t("restartPolicyOnFailure") }}</option>
|
||||||
|
<option value="no">{{ $t("restartPolicyNo") }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Environment Variables -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">
|
||||||
|
{{ $tc("environmentVariable", 2) }}
|
||||||
|
</label>
|
||||||
|
<ArrayInput name="environment" :display-name="$t('environmentVariable')" placeholder="KEY=VALUE" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Container Name -->
|
||||||
|
<div v-if="false" class="mb-4">
|
||||||
|
<label class="form-label">
|
||||||
|
{{ $t("containerName") }}
|
||||||
|
</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input
|
||||||
|
v-model="service.container_name"
|
||||||
|
class="form-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-text"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">
|
||||||
|
{{ $tc("network", 2) }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="networkList.length === 0 && service.networks && service.networks.length > 0" class="text-warning mb-3">
|
||||||
|
No networks available. You need to add internal networks or enable external networks in the right side first.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArraySelect name="networks" :display-name="$t('network')" placeholder="Network Name" :options="networkList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Depends on -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">
|
||||||
|
{{ $t("dependsOn") }}
|
||||||
|
</label>
|
||||||
|
<ArrayInput name="depends_on" :display-name="$t('dependsOn')" placeholder="Container Name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
|
import { parseDockerPort } from "../../../backend/util-common";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isEditMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
first: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
default: "N/A",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showConfig: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
|
||||||
|
networkList() {
|
||||||
|
let list = [];
|
||||||
|
for (const networkName in this.jsonObject.networks) {
|
||||||
|
list.push(networkName);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
|
||||||
|
bgStyle() {
|
||||||
|
if (this.status === "running" || this.status === "healthy") {
|
||||||
|
return "bg-primary";
|
||||||
|
} else if (this.status === "unhealthy") {
|
||||||
|
return "bg-danger";
|
||||||
|
} else {
|
||||||
|
return "bg-secondary";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
terminalRouteLink() {
|
||||||
|
return {
|
||||||
|
name: "containerTerminal",
|
||||||
|
params: {
|
||||||
|
stackName: this.stackName,
|
||||||
|
serviceName: this.name,
|
||||||
|
type: "bash",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
stackName() {
|
||||||
|
return this.$parent.$parent.stack.name;
|
||||||
|
},
|
||||||
|
|
||||||
|
service() {
|
||||||
|
if (!this.jsonObject.services[this.name]) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return this.jsonObject.services[this.name];
|
||||||
|
},
|
||||||
|
|
||||||
|
jsonObject() {
|
||||||
|
return this.$parent.$parent.jsonConfig;
|
||||||
|
},
|
||||||
|
imageName() {
|
||||||
|
if (this.service.image) {
|
||||||
|
return this.service.image.split(":")[0];
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
imageTag() {
|
||||||
|
if (this.service.image) {
|
||||||
|
let tag = this.service.image.split(":")[1];
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
return tag;
|
||||||
|
} else {
|
||||||
|
return "latest";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.first) {
|
||||||
|
//this.showConfig = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
parsePort(port) {
|
||||||
|
let hostname = this.$root.info.primaryHostname || location.hostname;
|
||||||
|
return parseDockerPort(port, hostname);
|
||||||
|
},
|
||||||
|
remove() {
|
||||||
|
delete this.jsonObject.services[this.name];
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../styles/vars";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
.image {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
.tag {
|
||||||
|
color: #33383b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.function {
|
||||||
|
align-content: center;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
87
frontend/src/components/HiddenInput.vue
Normal file
87
frontend/src/components/HiddenInput.vue
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input
|
||||||
|
ref="input"
|
||||||
|
v-model="model"
|
||||||
|
:type="visibility"
|
||||||
|
class="form-control"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:maxlength="maxlength"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:required="required"
|
||||||
|
:readonly="readonly"
|
||||||
|
>
|
||||||
|
|
||||||
|
<a v-if="visibility == 'password'" class="btn btn-outline-primary" @click="showInput()">
|
||||||
|
<font-awesome-icon icon="eye" />
|
||||||
|
</a>
|
||||||
|
<a v-if="visibility == 'text'" class="btn btn-outline-primary" @click="hideInput()">
|
||||||
|
<font-awesome-icon icon="eye-slash" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
/** The value of the input */
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
/** A placeholder to use */
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
/** Maximum length of the input */
|
||||||
|
maxlength: {
|
||||||
|
type: Number,
|
||||||
|
default: 255
|
||||||
|
},
|
||||||
|
/** Should the field auto complete */
|
||||||
|
autocomplete: {
|
||||||
|
type: String,
|
||||||
|
default: "new-password",
|
||||||
|
},
|
||||||
|
/** Is the input required? */
|
||||||
|
required: {
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
/** Should the input be read only? */
|
||||||
|
readonly: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: [ "update:modelValue" ],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
visibility: "password",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
model: {
|
||||||
|
get() {
|
||||||
|
return this.modelValue;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit("update:modelValue", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/** Show users input in plain text */
|
||||||
|
showInput() {
|
||||||
|
this.visibility = "text";
|
||||||
|
},
|
||||||
|
/** Censor users input */
|
||||||
|
hideInput() {
|
||||||
|
this.visibility = "password";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
114
frontend/src/components/Login.vue
Normal file
114
frontend/src/components/Login.vue
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-container">
|
||||||
|
<div class="form">
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<h1 class="h3 mb-3 fw-normal" />
|
||||||
|
|
||||||
|
<div v-if="!tokenRequired" class="form-floating">
|
||||||
|
<input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username" autocomplete="username" required>
|
||||||
|
<label for="floatingInput">{{ $t("Username") }}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!tokenRequired" class="form-floating mt-3">
|
||||||
|
<input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="Password" autocomplete="current-password" required>
|
||||||
|
<label for="floatingPassword">{{ $t("Password") }}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tokenRequired">
|
||||||
|
<div class="form-floating mt-3">
|
||||||
|
<input id="otp" v-model="token" type="text" maxlength="6" class="form-control" placeholder="123456" autocomplete="one-time-code" required>
|
||||||
|
<label for="otp">{{ $t("Token") }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3 mt-3 d-flex justify-content-center pe-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<input id="remember" v-model="$root.remember" type="checkbox" value="remember-me" class="form-check-input">
|
||||||
|
|
||||||
|
<label class="form-check-label" for="remember">
|
||||||
|
{{ $t("Remember me") }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="w-100 btn btn-primary" type="submit" :disabled="processing">
|
||||||
|
{{ $t("Login") }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="res && !res.ok" class="alert alert-danger mt-3" role="alert">
|
||||||
|
{{ $t(res.msg) }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
token: "",
|
||||||
|
res: null,
|
||||||
|
tokenRequired: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
document.title += " - Login";
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
document.title = document.title.replace(" - Login", "");
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Submit the user details and attempt to log in
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
submit() {
|
||||||
|
this.processing = true;
|
||||||
|
|
||||||
|
this.$root.login(this.username, this.password, this.token, (res) => {
|
||||||
|
this.processing = false;
|
||||||
|
|
||||||
|
if (res.tokenRequired) {
|
||||||
|
this.tokenRequired = true;
|
||||||
|
} else {
|
||||||
|
this.res = res;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.form-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating {
|
||||||
|
> label {
|
||||||
|
padding-left: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .form-control {
|
||||||
|
padding-left: 1.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 330px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
223
frontend/src/components/NetworkInput.vue
Normal file
223
frontend/src/components/NetworkInput.vue
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h5>{{ $t("Internal Networks") }}</h5>
|
||||||
|
<ul class="list-group">
|
||||||
|
<li v-for="(networkRow, index) in networkList" :key="index" class="list-group-item">
|
||||||
|
<input v-model="networkRow.key" type="text" class="no-bg domain-input" placeholder="Network name..." />
|
||||||
|
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" @click="remove(index)" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button class="btn btn-normal btn-sm mt-3 me-2" @click="addField">{{ $t("addInternalNetwork") }}</button>
|
||||||
|
|
||||||
|
<h5 class="mt-3">{{ $t("External Networks") }}</h5>
|
||||||
|
|
||||||
|
<div v-if="externalNetworkList.length === 0">
|
||||||
|
{{ $t("No External Networks") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(networkName, index) in externalNetworkList" :key="networkName" class="form-check form-switch my-3">
|
||||||
|
<input :id=" 'external-network' + index" v-model="selectedExternalList[networkName]" class="form-check-input" type="checkbox">
|
||||||
|
|
||||||
|
<label class="form-check-label" :for=" 'external-network' +index">
|
||||||
|
{{ networkName }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span v-if="false" class="text-danger ms-2 delete">Delete</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="false" class="input-group mb-3">
|
||||||
|
<input
|
||||||
|
placeholder="New external network name..."
|
||||||
|
class="form-control"
|
||||||
|
@keyup.enter="createExternelNetwork"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-normal btn-sm me-2" type="button">
|
||||||
|
{{ $t("createExternalNetwork") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="false">
|
||||||
|
<button class="btn btn-primary btn-sm mt-3 me-2" @click="applyToYAML">{{ $t("applyToYAML") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
networkList: [],
|
||||||
|
externalList: {},
|
||||||
|
selectedExternalList: {},
|
||||||
|
externalNetworkList: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
jsonConfig() {
|
||||||
|
return this.$parent.$parent.jsonConfig;
|
||||||
|
},
|
||||||
|
|
||||||
|
stack() {
|
||||||
|
return this.$parent.$parent.stack;
|
||||||
|
},
|
||||||
|
|
||||||
|
editorFocus() {
|
||||||
|
return this.$parent.$parent.editorFocus;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
"jsonConfig.networks": {
|
||||||
|
handler() {
|
||||||
|
if (this.editorFocus) {
|
||||||
|
console.debug("jsonConfig.networks changed");
|
||||||
|
this.loadNetworkList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"selectedExternalList": {
|
||||||
|
handler() {
|
||||||
|
for (const networkName in this.selectedExternalList) {
|
||||||
|
const enable = this.selectedExternalList[networkName];
|
||||||
|
|
||||||
|
if (enable) {
|
||||||
|
if (!this.externalList[networkName]) {
|
||||||
|
this.externalList[networkName] = {};
|
||||||
|
}
|
||||||
|
this.externalList[networkName].external = true;
|
||||||
|
} else {
|
||||||
|
delete this.externalList[networkName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.applyToYAML();
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"networkList": {
|
||||||
|
handler() {
|
||||||
|
this.applyToYAML();
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadNetworkList();
|
||||||
|
this.loadExternalNetworkList();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadNetworkList() {
|
||||||
|
this.networkList = [];
|
||||||
|
this.externalList = {};
|
||||||
|
|
||||||
|
for (const key in this.jsonConfig.networks) {
|
||||||
|
let obj = {
|
||||||
|
key: key,
|
||||||
|
value: this.jsonConfig.networks[key],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (obj.value && obj.value.external) {
|
||||||
|
this.externalList[key] = Object.assign({}, obj.value);
|
||||||
|
} else {
|
||||||
|
this.networkList.push(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore selectedExternalList
|
||||||
|
this.selectedExternalList = {};
|
||||||
|
for (const networkName in this.externalList) {
|
||||||
|
this.selectedExternalList[networkName] = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadExternalNetworkList() {
|
||||||
|
this.$root.getSocket().emit("getDockerNetworkList", (res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
this.externalNetworkList = res.dockerNetworkList.filter((n) => {
|
||||||
|
// Filter out this stack networks
|
||||||
|
if (n.startsWith(this.stack.name + "_")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// They should be not supported.
|
||||||
|
// https://docs.docker.com/compose/compose-file/06-networks/#host-or-none
|
||||||
|
if (n === "none" || n === "host" || n === "bridge") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.$root.toastRes(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addField() {
|
||||||
|
this.networkList.push({
|
||||||
|
key: "",
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(index) {
|
||||||
|
this.networkList.splice(index, 1);
|
||||||
|
this.applyToYAML();
|
||||||
|
},
|
||||||
|
|
||||||
|
applyToYAML() {
|
||||||
|
if (this.editorFocus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jsonConfig.networks = {};
|
||||||
|
|
||||||
|
// Internal networks
|
||||||
|
for (const networkRow of this.networkList) {
|
||||||
|
this.jsonConfig.networks[networkRow.key] = networkRow.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// External networks
|
||||||
|
for (const networkName in this.externalList) {
|
||||||
|
this.jsonConfig.networks[networkName] = this.externalList[networkName];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug("applyToYAML", this.jsonConfig.networks);
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../styles/vars.scss";
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
background-color: $dark-bg2;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0 10px 10px;
|
||||||
|
|
||||||
|
.domain-input {
|
||||||
|
flex-grow: 1;
|
||||||
|
background-color: $dark-bg2;
|
||||||
|
border: none;
|
||||||
|
color: $dark-font-color;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #1d2634;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
438
frontend/src/components/StackList.vue
Normal file
438
frontend/src/components/StackList.vue
Normal file
@ -0,0 +1,438 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shadow-box mb-3" :style="boxStyle">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="header-top">
|
||||||
|
<!-- TODO -->
|
||||||
|
<button v-if="false" class="btn btn-outline-normal ms-2" :class="{ 'active': selectMode }" type="button" @click="selectMode = !selectMode">
|
||||||
|
{{ $t("Select") }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="placeholder"></div>
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<a v-if="searchText == ''" class="search-icon">
|
||||||
|
<font-awesome-icon icon="search" />
|
||||||
|
</a>
|
||||||
|
<a v-if="searchText != ''" class="search-icon" style="cursor: pointer" @click="clearSearchText">
|
||||||
|
<font-awesome-icon icon="times" />
|
||||||
|
</a>
|
||||||
|
<form>
|
||||||
|
<input v-model="searchText" class="form-control search-input" autocomplete="off" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO -->
|
||||||
|
<div v-if="false" class="header-filter">
|
||||||
|
<!--<StackListFilter :filterState="filterState" @update-filter="updateFilter" />-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO: Selection Controls -->
|
||||||
|
<div v-if="selectMode && false" class="selection-controls px-2 pt-2">
|
||||||
|
<input
|
||||||
|
v-model="selectAll"
|
||||||
|
class="form-check-input select-input"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ $t("Pause") }}</button>
|
||||||
|
<button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ $t("Resume") }}</button>
|
||||||
|
|
||||||
|
<span v-if="selectedStackCount > 0">
|
||||||
|
{{ $t("selectedStackCount", [ selectedStackCount ]) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="stackList" class="stack-list" :class="{ scrollbar: scrollbar }" :style="stackListStyle">
|
||||||
|
<div v-if="Object.keys($root.stackList).length === 0" class="text-center mt-3">
|
||||||
|
<router-link to="/compose">{{ $t("addFirstStackMsg") }}</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StackListItem
|
||||||
|
v-for="(item, index) in sortedStackList"
|
||||||
|
:key="index"
|
||||||
|
:stack="item"
|
||||||
|
:isSelectMode="selectMode"
|
||||||
|
:isSelected="isSelected"
|
||||||
|
:select="select"
|
||||||
|
:deselect="deselect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
|
||||||
|
{{ $t("pauseStackMsg") }}
|
||||||
|
</Confirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Confirm from "../components/Confirm.vue";
|
||||||
|
import StackListItem from "../components/StackListItem.vue";
|
||||||
|
import { CREATED_FILE, CREATED_STACK, EXITED, RUNNING, UNKNOWN } from "../../../backend/util-common";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Confirm,
|
||||||
|
StackListItem,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
/** Should the scrollbar be shown */
|
||||||
|
scrollbar: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
searchText: "",
|
||||||
|
selectMode: false,
|
||||||
|
selectAll: false,
|
||||||
|
disableSelectAllWatcher: false,
|
||||||
|
selectedStacks: {},
|
||||||
|
windowTop: 0,
|
||||||
|
filterState: {
|
||||||
|
status: null,
|
||||||
|
active: null,
|
||||||
|
tags: null,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
/**
|
||||||
|
* Improve the sticky appearance of the list by increasing its
|
||||||
|
* height as user scrolls down.
|
||||||
|
* Not used on mobile.
|
||||||
|
* @returns {object} Style for stack list
|
||||||
|
*/
|
||||||
|
boxStyle() {
|
||||||
|
if (window.innerWidth > 550) {
|
||||||
|
return {
|
||||||
|
height: `calc(100vh - 160px + ${this.windowTop}px)`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
height: "calc(100vh - 160px)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a sorted list of stacks based on the applied filters and search text.
|
||||||
|
* @returns {Array} The sorted list of stacks.
|
||||||
|
*/
|
||||||
|
sortedStackList() {
|
||||||
|
let result = Object.values(this.$root.stackList);
|
||||||
|
|
||||||
|
result = result.filter(stack => {
|
||||||
|
// filter by search text
|
||||||
|
// finds stack name, tag name or tag value
|
||||||
|
let searchTextMatch = true;
|
||||||
|
if (this.searchText !== "") {
|
||||||
|
const loweredSearchText = this.searchText.toLowerCase();
|
||||||
|
searchTextMatch =
|
||||||
|
stack.name.toLowerCase().includes(loweredSearchText)
|
||||||
|
|| stack.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|
||||||
|
|| tag.value?.toLowerCase().includes(loweredSearchText));
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter by active
|
||||||
|
let activeMatch = true;
|
||||||
|
if (this.filterState.active != null && this.filterState.active.length > 0) {
|
||||||
|
activeMatch = this.filterState.active.includes(stack.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter by tags
|
||||||
|
let tagsMatch = true;
|
||||||
|
if (this.filterState.tags != null && this.filterState.tags.length > 0) {
|
||||||
|
tagsMatch = stack.tags.map(tag => tag.tag_id) // convert to array of tag IDs
|
||||||
|
.filter(stackTagId => this.filterState.tags.includes(stackTagId)) // perform Array Intersaction between filter and stack's tags
|
||||||
|
.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchTextMatch && activeMatch && tagsMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
result.sort((m1, m2) => {
|
||||||
|
if (m1.status !== m2.status) {
|
||||||
|
if (m2.status === RUNNING) {
|
||||||
|
return 1;
|
||||||
|
} else if (m1.status === RUNNING) {
|
||||||
|
return -1;
|
||||||
|
} else if (m2.status === EXITED) {
|
||||||
|
return 1;
|
||||||
|
} else if (m1.status === EXITED) {
|
||||||
|
return -1;
|
||||||
|
} else if (m2.status === CREATED_STACK) {
|
||||||
|
return 1;
|
||||||
|
} else if (m1.status === CREATED_STACK) {
|
||||||
|
return -1;
|
||||||
|
} else if (m2.status === CREATED_FILE) {
|
||||||
|
return 1;
|
||||||
|
} else if (m1.status === CREATED_FILE) {
|
||||||
|
return -1;
|
||||||
|
} else if (m2.status === UNKNOWN) {
|
||||||
|
return 1;
|
||||||
|
} else if (m1.status === UNKNOWN) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m1.name.localeCompare(m2.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
isDarkTheme() {
|
||||||
|
return document.body.classList.contains("dark");
|
||||||
|
},
|
||||||
|
|
||||||
|
stackListStyle() {
|
||||||
|
//let listHeaderHeight = 107;
|
||||||
|
let listHeaderHeight = 60;
|
||||||
|
|
||||||
|
if (this.selectMode) {
|
||||||
|
listHeaderHeight += 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"height": `calc(100% - ${listHeaderHeight}px)`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedStackCount() {
|
||||||
|
return Object.keys(this.selectedStacks).length;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if any filters are active.
|
||||||
|
* @returns {boolean} True if any filter is active, false otherwise.
|
||||||
|
*/
|
||||||
|
filtersActive() {
|
||||||
|
return this.filterState.status != null || this.filterState.active != null || this.filterState.tags != null || this.searchText !== "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
searchText() {
|
||||||
|
for (let stack of this.sortedStackList) {
|
||||||
|
if (!this.selectedStacks[stack.id]) {
|
||||||
|
if (this.selectAll) {
|
||||||
|
this.disableSelectAllWatcher = true;
|
||||||
|
this.selectAll = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectAll() {
|
||||||
|
if (!this.disableSelectAllWatcher) {
|
||||||
|
this.selectedStacks = {};
|
||||||
|
|
||||||
|
if (this.selectAll) {
|
||||||
|
this.sortedStackList.forEach((item) => {
|
||||||
|
this.selectedStacks[item.id] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.disableSelectAllWatcher = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectMode() {
|
||||||
|
if (!this.selectMode) {
|
||||||
|
this.selectAll = false;
|
||||||
|
this.selectedStacks = {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
window.addEventListener("scroll", this.onScroll);
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
window.removeEventListener("scroll", this.onScroll);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Handle user scroll
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
onScroll() {
|
||||||
|
if (window.top.scrollY <= 133) {
|
||||||
|
this.windowTop = window.top.scrollY;
|
||||||
|
} else {
|
||||||
|
this.windowTop = 133;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search bar
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
clearSearchText() {
|
||||||
|
this.searchText = "";
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Update the StackList Filter
|
||||||
|
* @param {object} newFilter Object with new filter
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
updateFilter(newFilter) {
|
||||||
|
this.filterState = newFilter;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Deselect a stack
|
||||||
|
* @param {number} id ID of stack
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
deselect(id) {
|
||||||
|
delete this.selectedStacks[id];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Select a stack
|
||||||
|
* @param {number} id ID of stack
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
select(id) {
|
||||||
|
this.selectedStacks[id] = true;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Determine if stack is selected
|
||||||
|
* @param {number} id ID of stack
|
||||||
|
* @returns {bool} Is the stack selected?
|
||||||
|
*/
|
||||||
|
isSelected(id) {
|
||||||
|
return id in this.selectedStacks;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Disable select mode and reset selection
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
cancelSelectMode() {
|
||||||
|
this.selectMode = false;
|
||||||
|
this.selectedStacks = {};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Show dialog to confirm pause
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
pauseDialog() {
|
||||||
|
this.$refs.confirmPause.show();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Pause each selected stack
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
pauseSelected() {
|
||||||
|
Object.keys(this.selectedStacks)
|
||||||
|
.filter(id => this.$root.stackList[id].active)
|
||||||
|
.forEach(id => this.$root.getSocket().emit("pauseStack", id, () => {}));
|
||||||
|
|
||||||
|
this.cancelSelectMode();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Resume each selected stack
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
resumeSelected() {
|
||||||
|
Object.keys(this.selectedStacks)
|
||||||
|
.filter(id => !this.$root.stackList[id].active)
|
||||||
|
.forEach(id => this.$root.getSocket().emit("resumeStack", id, () => {}));
|
||||||
|
|
||||||
|
this.cancelSelectMode();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../styles/vars.scss";
|
||||||
|
|
||||||
|
.shadow-box {
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
position: sticky;
|
||||||
|
top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-padding {
|
||||||
|
padding-left: 5px !important;
|
||||||
|
padding-right: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
margin: -10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background-color: $dark-header-bg;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 770px) {
|
||||||
|
.list-header {
|
||||||
|
margin: -20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
padding: 10px;
|
||||||
|
color: #c0c0c0;
|
||||||
|
|
||||||
|
// Clear filter button (X)
|
||||||
|
svg[data-icon="times"] {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all ease-in-out 0.1s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
max-width: 15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 67px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-style {
|
||||||
|
padding-left: 67px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-controls {
|
||||||
|
margin-top: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
154
frontend/src/components/StackListItem.vue
Normal file
154
frontend/src/components/StackListItem.vue
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<router-link :to="`/compose/${stack.name}`" :class="{ 'dim' : !stack.isManagedByDockge }" class="item">
|
||||||
|
<Uptime :stack="stack" :fixed-width="true" class="me-2" />
|
||||||
|
<span class="title">{{ stackName }}</span>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import Uptime from "./Uptime.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Uptime
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
/** Stack this represents */
|
||||||
|
stack: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
/** If the user is in select mode */
|
||||||
|
isSelectMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
/** How many ancestors are above this stack */
|
||||||
|
depth: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
/** Callback to determine if stack is selected */
|
||||||
|
isSelected: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
/** Callback fired when stack is selected */
|
||||||
|
select: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
/** Callback fired when stack is deselected */
|
||||||
|
deselect: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isCollapsed: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
depthMargin() {
|
||||||
|
return {
|
||||||
|
marginLeft: `${31 * this.depth}px`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
stackName() {
|
||||||
|
return this.stack.name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
isSelectMode() {
|
||||||
|
// TODO: Resize the heartbeat bar, but too slow
|
||||||
|
// this.$refs.heartbeatBar.resize();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Changes the collapsed value of the current stack and saves
|
||||||
|
* it to local storage
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
changeCollapsed() {
|
||||||
|
this.isCollapsed = !this.isCollapsed;
|
||||||
|
|
||||||
|
// Save collapsed value into local storage
|
||||||
|
let storage = window.localStorage.getItem("stackCollapsed");
|
||||||
|
let storageObject = {};
|
||||||
|
if (storage !== null) {
|
||||||
|
storageObject = JSON.parse(storage);
|
||||||
|
}
|
||||||
|
storageObject[`stack_${this.stack.id}`] = this.isCollapsed;
|
||||||
|
|
||||||
|
window.localStorage.setItem("stackCollapsed", JSON.stringify(storageObject));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle selection of stack
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
toggleSelection() {
|
||||||
|
if (this.isSelected(this.stack.id)) {
|
||||||
|
this.deselect(this.stack.id);
|
||||||
|
} else {
|
||||||
|
this.select(this.stack.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../styles/vars.scss";
|
||||||
|
|
||||||
|
.small-padding {
|
||||||
|
padding-left: 5px !important;
|
||||||
|
padding-right: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-padding {
|
||||||
|
padding-left: 8px !important;
|
||||||
|
padding-right: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// .stack-item {
|
||||||
|
// width: 100%;
|
||||||
|
// }
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 67px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated {
|
||||||
|
transition: all 0.2s $easing-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input-wrapper {
|
||||||
|
float: left;
|
||||||
|
margin-top: 15px;
|
||||||
|
margin-left: 3px;
|
||||||
|
margin-right: 10px;
|
||||||
|
padding-left: 4px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
228
frontend/src/components/Terminal.vue
Normal file
228
frontend/src/components/Terminal.vue
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shadow-box">
|
||||||
|
<div v-pre ref="terminal" class="main-terminal"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Terminal } from "xterm";
|
||||||
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||||
|
import { TERMINAL_COLS, TERMINAL_ROWS } from "../../../backend/util-common";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
/**
|
||||||
|
* @type {Terminal}
|
||||||
|
*/
|
||||||
|
terminal: null,
|
||||||
|
components: {
|
||||||
|
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
require: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Require if mode is interactive
|
||||||
|
stackName: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Require if mode is interactive
|
||||||
|
serviceName: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Require if mode is interactive
|
||||||
|
shell: {
|
||||||
|
type: String,
|
||||||
|
default: "bash",
|
||||||
|
},
|
||||||
|
|
||||||
|
rows: {
|
||||||
|
type: Number,
|
||||||
|
default: TERMINAL_ROWS,
|
||||||
|
},
|
||||||
|
|
||||||
|
cols: {
|
||||||
|
type: Number,
|
||||||
|
default: TERMINAL_COLS,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mode
|
||||||
|
// displayOnly: Only display terminal output
|
||||||
|
// mainTerminal: Allow input limited commands and output
|
||||||
|
// interactive: Free input and output
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: "displayOnly",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: [ "has-data" ],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
first: true,
|
||||||
|
terminalInputBuffer: "",
|
||||||
|
cursorPosition: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
let cursorBlink = true;
|
||||||
|
|
||||||
|
if (this.mode === "displayOnly") {
|
||||||
|
cursorBlink = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.terminal = new Terminal({
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
cursorBlink,
|
||||||
|
cols: this.cols,
|
||||||
|
rows: this.rows,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.mode === "mainTerminal") {
|
||||||
|
this.mainTerminalConfig();
|
||||||
|
} else if (this.mode === "interactive") {
|
||||||
|
this.interactiveTerminalConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
//this.terminal.loadAddon(new WebLinksAddon());
|
||||||
|
|
||||||
|
// Bind to a div
|
||||||
|
this.terminal.open(this.$refs.terminal);
|
||||||
|
this.terminal.focus();
|
||||||
|
|
||||||
|
// Notify parent component when data is received
|
||||||
|
this.terminal.onCursorMove(() => {
|
||||||
|
console.debug("onData triggered");
|
||||||
|
if (this.first) {
|
||||||
|
this.$emit("has-data");
|
||||||
|
this.first = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bind();
|
||||||
|
|
||||||
|
// Create a new Terminal
|
||||||
|
if (this.mode === "mainTerminal") {
|
||||||
|
this.$root.getSocket().emit("mainTerminal", this.name, (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
this.$root.toastRes(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (this.mode === "interactive") {
|
||||||
|
console.debug("Create Interactive terminal:", this.name);
|
||||||
|
this.$root.getSocket().emit("interactiveTerminal", this.stackName, this.serviceName, this.shell, (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
this.$root.toastRes(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
this.$root.unbindTerminal(this.name);
|
||||||
|
this.terminal.dispose();
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
bind(name) {
|
||||||
|
// Workaround: normally this.name should be set, but it is not sometimes, so we use the parameter, but eventually this.name and name must be the same name
|
||||||
|
if (name) {
|
||||||
|
this.$root.unbindTerminal(name);
|
||||||
|
this.$root.bindTerminal(name, this.terminal);
|
||||||
|
console.debug("Terminal bound via parameter: " + name);
|
||||||
|
} else if (this.name) {
|
||||||
|
this.$root.unbindTerminal(this.name);
|
||||||
|
this.$root.bindTerminal(this.name, this.terminal);
|
||||||
|
console.debug("Terminal bound: " + this.name);
|
||||||
|
} else {
|
||||||
|
console.debug("Terminal name not set");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeInput() {
|
||||||
|
const backspaceCount = this.terminalInputBuffer.length;
|
||||||
|
const backspaces = "\b \b".repeat(backspaceCount);
|
||||||
|
this.cursorPosition = 0;
|
||||||
|
this.terminal.write(backspaces);
|
||||||
|
this.terminalInputBuffer = "";
|
||||||
|
},
|
||||||
|
|
||||||
|
mainTerminalConfig() {
|
||||||
|
this.terminal.onKey(e => {
|
||||||
|
const code = e.key.charCodeAt(0);
|
||||||
|
console.debug("Encode: " + JSON.stringify(e.key));
|
||||||
|
|
||||||
|
if (e.key === "\r") {
|
||||||
|
// Return if no input
|
||||||
|
if (this.terminalInputBuffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = this.terminalInputBuffer;
|
||||||
|
|
||||||
|
// Remove the input from the terminal
|
||||||
|
this.removeInput();
|
||||||
|
|
||||||
|
this.$root.getSocket().emit("terminalInput", this.name, buffer + e.key, (err) => {
|
||||||
|
this.$root.toastError(err.msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (code === 127) { // Backspace
|
||||||
|
if (this.cursorPosition > 0) {
|
||||||
|
this.terminal.write("\b \b");
|
||||||
|
this.cursorPosition--;
|
||||||
|
this.terminalInputBuffer = this.terminalInputBuffer.slice(0, -1);
|
||||||
|
}
|
||||||
|
} else if (e.key === "\u001B\u005B\u0041" || e.key === "\u001B\u005B\u0042") { // UP OR DOWN
|
||||||
|
// Do nothing
|
||||||
|
|
||||||
|
} else if (e.key === "\u001B\u005B\u0043") { // RIGHT
|
||||||
|
// TODO
|
||||||
|
} else if (e.key === "\u001B\u005B\u0044") { // LEFT
|
||||||
|
// TODO
|
||||||
|
} else if (e.key === "\u0003") { // Ctrl + C
|
||||||
|
console.debug("Ctrl + C");
|
||||||
|
this.$root.getSocket().emit("terminalInput", this.name, e.key);
|
||||||
|
this.removeInput();
|
||||||
|
} else {
|
||||||
|
this.cursorPosition++;
|
||||||
|
this.terminalInputBuffer += e.key;
|
||||||
|
console.log(this.terminalInputBuffer);
|
||||||
|
this.terminal.write(e.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
interactiveTerminalConfig() {
|
||||||
|
this.terminal.onKey(e => {
|
||||||
|
this.$root.getSocket().emit("terminalInput", this.name, e.key, (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
this.$root.toastRes(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.main-terminal {
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.terminal {
|
||||||
|
padding: 10px 15px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
203
frontend/src/components/TwoFADialog.vue
Normal file
203
frontend/src/components/TwoFADialog.vue
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
{{ $t("Setup 2FA") }}
|
||||||
|
<span v-if="twoFAStatus == true" class="badge bg-primary">{{ $t("Active") }}</span>
|
||||||
|
<span v-if="twoFAStatus == false" class="badge bg-primary">{{ $t("Inactive") }}</span>
|
||||||
|
</h5>
|
||||||
|
<button :disabled="processing" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div v-if="uri && twoFAStatus == false" class="mx-auto text-center" style="width: 210px;">
|
||||||
|
<vue-qrcode :key="uri" :value="uri" type="image/png" :quality="1" :color="{ light: '#ffffffff' }" />
|
||||||
|
<button v-show="!showURI" type="button" class="btn btn-outline-primary btn-sm mt-2" @click="showURI = true">{{ $t("Show URI") }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="showURI && twoFAStatus == false" class="text-break mt-2">{{ uri }}</p>
|
||||||
|
|
||||||
|
<div v-if="!(uri && twoFAStatus == false)" class="mb-3">
|
||||||
|
<label for="current-password" class="form-label">
|
||||||
|
{{ $t("Current Password") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="current-password"
|
||||||
|
v-model="currentPassword"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="uri == null && twoFAStatus == false" class="btn btn-primary" type="button" @click="prepare2FA()">
|
||||||
|
{{ $t("Enable 2FA") }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-if="twoFAStatus == true" class="btn btn-danger" type="button" :disabled="processing" @click="confirmDisableTwoFA()">
|
||||||
|
{{ $t("Disable 2FA") }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="uri && twoFAStatus == false" class="mt-3">
|
||||||
|
<label for="basic-url" class="form-label">{{ $t("twoFAVerifyLabel") }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input v-model="token" type="text" maxlength="6" class="form-control" autocomplete="one-time-code" required>
|
||||||
|
<button class="btn btn-outline-primary" type="button" @click="verifyToken()">{{ $t("Verify Token") }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-show="tokenValid" class="mt-2" style="color: green;">{{ $t("tokenValidSettingsMsg") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uri && twoFAStatus == false" class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="processing || tokenValid == false" @click="confirmEnableTwoFA()">
|
||||||
|
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
|
||||||
|
{{ $t("Save") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Confirm ref="confirmEnableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="save2FA">
|
||||||
|
{{ $t("confirmEnableTwoFAMsg") }}
|
||||||
|
</Confirm>
|
||||||
|
|
||||||
|
<Confirm ref="confirmDisableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="disable2FA">
|
||||||
|
{{ $t("confirmDisableTwoFAMsg") }}
|
||||||
|
</Confirm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Modal } from "bootstrap";
|
||||||
|
import Confirm from "./Confirm.vue";
|
||||||
|
import VueQrcode from "vue-qrcode";
|
||||||
|
import { useToast } from "vue-toastification";
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Confirm,
|
||||||
|
VueQrcode,
|
||||||
|
},
|
||||||
|
props: {},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentPassword: "",
|
||||||
|
processing: false,
|
||||||
|
uri: null,
|
||||||
|
tokenValid: false,
|
||||||
|
twoFAStatus: null,
|
||||||
|
token: null,
|
||||||
|
showURI: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.modal = new Modal(this.$refs.modal);
|
||||||
|
this.getStatus();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/** Show the dialog */
|
||||||
|
show() {
|
||||||
|
this.modal.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Show dialog to confirm enabling 2FA */
|
||||||
|
confirmEnableTwoFA() {
|
||||||
|
this.$refs.confirmEnableTwoFA.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Show dialog to confirm disabling 2FA */
|
||||||
|
confirmDisableTwoFA() {
|
||||||
|
this.$refs.confirmDisableTwoFA.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Prepare 2FA configuration */
|
||||||
|
prepare2FA() {
|
||||||
|
this.processing = true;
|
||||||
|
|
||||||
|
this.$root.getSocket().emit("prepare2FA", this.currentPassword, (res) => {
|
||||||
|
this.processing = false;
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
this.uri = res.uri;
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Save the current 2FA configuration */
|
||||||
|
save2FA() {
|
||||||
|
this.processing = true;
|
||||||
|
|
||||||
|
this.$root.getSocket().emit("save2FA", this.currentPassword, (res) => {
|
||||||
|
this.processing = false;
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
this.$root.toastRes(res);
|
||||||
|
this.getStatus();
|
||||||
|
this.currentPassword = "";
|
||||||
|
this.modal.hide();
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Disable 2FA for this user */
|
||||||
|
disable2FA() {
|
||||||
|
this.processing = true;
|
||||||
|
|
||||||
|
this.$root.getSocket().emit("disable2FA", this.currentPassword, (res) => {
|
||||||
|
this.processing = false;
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
this.$root.toastRes(res);
|
||||||
|
this.getStatus();
|
||||||
|
this.currentPassword = "";
|
||||||
|
this.modal.hide();
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Verify the token generated by the user */
|
||||||
|
verifyToken() {
|
||||||
|
this.$root.getSocket().emit("verifyToken", this.token, this.currentPassword, (res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
this.tokenValid = res.valid;
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get current status of 2FA */
|
||||||
|
getStatus() {
|
||||||
|
this.$root.getSocket().emit("twoFAStatus", (res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
this.twoFAStatus = res.status;
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../styles/vars.scss";
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
.modal-dialog .form-text, .modal-dialog p {
|
||||||
|
color: $dark-font-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
56
frontend/src/components/Uptime.vue
Normal file
56
frontend/src/components/Uptime.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="className">{{ statusName }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { statusColor, statusNameShort } from "../../../backend/util-common";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
stack: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
fixedWidth: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
uptime() {
|
||||||
|
return this.$t("notAvailableShort");
|
||||||
|
},
|
||||||
|
|
||||||
|
color() {
|
||||||
|
return statusColor(this.stack?.status);
|
||||||
|
},
|
||||||
|
|
||||||
|
statusName() {
|
||||||
|
return this.$t(statusNameShort(this.stack?.status));
|
||||||
|
},
|
||||||
|
|
||||||
|
className() {
|
||||||
|
let className = `badge rounded-pill bg-${this.color}`;
|
||||||
|
|
||||||
|
if (this.fixedWidth) {
|
||||||
|
className += " fixed-width";
|
||||||
|
}
|
||||||
|
return className;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.badge {
|
||||||
|
min-width: 62px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-width {
|
||||||
|
width: 62px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
66
frontend/src/components/settings/About.vue
Normal file
66
frontend/src/components/settings/About.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-flex justify-content-center align-items-center">
|
||||||
|
<div class="logo d-flex flex-column justify-content-center align-items-center">
|
||||||
|
<object class="my-4" width="200" height="200" data="/icon.svg" />
|
||||||
|
<div class="fs-4 fw-bold">Dockge</div>
|
||||||
|
<div>{{ $t("Version") }}: {{ $root.info.version }}</div>
|
||||||
|
<div class="frontend-version">{{ $t("Frontend Version") }}: {{ $root.frontendVersion }}</div>
|
||||||
|
|
||||||
|
<div v-if="!$root.isFrontendBackendVersionMatched" class="alert alert-warning mt-4" role="alert">
|
||||||
|
⚠️ {{ $t("Frontend Version do not match backend version!") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="mt-1">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input v-model="settings.checkUpdate" type="checkbox" @change="saveSettings()" /> {{ $t("Show update if available") }}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input v-model="settings.checkBeta" type="checkbox" :disabled="!settings.checkUpdate" @change="saveSettings()" /> {{ $t("Also check beta release") }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
computed: {
|
||||||
|
settings() {
|
||||||
|
return this.$parent.$parent.$parent.settings;
|
||||||
|
},
|
||||||
|
saveSettings() {
|
||||||
|
return this.$parent.$parent.$parent.saveSettings;
|
||||||
|
},
|
||||||
|
settingsLoaded() {
|
||||||
|
return this.$parent.$parent.$parent.settingsLoaded;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.logo {
|
||||||
|
margin: 4em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-link {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frontend-version {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #cccccc;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
94
frontend/src/components/settings/Appearance.vue
Normal file
94
frontend/src/components/settings/Appearance.vue
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="my-4">
|
||||||
|
<label for="language" class="form-label">
|
||||||
|
{{ $t("Language") }}
|
||||||
|
</label>
|
||||||
|
<select id="language" v-model="$root.language" class="form-select">
|
||||||
|
<option
|
||||||
|
v-for="(lang, i) in $i18n.availableLocales"
|
||||||
|
:key="`Lang${i}`"
|
||||||
|
:value="lang"
|
||||||
|
>
|
||||||
|
{{ $i18n.messages[lang].languageName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-show="false" class="my-4">
|
||||||
|
<label for="timezone" class="form-label">{{ $t("Theme") }}</label>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="btn-group"
|
||||||
|
role="group"
|
||||||
|
aria-label="Basic checkbox toggle button group"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="btncheck1"
|
||||||
|
v-model="$root.userTheme"
|
||||||
|
type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
name="theme"
|
||||||
|
autocomplete="off"
|
||||||
|
value="light"
|
||||||
|
/>
|
||||||
|
<label class="btn btn-outline-primary" for="btncheck1">
|
||||||
|
{{ $t("Light") }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="btncheck2"
|
||||||
|
v-model="$root.userTheme"
|
||||||
|
type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
name="theme"
|
||||||
|
autocomplete="off"
|
||||||
|
value="dark"
|
||||||
|
/>
|
||||||
|
<label class="btn btn-outline-primary" for="btncheck2">
|
||||||
|
{{ $t("Dark") }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="btncheck3"
|
||||||
|
v-model="$root.userTheme"
|
||||||
|
type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
name="theme"
|
||||||
|
autocomplete="off"
|
||||||
|
value="auto"
|
||||||
|
/>
|
||||||
|
<label class="btn btn-outline-primary" for="btncheck3">
|
||||||
|
{{ $t("Auto") }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../styles/vars.scss";
|
||||||
|
|
||||||
|
.btn-check:active + .btn-outline-primary,
|
||||||
|
.btn-check:checked + .btn-outline-primary,
|
||||||
|
.btn-check:hover + .btn-outline-primary {
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
.list-group-item {
|
||||||
|
background-color: $dark-bg2;
|
||||||
|
color: $dark-font-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
114
frontend/src/components/settings/General.vue
Normal file
114
frontend/src/components/settings/General.vue
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<form class="my-4" autocomplete="off" @submit.prevent="saveGeneral">
|
||||||
|
<!-- Client side Timezone -->
|
||||||
|
<div v-if="false" class="mb-4">
|
||||||
|
<label for="timezone" class="form-label">
|
||||||
|
{{ $t("Display Timezone") }}
|
||||||
|
</label>
|
||||||
|
<select id="timezone" v-model="$root.userTimezone" class="form-select">
|
||||||
|
<option value="auto">
|
||||||
|
{{ $t("Auto") }}: {{ guessTimezone }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="(timezone, index) in timezoneList"
|
||||||
|
:key="index"
|
||||||
|
:value="timezone.value"
|
||||||
|
>
|
||||||
|
{{ timezone.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Timezone -->
|
||||||
|
<div v-if="false" class="mb-4">
|
||||||
|
<label for="timezone" class="form-label">
|
||||||
|
{{ $t("Server Timezone") }}
|
||||||
|
</label>
|
||||||
|
<select id="timezone" v-model="settings.serverTimezone" class="form-select">
|
||||||
|
<option value="UTC">UTC</option>
|
||||||
|
<option
|
||||||
|
v-for="(timezone, index) in timezoneList"
|
||||||
|
:key="index"
|
||||||
|
:value="timezone.value"
|
||||||
|
>
|
||||||
|
{{ timezone.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Primary Hostname -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="primaryBaseURL">
|
||||||
|
{{ $t("primaryHostname") }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input
|
||||||
|
v-model="settings.primaryHostname"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="(Unset: Follow current hostname)"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-outline-primary" type="button" @click="autoGetPrimaryHostname">
|
||||||
|
{{ $t("autoGet") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-text"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Button -->
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
{{ $t("Save") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { timezoneList } from "../../util-frontend";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
timezoneList: timezoneList(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
settings() {
|
||||||
|
return this.$parent.$parent.$parent.settings;
|
||||||
|
},
|
||||||
|
saveSettings() {
|
||||||
|
return this.$parent.$parent.$parent.saveSettings;
|
||||||
|
},
|
||||||
|
settingsLoaded() {
|
||||||
|
return this.$parent.$parent.$parent.settingsLoaded;
|
||||||
|
},
|
||||||
|
guessTimezone() {
|
||||||
|
return dayjs.tz.guess();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/** Save the settings */
|
||||||
|
saveGeneral() {
|
||||||
|
localStorage.timezone = this.$root.userTimezone;
|
||||||
|
this.saveSettings();
|
||||||
|
},
|
||||||
|
/** Get the base URL of the application */
|
||||||
|
autoGetPrimaryHostname() {
|
||||||
|
this.settings.primaryHostname = location.hostname;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
205
frontend/src/components/settings/Security.vue
Normal file
205
frontend/src/components/settings/Security.vue
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="settingsLoaded" class="my-4">
|
||||||
|
<!-- Change Password -->
|
||||||
|
<template v-if="!settings.disableAuth">
|
||||||
|
<p>
|
||||||
|
{{ $t("Current User") }}: <strong>{{ $root.username }}</strong>
|
||||||
|
<button v-if="! settings.disableAuth" id="logout-btn" class="btn btn-danger ms-4 me-2 mb-2" @click="$root.logout">{{ $t("Logout") }}</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h5 class="my-4 settings-subheading">{{ $t("Change Password") }}</h5>
|
||||||
|
<form class="mb-3" @submit.prevent="savePassword">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="current-password" class="form-label">
|
||||||
|
{{ $t("Current Password") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="current-password"
|
||||||
|
v-model="password.currentPassword"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new-password" class="form-label">
|
||||||
|
{{ $t("New Password") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
v-model="password.newPassword"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="repeat-new-password" class="form-label">
|
||||||
|
{{ $t("Repeat New Password") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="repeat-new-password"
|
||||||
|
v-model="password.repeatNewPassword"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': invalidPassword }"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
{{ $t("passwordNotMatchMsg") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
{{ $t("Update Password") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- TODO: Hidden for now -->
|
||||||
|
<div v-if="! settings.disableAuth && false" class="mt-5 mb-3">
|
||||||
|
<h5 class="my-4 settings-subheading">
|
||||||
|
{{ $t("Two Factor Authentication") }}
|
||||||
|
</h5>
|
||||||
|
<div class="mb-4">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary me-2"
|
||||||
|
type="button"
|
||||||
|
@click="$refs.TwoFADialog.show()"
|
||||||
|
>
|
||||||
|
{{ $t("2FA Settings") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-4">
|
||||||
|
<!-- Advanced -->
|
||||||
|
<h5 class="my-4 settings-subheading">{{ $t("Advanced") }}</h5>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<button v-if="settings.disableAuth" id="enableAuth-btn" class="btn btn-outline-primary me-2 mb-2" @click="enableAuth">{{ $t("Enable Auth") }}</button>
|
||||||
|
<button v-if="! settings.disableAuth" id="disableAuth-btn" class="btn btn-primary me-2 mb-2" @click="confirmDisableAuth">{{ $t("Disable Auth") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TwoFADialog ref="TwoFADialog" />
|
||||||
|
|
||||||
|
<Confirm ref="confirmDisableAuth" btn-style="btn-danger" :yes-text="$t('I understand, please disable')" :no-text="$t('Leave')" @yes="disableAuth">
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<p v-html="$t('disableauth.message1')"></p>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<p v-html="$t('disableauth.message2')"></p>
|
||||||
|
<p>{{ $t("Please use this option carefully!") }}</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="current-password2" class="form-label">
|
||||||
|
{{ $t("Current Password") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="current-password2"
|
||||||
|
v-model="password.currentPassword"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Confirm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Confirm from "../../components/Confirm.vue";
|
||||||
|
import TwoFADialog from "../../components/TwoFADialog.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Confirm,
|
||||||
|
TwoFADialog
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
invalidPassword: false,
|
||||||
|
password: {
|
||||||
|
currentPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
repeatNewPassword: "",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
settings() {
|
||||||
|
return this.$parent.$parent.$parent.settings;
|
||||||
|
},
|
||||||
|
saveSettings() {
|
||||||
|
return this.$parent.$parent.$parent.saveSettings;
|
||||||
|
},
|
||||||
|
settingsLoaded() {
|
||||||
|
return this.$parent.$parent.$parent.settingsLoaded;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
"password.repeatNewPassword"() {
|
||||||
|
this.invalidPassword = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
/** Check new passwords match before saving them */
|
||||||
|
savePassword() {
|
||||||
|
if (this.password.newPassword !== this.password.repeatNewPassword) {
|
||||||
|
this.invalidPassword = true;
|
||||||
|
} else {
|
||||||
|
this.$root
|
||||||
|
.getSocket()
|
||||||
|
.emit("changePassword", this.password, (res) => {
|
||||||
|
this.$root.toastRes(res);
|
||||||
|
if (res.ok) {
|
||||||
|
this.password.currentPassword = "";
|
||||||
|
this.password.newPassword = "";
|
||||||
|
this.password.repeatNewPassword = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Disable authentication for web app access */
|
||||||
|
disableAuth() {
|
||||||
|
this.settings.disableAuth = true;
|
||||||
|
|
||||||
|
// Need current password to disable auth
|
||||||
|
// Set it to empty if done
|
||||||
|
this.saveSettings(() => {
|
||||||
|
this.password.currentPassword = "";
|
||||||
|
this.$root.username = null;
|
||||||
|
this.$root.socketIO.token = "autoLogin";
|
||||||
|
}, this.password.currentPassword);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Enable authentication for web app access */
|
||||||
|
enableAuth() {
|
||||||
|
this.settings.disableAuth = false;
|
||||||
|
this.saveSettings();
|
||||||
|
this.$root.storage().removeItem("token");
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Show confirmation dialog for disable auth */
|
||||||
|
confirmDisableAuth() {
|
||||||
|
this.$refs.confirmDisableAuth.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
55
frontend/src/i18n.ts
Normal file
55
frontend/src/i18n.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// @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.
|
||||||
|
import { createI18n } from "vue-i18n/dist/vue-i18n.esm-browser.prod.js";
|
||||||
|
import en from "./lang/en.json";
|
||||||
|
|
||||||
|
const languageList = {
|
||||||
|
"bg-BG": "Български",
|
||||||
|
"es": "Español",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"fr": "Français",
|
||||||
|
"pl-PL": "Polski",
|
||||||
|
"pt": "Português",
|
||||||
|
"pt-BR": "Português-Brasil",
|
||||||
|
"sl": "Slovenščina",
|
||||||
|
"tr": "Türkçe",
|
||||||
|
"zh-CN": "简体中文",
|
||||||
|
"zh-TW": "繁體中文(台灣)",
|
||||||
|
"ur": "Urdu",
|
||||||
|
"ko-KR": "한국어",
|
||||||
|
"ru": "Русский",
|
||||||
|
"cs-CZ": "Čeština",
|
||||||
|
"ar": "العربية",
|
||||||
|
"th":"ไทย",
|
||||||
|
"it-IT":"Italiano",
|
||||||
|
"sv-SE":"Svenska",
|
||||||
|
"uk-UA":"Українська"
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages = {
|
||||||
|
en,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let lang in languageList) {
|
||||||
|
messages[lang] = {
|
||||||
|
languageName: languageList[lang]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rtlLangs = [ "fa", "ar-SY", "ur" ];
|
||||||
|
|
||||||
|
export const currentLocale = () => localStorage.locale
|
||||||
|
|| languageList[navigator.language] && navigator.language
|
||||||
|
|| languageList[navigator.language.substring(0, 2)] && navigator.language.substring(0, 2)
|
||||||
|
|| "en";
|
||||||
|
|
||||||
|
export const localeDirection = () => {
|
||||||
|
return rtlLangs.includes(currentLocale()) ? "rtl" : "ltr";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
locale: currentLocale(),
|
||||||
|
fallbackLocale: "en",
|
||||||
|
silentFallbackWarn: true,
|
||||||
|
silentTranslationWarn: true,
|
||||||
|
messages: messages,
|
||||||
|
});
|
115
frontend/src/icon.ts
Normal file
115
frontend/src/icon.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
|
|
||||||
|
// Add Free Font Awesome Icons
|
||||||
|
// https://fontawesome.com/v6/icons?d=gallery&p=2&s=solid&m=free
|
||||||
|
// In order to add an icon, you have to:
|
||||||
|
// 1) add the icon name in the import statement below;
|
||||||
|
// 2) add the icon name to the library.add() statement below.
|
||||||
|
import {
|
||||||
|
faArrowAltCircleUp,
|
||||||
|
faCog,
|
||||||
|
faEdit,
|
||||||
|
faEye,
|
||||||
|
faEyeSlash,
|
||||||
|
faList,
|
||||||
|
faPause,
|
||||||
|
faStop,
|
||||||
|
faPlay,
|
||||||
|
faPlus,
|
||||||
|
faSearch,
|
||||||
|
faTachometerAlt,
|
||||||
|
faTimes,
|
||||||
|
faTimesCircle,
|
||||||
|
faTrash,
|
||||||
|
faCheckCircle,
|
||||||
|
faStream,
|
||||||
|
faSave,
|
||||||
|
faExclamationCircle,
|
||||||
|
faBullhorn,
|
||||||
|
faArrowsAltV,
|
||||||
|
faUnlink,
|
||||||
|
faQuestionCircle,
|
||||||
|
faImages,
|
||||||
|
faUpload,
|
||||||
|
faCopy,
|
||||||
|
faCheck,
|
||||||
|
faFile,
|
||||||
|
faAward,
|
||||||
|
faLink,
|
||||||
|
faChevronDown,
|
||||||
|
faSignOutAlt,
|
||||||
|
faPen,
|
||||||
|
faExternalLinkSquareAlt,
|
||||||
|
faSpinner,
|
||||||
|
faUndo,
|
||||||
|
faPlusCircle,
|
||||||
|
faAngleDown,
|
||||||
|
faWrench,
|
||||||
|
faHeartbeat,
|
||||||
|
faFilter,
|
||||||
|
faInfoCircle,
|
||||||
|
faClone,
|
||||||
|
faCertificate,
|
||||||
|
faTerminal, faWarehouse, faHome, faRocket,
|
||||||
|
faRotate,
|
||||||
|
faCloudArrowDown, faArrowsRotate,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
library.add(
|
||||||
|
faArrowAltCircleUp,
|
||||||
|
faCog,
|
||||||
|
faEdit,
|
||||||
|
faEye,
|
||||||
|
faEyeSlash,
|
||||||
|
faList,
|
||||||
|
faPause,
|
||||||
|
faStop,
|
||||||
|
faPlay,
|
||||||
|
faPlus,
|
||||||
|
faSearch,
|
||||||
|
faTachometerAlt,
|
||||||
|
faTimes,
|
||||||
|
faTimesCircle,
|
||||||
|
faTrash,
|
||||||
|
faCheckCircle,
|
||||||
|
faStream,
|
||||||
|
faSave,
|
||||||
|
faExclamationCircle,
|
||||||
|
faBullhorn,
|
||||||
|
faArrowsAltV,
|
||||||
|
faUnlink,
|
||||||
|
faQuestionCircle,
|
||||||
|
faImages,
|
||||||
|
faUpload,
|
||||||
|
faCopy,
|
||||||
|
faCheck,
|
||||||
|
faFile,
|
||||||
|
faAward,
|
||||||
|
faLink,
|
||||||
|
faChevronDown,
|
||||||
|
faSignOutAlt,
|
||||||
|
faPen,
|
||||||
|
faExternalLinkSquareAlt,
|
||||||
|
faSpinner,
|
||||||
|
faUndo,
|
||||||
|
faPlusCircle,
|
||||||
|
faAngleDown,
|
||||||
|
faLink,
|
||||||
|
faWrench,
|
||||||
|
faHeartbeat,
|
||||||
|
faFilter,
|
||||||
|
faInfoCircle,
|
||||||
|
faClone,
|
||||||
|
faCertificate,
|
||||||
|
faTerminal,
|
||||||
|
faWarehouse,
|
||||||
|
faHome,
|
||||||
|
faRocket,
|
||||||
|
faRotate,
|
||||||
|
faCloudArrowDown,
|
||||||
|
faArrowsRotate,
|
||||||
|
);
|
||||||
|
|
||||||
|
export { FontAwesomeIcon };
|
||||||
|
|
14
frontend/src/lang/README.md
Normal file
14
frontend/src/lang/README.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Translations
|
||||||
|
|
||||||
|
A simple guide on how to translate `Dockge` in your native language.
|
||||||
|
|
||||||
|
## How to add a new language in the dropdown
|
||||||
|
|
||||||
|
(11-21-2023) Updated
|
||||||
|
|
||||||
|
1. Add your Language at `frontend/src/lang/` by creating a new file with your language Code, format: `zh-TW.json` .
|
||||||
|
2. Copy the content from `en.json` and make translations from that.
|
||||||
|
3. Add your language at the end of `languageList` in `frontend/src/i18n.ts`, format: `"zh-TW": "繁體中文 (台灣)"`,
|
||||||
|
4. Commit to new branch and make a new Pull Request for me to approve.
|
||||||
|
|
||||||
|
*Note:* Currently we are only accepting one Pull Request per Language Translate.
|
95
frontend/src/lang/ar.json
Normal file
95
frontend/src/lang/ar.json
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"languageName": "العربية",
|
||||||
|
"Create your admin account": "إنشاء حساب المشرف",
|
||||||
|
"authIncorrectCreds": "اسم المستخدم أو كلمة المرور غير صحيحة.",
|
||||||
|
"PasswordsDoNotMatch": "كلمة المرور غير مطابقة.",
|
||||||
|
"Repeat Password": "أعد كتابة كلمة السر",
|
||||||
|
"Create": "إنشاء",
|
||||||
|
"signedInDisp": "تم تسجيل الدخول باسم {0}",
|
||||||
|
"signedInDispDisabled": "تم تعطيل المصادقة.",
|
||||||
|
"home": "الرئيسية",
|
||||||
|
"console": "سطر الأوامر",
|
||||||
|
"registry": "السجل",
|
||||||
|
"compose": "أنشاء كمبوز",
|
||||||
|
"addFirstStackMsg": "أنشيء أول كمبوز!",
|
||||||
|
"stackName" : "اسم المكدسة",
|
||||||
|
"deployStack": "شنر",
|
||||||
|
"deleteStack": "حذف",
|
||||||
|
"stopStack": "إيقاف",
|
||||||
|
"restartStack": "إعادة تشغيل",
|
||||||
|
"updateStack": "تحديث",
|
||||||
|
"startStack": "تشغيل",
|
||||||
|
"downStack": "أيقاف",
|
||||||
|
"editStack": "تعديل",
|
||||||
|
"discardStack": "إهمال",
|
||||||
|
"saveStackDraft": "حفظ",
|
||||||
|
"notAvailableShort" : "غير متوفر",
|
||||||
|
"deleteStackMsg": "هل أنت متأكد أنك تريد حذف هذه المكدسة؟",
|
||||||
|
"stackNotManagedByDockgeMsg": "لا يتم إدارة هذه المكدس بواسطة Dockge.",
|
||||||
|
"primaryHostname": "اسم المضيف الرئيسي",
|
||||||
|
"general": "عام",
|
||||||
|
"container": "حاوية | حاويات",
|
||||||
|
"scanFolder": "مسح مجلد المكدسات",
|
||||||
|
"dockerImage": "صورة",
|
||||||
|
"restartPolicyUnlessStopped": "ما لم يوقف",
|
||||||
|
"restartPolicyAlways": "دائماً",
|
||||||
|
"restartPolicyOnFailure": "عند الفشل",
|
||||||
|
"restartPolicyNo": "لا",
|
||||||
|
"environmentVariable": "متغير البيئة | متغيرات البيئة",
|
||||||
|
"restartPolicy": "سياسة إعادة التشغيل",
|
||||||
|
"containerName": "اسم الحاوية",
|
||||||
|
"port": "منفذ | منافذ",
|
||||||
|
"volume": "مجلد | مجلدات",
|
||||||
|
"network": "شبكة | شبكات",
|
||||||
|
"dependsOn": "تبعية الحاوية | تبعية الحاويات",
|
||||||
|
"addListItem": "إضافة {0}",
|
||||||
|
"deleteContainer": "حذف",
|
||||||
|
"addContainer": "أضافة حاوية",
|
||||||
|
"addNetwork": "أضافة شبكة",
|
||||||
|
"disableauth.message1": "هل أنت متأكد أنك تريد <strong>تعطيل المصادقة</strong>?",
|
||||||
|
"disableauth.message2": "إنه مصمم للحالات <strong>التي تنوي فيها مصادقة الطرف الثالث</strong> أمام Dockge مثل Cloudflare Access, Authelia أو أي من آليات المصادقة الأخرى.",
|
||||||
|
"passwordNotMatchMsg": "كلمة المرور المكررة غير متطابقة.",
|
||||||
|
"autoGet": "الجلب التلقائي",
|
||||||
|
"add": "إضافة",
|
||||||
|
"Edit": "تعديل",
|
||||||
|
"applyToYAML": "تطبيق على YAML",
|
||||||
|
"createExternalNetwork": "إنشاء",
|
||||||
|
"addInternalNetwork": "إضافة",
|
||||||
|
"Save": "حفظ",
|
||||||
|
"Language": "اللغة",
|
||||||
|
"Current User": "المستخدم الحالي",
|
||||||
|
"Change Password": "تعديل كلمة المرور",
|
||||||
|
"Current Password": "كلمة المرور الحالية",
|
||||||
|
"New Password": "كلمة مرور جديدة",
|
||||||
|
"Repeat New Password": "أعد تكرار كلمة المرور",
|
||||||
|
"Update Password": "تحديث كلمة المرور",
|
||||||
|
"Advanced": "متقدم",
|
||||||
|
"Please use this option carefully!": "من فضلك استخدم هذا الخيار بعناية!",
|
||||||
|
"Enable Auth": "تفعيل المصادقة",
|
||||||
|
"Disable Auth": "تعطيل المصادقة",
|
||||||
|
"I understand, please disable": "أتفهم, أرجو التعطيل",
|
||||||
|
"Leave": "مغادرة",
|
||||||
|
"Frontend Version": "لإصدار الواجهة الأمامية",
|
||||||
|
"Check Update On GitHub": "تحق من التحديث على GitHub",
|
||||||
|
"Show update if available": "اعرض التحديث إذا كان متاحًا",
|
||||||
|
"Also check beta release": "تحقق أيضًا من إصدار النسخة التجريبية",
|
||||||
|
"Remember me": "تذكرني",
|
||||||
|
"Login": "تسجيل الدخول",
|
||||||
|
"Username": "اسم المستخدم",
|
||||||
|
"Password": "كلمة المرور",
|
||||||
|
"Settings": "الاعدادات",
|
||||||
|
"Logout": "تسجيل الخروج",
|
||||||
|
"Lowercase only": "أحرف صغيرة فقط",
|
||||||
|
"Convert to Compose": "تحويل إلى كومبوز",
|
||||||
|
"Docker Run": "تشغيل Docker",
|
||||||
|
"active": "نشيط",
|
||||||
|
"exited": "تم الخروج",
|
||||||
|
"inactive": "غير نشيط",
|
||||||
|
"Appearance": "المظهر",
|
||||||
|
"Security": "الأمان",
|
||||||
|
"About": "حول",
|
||||||
|
"Allowed commands:": "الأوامر المسموح بها:",
|
||||||
|
"Internal Networks": "الشبكات الداخلية",
|
||||||
|
"External Networks": "الشبكات الخارجية",
|
||||||
|
"No External Networks": "لا توجد شبكات خارجية"
|
||||||
|
}
|
94
frontend/src/lang/bg-BG.json
Normal file
94
frontend/src/lang/bg-BG.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Български",
|
||||||
|
"Create your admin account": "Създайте администраторски профил",
|
||||||
|
"authIncorrectCreds": "Грешно име или парола.",
|
||||||
|
"PasswordsDoNotMatch": "Паролите не съвпадат.",
|
||||||
|
"Repeat Password": "Повторете паролата",
|
||||||
|
"Create": "Създай",
|
||||||
|
"signedInDisp": "Вписан като {0}",
|
||||||
|
"signedInDispDisabled": "Удостоверяването е изключено.",
|
||||||
|
"home": "Начало",
|
||||||
|
"console": "Конзола",
|
||||||
|
"registry": "Регистър",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "Създайте вашия първи стак!",
|
||||||
|
"stackName" : "Име на стак",
|
||||||
|
"deployStack": "Разположи",
|
||||||
|
"deleteStack": "Изтрий",
|
||||||
|
"stopStack": "Спри",
|
||||||
|
"restartStack": "Рестартирай",
|
||||||
|
"updateStack": "Актуализирай",
|
||||||
|
"startStack": "Стартирай",
|
||||||
|
"editStack": "Редактирай",
|
||||||
|
"discardStack": "Отхвърли",
|
||||||
|
"saveStackDraft": "Запази",
|
||||||
|
"notAvailableShort" : "N/A",
|
||||||
|
"deleteStackMsg": "Сигурни ли сте, че желаете да изтриете този стак?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Този стак не се управлява от Dockge.",
|
||||||
|
"primaryHostname": "Основно име на хост",
|
||||||
|
"general": "Общи",
|
||||||
|
"container": "Контейнер | Контейнери",
|
||||||
|
"scanFolder": "Сканиране папката със стакове",
|
||||||
|
"dockerImage": "Изображение",
|
||||||
|
"restartPolicyUnlessStopped": "Докато не бъде спрян",
|
||||||
|
"restartPolicyAlways": "Винаги",
|
||||||
|
"restartPolicyOnFailure": "При неуспех",
|
||||||
|
"restartPolicyNo": "Не",
|
||||||
|
"environmentVariable": "Променлива на средата | Променливи на средата",
|
||||||
|
"restartPolicy": "Правила за рестартиране",
|
||||||
|
"containerName": "Име на контейнер",
|
||||||
|
"port": "Порт | Портове",
|
||||||
|
"volume": "Том | Томове",
|
||||||
|
"network": "Мрежа | Мрежи",
|
||||||
|
"dependsOn": "Зависимост от контейнер | Зависимост от контейнери",
|
||||||
|
"addListItem": "Добави {0}",
|
||||||
|
"deleteContainer": "Изтрий",
|
||||||
|
"addContainer": "Добави контейнер",
|
||||||
|
"addNetwork": "Добави мрежа",
|
||||||
|
"disableauth.message1": "Сигурни ли сте, че желаете да <strong>изключите удостоверяването</strong>?",
|
||||||
|
"disableauth.message2": "Използва се в случаите, <strong>когато има настроен алтернативен метод за удостоверяване</strong> преди Dockge, например Cloudflare Access, Authelia или друг механизъм за удостоверяване.",
|
||||||
|
"passwordNotMatchMsg": "Повторената парола не съвпада.",
|
||||||
|
"autoGet": "Автоматично получаване",
|
||||||
|
"add": "Добави",
|
||||||
|
"Edit": "Редактирай",
|
||||||
|
"applyToYAML": "Приложи към YAML",
|
||||||
|
"createExternalNetwork": "Създай",
|
||||||
|
"addInternalNetwork": "Добави",
|
||||||
|
"Save": "Запиши",
|
||||||
|
"Language": "Език",
|
||||||
|
"Current User": "Текущ потребител",
|
||||||
|
"Change Password": "Промени парола",
|
||||||
|
"Current Password": "Текуща парола",
|
||||||
|
"New Password": "Нова парола",
|
||||||
|
"Repeat New Password": "Повторете новата парола",
|
||||||
|
"Update Password": "Актуализирай парола",
|
||||||
|
"Advanced": "Разширени",
|
||||||
|
"Please use this option carefully!": "Моля, използвайте с повишено внимание!",
|
||||||
|
"Enable Auth": "Включи удостоверяване",
|
||||||
|
"Disable Auth": "Изключи удостоверяване",
|
||||||
|
"I understand, please disable": "Разбирам. Моля, изключи",
|
||||||
|
"Leave": "Напусни",
|
||||||
|
"Frontend Version": "Фронтенд версия",
|
||||||
|
"Check Update On GitHub": "Проверка за актуализация в GitHub",
|
||||||
|
"Show update if available": "Покажи актуализация, ако е налична",
|
||||||
|
"Also check beta release": "Проверявай и за бета версии",
|
||||||
|
"Remember me": "Запомни ме",
|
||||||
|
"Login": "Вписване",
|
||||||
|
"Username": "Потребител",
|
||||||
|
"Password": "Парола",
|
||||||
|
"Settings": "Настройки",
|
||||||
|
"Logout": "Изход",
|
||||||
|
"Lowercase only": "Само малки букви",
|
||||||
|
"Convert to Compose": "Конвертирай в \"Compose\" формат",
|
||||||
|
"Docker Run": "Стартирай Docker",
|
||||||
|
"active": "активен",
|
||||||
|
"exited": "излязъл",
|
||||||
|
"inactive": "неактивен",
|
||||||
|
"Appearance": "Изглед",
|
||||||
|
"Security": "Сигурност",
|
||||||
|
"About": "Относно",
|
||||||
|
"Allowed commands:": "Позволени команди:",
|
||||||
|
"Internal Networks": "Вътрешни мрежи",
|
||||||
|
"External Networks": "Външни мрежи",
|
||||||
|
"No External Networks": "Не са налични външни мрежи"
|
||||||
|
}
|
95
frontend/src/lang/cs-CZ.json
Normal file
95
frontend/src/lang/cs-CZ.json
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Čeština",
|
||||||
|
"Create your admin account": "Vytvořit účet administrátora",
|
||||||
|
"authIncorrectCreds": "Nesprávné uživatelské jméno nebo heslo.",
|
||||||
|
"PasswordsDoNotMatch": "Hesla se neshodují.",
|
||||||
|
"Repeat Password": "Opakujte heslo",
|
||||||
|
"Create": "Vytvořit",
|
||||||
|
"signedInDisp": "Přihlášen jako {0}",
|
||||||
|
"signedInDispDisabled": "Ověření zakázáno.",
|
||||||
|
"home": "Domů",
|
||||||
|
"console": "Konzole",
|
||||||
|
"registry": "Registry",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "Vytvořte svůj první stack!",
|
||||||
|
"stackName": "Název stacku",
|
||||||
|
"deployStack": "Nainstalovat",
|
||||||
|
"deleteStack": "Smazat",
|
||||||
|
"stopStack": "Zastavit",
|
||||||
|
"restartStack": "Restartovat",
|
||||||
|
"updateStack": "Aktualizovat",
|
||||||
|
"startStack": "Spustit",
|
||||||
|
"downStack": "Zastavit a vypnout",
|
||||||
|
"editStack": "Upravit",
|
||||||
|
"discardStack": "Zahodit",
|
||||||
|
"saveStackDraft": "Uložit",
|
||||||
|
"notAvailableShort": "N/A",
|
||||||
|
"deleteStackMsg": "Opravdu chcete smazat tento stack?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Tento stack není spravován systémem Dockge.",
|
||||||
|
"primaryHostname": "Primární název hostitele",
|
||||||
|
"general": "Obecné",
|
||||||
|
"container": "Kontejner | Kontejnery",
|
||||||
|
"scanFolder": "Prohledat složku se stacky",
|
||||||
|
"dockerImage": "Obrázek",
|
||||||
|
"restartPolicyUnlessStopped": "Pokud není zastaveno",
|
||||||
|
"restartPolicyAlways": "Vždy",
|
||||||
|
"restartPolicyOnFailure": "Při selhání",
|
||||||
|
"restartPolicyNo": "Ne",
|
||||||
|
"environmentVariable": "Proměnná prostředí | Proměnné prostředí",
|
||||||
|
"restartPolicy": "Politika restartu",
|
||||||
|
"containerName": "Název kontejneru",
|
||||||
|
"port": "Port | Porty",
|
||||||
|
"volume": "Svazek | Svazky",
|
||||||
|
"network": "Síť | Sítě",
|
||||||
|
"dependsOn": "Závisí na kontejneru | Závislosti na kontejneru",
|
||||||
|
"addListItem": "Přidat {0}",
|
||||||
|
"deleteContainer": "Smazat",
|
||||||
|
"addContainer": "Přidat kontejner",
|
||||||
|
"addNetwork": "Přidat síť",
|
||||||
|
"disableauth.message1": "Opravdu chcete <strong>zakázat ověřování</strong>?",
|
||||||
|
"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.",
|
||||||
|
"passwordNotMatchMsg": "Hesla se neshodují.",
|
||||||
|
"autoGet": "Automaticky získat",
|
||||||
|
"add": "Přidat",
|
||||||
|
"Edit": "Upravit",
|
||||||
|
"applyToYAML": "Použít na YAML",
|
||||||
|
"createExternalNetwork": "Vytvořit",
|
||||||
|
"addInternalNetwork": "Přidat",
|
||||||
|
"Save": "Uložit",
|
||||||
|
"Language": "Jazyk",
|
||||||
|
"Current User": "Aktuální uživatel",
|
||||||
|
"Change Password": "Změnit heslo",
|
||||||
|
"Current Password": "Aktuální heslo",
|
||||||
|
"New Password": "Nové heslo",
|
||||||
|
"Repeat New Password": "Opakujte nové heslo",
|
||||||
|
"Update Password": "Aktualizovat heslo",
|
||||||
|
"Advanced": "Pokročilé",
|
||||||
|
"Please use this option carefully!": "Používejte tuto možnost opatrně!",
|
||||||
|
"Enable Auth": "Povolit ověřování",
|
||||||
|
"Disable Auth": "Zakázat ověřování",
|
||||||
|
"I understand, please disable": "Rozumím, prosím zakážte",
|
||||||
|
"Leave": "Opustit",
|
||||||
|
"Frontend Version": "Verze rozhraní",
|
||||||
|
"Check Update On GitHub": "Zkontrolovat aktualizaci na GitHubu",
|
||||||
|
"Show update if available": "Zobrazit aktualizaci, pokud je k dispozici",
|
||||||
|
"Also check beta release": "Zkontrolovat také beta verzi",
|
||||||
|
"Remember me": "Zapamatovat údaje",
|
||||||
|
"Login": "Přihlásit se",
|
||||||
|
"Username": "Uživatelské jméno",
|
||||||
|
"Password": "Heslo",
|
||||||
|
"Settings": "Nastavení",
|
||||||
|
"Logout": "Odhlásit se",
|
||||||
|
"Lowercase only": "Pouze malá písmena",
|
||||||
|
"Convert to Compose": "Převést na Compose",
|
||||||
|
"Docker Run": "Docker Run",
|
||||||
|
"active": "Aktivní",
|
||||||
|
"exited": "Ukončený",
|
||||||
|
"inactive": "Neaktivní",
|
||||||
|
"Appearance": "Vzhled",
|
||||||
|
"Security": "Zabezpečení",
|
||||||
|
"About": "O aplikaci",
|
||||||
|
"Allowed commands:": "Povolené příkazy:",
|
||||||
|
"Internal Networks": "Interní sítě",
|
||||||
|
"External Networks": "Externí sítě",
|
||||||
|
"No External Networks": "Žádné externí sítě"
|
||||||
|
}
|
94
frontend/src/lang/de.json
Normal file
94
frontend/src/lang/de.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Deutsch",
|
||||||
|
"Create your admin account": "Erstelle dein Admin-Konto",
|
||||||
|
"authIncorrectCreds": "Falscher Benutzername oder falsches Passwort.",
|
||||||
|
"PasswordsDoNotMatch": "Passwörter stimmen nicht überein.",
|
||||||
|
"Repeat Password": "Passwort wiederholen",
|
||||||
|
"Create": "Erstellen",
|
||||||
|
"signedInDisp": "Angemeldet als {0}",
|
||||||
|
"signedInDispDisabled": "Authentifizierung deaktiviert.",
|
||||||
|
"home": "Startseite",
|
||||||
|
"console": "Konsole",
|
||||||
|
"registry": "Register",
|
||||||
|
"compose": "Zusammenstellen",
|
||||||
|
"addFirstStackMsg": "Stelle deinen ersten Stack zusammen!",
|
||||||
|
"stackName" : "Stack-Name",
|
||||||
|
"deployStack": "Bereitstellen",
|
||||||
|
"deleteStack": "Löschen",
|
||||||
|
"stopStack": "Anhalten",
|
||||||
|
"restartStack": "Neustarten",
|
||||||
|
"updateStack": "Aktualisieren",
|
||||||
|
"startStack": "Starten",
|
||||||
|
"editStack": "Bearbeiten",
|
||||||
|
"discardStack": "Verwerfen",
|
||||||
|
"saveStackDraft": "Speichern",
|
||||||
|
"notAvailableShort" : "N/A",
|
||||||
|
"deleteStackMsg": "Möchtest du diesen Stack wirklich löschen?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Dieser Stack wird nicht von Dockge verwaltet.",
|
||||||
|
"primaryHostname": "Primärer Hostname",
|
||||||
|
"general": "Allgemein",
|
||||||
|
"container": "Container | Container",
|
||||||
|
"scanFolder": "Stacks-Ordner durchsuchen",
|
||||||
|
"dockerImage": "Image",
|
||||||
|
"restartPolicyUnlessStopped": "Falls nicht gestoppt",
|
||||||
|
"restartPolicyAlways": "Immer",
|
||||||
|
"restartPolicyOnFailure": "Bei Fehler",
|
||||||
|
"restartPolicyNo": "Kein Neustart",
|
||||||
|
"environmentVariable": "Umgebungsvariable | Umgebungsvariablen",
|
||||||
|
"restartPolicy": "Neustart Richtlinie",
|
||||||
|
"containerName": "Container-Name",
|
||||||
|
"port": "Port | Ports",
|
||||||
|
"volume": "Volume | Volumes",
|
||||||
|
"network": "Netzwerk | Netzwerke",
|
||||||
|
"dependsOn": "Container-Abhängigkeit | Container-Abhängigkeiten",
|
||||||
|
"addListItem": "{0} hinzufügen",
|
||||||
|
"deleteContainer": "Löschen",
|
||||||
|
"addContainer": "Container hinzufügen",
|
||||||
|
"addNetwork": "Netzwerk hinzufügen",
|
||||||
|
"disableauth.message1": "Bist du sicher, dass du die <strong>Authentifizierung deaktivieren</strong> möchtest?",
|
||||||
|
"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.",
|
||||||
|
"passwordNotMatchMsg": "Das wiederholte Passwort stimmt nicht überein.",
|
||||||
|
"autoGet": "Automatisch holen",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"Edit": "Bearbeiten",
|
||||||
|
"applyToYAML": "Auf YAML anwenden",
|
||||||
|
"createExternalNetwork": "Erstellen",
|
||||||
|
"addInternalNetwork": "Hinzufügen",
|
||||||
|
"Save": "Speichern",
|
||||||
|
"Language": "Sprache",
|
||||||
|
"Current User": "Aktueller Benutzer",
|
||||||
|
"Change Password": "Passwort ändern",
|
||||||
|
"Current Password": "Aktuelles Passwort",
|
||||||
|
"New Password": "Neues Passwort",
|
||||||
|
"Repeat New Password": "Neues Passwort wiederholen",
|
||||||
|
"Update Password": "Passwort aktualisieren",
|
||||||
|
"Advanced": "Erweitert",
|
||||||
|
"Please use this option carefully!": "Bitte verwende diese Option sorgfältig!",
|
||||||
|
"Enable Auth": "Authentifizierung aktivieren",
|
||||||
|
"Disable Auth": "Authentifizierung deaktivieren",
|
||||||
|
"I understand, please disable": "Ich verstehe, bitte deaktivieren",
|
||||||
|
"Leave": "Verlassen",
|
||||||
|
"Frontend Version": "Frontend Version",
|
||||||
|
"Check Update On GitHub": "Update auf GitHub überprüfen",
|
||||||
|
"Show update if available": "Update anzeigen, wenn verfügbar",
|
||||||
|
"Also check beta release": "Auch Beta-Version überprüfen",
|
||||||
|
"Remember me": "Anmeldung beibehalten",
|
||||||
|
"Login": "Anmelden",
|
||||||
|
"Username": "Benutzername",
|
||||||
|
"Password": "Passwort",
|
||||||
|
"Settings": "Einstellungen",
|
||||||
|
"Logout": "Abmelden",
|
||||||
|
"Lowercase only": "Nur Kleinbuchstaben",
|
||||||
|
"Convert to Compose": "In Compose Syntax umwandeln",
|
||||||
|
"Docker Run": "Docker ausführen",
|
||||||
|
"active": "aktiv",
|
||||||
|
"exited": "beendet",
|
||||||
|
"inactive": "inaktiv",
|
||||||
|
"Appearance": "Erscheinungsbild",
|
||||||
|
"Security": "Sicherheit",
|
||||||
|
"About": "Über",
|
||||||
|
"Allowed commands:": "Zugelassene Befehle:",
|
||||||
|
"Internal Networks": "Interne Netzwerke",
|
||||||
|
"External Networks": "Externe Netzwerke",
|
||||||
|
"No External Networks": "Keine externen Netzwerke"
|
||||||
|
}
|
102
frontend/src/lang/en.json
Normal file
102
frontend/src/lang/en.json
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"languageName": "English",
|
||||||
|
"Create your admin account": "Create your admin account",
|
||||||
|
"authIncorrectCreds": "Incorrect username or password.",
|
||||||
|
"PasswordsDoNotMatch": "Passwords do not match.",
|
||||||
|
"Repeat Password": "Repeat Password",
|
||||||
|
"Create": "Create",
|
||||||
|
"signedInDisp": "Signed in as {0}",
|
||||||
|
"signedInDispDisabled": "Auth Disabled.",
|
||||||
|
"home": "Home",
|
||||||
|
"console": "Console",
|
||||||
|
"registry": "Registry",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "Compose your first stack!",
|
||||||
|
"stackName" : "Stack Name",
|
||||||
|
"deployStack": "Deploy",
|
||||||
|
"deleteStack": "Delete",
|
||||||
|
"stopStack": "Stop",
|
||||||
|
"restartStack": "Restart",
|
||||||
|
"updateStack": "Update",
|
||||||
|
"startStack": "Start",
|
||||||
|
"downStack": "Stop & Down",
|
||||||
|
"editStack": "Edit",
|
||||||
|
"discardStack": "Discard",
|
||||||
|
"saveStackDraft": "Save",
|
||||||
|
"notAvailableShort" : "N/A",
|
||||||
|
"deleteStackMsg": "Are you sure you want to delete this stack?",
|
||||||
|
"stackNotManagedByDockgeMsg": "This stack is not managed by Dockge.",
|
||||||
|
"primaryHostname": "Primary Hostname",
|
||||||
|
"general": "General",
|
||||||
|
"container": "Container | Containers",
|
||||||
|
"scanFolder": "Scan Stacks Folder",
|
||||||
|
"dockerImage": "Image",
|
||||||
|
"restartPolicyUnlessStopped": "Unless Stopped",
|
||||||
|
"restartPolicyAlways": "Always",
|
||||||
|
"restartPolicyOnFailure": "On Failure",
|
||||||
|
"restartPolicyNo": "No",
|
||||||
|
"environmentVariable": "Environment Variable | Environment Variables",
|
||||||
|
"restartPolicy": "Restart Policy",
|
||||||
|
"containerName": "Container Name",
|
||||||
|
"port": "Port | Ports",
|
||||||
|
"volume": "Volume | Volumes",
|
||||||
|
"network": "Network | Networks",
|
||||||
|
"dependsOn": "Container Dependency | Container Dependencies",
|
||||||
|
"addListItem": "Add {0}",
|
||||||
|
"deleteContainer": "Delete",
|
||||||
|
"addContainer": "Add Container",
|
||||||
|
"addNetwork": "Add Network",
|
||||||
|
"disableauth.message1": "Are you sure want to <strong>disable authentication</strong>?",
|
||||||
|
"disableauth.message2": "It is designed for scenarios <strong>where you intend to implement third-party authentication</strong> in front of Dockge such as Cloudflare Access, Authelia or other authentication mechanisms.",
|
||||||
|
"passwordNotMatchMsg": "The repeat password does not match.",
|
||||||
|
"autoGet": "Auto Get",
|
||||||
|
"add": "Add",
|
||||||
|
"Edit": "Edit",
|
||||||
|
"applyToYAML": "Apply to YAML",
|
||||||
|
"createExternalNetwork": "Create",
|
||||||
|
"addInternalNetwork": "Add",
|
||||||
|
"Save": "Save",
|
||||||
|
"Language": "Language",
|
||||||
|
"Current User": "Current User",
|
||||||
|
"Change Password": "Change Password",
|
||||||
|
"Current Password": "Current Password",
|
||||||
|
"New Password": "New Password",
|
||||||
|
"Repeat New Password": "Repeat New Password",
|
||||||
|
"Update Password": "Update Password",
|
||||||
|
"Advanced": "Advanced",
|
||||||
|
"Please use this option carefully!": "Please use this option carefully!",
|
||||||
|
"Enable Auth": "Enable Auth",
|
||||||
|
"Disable Auth": "Disable Auth",
|
||||||
|
"I understand, please disable": "I understand, please disable",
|
||||||
|
"Leave": "Leave",
|
||||||
|
"Frontend Version": "Frontend Version",
|
||||||
|
"Check Update On GitHub": "Check Update On GitHub",
|
||||||
|
"Show update if available": "Show update if available",
|
||||||
|
"Also check beta release": "Also check beta release",
|
||||||
|
"Remember me": "Remember me",
|
||||||
|
"Login": "Login",
|
||||||
|
"Username": "Username",
|
||||||
|
"Password": "Password",
|
||||||
|
"Settings": "Settings",
|
||||||
|
"Logout": "Logout",
|
||||||
|
"Lowercase only": "Lowercase only",
|
||||||
|
"Convert to Compose": "Convert to Compose",
|
||||||
|
"Docker Run": "Docker Run",
|
||||||
|
"active": "active",
|
||||||
|
"exited": "exited",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"Appearance": "Appearance",
|
||||||
|
"Security": "Security",
|
||||||
|
"About": "About",
|
||||||
|
"Allowed commands:": "Allowed commands:",
|
||||||
|
"Internal Networks": "Internal Networks",
|
||||||
|
"External Networks": "External Networks",
|
||||||
|
"No External Networks": "No External Networks",
|
||||||
|
"reverseProxyMsg1": "Using a Reverse Proxy?",
|
||||||
|
"reverseProxyMsg2": "Check how to config it for WebSocket",
|
||||||
|
"Cannot connect to the socket server.": "Cannot connect to the socket server.",
|
||||||
|
"reconnecting...": "Reconnecting...",
|
||||||
|
"connecting...": "Connecting to the socket server...",
|
||||||
|
"url": "URL | URLs",
|
||||||
|
"extra": "Extra"
|
||||||
|
}
|
94
frontend/src/lang/es.json
Normal file
94
frontend/src/lang/es.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Español",
|
||||||
|
"Create your admin account": "Crea tu cuenta de administrador",
|
||||||
|
"authIncorrectCreds": "Nombre de usuario o contraseña incorrectos.",
|
||||||
|
"PasswordsDoNotMatch": "Las contraseñas no coinciden.",
|
||||||
|
"Repeat Password": "Repetir Contraseña",
|
||||||
|
"Create": "Crear",
|
||||||
|
"signedInDisp": "Sesión iniciada como {0}",
|
||||||
|
"signedInDispDisabled": "Autenticación deshabilitada.",
|
||||||
|
"home": "Inicio",
|
||||||
|
"console": "Consola",
|
||||||
|
"registry": "Registro",
|
||||||
|
"compose": "Componer",
|
||||||
|
"addFirstStackMsg": "¡Compón tu primera pila!",
|
||||||
|
"stackName" : "Nombre de la Pila",
|
||||||
|
"deployStack": "Desplegar",
|
||||||
|
"deleteStack": "Eliminar",
|
||||||
|
"stopStack": "Detener",
|
||||||
|
"restartStack": "Reiniciar",
|
||||||
|
"updateStack": "Actualizar",
|
||||||
|
"startStack": "Iniciar",
|
||||||
|
"editStack": "Editar",
|
||||||
|
"discardStack": "Descartar",
|
||||||
|
"saveStackDraft": "Guardar",
|
||||||
|
"notAvailableShort" : "N/D",
|
||||||
|
"deleteStackMsg": "¿Estás seguro de que quieres eliminar esta pila?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Esta pila no está gestionada por Dockge.",
|
||||||
|
"primaryHostname": "Nombre de Host Primario",
|
||||||
|
"general": "General",
|
||||||
|
"container": "Contenedor | Contenedores",
|
||||||
|
"scanFolder": "Escanear Carpeta de Pilas",
|
||||||
|
"dockerImage": "Imagen",
|
||||||
|
"restartPolicyUnlessStopped": "A menos que se detenga",
|
||||||
|
"restartPolicyAlways": "Siempre",
|
||||||
|
"restartPolicyOnFailure": "En caso de fallo",
|
||||||
|
"restartPolicyNo": "No",
|
||||||
|
"environmentVariable": "Variable de Entorno | Variables de Entorno",
|
||||||
|
"restartPolicy": "Política de Reinicio",
|
||||||
|
"containerName": "Nombre del Contenedor",
|
||||||
|
"port": "Puerto | Puertos",
|
||||||
|
"volume": "Volumen | Volúmenes",
|
||||||
|
"network": "Red | Redes",
|
||||||
|
"dependsOn": "Dependencia del Contenedor | Dependencias del Contenedor",
|
||||||
|
"addListItem": "Agregar {0}",
|
||||||
|
"deleteContainer": "Eliminar",
|
||||||
|
"addContainer": "Agregar Contenedor",
|
||||||
|
"addNetwork": "Agregar Red",
|
||||||
|
"disableauth.message1": "¿Estás seguro de que deseas <strong>desactivar la autenticación</strong>?",
|
||||||
|
"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.",
|
||||||
|
"passwordNotMatchMsg": "La contraseña repetida no coincide.",
|
||||||
|
"autoGet": "Obtener Automáticamente",
|
||||||
|
"add": "Agregar",
|
||||||
|
"Edit": "Editar",
|
||||||
|
"applyToYAML": "Aplicar a YAML",
|
||||||
|
"createExternalNetwork": "Crear",
|
||||||
|
"addInternalNetwork": "Agregar",
|
||||||
|
"Save": "Guardar",
|
||||||
|
"Language": "Idioma",
|
||||||
|
"Current User": "Usuario Actual",
|
||||||
|
"Change Password": "Cambiar Contraseña",
|
||||||
|
"Current Password": "Contraseña Actual",
|
||||||
|
"New Password": "Nueva Contraseña",
|
||||||
|
"Repeat New Password": "Repetir Nueva Contraseña",
|
||||||
|
"Update Password": "Actualizar Contraseña",
|
||||||
|
"Advanced": "Avanzado",
|
||||||
|
"Please use this option carefully!": "¡Por favor, usa esta opción con cuidado!",
|
||||||
|
"Enable Auth": "Habilitar Autenticación",
|
||||||
|
"Disable Auth": "Deshabilitar Autenticación",
|
||||||
|
"I understand, please disable": "Entiendo, por favor deshabilitar",
|
||||||
|
"Leave": "Salir",
|
||||||
|
"Frontend Version": "Versión del Frontend",
|
||||||
|
"Check Update On GitHub": "Comprobar Actualización en GitHub",
|
||||||
|
"Show update if available": "Mostrar actualización si está disponible",
|
||||||
|
"Also check beta release": "También verificar la versión beta",
|
||||||
|
"Remember me": "Recuérdame",
|
||||||
|
"Login": "Iniciar Sesión",
|
||||||
|
"Username": "Nombre de Usuario",
|
||||||
|
"Password": "Contraseña",
|
||||||
|
"Settings": "Configuración",
|
||||||
|
"Logout": "Cerrar Sesión",
|
||||||
|
"Lowercase only": "Solo minúsculas",
|
||||||
|
"Convert to Compose": "Convertir a Compose",
|
||||||
|
"Docker Run": "Ejecutar Docker",
|
||||||
|
"active": "activo",
|
||||||
|
"exited": "finalizado",
|
||||||
|
"inactive": "inactivo",
|
||||||
|
"Appearance": "Apariencia",
|
||||||
|
"Security": "Seguridad",
|
||||||
|
"About": "Acerca de",
|
||||||
|
"Allowed commands:": "Comandos permitidos:",
|
||||||
|
"Internal Networks": "Redes Internas",
|
||||||
|
"External Networks": "Redes Externas",
|
||||||
|
"No External Networks": "Sin Redes Externas"
|
||||||
|
}
|
94
frontend/src/lang/fr.json
Normal file
94
frontend/src/lang/fr.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Francais",
|
||||||
|
"Create your admin account": "Créez votre compte administrateur",
|
||||||
|
"authIncorrectCreds": "identifiant ou mot de passe incorrect.",
|
||||||
|
"Repeat Password": "Répéter le mot de passe",
|
||||||
|
"PasswordsDoNotMatch": "Les mots de passe ne correspondent pas.",
|
||||||
|
"Create": "Créer",
|
||||||
|
"signedInDisp": "Connecté en tant que {0}",
|
||||||
|
"signedInDispDisabled": "Authentification désactivée.",
|
||||||
|
"home": "Accueil",
|
||||||
|
"console": "Console",
|
||||||
|
"registry": "Registre",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "Créez votre première pile!",
|
||||||
|
"stackName" : "Nom de la pile",
|
||||||
|
"deployStack": "Déployer",
|
||||||
|
"deleteStack": "Supprimer",
|
||||||
|
"stopStack": "Arrêter",
|
||||||
|
"restartStack": "Redémarrer",
|
||||||
|
"updateStack": "Mettre à jour",
|
||||||
|
"startStack": "Démarrer",
|
||||||
|
"editStack": "Modifier",
|
||||||
|
"discardStack": "Ignorer",
|
||||||
|
"saveStackDraft": "Sauvegarder",
|
||||||
|
"notAvailableShort" : "N/A",
|
||||||
|
"deleteStackMsg": "Êtes-vous sûr de vouloir supprimer cette pile ?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Cette pile n'est pas gérée par Dockge.",
|
||||||
|
"primaryHostname": "Nom d'hôte principal",
|
||||||
|
"general": "Générale",
|
||||||
|
"container": "Conteneur | Conteneurs",
|
||||||
|
"scanFolder": "Analyser le dossier des piles",
|
||||||
|
"dockerImage": "Image",
|
||||||
|
"restartPolicyUnlessStopped": "Sauf arrêt",
|
||||||
|
"restartPolicyAlways": "Toujours",
|
||||||
|
"restartPolicyOnFailure": "En cas d'échec",
|
||||||
|
"restartPolicyNo": "Non",
|
||||||
|
"environmentVariable": "Variable d'environnement | Variables d'environnement",
|
||||||
|
"restartPolicy": "Politique de redémarrage",
|
||||||
|
"containerName": "Nom du conteneur",
|
||||||
|
"port": "Port | Ports",
|
||||||
|
"volume": "Volume | Volumes",
|
||||||
|
"network": "Réseau | Réseaux",
|
||||||
|
"dependsOn": "Dépendance du conteneur | Dépendances du conteneur",
|
||||||
|
"addListItem": "Ajouter {0}",
|
||||||
|
"deleteContainer": "Supprimer",
|
||||||
|
"addContainer": "Ajouter un conteneur",
|
||||||
|
"addNetwork": "Ajouter un réseau",
|
||||||
|
"disableauth.message1": "Voulez-vous vraiment <strong>désactiver l'authentification</strong> ?",
|
||||||
|
"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.",
|
||||||
|
"passwordNotMatchMsg": "Le mot de passe de confirmation ne correspond pas.",
|
||||||
|
"autoGet": "Obtention automatique",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"Edit": "Modifier",
|
||||||
|
"applyToYAML": "Appliquer à YAML",
|
||||||
|
"createExternalNetwork": "Créer",
|
||||||
|
"addInternalNetwork": "Ajouter",
|
||||||
|
"Save": "Enregistrer",
|
||||||
|
"Language": "Langue",
|
||||||
|
"Current User": "Utilisateur Actuel",
|
||||||
|
"Change Password": "Changer le Mot de Passe",
|
||||||
|
"Current Password": "Mot de passe actuel",
|
||||||
|
"New Password": "Nouveau Mot de Passe",
|
||||||
|
"Repeat New Password": "Répéter le Nouveau Mot de Passe",
|
||||||
|
"Update Password": "Mettre à Jour le Mot de Passe",
|
||||||
|
"Advanced": "Avancé",
|
||||||
|
"Please use this option carefully!": "Veuillez utiliser cette option avec précaution !",
|
||||||
|
"Enable Auth": "Activer l'Authentification",
|
||||||
|
"Disable Auth": "Désactiver l'Authentification",
|
||||||
|
"I understand, please disable": "Je comprends, veuillez désactiver",
|
||||||
|
"Leave": "Quitter",
|
||||||
|
"Frontend Version": "Version Frontend",
|
||||||
|
"Check Update On GitHub": "Vérifier la Mise à Jour sur GitHub",
|
||||||
|
"Show update if available": "Afficher la mise à jour si disponible",
|
||||||
|
"Also check beta release": "Vérifier également la version bêta",
|
||||||
|
"Remember me": "Se souvenir de moi",
|
||||||
|
"Login": "Connexion",
|
||||||
|
"Username": "Nom d'utilisateur",
|
||||||
|
"Password": "Mot de Passe",
|
||||||
|
"Settings": "Paramètres",
|
||||||
|
"Logout": "Déconnexion",
|
||||||
|
"Lowercase only": "Minuscules uniquement",
|
||||||
|
"Convert to Compose": "Convertir en Compose",
|
||||||
|
"Docker Run": "Exécution Docker",
|
||||||
|
"active": "actif",
|
||||||
|
"exited": "arrêté",
|
||||||
|
"inactive": "inactif",
|
||||||
|
"Appearance": "Apparence",
|
||||||
|
"Security": "Sécurité",
|
||||||
|
"About": "À propos",
|
||||||
|
"Allowed commands:": "Commandes autorisées:",
|
||||||
|
"Internal Networks": "Réseaux Internes",
|
||||||
|
"External Networks": "Réseaux Externes",
|
||||||
|
"No External Networks": "Aucun Réseau Externe"
|
||||||
|
}
|
95
frontend/src/lang/it-IT.json
Normal file
95
frontend/src/lang/it-IT.json
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Italiano",
|
||||||
|
"Create your admin account": "Crea il tuo account amministratore",
|
||||||
|
"authIncorrectCreds": "Username e/o password errati.",
|
||||||
|
"PasswordsDoNotMatch": "Le password non corrispondono.",
|
||||||
|
"Repeat Password": "Ripetere la password",
|
||||||
|
"Create": "Crea",
|
||||||
|
"signedInDisp": "Autenticato come {0}",
|
||||||
|
"signedInDispDisabled": "Autenticazione disabilitata.",
|
||||||
|
"home": "Home",
|
||||||
|
"console": "Console",
|
||||||
|
"registry": "Registro",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "Componi il tuo primo stack!",
|
||||||
|
"stackName" : "Nome dello stack",
|
||||||
|
"deployStack": "Deploy",
|
||||||
|
"deleteStack": "Cancella",
|
||||||
|
"stopStack": "Stop",
|
||||||
|
"restartStack": "Riavvia",
|
||||||
|
"updateStack": "Aggiorna",
|
||||||
|
"startStack": "Avvia",
|
||||||
|
"downStack": "Stop & Down",
|
||||||
|
"editStack": "Modifica",
|
||||||
|
"discardStack": "Annulla",
|
||||||
|
"saveStackDraft": "Salva",
|
||||||
|
"notAvailableShort" : "N/D",
|
||||||
|
"deleteStackMsg": "Sei sicuro di voler eliminare questo stack?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Questo stack non è gestito da Dockge.",
|
||||||
|
"primaryHostname": "Hostname primario",
|
||||||
|
"general": "Generale",
|
||||||
|
"container": "Container | Container",
|
||||||
|
"scanFolder": "Scansiona la cartella degli stack",
|
||||||
|
"dockerImage": "Immagine",
|
||||||
|
"restartPolicyUnlessStopped": "A meno che non venga fermato",
|
||||||
|
"restartPolicyAlways": "Sempre",
|
||||||
|
"restartPolicyOnFailure": "Quando fallisce",
|
||||||
|
"restartPolicyNo": "No",
|
||||||
|
"environmentVariable": "Variabile d'ambiente | Variabili d'ambiente",
|
||||||
|
"restartPolicy": "Politica di riavvio",
|
||||||
|
"containerName": "Nome del container",
|
||||||
|
"port": "Porta | Porte",
|
||||||
|
"volume": "Volume | Volumi",
|
||||||
|
"network": "Rete | Reti",
|
||||||
|
"dependsOn": "Dipendenza del container | Dipendenze del container",
|
||||||
|
"addListItem": "Aggiungi {0}",
|
||||||
|
"deleteContainer": "Elimina",
|
||||||
|
"addContainer": "Aggiungi container",
|
||||||
|
"addNetwork": "Aggiungi rete",
|
||||||
|
"disableauth.message1": "Sei sicuro di voler <strong>disabilitare l'autenticazione</strong>?",
|
||||||
|
"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.",
|
||||||
|
"passwordNotMatchMsg": "La password ripetuta non corrisponde.",
|
||||||
|
"autoGet": "Ottieni automaticamente",
|
||||||
|
"add": "Aggiungi",
|
||||||
|
"Edit": "Modifica",
|
||||||
|
"applyToYAML": "Applica al file YAML",
|
||||||
|
"createExternalNetwork": "Crea",
|
||||||
|
"addInternalNetwork": "Aggiungi",
|
||||||
|
"Save": "Salva",
|
||||||
|
"Language": "Lingua",
|
||||||
|
"Current User": "Utente corrente",
|
||||||
|
"Change Password": "Cambia la password",
|
||||||
|
"Current Password": "Password corrente",
|
||||||
|
"New Password": "Nuova password",
|
||||||
|
"Repeat New Password": "Ripeti la nuova password",
|
||||||
|
"Update Password": "Aggiornamento password",
|
||||||
|
"Advanced": "Avanzato",
|
||||||
|
"Please use this option carefully!": "Per favore usa questa opzione con cautela!",
|
||||||
|
"Enable Auth": "Abilita l'autenticazione",
|
||||||
|
"Disable Auth": "Disabilita l'autenticazione",
|
||||||
|
"I understand, please disable": "Lo capisco, disabilita",
|
||||||
|
"Leave": "Lascia",
|
||||||
|
"Frontend Version": "Versione del frontend",
|
||||||
|
"Check Update On GitHub": "Controlla la presenza di aggiornamenti su GitHub",
|
||||||
|
"Show update if available": "Mostra l'aggiornamento se è disponibile",
|
||||||
|
"Also check beta release": "Controlla anche le release in beta",
|
||||||
|
"Remember me": "Ricordami",
|
||||||
|
"Login": "Login",
|
||||||
|
"Username": "Username",
|
||||||
|
"Password": "Password",
|
||||||
|
"Settings": "Impostazioni",
|
||||||
|
"Logout": "Logout",
|
||||||
|
"Lowercase only": "Solo lettere minuscole",
|
||||||
|
"Convert to Compose": "Converti a Compose",
|
||||||
|
"Docker Run": "Docker Run",
|
||||||
|
"active": "attivo",
|
||||||
|
"exited": "uscito",
|
||||||
|
"inactive": "inattivo",
|
||||||
|
"Appearance": "Aspetto",
|
||||||
|
"Security": "Sicurezza",
|
||||||
|
"About": "Informazioni su",
|
||||||
|
"Allowed commands:": "Comandi permessi:",
|
||||||
|
"Internal Networks": "Reti interne",
|
||||||
|
"External Networks": "Reti esterne",
|
||||||
|
"No External Networks": "Nessuna rete esterna"
|
||||||
|
}
|
94
frontend/src/lang/ko-KR.json
Normal file
94
frontend/src/lang/ko-KR.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "한국어",
|
||||||
|
"Create your admin account": "관리자 계정 만들기",
|
||||||
|
"authIncorrectCreds": "사용자명 또는 비밀번호가 일치하지 않아요.",
|
||||||
|
"PasswordsDoNotMatch": "비밀번호가 일치하지 않아요.",
|
||||||
|
"Repeat Password": "비밀번호 재입력",
|
||||||
|
"Create": "생성",
|
||||||
|
"signedInDisp": "{0}(으)로 로그인됨",
|
||||||
|
"signedInDispDisabled": "인증 비활성화됨.",
|
||||||
|
"home": "홈",
|
||||||
|
"console": "콘솔",
|
||||||
|
"registry": "레지스트리",
|
||||||
|
"compose": "생성",
|
||||||
|
"addFirstStackMsg": "첫 번째 스택을 만들어 보세요!",
|
||||||
|
"stackName": "스택 이름",
|
||||||
|
"deployStack": "배포",
|
||||||
|
"deleteStack": "삭제",
|
||||||
|
"stopStack": "정지",
|
||||||
|
"restartStack": "재시작",
|
||||||
|
"updateStack": "업데이트",
|
||||||
|
"startStack": "시작",
|
||||||
|
"editStack": "수정",
|
||||||
|
"discardStack": "취소",
|
||||||
|
"saveStackDraft": "저장",
|
||||||
|
"notAvailableShort": "N/A",
|
||||||
|
"deleteStackMsg": "정말로 이 스택을 삭제하시겠습니까?",
|
||||||
|
"stackNotManagedByDockgeMsg": "이 스택은 Dockge에 의해 관리되지 않아요.",
|
||||||
|
"primaryHostname": "주 호스트명",
|
||||||
|
"general": "일반",
|
||||||
|
"container": "컨테이너",
|
||||||
|
"scanFolder": "스택 폴더 스캔",
|
||||||
|
"dockerImage": "이미지",
|
||||||
|
"restartPolicyUnlessStopped": "종료되기 전까지",
|
||||||
|
"restartPolicyAlways": "항상",
|
||||||
|
"restartPolicyOnFailure": "오류 발생 시",
|
||||||
|
"restartPolicyNo": "안 함",
|
||||||
|
"environmentVariable": "환경 변수",
|
||||||
|
"restartPolicy": "재시작 정책",
|
||||||
|
"containerName": "컨테이너 이름",
|
||||||
|
"port": "포트",
|
||||||
|
"volume": "볼륨",
|
||||||
|
"network": "네트워크",
|
||||||
|
"dependsOn": "컨테이너 의존성",
|
||||||
|
"addListItem": "{0} 추가",
|
||||||
|
"deleteContainer": "삭제",
|
||||||
|
"addContainer": "컨테이너 추가",
|
||||||
|
"addNetwork": "네트워크 추가",
|
||||||
|
"disableauth.message1": "정말로 <strong>인증을 비활성화</strong>하시겠습니까?",
|
||||||
|
"disableauth.message2": "이 기능은 Dockge 앞에 Cloudflare Access, Authelia 등과 같은 <strong>서드 파티 인증을 사용하려는 경우</strong>에 사용하기 위해서 만들어졌어요.",
|
||||||
|
"passwordNotMatchMsg": "비밀번호 재입력이 일치하지 않아요..",
|
||||||
|
"autoGet": "자동으로 가져오기",
|
||||||
|
"add": "추가",
|
||||||
|
"Edit": "수정",
|
||||||
|
"applyToYAML": "YAML에 적용",
|
||||||
|
"createExternalNetwork": "생성",
|
||||||
|
"addInternalNetwork": "추가",
|
||||||
|
"Save": "저장",
|
||||||
|
"Language": "언어",
|
||||||
|
"Current User": "현재 사용자",
|
||||||
|
"Change Password": "비밀번호 변경",
|
||||||
|
"Current Password": "현재 비밀번호",
|
||||||
|
"New Password": "새 비밀번호",
|
||||||
|
"Repeat New Password": "새 비밀번호 재입력",
|
||||||
|
"Update Password": "비밀번호 변경",
|
||||||
|
"Advanced": "고급",
|
||||||
|
"Please use this option carefully!": "이 설정은 신중히 사용하세요!",
|
||||||
|
"Enable Auth": "인증 활성화",
|
||||||
|
"Disable Auth": "인증 비활성화",
|
||||||
|
"I understand, please disable": "이해하고 있습니다. 비활성화해 주세요",
|
||||||
|
"Leave": "취소",
|
||||||
|
"Frontend Version": "프론트엔드 버전",
|
||||||
|
"Check Update On GitHub": "GitHub에서 업데이트 확인",
|
||||||
|
"Show update if available": "업데이트가 있을 때 표시",
|
||||||
|
"Also check beta release": "베타 버전도 확인",
|
||||||
|
"Remember me": "기억하기",
|
||||||
|
"Login": "로그인",
|
||||||
|
"Username": "사용자명",
|
||||||
|
"Password": "비밀번호",
|
||||||
|
"Settings": "설정",
|
||||||
|
"Logout": "로그아웃",
|
||||||
|
"Lowercase only": "소문자만",
|
||||||
|
"Convert to Compose": "Compose로 변환",
|
||||||
|
"Docker Run": "Docker Run",
|
||||||
|
"active": "활성",
|
||||||
|
"exited": "종료됨",
|
||||||
|
"inactive": "비활성",
|
||||||
|
"Appearance": "디스플레이",
|
||||||
|
"Security": "보안",
|
||||||
|
"About": "정보",
|
||||||
|
"Allowed commands:": "허용된 명령어:",
|
||||||
|
"Internal Networks": "내부 네트워크",
|
||||||
|
"External Networks": "외부 네트워크",
|
||||||
|
"No External Networks": "외부 네트워크 없음"
|
||||||
|
}
|
94
frontend/src/lang/pl-PL.json
Normal file
94
frontend/src/lang/pl-PL.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Polski",
|
||||||
|
"Create your admin account": "Utwórz konto administratora",
|
||||||
|
"authIncorrectCreds": "Nieprawidłowa nazwa użytkownika lub hasło.",
|
||||||
|
"PasswordsDoNotMatch": "Hasła nie pasują do siebie.",
|
||||||
|
"Repeat Password": "Powtórz hasło",
|
||||||
|
"Create": "Utwórz",
|
||||||
|
"signedInDisp": "Zalogowany jako {0}",
|
||||||
|
"signedInDispDisabled": "Autoryzacja wyłączona.",
|
||||||
|
"home": "Strona główna",
|
||||||
|
"console": "Konsola",
|
||||||
|
"registry": "Rejestr",
|
||||||
|
"compose": "Stwórz",
|
||||||
|
"addFirstStackMsg": "Stwórz swój pierwszy stos!",
|
||||||
|
"stackName" : "Nazwa stosu",
|
||||||
|
"deployStack": "Wdroż",
|
||||||
|
"deleteStack": "Usuń",
|
||||||
|
"stopStack": "Zatrzymaj",
|
||||||
|
"restartStack": "Uruchom ponownie",
|
||||||
|
"updateStack": "Aktualizuj",
|
||||||
|
"startStack": "Uruchom",
|
||||||
|
"editStack": "Edytuj",
|
||||||
|
"discardStack": "Odrzuć",
|
||||||
|
"saveStackDraft": "Zapisz",
|
||||||
|
"notAvailableShort" : "N/A",
|
||||||
|
"deleteStackMsg": "Czy na pewno chcesz usunąć ten stos?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Ten stos nie jest zarządzany przez Dockge.",
|
||||||
|
"primaryHostname": "Podstawowa nazwa hosta",
|
||||||
|
"general": "Ogólne",
|
||||||
|
"container": "Kontener | Kontenery",
|
||||||
|
"scanFolder": "Skanuj folder ze stosami",
|
||||||
|
"dockerImage": "Obraz",
|
||||||
|
"restartPolicyUnlessStopped": "Jeśli nie zatrzymano",
|
||||||
|
"restartPolicyAlways": "Zawsze",
|
||||||
|
"restartPolicyOnFailure": "Po awarii",
|
||||||
|
"restartPolicyNo": "Nie restartuj",
|
||||||
|
"environmentVariable": "Zmienna środowiskowa | Zmienne środowiskowe",
|
||||||
|
"restartPolicy": "Polityka restartu",
|
||||||
|
"containerName": "Nazwa kontenera",
|
||||||
|
"port": "Port | Porty",
|
||||||
|
"volume": "Wolumin | Woluminy",
|
||||||
|
"network": "Sieć | Sieci",
|
||||||
|
"dependsOn": "Zależność kontenera | Zależności kontenera",
|
||||||
|
"addListItem": "Dodaj {0}",
|
||||||
|
"deleteContainer": "Usuń kontener",
|
||||||
|
"addContainer": "Dodaj kontener",
|
||||||
|
"addNetwork": "Dodaj sieć",
|
||||||
|
"disableauth.message1": "Czy na pewno chcesz <strong>wyłączyć uwierzytelnianie</strong>?",
|
||||||
|
"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.",
|
||||||
|
"passwordNotMatchMsg": "Hasła się nie zgadzają.",
|
||||||
|
"autoGet": "Automatyczne pobieranie",
|
||||||
|
"add": "Dodaj",
|
||||||
|
"Edit": "Edytuj",
|
||||||
|
"applyToYAML": "Zastosuj do YAML",
|
||||||
|
"createExternalNetwork": "Utwórz",
|
||||||
|
"addInternalNetwork": "Dodaj",
|
||||||
|
"Save": "Zapisz",
|
||||||
|
"Language": "Język",
|
||||||
|
"Current User": "Aktualny użytkownik",
|
||||||
|
"Change Password": "Zmień hasło",
|
||||||
|
"Current Password": "Aktualne hasło",
|
||||||
|
"New Password": "Nowe hasło",
|
||||||
|
"Repeat New Password": "Powtórz nowe hasło",
|
||||||
|
"Update Password": "Aktualizuj hasło",
|
||||||
|
"Advanced": "Zaawansowane",
|
||||||
|
"Please use this option carefully!": "Proszę używać tej opcji ostrożnie!",
|
||||||
|
"Enable Auth": "Włącz autoryzację",
|
||||||
|
"Disable Auth": "Wyłącz autoryzację",
|
||||||
|
"I understand, please disable": "Rozumiem, proszę wyłączyć",
|
||||||
|
"Leave": "Wyjdź",
|
||||||
|
"Frontend Version": "Wersja interfejsu graficznego",
|
||||||
|
"Check Update On GitHub": "Sprawdź dostępność aktualizacji na GitHub",
|
||||||
|
"Show update if available": "Pokaż aktualizacje, jeśli są dostępne",
|
||||||
|
"Also check beta release": "Sprawdź także wersje beta",
|
||||||
|
"Remember me": "Zapamiętaj mnie",
|
||||||
|
"Login": "Zaloguj się",
|
||||||
|
"Username": "Nazwa użytkownika",
|
||||||
|
"Password": "Hasło",
|
||||||
|
"Settings": "Ustawienia",
|
||||||
|
"Logout": "Wyloguj się",
|
||||||
|
"Lowercase only": "Tylko małe litery",
|
||||||
|
"Convert to Compose": "Przekształć na składnię Compose",
|
||||||
|
"Docker Run": "Uruchom za pomocą Dockera",
|
||||||
|
"active": "aktywny",
|
||||||
|
"exited": "wyłączony",
|
||||||
|
"inactive": "nieaktywny",
|
||||||
|
"Appearance": "Wygląd",
|
||||||
|
"Security": "Bezpieczeństwo",
|
||||||
|
"About": "O programie",
|
||||||
|
"Allowed commands:": "Dozwolone polecenia:",
|
||||||
|
"Internal Networks": "Sieci wewnętrzne",
|
||||||
|
"External Networks": "Sieci zewnętrzne",
|
||||||
|
"No External Networks": "Brak sieci zewnętrznych"
|
||||||
|
}
|
94
frontend/src/lang/pt-BR.json
Normal file
94
frontend/src/lang/pt-BR.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Português-Brasil",
|
||||||
|
"Create your admin account": "Crie sua conta de administrador",
|
||||||
|
"authIncorrectCreds": "Nome de usuário ou senha incorretos.",
|
||||||
|
"PasswordsDoNotMatch": "As senhas não correspondem.",
|
||||||
|
"Repeat Password": "Repetir a senha",
|
||||||
|
"Create": "Criar",
|
||||||
|
"signedInDisp": "Logado como {0}",
|
||||||
|
"signedInDispDisabled": "Autenticação desativada.",
|
||||||
|
"home": "Início",
|
||||||
|
"console": "Console",
|
||||||
|
"registry": "Registro",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "Crie sua primeira stack!",
|
||||||
|
"stackName" : "Nome da stack",
|
||||||
|
"deployStack": "Deploy",
|
||||||
|
"deleteStack": "Excluir",
|
||||||
|
"stopStack": "Parar",
|
||||||
|
"restartStack": "Reiniciar",
|
||||||
|
"updateStack": "Atualizar",
|
||||||
|
"startStack": "Iniciar",
|
||||||
|
"editStack": "Editar",
|
||||||
|
"discardStack": "Descartar",
|
||||||
|
"saveStackDraft": "Salvar",
|
||||||
|
"notAvailableShort" : "N/D",
|
||||||
|
"deleteStackMsg": "Tem certeza que deseja excluir esta stack?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Esta stack não é gerenciada pelo Dockge.",
|
||||||
|
"primaryHostname": "Nome do Host Primário",
|
||||||
|
"general": "Geral",
|
||||||
|
"container": "Contêiner | Contêineres",
|
||||||
|
"scanFolder": "Pesquisar na pasta de stacks",
|
||||||
|
"dockerImage": "Imagem",
|
||||||
|
"restartPolicyUnlessStopped": "A menos que seja parado",
|
||||||
|
"restartPolicyAlways": "Sempre",
|
||||||
|
"restartPolicyOnFailure": "Em caso de falha",
|
||||||
|
"restartPolicyNo": "Não",
|
||||||
|
"environmentVariable": "Variável de ambiente | Variáveis de ambiente",
|
||||||
|
"restartPolicy": "Política de reinicialização",
|
||||||
|
"containerName": "Nome do contêiner",
|
||||||
|
"port": "Porta | Portas",
|
||||||
|
"volume": "Volume | Volumes",
|
||||||
|
"network": "Rede | Redes",
|
||||||
|
"dependsOn": "Dependência do contêiner | Dependências do contêiner",
|
||||||
|
"addListItem": "Adicionar {0}",
|
||||||
|
"deleteContainer": "Excluir",
|
||||||
|
"addContainer": "Adicionar contêiner",
|
||||||
|
"addNetwork": "Adicionar rede",
|
||||||
|
"disableauth.message1": "Tem certeza que deseja <strong>desativar a autenticação</strong>?",
|
||||||
|
"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.",
|
||||||
|
"passwordNotMatchMsg": "A senha repetida não corresponde.",
|
||||||
|
"autoGet": "Obter automaticamente",
|
||||||
|
"add": "Adicionar",
|
||||||
|
"Edit": "Editar",
|
||||||
|
"applyToYAML": "Aplicar ao YAML",
|
||||||
|
"createExternalNetwork": "Criar",
|
||||||
|
"addInternalNetwork": "Adicionar",
|
||||||
|
"Save": "Salvar",
|
||||||
|
"Language": "Idioma",
|
||||||
|
"Current User": "Usuário atual",
|
||||||
|
"Change Password": "Alterar senha",
|
||||||
|
"Current Password": "Senha atual",
|
||||||
|
"New Password": "Nova senha",
|
||||||
|
"Repeat New Password": "Repetir nova senha",
|
||||||
|
"Update Password": "Atualizar senha",
|
||||||
|
"Advanced": "Avançado",
|
||||||
|
"Please use this option carefully!": "Por favor, use esta opção com atenção!",
|
||||||
|
"Enable Auth": "Habilitar autenticação",
|
||||||
|
"Disable Auth": "Desabilitar autenticação",
|
||||||
|
"I understand, please disable": "Entendido, por favor desabilitar",
|
||||||
|
"Leave": "Sair",
|
||||||
|
"Frontend Version": "Versão da interface",
|
||||||
|
"Check Update On GitHub": "Verificar atualização no GitHub",
|
||||||
|
"Show update if available": "Mostrar atualização se disponível",
|
||||||
|
"Also check beta release": "Também verificar versão beta",
|
||||||
|
"Remember me": "Lembrar-me",
|
||||||
|
"Login": "Entrar",
|
||||||
|
"Username": "Nome de usuário",
|
||||||
|
"Password": "Senha",
|
||||||
|
"Settings": "Configurações",
|
||||||
|
"Logout": "Sair",
|
||||||
|
"Lowercase only": "Somente minúsculas",
|
||||||
|
"Convert to Compose": "Converter para compose",
|
||||||
|
"Docker Run": "Executar Docker",
|
||||||
|
"active": "ativo",
|
||||||
|
"exited": "encerrado",
|
||||||
|
"inactive": "inativo",
|
||||||
|
"Appearance": "Aparência",
|
||||||
|
"Security": "Segurança",
|
||||||
|
"About": "Sobre",
|
||||||
|
"Allowed commands:": "Comandos permitidos:",
|
||||||
|
"Internal Networks": "Redes internas",
|
||||||
|
"External Networks": "Redes externas",
|
||||||
|
"No External Networks": "Sem redes externas"
|
||||||
|
}
|
94
frontend/src/lang/pt.json
Normal file
94
frontend/src/lang/pt.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Português",
|
||||||
|
"Create your admin account": "Crie sua conta de administrador",
|
||||||
|
"authIncorrectCreds": "Nome de usuário ou senha incorretos.",
|
||||||
|
"PasswordsDoNotMatch": "As senhas não coincidem.",
|
||||||
|
"Repeat Password": "Repetir Senha",
|
||||||
|
"Create": "Criar",
|
||||||
|
"signedInDisp": "Logado como {0}",
|
||||||
|
"signedInDispDisabled": "Autenticação desativada.",
|
||||||
|
"home": "Início",
|
||||||
|
"console": "Console",
|
||||||
|
"registry": "Registro",
|
||||||
|
"compose": "Compor",
|
||||||
|
"addFirstStackMsg": "Componha sua primeira pilha!",
|
||||||
|
"stackName" : "Nome da Pilha",
|
||||||
|
"deployStack": "Implantar",
|
||||||
|
"deleteStack": "Excluir",
|
||||||
|
"stopStack": "Parar",
|
||||||
|
"restartStack": "Reiniciar",
|
||||||
|
"updateStack": "Atualizar",
|
||||||
|
"startStack": "Iniciar",
|
||||||
|
"editStack": "Editar",
|
||||||
|
"discardStack": "Descartar",
|
||||||
|
"saveStackDraft": "Salvar",
|
||||||
|
"notAvailableShort" : "N/D",
|
||||||
|
"deleteStackMsg": "Tem certeza de que deseja excluir esta pilha?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Esta pilha não é gerenciada pelo Dockge.",
|
||||||
|
"primaryHostname": "Nome do Host Primário",
|
||||||
|
"general": "Geral",
|
||||||
|
"container": "Contêiner | Contêineres",
|
||||||
|
"scanFolder": "Digitalizar Pasta de Pilhas",
|
||||||
|
"dockerImage": "Imagem",
|
||||||
|
"restartPolicyUnlessStopped": "A menos que seja parado",
|
||||||
|
"restartPolicyAlways": "Sempre",
|
||||||
|
"restartPolicyOnFailure": "Em caso de falha",
|
||||||
|
"restartPolicyNo": "Não",
|
||||||
|
"environmentVariable": "Variável de Ambiente | Variáveis de Ambiente",
|
||||||
|
"restartPolicy": "Política de Reinicialização",
|
||||||
|
"containerName": "Nome do Contêiner",
|
||||||
|
"port": "Porta | Portas",
|
||||||
|
"volume": "Volume | Volumes",
|
||||||
|
"network": "Rede | Redes",
|
||||||
|
"dependsOn": "Dependência do Contêiner | Dependências do Contêiner",
|
||||||
|
"addListItem": "Adicionar {0}",
|
||||||
|
"deleteContainer": "Excluir",
|
||||||
|
"addContainer": "Adicionar Contêiner",
|
||||||
|
"addNetwork": "Adicionar Rede",
|
||||||
|
"disableauth.message1": "Tem certeza de que deseja <strong>desativar a autenticação</strong>?",
|
||||||
|
"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.",
|
||||||
|
"passwordNotMatchMsg": "A senha repetida não coincide.",
|
||||||
|
"autoGet": "Obter Automaticamente",
|
||||||
|
"add": "Adicionar",
|
||||||
|
"Edit": "Editar",
|
||||||
|
"applyToYAML": "Aplicar ao YAML",
|
||||||
|
"createExternalNetwork": "Criar",
|
||||||
|
"addInternalNetwork": "Adicionar",
|
||||||
|
"Save": "Salvar",
|
||||||
|
"Language": "Idioma",
|
||||||
|
"Current User": "Usuário Atual",
|
||||||
|
"Change Password": "Alterar Senha",
|
||||||
|
"Current Password": "Senha Atual",
|
||||||
|
"New Password": "Nova Senha",
|
||||||
|
"Repeat New Password": "Repetir Nova Senha",
|
||||||
|
"Update Password": "Atualizar Senha",
|
||||||
|
"Advanced": "Avançado",
|
||||||
|
"Please use this option carefully!": "Por favor, use esta opção com cuidado!",
|
||||||
|
"Enable Auth": "Habilitar Autenticação",
|
||||||
|
"Disable Auth": "Desabilitar Autenticação",
|
||||||
|
"I understand, please disable": "Entendo, por favor desabilitar",
|
||||||
|
"Leave": "Sair",
|
||||||
|
"Frontend Version": "Versão da Interface",
|
||||||
|
"Check Update On GitHub": "Verificar Atualização no GitHub",
|
||||||
|
"Show update if available": "Mostrar atualização se disponível",
|
||||||
|
"Also check beta release": "Também verificar versão beta",
|
||||||
|
"Remember me": "Lembrar-me",
|
||||||
|
"Login": "Entrar",
|
||||||
|
"Username": "Nome de Usuário",
|
||||||
|
"Password": "Senha",
|
||||||
|
"Settings": "Configurações",
|
||||||
|
"Logout": "Sair",
|
||||||
|
"Lowercase only": "Somente minúsculas",
|
||||||
|
"Convert to Compose": "Converter para Compose",
|
||||||
|
"Docker Run": "Executar Docker",
|
||||||
|
"active": "ativo",
|
||||||
|
"exited": "encerrado",
|
||||||
|
"inactive": "inativo",
|
||||||
|
"Appearance": "Aparência",
|
||||||
|
"Security": "Segurança",
|
||||||
|
"About": "Sobre",
|
||||||
|
"Allowed commands:": "Comandos permitidos:",
|
||||||
|
"Internal Networks": "Redes Internas",
|
||||||
|
"External Networks": "Redes Externas",
|
||||||
|
"No External Networks": "Sem Redes Externas"
|
||||||
|
}
|
94
frontend/src/lang/ru.json
Normal file
94
frontend/src/lang/ru.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Русский",
|
||||||
|
"Create your admin account": "Создайте учетку администратора",
|
||||||
|
"authIncorrectCreds": "Неверный логин или пароль.",
|
||||||
|
"PasswordsDoNotMatch": "Пароль не совпадает.",
|
||||||
|
"Repeat Password": "Повторите пароль",
|
||||||
|
"Create": "Создать",
|
||||||
|
"signedInDisp": "Авторизлван как {0}",
|
||||||
|
"signedInDispDisabled": "Авторизация выключена.",
|
||||||
|
"home": "Главная",
|
||||||
|
"console": "Консоль",
|
||||||
|
"registry": "Registry",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "Создайте свой первый стек!",
|
||||||
|
"stackName" : "Имя стека",
|
||||||
|
"deployStack": "Развернуть",
|
||||||
|
"deleteStack": "Удалить",
|
||||||
|
"stopStack": "Остановить",
|
||||||
|
"restartStack": "Перезапустить",
|
||||||
|
"updateStack": "Обновить",
|
||||||
|
"startStack": "Запустить",
|
||||||
|
"editStack": "Изменить",
|
||||||
|
"discardStack": "Отменить",
|
||||||
|
"saveStackDraft": "Сохранить",
|
||||||
|
"notAvailableShort" : "Н/Д",
|
||||||
|
"deleteStackMsg": "Вы уверены что хотите удалить этот стек?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Данный стек не обслуживается Dockge.",
|
||||||
|
"primaryHostname": "Имя хоста",
|
||||||
|
"general": "Главное",
|
||||||
|
"container": "Контейнер | Контейнеры",
|
||||||
|
"scanFolder": "Сканировать папку стеков",
|
||||||
|
"dockerImage": "Образ",
|
||||||
|
"restartPolicyUnlessStopped": "Пока не будет остановлен",
|
||||||
|
"restartPolicyAlways": "Всегда",
|
||||||
|
"restartPolicyOnFailure": "При падении",
|
||||||
|
"restartPolicyNo": "Никогда",
|
||||||
|
"environmentVariable": "Переменная окружения | Переменные окружения",
|
||||||
|
"restartPolicy": "Политика рестарта",
|
||||||
|
"containerName": "Имя контейнера",
|
||||||
|
"port": "Порт | Порты",
|
||||||
|
"volume": "Хранилище | Хранилища",
|
||||||
|
"network": "Сеть | Сети",
|
||||||
|
"dependsOn": "Зависимость контейнера | Зависимости контейнера",
|
||||||
|
"addListItem": "Добавить {0}",
|
||||||
|
"deleteContainer": "Удалить",
|
||||||
|
"addContainer": "Добавить Контейнер",
|
||||||
|
"addNetwork": "Добавить Сеть",
|
||||||
|
"disableauth.message1": "Вы уверены что хотите <strong>выключить авторизацию</strong>?",
|
||||||
|
"disableauth.message2": "Он предназначен для сценариев, <strong>где вы собираетесь реализовать стороннюю аутентификацию</strong> перед Dockge, например Cloudflare Access, Authelia или другие механизмы аутентификации.",
|
||||||
|
"passwordNotMatchMsg": "Повторный пароль не совпадает.",
|
||||||
|
"autoGet": "Auto Get",
|
||||||
|
"add": "Добавить",
|
||||||
|
"Edit": "Изменить",
|
||||||
|
"applyToYAML": "Применить к YAML",
|
||||||
|
"createExternalNetwork": "Создать",
|
||||||
|
"addInternalNetwork": "Добавить",
|
||||||
|
"Save": "Сохранить",
|
||||||
|
"Language": "Язык",
|
||||||
|
"Current User": "Текущий пользователь",
|
||||||
|
"Change Password": "Изменить пароль",
|
||||||
|
"Current Password": "Текущий пароль",
|
||||||
|
"New Password": "Новый пароль",
|
||||||
|
"Repeat New Password": "Повторите новый пароль",
|
||||||
|
"Update Password": "Обновить пароль",
|
||||||
|
"Advanced": "Продвинутые опции",
|
||||||
|
"Please use this option carefully!": "Пожалуйста, используйте эту опцию осторожно!",
|
||||||
|
"Enable Auth": "Включить аутентификацию",
|
||||||
|
"Disable Auth": "Отключить аутентификацию",
|
||||||
|
"I understand, please disable": "Я понимаю, пожалуйста, отключите",
|
||||||
|
"Leave": "Покинуть",
|
||||||
|
"Frontend Version": "Версия внешнего интерфейса",
|
||||||
|
"Check Update On GitHub": "Проверьте обновление на GitHub",
|
||||||
|
"Show update if available": "Показать обновление, если оно доступно",
|
||||||
|
"Also check beta release": "Также проверьте бета-версию",
|
||||||
|
"Remember me": "Запомнить меня",
|
||||||
|
"Login": "Логин",
|
||||||
|
"Username": "Имя пользователя",
|
||||||
|
"Password": "Пароль",
|
||||||
|
"Settings": "Настройки",
|
||||||
|
"Logout": "Выйти",
|
||||||
|
"Lowercase only": "Только нижний регистр",
|
||||||
|
"Convert to Compose": "Преобразовать вCompose",
|
||||||
|
"Docker Run": "Запустить Docker",
|
||||||
|
"active": "активный",
|
||||||
|
"exited": "завершенный",
|
||||||
|
"inactive": "неактинвый",
|
||||||
|
"Appearance": "Внешний вид",
|
||||||
|
"Security": "Безопасность",
|
||||||
|
"About": "О продукте",
|
||||||
|
"Allowed commands:": "Разрешенные команды:",
|
||||||
|
"Internal Networks": "Внутренние сети",
|
||||||
|
"External Networks": "Внешние сети",
|
||||||
|
"No External Networks": "Нет внешних сетей"
|
||||||
|
}
|
94
frontend/src/lang/sl.json
Normal file
94
frontend/src/lang/sl.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Slovenščina",
|
||||||
|
"Create your admin account": "Ustvarite svoj skrbniški račun",
|
||||||
|
"authIncorrectCreds": "Napačno uporabniško ime ali geslo.",
|
||||||
|
"PasswordsDoNotMatch": "Gesli se ne ujemata.",
|
||||||
|
"Repeat Password": "Ponovi geslo",
|
||||||
|
"Create": "Ustvari",
|
||||||
|
"signedInDisp": "Prijavljeni kot {0}",
|
||||||
|
"signedInDispDisabled": "Preverjanje pristnosti onemogočeno.",
|
||||||
|
"home": "Domov",
|
||||||
|
"console": "Konzola",
|
||||||
|
"registry": "Register",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "Ustvarite svoj prvi Stack!",
|
||||||
|
"stackName": "Ime Stack-a",
|
||||||
|
"deployStack": "Razporedi",
|
||||||
|
"deleteStack": "Izbriši",
|
||||||
|
"stopStack": "Ustavi",
|
||||||
|
"restartStack": "Ponovni zagon",
|
||||||
|
"updateStack": "Posodobi",
|
||||||
|
"startStack": "Zaženi",
|
||||||
|
"editStack": "Uredi",
|
||||||
|
"discardStack": "Zavrzi",
|
||||||
|
"saveStackDraft": "Shrani",
|
||||||
|
"notAvailableShort": "Ni na voljo",
|
||||||
|
"deleteStackMsg": "Ste prepričani, da želite izbrisati ta Stack?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Ta Stack ni upravljan s strani Dockge.",
|
||||||
|
"primaryHostname": "Osnovno gostiteljsko ime",
|
||||||
|
"general": "Splošno",
|
||||||
|
"container": "Kontejner | Kontejnerji",
|
||||||
|
"scanFolder": "Preglej Stack mapo",
|
||||||
|
"dockerImage": "Slika",
|
||||||
|
"restartPolicyUnlessStopped": "Razen ko je zaustavljeno",
|
||||||
|
"restartPolicyAlways": "Vedno",
|
||||||
|
"restartPolicyOnFailure": "Ob napaki",
|
||||||
|
"restartPolicyNo": "Ne",
|
||||||
|
"environmentVariable": "Okoljska spremenljivka | Okoljske spremenljivke",
|
||||||
|
"restartPolicy": "Politika ponovnega zagona",
|
||||||
|
"containerName": "Ime kontejnerja",
|
||||||
|
"port": "Vrata | Vrata",
|
||||||
|
"volume": "Zvezek | Zvezki",
|
||||||
|
"network": "Omrežje | Omrežja",
|
||||||
|
"dependsOn": "Odvisnost kontejnerja | Odvisnosti kontejnerjev",
|
||||||
|
"addListItem": "Dodaj {0}",
|
||||||
|
"deleteContainer": "Izbriši",
|
||||||
|
"addContainer": "Dodaj kontejner",
|
||||||
|
"addNetwork": "Dodaj omrežje",
|
||||||
|
"disableauth.message1": "Ste prepričani, da želite <strong>onemogočiti overjanje</strong>?",
|
||||||
|
"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.",
|
||||||
|
"passwordNotMatchMsg": "Ponovljeno geslo se ne ujema.",
|
||||||
|
"autoGet": "Samodejno pridobi",
|
||||||
|
"add": "Dodaj",
|
||||||
|
"Edit": "Uredi",
|
||||||
|
"applyToYAML": "Uporabi za YAML",
|
||||||
|
"createExternalNetwork": "Ustvari",
|
||||||
|
"addInternalNetwork": "Dodaj",
|
||||||
|
"Save": "Shrani",
|
||||||
|
"Language": "Jezik",
|
||||||
|
"Current User": "Trenutni uporabnik",
|
||||||
|
"Change Password": "Spremeni geslo",
|
||||||
|
"Current Password": "Trenutno geslo",
|
||||||
|
"New Password": "Novo geslo",
|
||||||
|
"Repeat New Password": "Ponovi novo geslo",
|
||||||
|
"Update Password": "Posodobi geslo",
|
||||||
|
"Advanced": "Napredno",
|
||||||
|
"Please use this option carefully!": "Prosimo, uporabite to možnost previdno!",
|
||||||
|
"Enable Auth": "Omogoči overjanje",
|
||||||
|
"Disable Auth": "Onemogoči overjanje",
|
||||||
|
"I understand, please disable": "Razumem, prosim onemogočite",
|
||||||
|
"Leave": "Zapusti",
|
||||||
|
"Frontend Version": "Različica vmesnika",
|
||||||
|
"Check Update On GitHub": "Preveri posodobitve na GitHubu",
|
||||||
|
"Show update if available": "Prikaži posodobitve, če so na voljo",
|
||||||
|
"Also check beta release": "Preveri tudi beta izdaje",
|
||||||
|
"Remember me": "Zapomni si me",
|
||||||
|
"Login": "Prijava",
|
||||||
|
"Username": "Uporabniško ime",
|
||||||
|
"Password": "Geslo",
|
||||||
|
"Settings": "Nastavitve",
|
||||||
|
"Logout": "Odjava",
|
||||||
|
"Lowercase only": "Samo male črke",
|
||||||
|
"Convert to Compose": "Pretvori v Compose",
|
||||||
|
"Docker Run": "Zagon Dockerja",
|
||||||
|
"active": "aktivno",
|
||||||
|
"exited": "izklopljeno",
|
||||||
|
"inactive": "neaktivno",
|
||||||
|
"Appearance": "Videz",
|
||||||
|
"Security": "Varnost",
|
||||||
|
"About": "O nas",
|
||||||
|
"Allowed commands:": "Dovoljeni ukazi:",
|
||||||
|
"Internal Networks": "Notranja omrežja",
|
||||||
|
"External Networks": "Zunanja omrežja",
|
||||||
|
"No External Networks": "Ni zunanjih omrežij"
|
||||||
|
}
|
95
frontend/src/lang/sv-SE.json
Normal file
95
frontend/src/lang/sv-SE.json
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Svenska",
|
||||||
|
"Create your admin account": "Skapa ditt Admin-konto.",
|
||||||
|
"authIncorrectCreds": "Fel användarnamn eller lösenord.",
|
||||||
|
"PasswordsDoNotMatch": "Lösenorden matchar inte.",
|
||||||
|
"Repeat Password": "Repetera lösenord",
|
||||||
|
"Create": "Skapa",
|
||||||
|
"signedInDisp": "Inloggad som {0}",
|
||||||
|
"signedInDispDisabled": "Auth inaktiverad.",
|
||||||
|
"home": "Hem",
|
||||||
|
"console": "Konsol",
|
||||||
|
"registry": "Register",
|
||||||
|
"compose": "Komponera",
|
||||||
|
"addFirstStackMsg": "Komponera din första stack!",
|
||||||
|
"stackName" : "Stacknamn",
|
||||||
|
"deployStack": "Distribuera",
|
||||||
|
"deleteStack": "Radera",
|
||||||
|
"stopStack": "Stop",
|
||||||
|
"restartStack": "Starta om",
|
||||||
|
"updateStack": "Uppdatera",
|
||||||
|
"startStack": "Starta",
|
||||||
|
"downStack": "Stop & Ner",
|
||||||
|
"editStack": "Redigera",
|
||||||
|
"discardStack": "Kasta",
|
||||||
|
"saveStackDraft": "Spara",
|
||||||
|
"notAvailableShort" : "N/A",
|
||||||
|
"deleteStackMsg": "Är du säker på att du vill radera stacken?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Denna stacken hanteras inte av Dockge.",
|
||||||
|
"primaryHostname": "Primärt värdnamn",
|
||||||
|
"general": "Allmän",
|
||||||
|
"container": "Container | Containrar",
|
||||||
|
"scanFolder": "Scanna Stackfolder",
|
||||||
|
"dockerImage": "Bild",
|
||||||
|
"restartPolicyUnlessStopped": "Om inte stoppas",
|
||||||
|
"restartPolicyAlways": "Alltid",
|
||||||
|
"restartPolicyOnFailure": "Vid Misslyckande",
|
||||||
|
"restartPolicyNo": "Nej",
|
||||||
|
"environmentVariable": "Miljövariabel | Miljövariabler",
|
||||||
|
"restartPolicy": "Omstartspolicy",
|
||||||
|
"containerName": "Containernamn",
|
||||||
|
"port": "Port | Portar",
|
||||||
|
"volume": "Volym | Volymer",
|
||||||
|
"network": "Nätverk | Nätverk",
|
||||||
|
"dependsOn": "Containerberoende | Containerberoenden",
|
||||||
|
"addListItem": "Lägg till {0}",
|
||||||
|
"deleteContainer": "Radera",
|
||||||
|
"addContainer": "Lägg till Container",
|
||||||
|
"addNetwork": "Lägg till Nätverk",
|
||||||
|
"disableauth.message1": "Är du säker på att du vill <strong>inaktivera autentisering</strong>?",
|
||||||
|
"disableauth.message2": "Det är designat för senarion <stong>när du ska implementera tredjeparts autentisering</strong> framör Dockge som Cloudflare Access, Authelia eller andra autentiseringsmekanismer.",
|
||||||
|
"passwordNotMatchMsg": "Det upprepade lösenordet matchar inte",
|
||||||
|
"autoGet": "Auto Hämta",
|
||||||
|
"add": "Lägg till",
|
||||||
|
"Edit": "Redigera",
|
||||||
|
"applyToYAML": "Lägg till i YAML",
|
||||||
|
"createExternalNetwork": "Skapa",
|
||||||
|
"addInternalNetwork": "Lägg till",
|
||||||
|
"Save": "Spara",
|
||||||
|
"Language": "Språk",
|
||||||
|
"Current User": "Nuvarande användaren",
|
||||||
|
"Change Password": "Byt lösenord",
|
||||||
|
"Current Password": "Nuvarande lösenord",
|
||||||
|
"New Password": "Nytt lösenord",
|
||||||
|
"Repeat New Password": "Upprepa nytt lösenord",
|
||||||
|
"Update Password": "Uppdatera lösenord",
|
||||||
|
"Advanced": "Avancerat",
|
||||||
|
"Please use this option carefully!": "Använd detta alternativ försiktigt!",
|
||||||
|
"Enable Auth": "Aktivera Auth",
|
||||||
|
"Disable Auth": "Avaktivera Auth",
|
||||||
|
"I understand, please disable": "Jag förstår, vänligen inaktivera",
|
||||||
|
"Leave": "Lämna",
|
||||||
|
"Frontend Version": "Frontendversion",
|
||||||
|
"Check Update On GitHub": "Kontrollera Uppdatering på GitHub",
|
||||||
|
"Show update if available": "Visa uppdatering om tillgänglig",
|
||||||
|
"Also check beta release": "Kontrollera även betaversionen",
|
||||||
|
"Remember me": "Kom ihåg mig",
|
||||||
|
"Login": "Logga in",
|
||||||
|
"Username": "Användarnamn",
|
||||||
|
"Password": "Lösenord",
|
||||||
|
"Settings": "Inställningar",
|
||||||
|
"Logout": "Logga ut",
|
||||||
|
"Lowercase only": "Endast små tecken",
|
||||||
|
"Convert to Compose": "Omvandla till Compose",
|
||||||
|
"Docker Run": "Docker Run",
|
||||||
|
"active": "aktiv",
|
||||||
|
"exited": "avslutad",
|
||||||
|
"inactive": "inaktiv",
|
||||||
|
"Appearance": "Utseende",
|
||||||
|
"Security": "Säkerhet",
|
||||||
|
"About": "Om",
|
||||||
|
"Allowed commands:": "Tillåtna kommandon:",
|
||||||
|
"Internal Networks": "Interna Nätverk",
|
||||||
|
"External Networks": "Externa Nätverk",
|
||||||
|
"No External Networks": "Inga Externa Nätverk"
|
||||||
|
}
|
95
frontend/src/lang/th.json
Normal file
95
frontend/src/lang/th.json
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"languageName": "ไทย",
|
||||||
|
"Create your admin account": "สร้างบัญชีผู้ดูแลระบบของคุณ",
|
||||||
|
"authIncorrectCreds": "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง",
|
||||||
|
"PasswordsDoNotMatch": "รหัสผ่านไม่ตรงกัน",
|
||||||
|
"Repeat Password": "ยืนยันรหัสผ่าน",
|
||||||
|
"Create": "สร้าง",
|
||||||
|
"signedInDisp": "ลงชื่อเข้าใช้ในชื่อ {0}",
|
||||||
|
"signedInDispDisabled": "ปิดใช้งาน Auth",
|
||||||
|
"home": "หน้าหลักe",
|
||||||
|
"console": "คอนโซล",
|
||||||
|
"registry": "Registry",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "Compose stack แรกของคุณ",
|
||||||
|
"stackName": "ชื่อ Stack",
|
||||||
|
"deployStack": "ปรับใช้",
|
||||||
|
"deleteStack": "ลบ",
|
||||||
|
"stopStack": "หยุด",
|
||||||
|
"restartStack": "เริ่มใหม่",
|
||||||
|
"updateStack": "อัปเดต",
|
||||||
|
"startStack": "เริ่มต้น",
|
||||||
|
"downStack": "หยุดและปิด",
|
||||||
|
"editStack": "แก้ไข",
|
||||||
|
"discardStack": "ยกเลิก",
|
||||||
|
"saveStackDraft": "บันทึก",
|
||||||
|
"notAvailableShort": "N/A",
|
||||||
|
"deleteStackMsg": "คุณแน่ใจหรือไม่ว่าต้องการลบ stack นี้",
|
||||||
|
"stackNotManagedByDockgeMsg": "stack นี้ไม่ได้รับการจัดการโดย Dockge",
|
||||||
|
"primaryHostname": "ชื่อโฮสต์หลัก",
|
||||||
|
"general": "ทั่วไป",
|
||||||
|
"container": "Container | Containers",
|
||||||
|
"scanFolder": "สแกนโฟลเดอร์ Stacks",
|
||||||
|
"dockerImage": "Image",
|
||||||
|
"restartPolicyUnlessStopped": "Unless Stopped",
|
||||||
|
"restartPolicyAlways": "Always",
|
||||||
|
"restartPolicyOnFailure": "On Failure",
|
||||||
|
"restartPolicyNo": "No",
|
||||||
|
"environmentVariable": "Environment Variable | Environment Variables",
|
||||||
|
"restartPolicy": "เริ่มต้น Policy ใหม่",
|
||||||
|
"containerName": "ชื่อ Container",
|
||||||
|
"port": "พอร์ต | พอร์ต",
|
||||||
|
"volume": "ปริมาณ | ปริมาณ",
|
||||||
|
"network": "เครือข่าย | เครือข่าย",
|
||||||
|
"dependsOn": "Container Dependency | Container Dependencies",
|
||||||
|
"addListItem": "เพิ่ม {0}",
|
||||||
|
"deleteContainer": "ลบ",
|
||||||
|
"addContainer": "เพิ่ม Container",
|
||||||
|
"addNetwork": "เพิ่ม เครือข่าย",
|
||||||
|
"disableauth.message1": "คุณแน่ใจหรือไม่ว่าต้องการ <strong>ปิดใช้งานการตรวจสอบสิทธิ์</strong>?",
|
||||||
|
"disableauth.message2": "ได้รับการออกแบบมาสำหรับสถานการณ์ <strong>ที่คุณตั้งใจจะใช้การตรวจสอบสิทธิ์ของบุคคลที่สาม</strong> หน้า Dockge เช่น Cloudflare Access, Authelia หรือกลไกการตรวจสอบสิทธิ์อื่นๆ",
|
||||||
|
"passwordNotMatchMsg": "รหัสผ่านซ้ำไม่ตรงกัน",
|
||||||
|
"autoGet": "รับอัตโนมัติ",
|
||||||
|
"add": "เพิ่ม",
|
||||||
|
"Edit": "แก้ไข",
|
||||||
|
"applyToYAML": "นำไปใช้เป็น YAML",
|
||||||
|
"createExternalNetwork": "สร้าง",
|
||||||
|
"addInternalNetwork": "เพิ่ม",
|
||||||
|
"Save": "บันทึก",
|
||||||
|
"Language": "ภาษา",
|
||||||
|
"Current User": "ผู้ใช้งานปัจจุบัน",
|
||||||
|
"Change Password": "เปลี่ยนรหัสผ่าน",
|
||||||
|
"Current Password": "รหัสผ่านปัจจุบัน",
|
||||||
|
"New Password": "รหัสผ่านใหม่",
|
||||||
|
"Repeat New Password": "รหัสผ่านใหม่ซ้ำ",
|
||||||
|
"Update Password": "อัปเดตรหัสผ่าน",
|
||||||
|
"Advanced": "ขั้นสูง",
|
||||||
|
"Please use this option carefully!": "โปรดใช้ตัวเลือกนี้อย่างระมัดระวัง!",
|
||||||
|
"Enable Auth": "เปิดใช้งาน Auth",
|
||||||
|
"Disable Auth": "ปิดใช้งาน Auth",
|
||||||
|
"I understand, please disable": "ฉันเข้าใจ กรุณาปิดการใช้งาน",
|
||||||
|
"Leave": "ออก",
|
||||||
|
"Frontend Version": "เวอร์ชัน Frontend",
|
||||||
|
"Check Update On GitHub": "ตรวจสอบการอัปเดตบน GitHub",
|
||||||
|
"Show update if available": "แสดงการอัปเดตหากมี",
|
||||||
|
"Also check beta release": "สามารถตรวจสอบรุ่นเบต้าได้",
|
||||||
|
"Remember me": "จดจำฉัน",
|
||||||
|
"Login": "เข้าสู่ระบบ",
|
||||||
|
"Username": "ชื่อผู้ใช้",
|
||||||
|
"Password": "รหัสผ่าน",
|
||||||
|
"Settings": "การตั้งค่า",
|
||||||
|
"Logout": "ออกจากระบบ",
|
||||||
|
"Lowercase only": "ตัวเล็กทั้งหมด",
|
||||||
|
"Convert to Compose": "แปลงเป็น Compose",
|
||||||
|
"Docker Run": "เรียกใช้ Docker",
|
||||||
|
"active": "ใช้งานอยู่",
|
||||||
|
"exited": "ปิดลงแล้ว",
|
||||||
|
"inactive": "ไม่ได้ใช้งาน",
|
||||||
|
"Appearance": "รูปลักษณ์",
|
||||||
|
"Security": "ความปลอดภัย",
|
||||||
|
"About": "เกี่ยวกับ",
|
||||||
|
"Allowed commands:": "คำสั่งที่อนุญาต:",
|
||||||
|
"Internal Networks": "เครือข่ายภายใน",
|
||||||
|
"External Networks": "เครือข่ายภายนอก",
|
||||||
|
"No External Networks": "ไม่มีเครือข่ายภายนอก"
|
||||||
|
}
|
94
frontend/src/lang/tr.json
Normal file
94
frontend/src/lang/tr.json
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"languageName": "Türkçe",
|
||||||
|
"Create your admin account": "Yönetici hesabınızı oluşturun",
|
||||||
|
"authIncorrectCreds": "Yanlış kullanıcı adı veya parola.",
|
||||||
|
"PasswordsDoNotMatch": "Parolalar eşleşmiyor.",
|
||||||
|
"Repeat Password": "Parolayı Tekrarla",
|
||||||
|
"Create": "Oluştur",
|
||||||
|
"signedInDisp": "{0} olarak oturum açıldı",
|
||||||
|
"signedInDispDisabled": "Yetkilendirme Devre Dışı.",
|
||||||
|
"home": "Anasayfa",
|
||||||
|
"console": "Konsol",
|
||||||
|
"registry": "Kayıt Defteri",
|
||||||
|
"compose": "Compose",
|
||||||
|
"addFirstStackMsg": "İlk yığınınızı oluşturun!",
|
||||||
|
"stackName" : "Yığın Adı",
|
||||||
|
"deployStack": "Dağıtmak",
|
||||||
|
"deleteStack": "Sil",
|
||||||
|
"stopStack": "Dudur",
|
||||||
|
"restartStack": "Yeniden Başlat",
|
||||||
|
"updateStack": "Güncelle",
|
||||||
|
"startStack": "Başlat",
|
||||||
|
"editStack": "Düzenle",
|
||||||
|
"discardStack": "Çıkar",
|
||||||
|
"saveStackDraft": "Kaydet",
|
||||||
|
"notAvailableShort" : "N/A",
|
||||||
|
"deleteStackMsg": "Bu yığını silmek istediğinizden emin misiniz?",
|
||||||
|
"stackNotManagedByDockgeMsg": "Bu yığın Dockge tarafından yönetilmemektedir.",
|
||||||
|
"primaryHostname": "Birincil Ana Bilgisayar Adı",
|
||||||
|
"general": "Genel",
|
||||||
|
"container": "Konteyner | Konteynerler",
|
||||||
|
"scanFolder": "Yığınlar Klasörünü Tara",
|
||||||
|
"dockerImage": "Görüntü",
|
||||||
|
"restartPolicyUnlessStopped": "Durdurulana Kadar",
|
||||||
|
"restartPolicyAlways": "Her zaman",
|
||||||
|
"restartPolicyOnFailure": "Başarısızlıkta",
|
||||||
|
"restartPolicyNo": "Hayır",
|
||||||
|
"environmentVariable": "Ortam Değişkeni | Ortam Değişkenleri",
|
||||||
|
"restartPolicy": "Yeniden Başlatma Politikası",
|
||||||
|
"containerName": "Konteyner Adı",
|
||||||
|
"port": "Port | Portlar",
|
||||||
|
"volume": "Disk Bölümü | Disk Bölümleri",
|
||||||
|
"network": "Ağ | Ağlar",
|
||||||
|
"dependsOn": "Konteyner Bağımlılığı | Konteyner Bağımlılıkları",
|
||||||
|
"addListItem": "{0} Ekle",
|
||||||
|
"deleteContainer": "Sil",
|
||||||
|
"addContainer": "Konteyner Ekle",
|
||||||
|
"addNetwork": "Ağ Ekle",
|
||||||
|
"disableauth.message1": "<strong>Kimlik doğrulamayı devre dışı</strong> bırakmak istediğinizden emin misiniz?",
|
||||||
|
"disableauth.message2": "Cloudflare Access, Authelia veya diğer kimlik doğrulama mekanizmaları gibi Uptime Kuma'nın önünde <strong>üçüncü taraf kimlik doğrulaması uygulamak</strong> istediğiniz senaryolar için tasarlanmıştır.",
|
||||||
|
"passwordNotMatchMsg": "Tekrarlanan parola eşleşmiyor.",
|
||||||
|
"autoGet": "Otomatik Al",
|
||||||
|
"add": "Ekle",
|
||||||
|
"Edit": "Düzenle",
|
||||||
|
"applyToYAML": "YAML'ye uygulayın",
|
||||||
|
"createExternalNetwork": "Oluştur",
|
||||||
|
"addInternalNetwork": "Ekle",
|
||||||
|
"Save": "Kaydet",
|
||||||
|
"Language": "Dil",
|
||||||
|
"Current User": "Mevcut Kullanıcı",
|
||||||
|
"Change Password": "Mevcut Parola",
|
||||||
|
"Current Password": "Mevcut Parola",
|
||||||
|
"New Password": "Yeni Parola",
|
||||||
|
"Repeat New Password": "Yeni Parolayı Tekrarla",
|
||||||
|
"Update Password": "Parolayı Güncelle",
|
||||||
|
"Advanced": "Gelişmiş",
|
||||||
|
"Please use this option carefully!": "Lütfen bu seçeneği dikkatli kullanın!",
|
||||||
|
"Enable Auth": "Kimlik Doğrulamayı Etkinleştir",
|
||||||
|
"Disable Auth": "Kimlik Doğrulamayı Devre Dışı Bırak",
|
||||||
|
"I understand, please disable": "Anlıyorum, lütfen devre dışı bırakın",
|
||||||
|
"Leave": "Ayrıl",
|
||||||
|
"Frontend Version": "Frontend Versiyon",
|
||||||
|
"Check Update On GitHub": "GitHub'da Güncellemeyi Kontrol Edin",
|
||||||
|
"Show update if available": "Varsa güncellemeyi göster",
|
||||||
|
"Also check beta release": "Ayrıca beta sürümünü kontrol edin",
|
||||||
|
"Remember me": "Beni Hatırla",
|
||||||
|
"Login": "Oturum Aç",
|
||||||
|
"Username": "Kullanıcı Adı",
|
||||||
|
"Password": "Parola",
|
||||||
|
"Settings": "Ayarlar",
|
||||||
|
"Logout": "Oturumu Kapat",
|
||||||
|
"Lowercase only": "Yalnızca küçük harf",
|
||||||
|
"Convert to Compose": "Compose'a Dönüştür",
|
||||||
|
"Docker Run": "Docker Run",
|
||||||
|
"active": "aktif",
|
||||||
|
"exited": "çıkış yaptı",
|
||||||
|
"inactive": "aktif değil",
|
||||||
|
"Appearance": "Görünüm",
|
||||||
|
"Security": "Güvenlik",
|
||||||
|
"About": "Hakkında",
|
||||||
|
"Allowed commands:": "İzin verilen komutlar:",
|
||||||
|
"Internal Networks": "İç Ağlar",
|
||||||
|
"External Networks": "Dış Ağlar",
|
||||||
|
"No External Networks": "Dış Ağ Yok"
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user