Compare commits

...

146 Commits
wip ... sort

Author SHA1 Message Date
79552b90a3 Sort non managed stack to the end 2023-12-03 21:19:53 +08:00
027d9e9b59 Add new languages in the list 2023-12-03 20:41:59 +08:00
80876a463d Merge pull request #190 from UptimeKumaBot/weblate-dockge-dockge
Translations update from Kuma Weblate
2023-12-03 20:41:22 +08:00
beefe41264 Translated using Weblate (Danish)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/da/
2023-12-03 11:28:43 +00:00
e4f9b9c9fe Added translation using Weblate (Danish) 2023-12-03 10:40:02 +00:00
a85813ab95 Translated using Weblate (Dutch)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/nl/
2023-12-03 10:13:01 +00:00
47debeddc9 Translated using Weblate (Russian)
Currently translated at 91.0% (91 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ru/
2023-12-03 10:13:01 +00:00
9ce68f67fa Translated using Weblate (German)
Currently translated at 99.0% (99 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/de/
2023-12-03 10:13:01 +00:00
c3b9db8549 Translated using Weblate (Russian)
Currently translated at 92.0% (92 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ru/
2023-12-03 10:13:01 +00:00
39279cc7df Translated using Weblate (French)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/fr/
2023-12-03 10:13:01 +00:00
f4eeb38d18 Translated using Weblate (Urdu)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ur/
2023-12-03 10:13:01 +00:00
49187577ca Translated using Weblate (Romanian)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ro/
2023-12-03 10:13:01 +00:00
7ffb36ec54 Translated using Weblate (Romanian)
Currently translated at 19.0% (19 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ro/
2023-12-03 10:13:01 +00:00
cd596b2d37 Translated using Weblate (Japanese)
Currently translated at 96.0% (96 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ja/
2023-12-03 10:13:01 +00:00
1711e4f47c Translated using Weblate (Dutch)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/nl/
2023-12-03 10:13:01 +00:00
2c0fb7f7e0 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/zh_Hant/
2023-12-03 10:13:01 +00:00
ef7f26b142 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/zh_Hans/
2023-12-03 10:13:01 +00:00
80b907577f Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/zh_Hans/
2023-12-03 10:13:01 +00:00
e54211de1b Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/zh_Hans/
2023-12-03 10:13:01 +00:00
fc42639cb5 Translated using Weblate (Urdu)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ur/
2023-12-03 10:13:01 +00:00
fde2ef4869 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/uk/
2023-12-03 10:13:01 +00:00
d554adb0c7 Translated using Weblate (Turkish)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/tr/
2023-12-03 10:13:01 +00:00
c1e788d22a Translated using Weblate (French)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/fr/
2023-12-03 10:13:01 +00:00
972179b4a8 Translated using Weblate (German)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/de/
2023-12-03 10:13:01 +00:00
d5e1ce51ed Translated using Weblate (Arabic)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ar/
2023-12-03 10:13:01 +00:00
9155cf14a5 Translated using Weblate (English)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/en/
2023-12-03 10:13:01 +00:00
17eb6583df Added translation using Weblate (Romanian) 2023-12-03 10:13:01 +00:00
b717fc6655 Added translation using Weblate (Japanese) 2023-12-03 10:13:01 +00:00
da50860211 Added translation using Weblate (Dutch) 2023-12-03 10:13:01 +00:00
340ea0abe9 Translated using Weblate (Arabic)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ar/
2023-12-03 10:13:01 +00:00
d53cb7ca71 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/uk/
2023-12-03 10:13:01 +00:00
e29eed4602 Translated using Weblate (French)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/fr/
2023-12-03 10:13:01 +00:00
8e9f2209c2 Translated using Weblate (Arabic)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ar/
2023-12-03 10:13:01 +00:00
a8d95d06b9 UI for editing .env file (#218)
* Add .env file editing with syntax highlighting

* Add example env file to new compose

* Changed .env editing section title

* Better stack constuctor parameter order

* Minor

---------

Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-12-03 18:12:54 +08:00
e2c81bd3e0 Add pnpm run reset-password (#212) 2023-11-30 16:11:00 +08:00
bf2d4c95f9 WIP (#211) 2023-11-30 16:10:37 +08:00
b4aa1b83a2 Add ARM github runner and change Node.js version (#210)
* WIP

* WIP
2023-11-29 14:59:39 +08:00
157a74aafc Update README.md 2023-11-29 09:28:47 +08:00
b7a2bab808 Update README.md 2023-11-29 09:27:40 +08:00
71446b9eb9 add missing 'stackName' param to return value (#206)
Maybe a mishap, but this was causing issues with opening the correct terminals for each container

Co-authored-by: Thales <thcd@cock.li>
2023-11-29 07:04:30 +08:00
06dbc3fa28 Update compose.yaml 2023-11-28 00:53:18 +08:00
a0fca4df4d Update Base.Dockerfile 2023-11-27 21:01:11 +08:00
694923cd42 Update README.md 2023-11-27 18:58:15 +08:00
bfaa8fd795 Update README.md 2023-11-26 23:25:52 +08:00
d5721dd8ca chore(ci): Add workflow to prevent changing lang files (#191)
* Create prevent-file-change.yml

* fix
2023-11-26 22:17:45 +08:00
5c35b09e93 Update CONTRIBUTING.md 2023-11-26 18:53:17 +08:00
9a5d403219 Update to 1.2.0 2023-11-26 18:02:36 +08:00
9ca65ec94d Merge pull request #184 from UptimeKumaBot/weblate-dockge-dockge
Translations update from Uptime Kuma Weblate
2023-11-26 17:54:57 +08:00
5b02b63c95 Translated using Weblate (Urdu)
Currently translated at 100.0% (100 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ur/
2023-11-26 09:51:33 +00:00
88d33aace8 Translated using Weblate (Chinese (Simplified))
Currently translated at 97.0% (97 of 100 strings)

Translation: Dockge/dockge
Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/zh_Hans/
2023-11-26 09:51:33 +00:00
b5f6919bab Fix 100% cpu usage and terminal issue by downgrading Node.js to 18.17.1 (#186)
* Downgrade from Node.js 20 to Node.js 18.17.1

* Enable back socket.io polling

* Avoid multiple commands at the same for a stack
2023-11-26 17:51:26 +08:00
0546f6a24e chore(docs): Update Translation Guide (#185)
* update

* Update README.md
2023-11-26 13:30:57 +08:00
7385d216a3 Fix 2023-11-25 22:26:12 +08:00
631bc60cb2 Add support for extra info: URLs (#182)
* Always show Down button

* Add URL and improve ArrayInput handling

* Minor
2023-11-25 22:14:21 +08:00
d23e2d8aa1 Enforce Websocket (#181)
* Always show Down button

* Force WebSocket

* Force WebSocket
2023-11-25 21:21:23 +08:00
457f038108 Fix css 2023-11-25 13:50:15 +08:00
f862bbc7cd Added Traditional Chinese (#96)
* Fix a typo

* Added Traditional Chinese

* Removed zh-HK

* Update zh-TW.json
2023-11-25 13:08:14 +08:00
3d56846cd6 Add Ukrainian translation (#175)
* Create uk-UA.json

Ukrainian translation

* Update i18n.ts

Add Ukrainian translation

* Update i18n.ts

* Update i18n.ts
2023-11-25 12:06:24 +08:00
cff929c69d Remove compose version (#174) 2023-11-25 03:08:48 +08:00
766e751522 Close terminal if there is no clients (#60)
* Close terminal if there is no clients connecting

* Only enable

* Join the terminal only if it is managed by Dockge

* Done
2023-11-25 02:04:16 +08:00
45ab36db98 Fix: dockge cannot be self-managed (#171) 2023-11-25 01:02:04 +08:00
47435d41cd Added Swedish (#167) 2023-11-24 13:27:55 +08:00
ee8f39699a Add Italian translation (#166)
* Create it-IT.json

* Add Italian translation to i18n.ts
2023-11-24 02:33:22 +08:00
204c776b0d feat : Added Thai language (#164) 2023-11-23 21:27:38 +08:00
724b5d6d7e Update README.md 2023-11-23 11:03:20 +08:00
766ecb070d Arabic translation (#162)
* Create ar.json

Added Arabic translation

* Update i18n.ts

Add Arabic translation

* Update i18n.ts

---------

Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-11-23 10:19:58 +08:00
d1d3a54377 Add step to build the app aswell (#158) 2023-11-23 08:49:34 +08:00
d17a63fcab Created Brazilian-Portuguese lang (#159) 2023-11-23 08:46:20 +08:00
5454b44a1c Fix cs-CZ language (#160)
* fix cs-CZ

* fix admin acc translation
2023-11-23 08:45:14 +08:00
9e8bccbf2f Update ask-for-help.yml 2023-11-22 23:13:34 +08:00
866fa380dd Merge pull request #157 from louislam/rebase-discussion-form
[Rebase to master] Add Discussion Forms for Feature Request and Help (#156)
2023-11-22 23:10:56 +08:00
49b28d0e36 Add Discussion Forms for Feature Request and Help (#156)
* created DISCUSSIONS folder and added feature request form

* Added ask-for-help discussion form

* Updated file formats to GH approved

* Updated naming for feature request. Fixed  to

* Updated ISSUE_TEMPLATE to point to the discussion forms

* Removed description from checkbox

* Removed description from checkboxes

---------

Co-authored-by: Justin Masse <justin@Justins-MacBook-Pro.local>
2023-11-22 23:08:33 +08:00
1e7dd0504b Čeština language file [cs-CZ] (#154) 2023-11-22 18:53:42 +08:00
05191b14de Readme reference compose file contents (#117)
* Reference compose.yaml directly

* fixup! Reference compose.yaml directly
2023-11-22 04:36:09 +08:00
81cacbdddd Fix the port issue 2023-11-22 02:22:38 +08:00
0279d431e0 Add @actions/github 2023-11-22 01:12:54 +08:00
0e768abfb4 Try to fix "close-incorrect-issue.yml" 2023-11-22 01:07:51 +08:00
62e952f1e6 Try to fix "close-incorrect-issue.yml" 2023-11-22 01:04:28 +08:00
f044c2e328 Try to fix issue template 2023-11-22 00:59:11 +08:00
dc8787d204 Add workflows, templates and docs (#134)
* Copy workflows, templates and docs from Uptime Kuma

* Update bug report template

* Add exclude.txt

* Fix tsconfig.json

* Update CONTRIBUTING.md

* Update

* Update
2023-11-22 00:55:34 +08:00
d92ea2dac9 Added Polski (#132)
* Create pl-PL.json

from https://github.com/louislam/dockge/pull/90 with merged review

* Update pl-PL.json

missed a few and noticed a few things that could be changed

* Add to list

* No overflow text

---------

Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-11-22 00:04:51 +08:00
67819ecd73 Added a script for preparing changelog 2023-11-21 20:44:27 +08:00
73d23373cf Update to 1.1.1 2023-11-21 20:18:38 +08:00
891a217682 Show the correct version 2023-11-21 20:11:02 +08:00
31726315c3 Improvements (#124)
* Improvements

* Add inspect to dev:backend

* Skip debug log at the beginning
2023-11-21 18:51:38 +08:00
e95ef66ca1 Add "docker compose down" (#122) 2023-11-21 18:17:11 +08:00
b7c6bbba67 Slovenian language file (#108)
* Slovenian language file

This is a translation from the en.json file to the Slovenian language

* Update for Slovenian language in i18n.ts

Slovenian languge added to the language list

* Update i18n.ts

Removed trailing spaces
2023-11-21 18:12:54 +08:00
dc8c3a7568 Update Turkish language (#121) 2023-11-21 15:13:12 +08:00
b50b1cc6e1 Trim trailing whitespace (#116) 2023-11-21 13:28:04 +08:00
2e26178d2d feat: Add Translation Guide (#111)
* add README for translations

* add ref in README aswell
2023-11-21 12:15:09 +08:00
6ef861c989 german language added (#101)
Co-authored-by: cellerich <cellerich@cello.ch>
2023-11-21 03:51:32 +08:00
853b43a876 Add Russian language translation (#107) 2023-11-21 03:50:58 +08:00
16a4dd63ac Changed: Readme (#97) 2023-11-21 00:06:31 +08:00
0847a4a0c0 chore: Add Urdu language (#77)
* Update i18n.ts

* Create ur.json

* complete
2023-11-20 23:52:35 +08:00
889f0c133f Added: Bulgarian (#93)
* Added: Bulgarian

Added Bulgarian Language Support.

* Fix: Bulgarian

* Create bg-BG.json

Bulgarian translation
2023-11-20 18:14:00 +08:00
7cff52f614 Added 한국어 (#86) 2023-11-20 13:23:41 +08:00
01398aa498 Added Español and Português (#82)
* Created spanish lang.

* Included Portuguese

* Added missing line of code.
2023-11-20 13:23:08 +08:00
afe0bc561f Added Simplified Chinese (#70)
* Added Simplified Chinese

* Added Simplified Chinese

* Fix a typo
2023-11-20 00:55:00 +08:00
c8770a9605 Improve handling of stack status, close #11 2023-11-20 00:52:33 +08:00
0208684b50 Update a stopped stack, will no longer start the stack 2023-11-20 00:15:37 +08:00
a007ec56f7 Update to 1.1.0 2023-11-19 17:44:30 +08:00
7bb0a1cb08 Minor 2023-11-19 17:36:19 +08:00
4df799b5b6 Added Turkish Language (#61)
* Added Turkish Language

* Add to list

---------

Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-11-19 17:30:09 +08:00
03bc2b6a34 Added Fr language and added missing translation keys (#66)
* Added Fr language and added missing translation keys

* forgotten key

* forgotten key

* fix
2023-11-19 17:19:33 +08:00
53b052c1e5 Check TypeScript for backend (#64)
* Check Typescript

* Fix backend typescript issues

* Update
2023-11-18 15:54:43 +08:00
13c3dac44d ESLint, update vite to 5.0.0 and other dependencies (#63)
* Update vite to 5.0.0 and other dependencies

* Eslint

* Update workflow
2023-11-18 13:59:40 +08:00
5ce6b90546 Support compose.y[a]ml and docker-compose.y[a]ml (#55)
* Support compose.y[a]ml and docker-compose.y[a]ml

* using for-loop to iterate over supported compose filenames

* Fix lint

---------

Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-11-18 13:36:57 +08:00
a488518f6e Add health status check (#58)
* set Health value to Status if existent

Check if Health has any value and save it to be displayed.
If Health is empty, continue as normal.

* add healthy and unhealthy status to be displayed

Check if status is either Running or Healthy to set span class to bg-primary,
and check if status is Unhealthy to set span class to bg-danger.

* Add lint to workflow

* Fix lint

---------

Co-authored-by: Thales <thcd@cock.li>
Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-11-18 13:27:39 +08:00
8c4004f32d Update to 1.0.4 2023-11-17 01:04:14 +08:00
393bbcae79 Fix #52 2023-11-17 01:02:20 +08:00
9fbf94586b fix ci (#54) 2023-11-16 22:10:53 +08:00
0a46a7df1a Add Github Workflow (#38)
* Create ci..yml

* Rename ci..yml to ci.yml
2023-11-16 21:53:40 +08:00
d1732af529 Update README.md 2023-11-14 00:33:11 +08:00
87a6436f28 Update compose.yaml 2023-11-13 23:32:37 +08:00
ac75283b0f Update README.md 2023-11-13 23:31:34 +08:00
8d6160ec5b Update to 1.0.3 2023-11-13 20:48:23 +08:00
ecb16ae007 Update README.md 2023-11-13 20:38:42 +08:00
c296069a8d Update dependencies 2023-11-13 18:10:33 +08:00
d76442434f Fix #19 2023-11-13 18:10:33 +08:00
54e8484efd Update README.md 2023-11-13 13:47:30 +08:00
2cd10ad16d Remove --rmi 2023-11-13 13:38:24 +08:00
96a4f2fd0c Update README.md 2023-11-13 13:10:07 +08:00
700a24171b Add Badges (#13)
* Update README.md

* Update README.md

* Update README.md
2023-11-13 02:26:39 +08:00
6ce75a2df3 Update README.md for Podman 2023-11-13 02:04:10 +08:00
317c97650d Update to 1.0.2 2023-11-12 23:24:38 +08:00
9295583727 Fix #9 2023-11-12 23:09:31 +08:00
6dc998bedf Fix frontend version do not match 2023-11-12 16:47:05 +08:00
f5552b3344 Update README.md 2023-11-12 16:44:49 +08:00
b90fd35348 Merge pull request #5 from louislam/release-process
Release process
2023-11-12 16:43:30 +08:00
3dca9e735a Update to 1.0.1 2023-11-12 16:39:48 +08:00
200ba0ca07 Update 2023-11-12 16:39:21 +08:00
dd58a9cbc4 Update 2023-11-12 16:31:38 +08:00
7f41cc099c Update 2023-11-12 16:29:17 +08:00
4ce696181b Add release process 2023-11-12 16:27:02 +08:00
959dbba776 Set a title with hostname 2023-11-12 16:14:35 +08:00
ffa978eea1 feat: Using monospace fonts in editors (#4)
*  feat: Using monospace fonts in editors

Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev>

* Update README

---------

Signed-off-by: Muhammed Hussein Karimi <info@karimi.dev>
Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
2023-11-12 15:52:31 +08:00
9fd0c6416a Update README.md 2023-11-12 13:41:15 +08:00
e6fc623758 Update README.md 2023-11-12 12:51:36 +08:00
209dedf682 Update README.md 2023-11-12 02:38:20 +08:00
cf49a2ef2a Update README.md 2023-11-12 02:11:03 +08:00
c5d3b23af2 Update README.md 2023-11-12 01:55:11 +08:00
b12056aa83 Update README.md 2023-11-12 00:34:21 +08:00
6ad11277e0 Update README 2023-11-12 00:27:05 +08:00
ab48866ae6 Update Docker 2023-11-11 23:43:53 +08:00
d55d7c62a2 Fix 2023-11-11 23:43:25 +08:00
6749e343ba Init (#1) 2023-11-11 22:18:37 +08:00
135 changed files with 18850 additions and 0 deletions

17
.dockerignore Normal file
View 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
View 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
View 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",
},
};

View 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

View 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
View 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']

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

View 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
View 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
View 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
View 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
View 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, ARM64]
node: [18.17.1] # 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..

View 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 }}

View 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

View File

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

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Should update .dockerignore as well
.env
node_modules
.idea
data
stacks
tmp
/private
# Git only
frontend-dist

141
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,141 @@
## Can I create a pull request for Dockge?
Yes or no, it depends on what you will try to do. Since I don't want to waste your time, be sure to **create open a discussion, so we can have a discussion first**. Especially for a large pull request or you don't know if it will be merged or not.
Here are some references:
### ✅ Usually accepted:
- Bug fix
- Security fix
- Adding new language files (see [these instructions](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md))
- Adding new language keys: `$t("...")`
### ⚠️ Discussion required:
- Large pull requests
- New features
### ❌ Won't be merged:
- A dedicated PR for translating existing languages (see [these instructions](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md))
- Do not pass the auto-test
- Any breaking changes
- Duplicated pull requests
- Buggy
- UI/UX is not close to Dockge
- Modifications or deletions of existing logic without a valid reason.
- Adding functions that is completely out of scope
- Converting existing code into other programming languages
- Unnecessarily large code changes that are hard to review and cause conflicts with other PRs.
The above cases may not cover all possible situations.
I (@louislam) have the final say. If your pull request does not meet my expectations, I will reject it, no matter how much time you spend on it. Therefore, it is essential to have a discussion beforehand.
I will assign your pull request to a [milestone](https://github.com/louislam/dockge/milestones), if I plan to review and merge it.
Also, please don't rush or ask for an ETA, because I have to understand the pull request, make sure it is no breaking changes and stick to my vision of this project, especially for large pull requests.
## Project Styles
I personally do not like something that requires so many configurations before you can finally start the app.
- Settings should be configurable in the frontend. Environment variables are discouraged, unless it is related to startup such as `DOCKGE_STACKS_DIR`
- Easy to use
- The web UI styling should be consistent and nice
- No native build dependency
## Coding Styles
- 4 spaces indentation
- Follow `.editorconfig`
- Follow ESLint
- Methods and functions should be documented with JSDoc
## Name Conventions
- Javascript/Typescript: camelCaseType
- SQLite: snake_case (Underscore)
- CSS/SCSS: kebab-case (Dash)
## Tools
- [`Node.js`](https://nodejs.org/) >= 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
View 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.

164
README.md
View File

@ -1 +1,165 @@
<div align="center" width="100%">
<img src="./frontend/public/icon.svg" width="128" alt="" />
</div>
# Dockge
A fancy, easy-to-use and reactive self-hosted docker compose.yaml stack-oriented manager.
![GitHub Repo stars](https://img.shields.io/github/stars/louislam/dockge?logo=github) ![GitHub issues](https://img.shields.io/github/issues/louislam/dockge?logo=github) ![GitHub pull requests](https://img.shields.io/github/issues-pr/louislam/dockge?logo=github) ![Docker Pulls](https://img.shields.io/docker/pulls/louislam/dockge?logo=docker) ![Docker Image Version (latest semver)](https://img.shields.io/docker/v/louislam/dockge/latest?label=docker%20image%20ver.) ![GitHub last commit (branch)](https://img.shields.io/github/last-commit/louislam/dockge/master?logo=github) ![GitHub](https://img.shields.io/github/license/louislam/dockge?logo=github)
<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 />
![](https://github.com/louislam/dockge/assets/1336778/89fc1023-b069-42c0-a01c-918c495f1a6a)
## 🔧 How to Install
Requirements:
- [Docker](https://docs.docker.com/engine/install/) 20+ / Podman
- (Podman only) podman-docker (Debian: `apt install podman-docker`)
- OS:
- Major Linux distros that can run Docker/Podman such as:
- ✅ Ubuntu
- ✅ Debian (Bullseye or newer)
- ✅ Raspbian (Bullseye or newer)
- ✅ CentOS
- ✅ Fedora
- ✅ ArchLinux
- ❌ Debian/Raspbian Buster or lower is not supported
- ❌ Windows (Will be supported later)
- Arch: armv7, arm64, amd64 (a.k.a x86_64)
### Basic
- Default Stacks Directory: `/opt/stacks`
- Default Port: 5001
```
# Create directories that store your stacks and stores Dockge's stack
mkdir -p /opt/stacks /opt/dockge
cd /opt/dockge
# Download the compose.yaml
curl https://raw.githubusercontent.com/louislam/dockge/master/compose.yaml --output compose.yaml
# Start the server
docker compose up -d
# If you are using docker-compose V1 or Podman
# docker-compose up -d
```
Dockge is now running on http://localhost:5001
### Advanced
If you want to store your stacks in another directory, you can generate your compose.yaml file by using the following URL with custom query strings.
```
# Download your compose.yaml
curl "https://dockge.kuma.pet/compose.yaml?port=5001&stacksPath=/opt/stacks" --output compose.yaml
```
- port=`5001`
- stacksPath=`/opt/stacks`
Interactive compose.yaml generator is available on:
https://dockge.kuma.pet
## How to Update
```bash
cd /opt/dockge
docker compose pull && docker compose up -d
```
## Screenshots
![](https://github.com/louislam/dockge/assets/1336778/e7ff0222-af2e-405c-b533-4eab04791b40)
![](https://github.com/louislam/dockge/assets/1336778/7139e88c-77ed-4d45-96e3-00b66d36d871)
![](https://github.com/louislam/dockge/assets/1336778/f019944c-0e87-405b-a1b8-625b35de1eeb)
![](https://github.com/louislam/dockge/assets/1336778/a4478d23-b1c4-4991-8768-1a7cad3472e3)
## Motivations
- I have been using Portainer for some time, but for the stack management, I am sometimes not satisfied with it. For example, sometimes when I try to deploy a stack, the loading icon keeps spinning for a few minutes without progress. And sometimes error messages are not clear.
- Try to develop with ES Module + TypeScript (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 ⭐.
## 🗣️ Community and Contribution
### Bug Report
https://github.com/louislam/dockge/issues
### Ask for Help / Discussions
https://github.com/louislam/dockge/discussions
### Translation
If you want to translate Dockge into your language, please read [Translation Guide](https://github.com/louislam/dockge/blob/master/frontend/src/lang/README.md)
### Create a Pull Request
Be sure to read the [guide](https://github.com/louislam/dockge/blob/master/CONTRIBUTING.md), as we don't accept all types of pull requests and don't want to waste your time.
## FAQ
#### "Dockge"?
"Dockge" is a coinage word which is created by myself. I 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
#### Is Dockge a Portainer replcement?
Yes or no. Portainer provides a lot of Docker features. While Dockge is currently only focusing on docker-compose with a better user interface and better user experience.
If you want to manage your container with docker-compose only, the answer may be yes.
If you still need to manage something like docker networks, signle containers, the answer may be no.
#### Can I install both Dockge and Portainer?
Yes, you can.
## Others
Dockge is built on top of [Compose V2](https://docs.docker.com/compose/migrate/). `compose.yaml` also known as `docker-compose.yml`.

12
SECURITY.md Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
export class Docker {
}

571
backend/dockge-server.ts Normal file
View 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 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() {
// Create all the necessary directories
this.initDataDir();
// Connect to database
try {
await Database.init(this);
} catch (e) {
if (e instanceof Error) {
log.error("server", "Failed to prepare your database: " + e.message);
}
process.exit(1);
}
// First time setup if needed
let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
"jwtSecret",
]);
if (! jwtSecretBean) {
log.info("server", "JWT secret is not found, generate one.");
jwtSecretBean = await this.initJWTSecret();
log.info("server", "Stored JWT secret into database");
} else {
log.debug("server", "Load JWT secret from database.");
}
this.jwtSecret = jwtSecretBean.value;
const userCount = (await R.knex("user").count("id as count").first()).count;
log.debug("server", "User count: " + userCount);
// If there is no record in user table, it is a new Dockge instance, need to setup
if (userCount == 0) {
log.info("server", "No user, need setup");
this.needSetup = true;
}
// Listen
this.httpServer.listen(this.config.port, this.config.hostname, () => {
if (this.config.hostname) {
log.info( "server", `Listening on ${this.config.hostname}:${this.config.port}`);
} else {
log.info("server", `Listening on ${this.config.port}`);
}
// Run every 10 seconds
Cron("*/10 * * * * *", {
protect: true, // Enabled over-run protection.
}, () => {
//log.debug("server", "Cron job running");
this.sendStackList(true);
});
});
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
View 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
View 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();

View 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");
}

View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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;
}
}
}

View 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;
}

View File

@ -0,0 +1,288 @@
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, composeENV : unknown, isAdd : unknown, callback) => {
try {
checkLogin(socket);
const stack = this.saveStack(socket, server, name, composeYAML, composeENV, 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, composeENV : unknown, isAdd : unknown, callback) => {
try {
checkLogin(socket);
this.saveStack(socket, server, name, composeYAML, composeENV, 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, composeENV : 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(composeENV) !== "string") {
throw new ValidationError("Compose ENV must be a string");
}
if (typeof(isAdd) !== "boolean") {
throw new ValidationError("isAdd must be a boolean");
}
const stack = new Stack(server, name, composeYAML, composeENV, false);
stack.save(isAdd);
return stack;
}
}

View 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;
}
}

View 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) => {
});
}
}

445
backend/stack.ts Normal file
View File

@ -0,0 +1,445 @@
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 _composeENV?: string;
protected _configFilePath?: string;
protected _composeFileName: string = "compose.yaml";
protected server: DockgeServer;
protected combinedTerminal? : Terminal;
protected static managedStackList: Map<string, Stack> = new Map();
constructor(server : DockgeServer, name : string, composeYAML? : string, composeENV? : string, skipFSOperations = false) {
this.name = name;
this.server = server;
this._composeYAML = composeYAML;
this._composeENV = composeENV;
if (!skipFSOperations) {
// Check if compose file name is different from compose.yaml
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,
composeENV: this.composeENV,
};
}
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 composeENV() : string {
if (this._composeENV === undefined) {
try {
this._composeENV = fs.readFileSync(path.join(this.path, ".env"), "utf-8");
} catch (e) {
this._composeENV = "";
}
}
return this._composeENV;
}
get path() : string {
return path.join(this.server.stacksDir, this.name);
}
get fullPath() : string {
let dir = this.path;
// Compose up via node-pty
let fullPathDir;
// if dir is relative, make it absolute
if (!path.isAbsolute(dir)) {
fullPathDir = path.join(process.cwd(), dir);
} else {
fullPathDir = dir;
}
return fullPathDir;
}
/**
* Save the stack to the disk
* @param isAdd
*/
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);
// Write or overwrite the .env
fs.writeFileSync(path.join(dir, ".env"), this.composeENV);
}
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, 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
View 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
View 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-" + stackName + "-" + container + "-" + index;
}
export function copyYAMLComments(doc : Document, src : Document) {
doc.comment = src.comment;
doc.commentBefore = src.commentBefore;
if (doc && doc.contents && src && src.contents) {
// @ts-ignore
copyYAMLCommentsItems(doc.contents.items, src.contents.items);
}
}
/**
* Copy yaml comments from srcItems to items
* 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
View 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;
}

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

24
docker/Base.Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# Due to the bug of #145, Node.js's version cannot be changed, unless upstream is fixed.
FROM node:18.17.1-bookworm-slim
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
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

View File

@ -0,0 +1,10 @@
############################################
# Build in Golang
############################################
FROM golang:1.21.4-bookworm
WORKDIR /app
ARG TARGETPLATFORM
COPY ./extra/healthcheck.go ./extra/healthcheck.go
# Compile healthcheck.go
RUN go build -x -o ./extra/healthcheck ./extra/healthcheck.go

35
docker/Dockerfile Normal file
View File

@ -0,0 +1,35 @@
############################################
# Healthcheck Binary
############################################
FROM louislam/dockge:build-healthcheck AS build_healthcheck
############################################
# Build
############################################
FROM louislam/dockge:base AS build
WORKDIR /app
COPY --chown=node:node ./package.json ./package.json
COPY --chown=node:node ./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 --from=build_healthcheck /app/extra/healthcheck /app/extra/healthcheck
COPY --from=build /app/node_modules /app/node_modules
COPY --chown=node:node . .
RUN mkdir ./data
VOLUME /app/data
EXPOSE 5001
HEALTHCHECK --interval=60s --timeout=30s --start-period=60s --retries=5 CMD extra/healthcheck
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["tsx", "./backend/index.ts"]
############################################
# Mark as Nightly
############################################
FROM release AS nightly
RUN pnpm run mark-as-nightly

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

74
extra/healthcheck.go Normal file
View File

@ -0,0 +1,74 @@
/*
* If changed, have to run `npm run build-docker-builder-go`.
* This script should be run after a period of time (180s), because the server may need some time to prepare.
*/
package main
import (
"crypto/tls"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"time"
)
func main() {
// Is K8S + "dockge" as the container name
// See https://github.com/louislam/uptime-kuma/pull/2083
isK8s := strings.HasPrefix(os.Getenv("DOCKGE_PORT"), "tcp://")
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
client := http.Client{
Timeout: 28 * time.Second,
}
sslKey := os.Getenv("DOCKGE_SSL_KEY")
sslCert := os.Getenv("DOCKGE_SSL_CERT")
hostname := os.Getenv("DOCKGE_HOST")
if len(hostname) == 0 {
hostname = "127.0.0.1"
}
port := ""
// DOCKGE_PORT is override by K8S unexpectedly,
if !isK8s {
port = os.Getenv("DOCKGE_PORT")
}
if len(port) == 0 {
port = "5001"
}
protocol := ""
if len(sslKey) != 0 && len(sslCert) != 0 {
protocol = "https"
} else {
protocol = "http"
}
url := protocol + "://" + hostname + ":" + port
log.Println("Checking " + url)
resp, err := client.Get(url)
if err != nil {
log.Fatalln(err)
}
defer resp.Body.Close()
_, err = ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatalln(err)
}
log.Printf("Health Check OK [Res Code: %d]\n", resp.StatusCode)
}

22
extra/mark-as-nightly.ts Normal file
View 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));
}
}

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

84
extra/reset-password.ts Normal file
View File

@ -0,0 +1,84 @@
import { Database } from "../backend/database";
import { R } from "redbean-node";
import readline from "readline";
import { User } from "../backend/models/user";
import { DockgeServer } from "../backend/dockge-server";
import { log } from "../backend/log";
console.log("== Dockge Reset Password Tool ==");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
export const main = async () => {
const server = new DockgeServer();
// Check if
console.log("Connecting the database");
try {
await Database.init(server);
} catch (e) {
if (e instanceof Error) {
log.error("server", "Failed to connect to your database: " + e.message);
}
process.exit(1);
}
try {
// No need to actually reset the password for testing, just make sure no connection problem. It is ok for now.
if (!process.env.TEST_BACKEND) {
const user = await R.findOne("user");
if (! user) {
throw new Error("user not found, have you installed?");
}
console.log("Found user: " + user.username);
while (true) {
let password = await question("New Password: ");
let confirmPassword = await question("Confirm New Password: ");
if (password === confirmPassword) {
await User.resetPassword(user.id, password);
// Reset all sessions by reset jwt secret
await server.initJWTSecret();
break;
} else {
console.log("Passwords do not match, please try again.");
}
}
console.log("Password reset successfully.");
}
} catch (e) {
if (e instanceof Error) {
console.error("Error: " + e.message);
}
}
await Database.close();
rl.close();
console.log("Finished.");
};
/**
* Ask question of user
* @param question Question to ask
* @returns Users response
*/
function question(question : string) : Promise<string> {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer);
});
});
}
if (!process.env.TEST_BACKEND) {
main();
}

View File

@ -0,0 +1,9 @@
version: "3.8"
services:
mariadb:
image: mariadb:latest
restart: unless-stopped
ports:
- 3306:3306
environment:
- MARIADB_ROOT_PASSWORD=123456

View 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

14
frontend/public/icon.svg Normal file
View 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

View 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
View File

@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script>
export default {
};
</script>

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

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

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

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

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

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

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

View File

@ -0,0 +1,446 @@
<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) => {
// sort by managed by dockge
if (m1.isManagedByDockge && !m2.isManagedByDockge) {
return -1;
} else if (!m1.isManagedByDockge && m2.isManagedByDockge) {
return 1;
}
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>

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

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

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

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

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

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

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

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

59
frontend/src/i18n.ts Normal file
View File

@ -0,0 +1,59 @@
// @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": "Українська",
"da": "Dansk",
"ja": "日本語",
"nl": "Nederlands",
"ro": "Română",
};
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
View 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 };

View File

@ -0,0 +1,19 @@
# Translations
A simple guide on how to translate `Dockge` in your native language.
## How to Translate
(11-26-2023) Updated
1. Go to <https://weblate.kuma.pet>
2. Register an account on Weblate
3. Make sure your GitHub email is matched with Weblate's account, so that it could show you as a contributor on GitHub
4. Choose your language on Weblate and start translating.
## How to add a new language in the dropdown
1. Add your Language at <https://weblate.kuma.pet/projects/dockge/dockge/>.
2. Find the language code (You can find it at the end of the URL)
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.

102
frontend/src/lang/ar.json Normal file
View File

@ -0,0 +1,102 @@
{
"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": "لا توجد شبكات خارجية",
"reverseProxyMsg2": "تحقق كيف يتم إعداده لمقبس ويب",
"Cannot connect to the socket server.": "تعذر الاتصال بخادم المقبس.",
"reconnecting...": "إعادة الاتصال…",
"url": "رابط | روابط",
"extra": "إضافات",
"reverseProxyMsg1": "هل تستدخم خادم عكسي؟",
"connecting...": "جاري الاتصال بخادم المقبس…"
}

View 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": "Не са налични външни мрежи"
}

View 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ě"
}

102
frontend/src/lang/da.json Normal file
View File

@ -0,0 +1,102 @@
{
"languageName": "Dansk",
"authIncorrectCreds": "Forkert brugernavn eller adgangskode.",
"PasswordsDoNotMatch": "Adgangskode stemmer ikke overens.",
"Repeat Password": "Gentag adgangskode",
"Create": "Opret",
"signedInDisp": "Logget ind som {0}",
"signedInDispDisabled": "Auth Deaktiveret.",
"home": "Hjem",
"console": "Konsol",
"registry": "Registry",
"compose": "Compose",
"stackName": "Stack-navn",
"deployStack": "Udrulle",
"deleteStack": "Slet",
"stopStack": "Stop",
"restartStack": "Genstart",
"updateStack": "Opdatere",
"startStack": "Start",
"downStack": "Stop & Sluk",
"editStack": "Editere",
"discardStack": "Annuller",
"saveStackDraft": "Gem",
"notAvailableShort": "Ugyldig",
"stackNotManagedByDockgeMsg": "Denne stack administreres ikke af Dockge.",
"primaryHostname": "Primært værtsnavn",
"general": "Generelt",
"container": "Container | Containere",
"scanFolder": "Scan Stack-mappe",
"dockerImage": "Billede",
"restartPolicyUnlessStopped": "Medmindre stoppet",
"restartPolicyAlways": "Altid",
"restartPolicyOnFailure": "Ved fejl",
"restartPolicyNo": "Nej",
"restartPolicy": "Genstart politik",
"containerName": "Container navn",
"port": "Port | Porte",
"volume": "Volumen | Voluminer",
"network": "Netværk | Netværker",
"dependsOn": "Container Dependency | Container Dependencies",
"addListItem": "Tilføj {0}",
"deleteContainer": "Slet",
"addNetwork": "Tilføj Netværk",
"passwordNotMatchMsg": "Koden du gentog stemmer ikke overens.",
"autoGet": "Auto Get",
"add": "Tilføj",
"Edit": "Redigere",
"applyToYAML": "Anvend til YAML",
"createExternalNetwork": "Skabe",
"addInternalNetwork": "Tilføj",
"Save": "Gem",
"Language": "Sprog",
"Current User": "Nuværende bruger",
"Change Password": "Ændre adgangskode",
"Current Password": "Nuværende adgangskode",
"New Password": "Ny adgangskode",
"Repeat New Password": "Gentag ny adgangskode",
"Update Password": "Opdater adgangskode",
"Advanced": "Avanceret",
"Please use this option carefully!": "Brug venligst denne indstilling forsigtigt!",
"Enable Auth": "Aktiver Auth",
"Disable Auth": "Deaktiver Auth",
"I understand, please disable": "Jeg forstår, venligst deaktiver",
"Leave": "Forlad",
"Frontend Version": "Frontend Version",
"Check Update On GitHub": "Tjek opdatering på GitHub",
"Also check beta release": "Tjek også betaversionen",
"Remember me": "Husk mig",
"Login": "Login",
"Username": "Brugernavn",
"Password": "Adgangskode",
"Settings": "Indstillinger",
"Logout": "Log ud",
"Convert to Compose": "Konverter til Compose",
"active": "aktive",
"exited": "forladt",
"inactive": "inaktive",
"Appearance": "Udseende",
"Security": "Sikkerhed",
"Docker Run": "Docker Kør",
"About": "Om",
"Allowed commands:": "Tilladte kommandoer:",
"Internal Networks": "Interne netværk",
"External Networks": "Eksterne netværk",
"No External Networks": "Ingen eksterne netværk",
"reverseProxyMsg1": "Bruger du en Reverse-Proxy?",
"reverseProxyMsg2": "Tjek, hvordan du konfigurerer det til WebSocket",
"Cannot connect to the socket server.": "Kan ikke oprette forbindelse til socket-serveren.",
"reconnecting...": "Genopretter forbindelse…",
"connecting...": "Opretter forbindelse til socket-serveren…",
"url": "URL | URL'er",
"extra": "Ekstra",
"Create your admin account": "Opret din administratorkonto",
"addFirstStackMsg": "Compose din første stack!",
"deleteStackMsg": "Er du sikker på, at du vil slette denne stack?",
"environmentVariable": "Miljøvariabel | miljøvariabler",
"addContainer": "Tilføj Container",
"disableauth.message1": "Er du sikker på, at du vil <strong>deaktivere godkendelse</strong>?",
"disableauth.message2": "Det er designet til scenarier <strong>hvor du har til hensigt at implementere tredjepartsgodkendelse</strong> foran Dockge såsom Cloudflare Access, Authelia eller andre godkendelsesmekanismer.",
"Show update if available": "Vis opdatering, hvis tilgængelig",
"Lowercase only": "Kun små bogstaver"
}

102
frontend/src/lang/de.json Normal file
View File

@ -0,0 +1,102 @@
{
"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": "Anmeldung deaktiviert.",
"home": "Startseite",
"console": "Konsole",
"registry": "Container Registry",
"compose": "",
"addFirstStackMsg": "Stelle deinen ersten Stack zusammen!",
"stackName": "Stack-Name",
"deployStack": "Deployen",
"deleteStack": "Löschen",
"stopStack": "Anhalten",
"restartStack": "Neustarten",
"updateStack": "Aktualisieren",
"startStack": "Starten",
"editStack": "Bearbeiten",
"discardStack": "Verwerfen",
"saveStackDraft": "Speichern",
"notAvailableShort": "N/V",
"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",
"scanFolder": "Stacks-Ordner durchsuchen",
"dockerImage": "Image",
"restartPolicyUnlessStopped": "Falls nicht gestoppt",
"restartPolicyAlways": "Immer",
"restartPolicyOnFailure": "Bei Fehler",
"restartPolicyNo": "Kein Neustart",
"environmentVariable": "Umgebungsvariable/n",
"restartPolicy": "Neustart Richtlinie",
"containerName": "Container-Name",
"port": "Port / Ports",
"volume": "Volume / Volumes",
"network": "Netzwerk | Netzwerke",
"dependsOn": "Container-Abhängigkeit/en",
"addListItem": "{0} hinzufügen",
"deleteContainer": "Löschen",
"addContainer": "Container hinzufügen",
"addNetwork": "Netzwerk hinzufügen",
"disableauth.message1": "Bist du sicher, dass du die <strong>Anmeldung 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 laden",
"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": "Anmeldung aktivieren",
"Disable Auth": "Anmeldung 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",
"Cannot connect to the socket server.": "Keine Verbindung zum Socket Server.",
"reverseProxyMsg1": "Wird ein Reverse Proxy genutzt?",
"reconnecting...": "Erneuter Verbindungsaufbau…",
"downStack": "Stoppen & Aus",
"extra": "Extra",
"url": "URL / URLs",
"reverseProxyMsg2": "Lerne wie dieser für WebSockets zu konfigurieren ist.",
"connecting...": "Verbindungsaufbau zum Socket Server…"
}

102
frontend/src/lang/en.json Normal file
View 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
View 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"
}

102
frontend/src/lang/fr.json Normal file
View File

@ -0,0 +1,102 @@
{
"languageName": "Français",
"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éral",
"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 au 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",
"reverseProxyMsg2": "Vérifier comment le configurer pour WebSocket",
"connecting...": "Connexion au serveur socket…",
"url": "URL | URLs",
"extra": "Supplémentaire",
"downStack": "Arrêter et désactiver",
"reverseProxyMsg1": "Utilisez vous un proxy inverse ?",
"Cannot connect to the socket server.": "Impossible de se connecter au serveur socket.",
"reconnecting...": "Reconnexion…"
}

View 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"
}

98
frontend/src/lang/ja.json Normal file
View File

@ -0,0 +1,98 @@
{
"authIncorrectCreds": "ユーザーネームまたはパスワードが正しくありません。",
"PasswordsDoNotMatch": "パスワードが一致しません。",
"Repeat Password": "パスワードを再度入力してください",
"Create": "作成",
"signedInDispDisabled": "認証が無効化されています。",
"home": "ホーム",
"console": "コンソール",
"registry": "レジストリ",
"stackName": "スタック名",
"deployStack": "デプロイ",
"deleteStack": "削除",
"stopStack": "停止",
"restartStack": "再起動",
"updateStack": "更新",
"startStack": "起動",
"editStack": "編集",
"discardStack": "破棄",
"saveStackDraft": "保存",
"stackNotManagedByDockgeMsg": "このスタックはDockageによって管理されていません。",
"general": "一般",
"scanFolder": "スタックフォルダをスキャン",
"dockerImage": "イメージ",
"environmentVariable": "環境変数",
"restartPolicy": "再起動ポリシー",
"containerName": "コンテナ名",
"port": "ポート",
"volume": "ボリューム",
"network": "ネットワーク",
"addListItem": "{0} を追加",
"addContainer": "コンテナを追加",
"addNetwork": "ネットワークを追加",
"compose": "Compose",
"primaryHostname": "主ホスト名",
"container": "コンテナ",
"dependsOn": "コンテナ依存関係",
"downStack": "停止して削除",
"notAvailableShort": "N/A",
"restartPolicyUnlessStopped": "手動で停止されるまで",
"restartPolicyAlways": "常時",
"restartPolicyOnFailure": "失敗時",
"restartPolicyNo": "しない",
"passwordNotMatchMsg": "繰り返しのパスワードが一致しません。",
"autoGet": "自動取得",
"add": "追加",
"Edit": "編集",
"applyToYAML": "YAMLに適用",
"createExternalNetwork": "作成",
"addInternalNetwork": "追加",
"Save": "保存",
"Language": "言語",
"Change Password": "パスワードを変更する",
"Current Password": "現在のパスワード",
"New Password": "新しいパスワード",
"Update Password": "パスワードを更新",
"Advanced": "高度",
"Please use this option carefully!": "このオプションは注意して使用してください!",
"Enable Auth": "認証を有効化",
"Disable Auth": "認証を無効化",
"Check Update On GitHub": "GitHubで更新を確認",
"Show update if available": "アップデートがある場合表示",
"Also check beta release": "ベータ版のリリースも確認する",
"Login": "ログイン",
"Username": "ユーザー名",
"Password": "パスワード",
"Settings": "設定",
"Logout": "ログアウト",
"Convert to Compose": "Composeに変換",
"Appearance": "外観",
"Security": "セキュリティ",
"Allowed commands:": "許可されたコマンド:",
"Internal Networks": "内部ネットワーク",
"External Networks": "外部ネットワーク",
"reverseProxyMsg2": "WebSocketの設定方法を確認",
"Cannot connect to the socket server.": "ソケットサーバーに接続できません。",
"reconnecting...": "再接続中…",
"Leave": "やめる",
"Frontend Version": "フロントエンドバージョン",
"Remember me": "覚えておく",
"No External Networks": "外部ネットワークなし",
"exited": "終了済み",
"inactive": "非アクティブ",
"active": "アクティブ",
"languageName": "日本語",
"Create your admin account": "管理者アカウントを作成してください",
"signedInDisp": "{0} としてログイン中",
"addFirstStackMsg": "最初のスタックを組み立てましょう!",
"deleteStackMsg": "本当にこのスタックを削除しますか?",
"deleteContainer": "削除",
"disableauth.message1": "本当に<strong>認証を無効化</strong>しますか?",
"disableauth.message2": "これはCloudflare AccessやAutheliaなどの認証手段をDockgeの前段に置いて<strong>サードパーティー認証を実装することをあなたが意図している</strong>場合のために設計されています。",
"Current User": "現在のユーザー",
"Repeat New Password": "新しいパスワードを繰り返してください",
"I understand, please disable": "理解しました。無効化してください",
"Lowercase only": "小文字のみ",
"reverseProxyMsg1": "リバースプロキシを使用していますか?",
"connecting...": "ソケットサーバーに接続中…"
}

View 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": "외부 네트워크 없음"
}

102
frontend/src/lang/nl.json Normal file
View File

@ -0,0 +1,102 @@
{
"languageName": "Nederlands",
"authIncorrectCreds": "Onjuiste gebruikersnaam of wachtwoord.",
"PasswordsDoNotMatch": "Paswoorden komen niet overeen.",
"Repeat Password": "Herhaal wachtwoord",
"Create": "Aanmaken",
"signedInDisp": "Ingelogd als {0}",
"home": "Startpagina",
"console": "Console",
"registry": "Register",
"compose": "Samenstellen",
"stackName": "Stack naam",
"deployStack": "Opzetten",
"deleteStack": "Verwijder",
"stopStack": "Stop",
"restartStack": "Herstart",
"updateStack": "Update",
"startStack": "Start",
"downStack": "Stop & Down",
"editStack": "Bewerken",
"discardStack": "Verwijderen",
"saveStackDraft": "Opslaan",
"notAvailableShort": "NVT",
"stackNotManagedByDockgeMsg": "Deze stack wordt niet beheerd door Dockge.",
"primaryHostname": "Primaire hostnaam",
"general": "Algemeen",
"scanFolder": "Scan stacks folder",
"dockerImage": "Image",
"restartPolicyUnlessStopped": "Tenzij gestopt",
"restartPolicyAlways": "Altijd",
"restartPolicyOnFailure": "Bij fout",
"restartPolicyNo": "Neen",
"environmentVariable": "Omgevings variabele(n)",
"restartPolicy": "Herstart policy",
"containerName": "Containernaam",
"port": "Poort(en)",
"volume": "Volume(s)",
"network": "Netwerk(en)",
"addListItem": "Voeg {0} toe",
"deleteContainer": "Verwijder",
"addContainer": "Container toevoegen",
"addNetwork": "Netwerk toevoegen",
"signedInDispDisabled": "Aanmelden uitgeschakeld.",
"container": "Container(s)",
"autoGet": "Auto ophalen",
"add": "Toevoegen",
"Edit": "Bewerken",
"applyToYAML": "Toevoegen aan YAML",
"createExternalNetwork": "Aanmaken",
"addInternalNetwork": "Toevoegen",
"Save": "Opslaan",
"Language": "Taal",
"Change Password": "Verander wachtwoord",
"Current Password": "Huidig wachtwoord",
"New Password": "Nieuw wachtwoord",
"Repeat New Password": "Herhaal nieuw wachtwoord",
"Update Password": "Update wachtwoord",
"Advanced": "Geavanceerd",
"I understand, please disable": "Begrepen, dit uitschakelen",
"Disable Auth": "Aanmelden uitschakelen",
"Enable Auth": "Aanmelden inschakelen",
"Leave": "Afmelden",
"Frontend Version": "Frontend versie",
"Check Update On GitHub": "Controleer via GitHub op updates",
"Show update if available": "Toon update indien beschikbaar",
"Remember me": "Onthoud mij",
"Login": "Inloggen",
"Username": "Gebruikersnaam",
"Password": "Wachtwoord",
"Settings": "Instellingen",
"Logout": "Uitloggen",
"Lowercase only": "Geen hoofdletters",
"Docker Run": "Docker run",
"active": "actief",
"exited": "gestopt",
"inactive": "inactief",
"Appearance": "Uiterlijk",
"Security": "Beveiliging",
"About": "Over",
"Allowed commands:": "Toegelaten commando's:",
"Internal Networks": "Interne netwerken",
"No External Networks": "Geen externe netwerken",
"reverseProxyMsg1": "Reverse proxy in gebruik?",
"reverseProxyMsg2": "Controleer hoe te configureren voor WebSocket",
"Cannot connect to the socket server.": "Kan geen verbinding maken met de socket server.",
"reconnecting...": "Herverbinden...",
"connecting...": "Verbinden met de socket server...",
"url": "Url(s)",
"extra": "Extra",
"Create your admin account": "Creëer je beheerders-account",
"addFirstStackMsg": "Maak je eerste stack!",
"deleteStackMsg": "Zeker dat je deze stack wilt verwijderen?",
"dependsOn": "Container afhankelijkheid | afhankelijkheden",
"disableauth.message1": "Zeker dat u <strong>aanmelden</strong> wilt uitschakelen?",
"disableauth.message2": "Dit is enkel bedoeld om te gebruiken wanneer je<strong> third-party autorisatie wilt gebruiken voor Dockge</strong>, zoals Cloudflare Acces, Authelia, ...",
"passwordNotMatchMsg": "De wachtwoorden komen niet overeen.",
"Current User": "Huidige gebruiker",
"Please use this option carefully!": "Wees voorzichtig met deze optie!",
"Also check beta release": "Controleer ook op beta releases",
"Convert to Compose": "Converteer naar compose",
"External Networks": "Externe netwerken"
}

View 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"
}

Some files were not shown because too many files have changed in this diff Show More