This commit is contained in:
Louis Lam 2023-11-11 22:18:37 +08:00 committed by GitHub
parent 205bff2359
commit 6749e343ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 14443 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

97
.eslintrc.cjs Normal file
View File

@ -0,0 +1,97 @@
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",
"prefer-const" : "off",
},
};

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

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

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.

130
README.md
View File

@ -1 +1,131 @@
<div align="center" width="100%">
<img src="./frontend/public/icon.svg" width="128" alt="" />
</div>
# Dockge
A fancy, easy-to-use and reactive docker `compose.yaml` stack-oriented manager.
<img src="https://github.com/louislam/dockge/assets/1336778/26a583e1-ecb1-4a8d-aedf-76157d714ad7" width="900" alt="" />
## ⭐ Features
- Manage `compose.yaml`
- 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 too
- Convert `docker run ...` commands into `compose.yaml`
## 🔧 How to Install
Requirements:
- [Docker CE](https://docs.docker.com/engine/install/) 20+ is recommended
- [Docker Compose V2](https://docs.docker.com/compose/install/linux/)
- OS:
- As long as you can run Docker CE, it should be fine, but:
- Debian/Raspbian Buster or lower is not supported, please upgrade to Bullseye
- Arch: armv7, arm64, amd64 (a.k.a x86_64)
### Basic
Default stacks directory is `/opt/stacks`.
```
# Create a directory that stores your stacks and stores dockge's compose.yaml
mkdir -p /opt/stacks /opt/dockge
cd /opt/dockge
# Download the compose.yaml
curl https://raw.githubusercontent.com/louislam/dockge/master/compose.yaml --output compose.yaml
# Start Server
docker compose up -d
# If you are using docker-compose V1
# docker-compose up -d
```
### Advanced
If you want to store your stacks in another directory, you can change the `DOCKGE_STACKS_DIR` environment variable and volumes.
For exmaples, if you want to store your stacks in `/my-stacks`:
```yaml
version: "3.8"
services:
dockge:
image: louislam/dockge:1
restart: unless-stopped
ports:
- 5001:5001
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/app/data
# Your stacks directory in the host
# (The paths inside container must be the same as the host)
- /my-stacks:/my-stacks
environment:
# Tell Dockge where is your stacks directory
- DOCKGE_STACKS_DIR=/my-stacks
```
## How to Update
```bash
cd /opt/stacks
docker compose pull
docker compose up -d
```
## 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 do not support for arm64, so I stepped back to Node.js)
If you love this project, please consider giving this project a ⭐.
## FAQ
#### "Dockge"?
"Dockge" is a coinage word which is created by myself. I hope it sounds like `Badge` but replacing with `Dock` - `Dock-ge`.
The naming idea was coming from Twitch emotes like `sadge`, `bedge` or `wokege`. They are all ending with `-ge`.
If you are not comfortable with the pronunciation, you can call it `Dockage`
#### Can I manage a single container without `compose.yaml`?
The main objective of Dockge is that try to use docker `compose.yaml` for everything. If you want to manage a single container, you can just use Portainer or Docker CLI.
#### Can I manage existing stacks?
Yes, you can. However, you need to move your compose file into the stacks directory:
1. Stop your stack
2. Move your compose file into `/opt/stacks/<stackName>/compose.yaml`
3. In Dockge, click the " Scan Stacks Folder" button in the top-right corner's dropdown menu
4. Now you should see your stack in the list
## More Ideas?
- Stats
- File manager
- App store for yaml templates
- Get app icons
- Switch Docker context
- Support Dockerfile and build
- Support Docker swarm
# Others
Dockge is built on top of [Compose V2](https://docs.docker.com/compose/migrate/). `compose.yaml` is also known as `docker-compose.yml`.

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

247
backend/database.ts Normal file
View File

@ -0,0 +1,247 @@
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";
import Dialect from "knex/lib/dialects/sqlite3/index.js";
import sqlite from "@louislam/sqlite3";
import { sleep } from "./util-common";
interface DBConfig {
type?: "sqlite" | "mysql";
}
export class Database {
/**
* SQLite file path (Default: ./data/dockge.db)
* @type {string}
*/
static sqlitePath;
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() {
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 {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} dbConfig the database configuration that should be written
* @returns {void}
*/
static writeDBConfig(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, noLog = false) {
const acquireConnectionTimeout = 120 * 1000;
let dbConfig;
try {
dbConfig = this.readDBConfig();
Database.dbConfig = dbConfig;
} catch (err) {
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) {
// 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 {
}

559
backend/dockge-server.ts Normal file
View File

@ -0,0 +1,559 @@
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";
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 || parseInt(process.env.DOCKGE_PORT) || 5001;
this.config.hostname = args.hostname || process.env.DOCKGE_HOSTNAME || undefined;
this.config.dataDir = args.dataDir || process.env.DOCKGE_DATA_DIR || "./data/";
this.config.stacksDir = args.stacksDir || process.env.DOCKGE_STACKS_DIR || defaultStacksDir;
this.stacksDir = this.config.stacksDir;
log.debug("server", this.config);
this.packageJSON = packageJSON as PackageJson;
try {
this.indexHTML = fs.readFileSync("./frontend-dist/index.html").toString();
} catch (e) {
// "dist/index.html" is not necessary for development
if (process.env.NODE_ENV !== "development") {
log.error("server", "Error: Cannot find 'frontend-dist/index.html', did you install correctly?");
process.exit(1);
}
}
// Create all the necessary directories
this.initDataDir();
// Create express
this.app = express();
// Create HTTP server
if (this.config.sslKey && this.config.sslCert) {
log.info("server", "Server Type: HTTPS");
this.httpServer = https.createServer({
key: fs.readFileSync(this.config.sslKey),
cert: fs.readFileSync(this.config.sslCert),
passphrase: this.config.sslKeyPassphrase,
}, this.app);
} else {
log.info("server", "Server Type: HTTP");
this.httpServer = http.createServer(this.app);
}
// Binding Routers
for (const router of this.routerList) {
this.app.use(router.create(this.app, this));
}
// Static files
this.app.use("/", expressStaticGzip("frontend-dist", {
enableBrotli: true,
}));
// Universal Route Handler, must be at the end of all express routes.
this.app.get("*", async (_request, response) => {
response.send(this.indexHTML);
});
// Allow all CORS origins in development
let cors = undefined;
if (isDev) {
cors = {
origin: "*",
};
}
// Create Socket.io
this.io = new socketIO.Server(this.httpServer, {
cors,
});
this.io.on("connection", async (socket: Socket) => {
log.info("server", "Socket connected!");
this.sendInfo(socket, true);
if (this.needSetup) {
log.info("server", "Redirect to setup page");
socket.emit("setup");
}
// Create socket handlers
for (const socketHandler of this.socketHandlerList) {
socketHandler.create(socket as DockgeSocket, this);
}
// ***************************
// Better do anything after added all socket handlers here
// ***************************
log.debug("auth", "check auto login");
if (await Settings.get("disableAuth")) {
log.info("auth", "Disabled Auth: auto login to admin");
this.afterLogin(socket as DockgeSocket, await R.findOne("user"));
socket.emit("autoLogin");
} else {
log.debug("auth", "need auth");
}
});
this.io.on("disconnect", () => {
});
}
async afterLogin(socket : DockgeSocket, user : User) {
socket.userID = user.id;
socket.join(user.id.toString());
this.sendInfo(socket);
try {
this.sendStackList();
} catch (e) {
log.error("server", e);
}
}
/**
*
*/
async serve() {
// Connect to database
try {
await Database.init(this);
} catch (e) {
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(5001, 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 5 seconds
const job = Cron("*/2 * * * * *", {
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) {
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) {
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();

208
backend/log.ts Normal file
View File

@ -0,0 +1,208 @@
// 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 (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, hash) {
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, len) {
if (!data) {
return "";
}
return crypto.createHash("shake256", { outputLength: len })
.update(data)
.digest("hex");
}

75
backend/rate-limiter.ts Normal file
View File

@ -0,0 +1,75 @@
// "limit" is bugged in Typescript, use "limiter-es6-compat" instead
// See https://github.com/jhurliman/node-rate-limiter/issues/80
import { RateLimiter } from "limiter-es6-compat";
import { log } from "./log";
class KumaRateLimiter {
errorMessage : string;
rateLimiter : RateLimiter;
/**
* @param {object} config Rate limiter configuration object
*/
constructor(config) {
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 {passCB} 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, 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 "../dockgeServer";
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";
export class Settings {
/**
* Example:
* {
* key1: {
* value: "value2",
* timestamp: 12345678
* },
* key2: {
* value: 2,
* timestamp: 12345678
* },
* }
* @type {{}}
*/
static cacheList = {
};
static cacheCleaner = null;
/**
* Retrieve value of setting based on key
* @param {string} key Key of setting to retrieve
* @returns {Promise<any>} Value
*/
static async get(key) {
// 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 {string} key Key of setting to set
* @param {any} value Value to set to
* @param {?string} type Type of setting
* @returns {Promise<void>}
*/
static async set(key, value, type = 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 {string} type The type of setting
* @returns {Promise<Bean>} Settings
*/
static async getSettings(type) {
const list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [
type,
]);
const result = {};
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 {string} type Type of settings to set
* @param {object} data Values of settings
* @returns {Promise<void>}
*/
static async setSettings(type, data) {
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) {
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 = null;
}
}
}

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,262 @@
import { SocketHandler } from "../socket-handler.js";
import { DockgeServer } from "../dockge-server";
import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server";
import { Stack } from "../stack";
// @ts-ignore
import composerize from "composerize";
export class DockerSocketHandler extends SocketHandler {
create(socket : DockgeSocket, server : DockgeServer) {
socket.on("deployStack", async (name : unknown, composeYAML : unknown, isAdd : unknown, callback) => {
try {
checkLogin(socket);
const stack = this.saveStack(socket, server, name, composeYAML, isAdd);
await stack.deploy(socket);
server.sendStackList();
callback({
ok: true,
msg: "Deployed",
});
stack.joinCombinedTerminal(socket);
} catch (e) {
callbackError(e, callback);
}
});
socket.on("saveStack", async (name : unknown, composeYAML : unknown, isAdd : unknown, callback) => {
try {
checkLogin(socket);
this.saveStack(socket, server, name, composeYAML, isAdd);
callback({
ok: true,
"msg": "Saved"
});
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
socket.on("deleteStack", async (name : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(name) !== "string") {
throw new ValidationError("Name must be a string");
}
const stack = Stack.getStack(server, name);
try {
await stack.delete(socket);
} catch (e) {
server.sendStackList();
throw e;
}
server.sendStackList();
callback({
ok: true,
msg: "Deleted"
});
} catch (e) {
callbackError(e, callback);
}
});
socket.on("getStack", (stackName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string");
}
const stack = Stack.getStack(server, stackName);
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);
}
});
// 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);
const serviceStatusList = Object.fromEntries(await stack.getServiceStatusList());
callback({
ok: true,
serviceStatusList,
});
} catch (e) {
callbackError(e, callback);
}
});
// getExternalNetworkList
socket.on("getDockerNetworkList", async (callback) => {
try {
checkLogin(socket);
const dockerNetworkList = server.getDockerNetworkList();
callback({
ok: true,
dockerNetworkList,
});
} catch (e) {
callbackError(e, callback);
}
});
// composerize
socket.on("composerize", async (dockerRunCommand : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(dockerRunCommand) !== "string") {
throw new ValidationError("dockerRunCommand must be a string");
}
const composeTemplate = composerize(dockerRunCommand);
callback({
ok: true,
composeTemplate,
});
} catch (e) {
callbackError(e, callback);
}
});
}
saveStack(socket : DockgeSocket, server : DockgeServer, name : unknown, composeYAML : unknown, isAdd : unknown) : Stack {
// Check types
if (typeof(name) !== "string") {
throw new ValidationError("Name must be a string");
}
if (typeof(composeYAML) !== "string") {
throw new ValidationError("Compose YAML must be a string");
}
if (typeof(isAdd) !== "boolean") {
throw new ValidationError("isAdd must be a boolean");
}
const stack = new Stack(server, name, composeYAML);
stack.save(isAdd);
return stack;
}
}

View File

@ -0,0 +1,295 @@
import { SocketHandler } from "../socket-handler.js";
import { Socket } from "socket.io";
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 } 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) {
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);
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) {
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) {
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) {
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) {
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) {
callback({
ok: false,
msg: e.message,
});
}
});
}
async login(username : string, password : string) {
if (typeof username !== "string" || typeof password !== "string") {
return null;
}
const user = await R.findOne("user", " username = ? AND active = 1 ", [
username,
]);
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,151 @@
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) {
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);
}
});
// Close Terminal
socket.on("terminalClose", async (terminalName : unknown, callback : unknown) => {
});
// TODO: Resize Terminal
socket.on("terminalResize", async (rows : unknown) => {
});
}
}

356
backend/stack.ts Normal file
View File

@ -0,0 +1,356 @@
import { DockgeServer } from "./dockge-server";
import fs from "fs";
import { log } from "./log";
import yaml from "yaml";
import { DockgeSocket, ValidationError } from "./util-server";
import path from "path";
import {
COMBINED_TERMINAL_COLS,
COMBINED_TERMINAL_ROWS,
CREATED_FILE,
CREATED_STACK,
EXITED, getCombinedTerminalName,
getComposeTerminalName, getContainerExecTerminalName,
PROGRESS_TERMINAL_ROWS,
RUNNING, TERMINAL_ROWS,
UNKNOWN
} from "./util-common";
import { InteractiveTerminal, Terminal } from "./terminal";
import childProcess from "child_process";
export class Stack {
name: string;
protected _status: number = UNKNOWN;
protected _composeYAML?: string;
protected _configFilePath?: string;
protected server: DockgeServer;
protected combinedTerminal? : Terminal;
protected static managedStackList: Map<string, Stack> = new Map();
constructor(server : DockgeServer, name : string, composeYAML? : string) {
this.name = name;
this.server = server;
this._composeYAML = composeYAML;
}
toJSON() : object {
let obj = this.toSimpleJSON();
return {
...obj,
composeYAML: this.composeYAML,
};
}
toSimpleJSON() : object {
return {
name: this.name,
status: this._status,
tags: [],
isManagedByDockge: this.isManagedByDockge,
};
}
/**
* 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][A-Z][0-9] _ - only
if (!this.name.match(/^[a-zA-Z0-9_-]+$/)) {
throw new ValidationError("Stack name can only contain [a-z][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, "compose.yaml"), "utf-8");
} catch (e) {
this._composeYAML = "";
}
}
return this._composeYAML;
}
get path() : string {
return path.join(this.server.stacksDir, this.name);
}
get fullPath() : string {
let dir = this.path;
// Compose up via node-pty
let fullPathDir;
// if dir is relative, make it absolute
if (!path.isAbsolute(dir)) {
fullPathDir = path.join(process.cwd(), dir);
} else {
fullPathDir = dir;
}
return fullPathDir;
}
/**
* Save the stack to the disk
* @param isAdd
*/
save(isAdd : boolean) {
this.validate();
let dir = this.path;
// Check if the name is used if isAdd
if (isAdd) {
if (fs.existsSync(dir)) {
throw new ValidationError("Stack name already exists");
}
// Create the stack folder
fs.mkdirSync(dir);
} else {
if (!fs.existsSync(dir)) {
throw new ValidationError("Stack not found");
}
}
// Write or overwrite the compose.yaml
fs.writeFileSync(path.join(dir, "compose.yaml"), this.composeYAML);
}
async deploy(socket? : DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to deploy, please check the terminal output for more information.");
}
return exitCode;
}
async delete(socket?: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans", "--rmi", "all" ], 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;
}
static getStackList(server : DockgeServer, useCacheForManaged = false) : Map<string, Stack> {
let stacksDir = server.stacksDir;
let stackList : Map<string, Stack>;
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 {
let stack = this.getStack(server, filename);
stack._status = CREATED_FILE;
stackList.set(filename, stack);
} catch (e) {
log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`);
}
}
// Cache by copying
this.managedStackList = new Map(stackList);
}
// Also get the list from `docker compose ls --all --format json`
let res = childProcess.execSync("docker compose ls --all --format json");
let composeList = JSON.parse(res.toString());
for (let composeStack of composeList) {
// Skip the dockge stack
// TODO: Could be self managed?
if (composeStack.Name === "dockge") {
continue;
}
let stack = stackList.get(composeStack.Name);
// This stack probably is not managed by Dockge, but we still want to show it
if (!stack) {
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
* @param status
*/
static statusConvert(status : string) : number {
if (status.startsWith("created")) {
return CREATED_STACK;
} else if (status.startsWith("running")) {
return RUNNING;
} else if (status.startsWith("exited")) {
return EXITED;
} else {
return UNKNOWN;
}
}
static getStack(server: DockgeServer, stackName: string) : Stack {
let dir = path.join(server.stacksDir, stackName);
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
// Maybe it is a stack managed by docker compose directly
let stackList = this.getStackList(server);
let stack = stackList.get(stackName);
if (stack) {
return stack;
} else {
// Really not found
throw new ValidationError("Stack not found");
}
}
let stack = new Stack(server, stackName);
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 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.");
}
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.rows = COMBINED_TERMINAL_ROWS;
terminal.cols = COMBINED_TERMINAL_COLS;
terminal.join(socket);
terminal.start();
}
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.execSync("docker compose ps --format json", {
cwd: this.path,
});
let lines = res.toString().split("\n");
for (let line of lines) {
try {
let obj = JSON.parse(line);
statusList.set(obj.Service, obj.State);
} catch (e) {
}
}
return statusList;
}
}

230
backend/terminal.ts Normal file
View File

@ -0,0 +1,230 @@
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,
getComposeTerminalName,
getCryptoRandomInt,
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;
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) {
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) {
log.debug("Terminal", "Failed to resize terminal: " + e.message);
}
}
public start() {
if (this._ptyProcess) {
return;
}
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.push(data);
if (this.server.io) {
this.server.io.to(this.name).emit("terminalWrite", this.name, data);
}
});
// On Exit
this._ptyProcess.onExit((res) => {
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);
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() {
this._ptyProcess?.kill();
}
/**
* 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> {
const terminal = new Terminal(server, terminalName, file, args, cwd);
terminal.rows = PROGRESS_TERMINAL_ROWS;
if (socket) {
terminal.join(socket);
}
return new Promise((resolve) => {
terminal.onExit((exitCode : number) => {
resolve(exitCode);
});
terminal.start();
});
}
}
/**
* 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);
}
}

337
backend/util-common.ts Normal file
View File

@ -0,0 +1,337 @@
/*
* 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);
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 = 56;
export const COMBINED_TERMINAL_ROWS = 15;
export const ERROR_TYPE_VALIDATION = 1;
export const allowedCommandList : string[] = [
"docker",
"ls",
"cd",
"dir",
];
export const allowedRawKeys = [
"\u0003", // Ctrl + C
];
/**
* Generate a decimal integer number from a string
* @param str Input
* @param length Default is 10 which means 0 - 9
*/
export function intHash(str : string, length = 10) : number {
// A simple hashing function (you can use more complex hash functions if needed)
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash += str.charCodeAt(i);
}
// Normalize the hash to the range [0, 10]
return (hash % length + length) % length; // Ensure the result is non-negative
}
/**
* Delays for specified number of seconds
* @param ms Number of milliseconds to sleep for
*/
export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Generate a random alphanumeric string of fixed length
* @param length Length of string to generate
* @returns string
*/
export function genSecret(length = 64) {
let secret = "";
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charsLength = chars.length;
for ( let i = 0; i < length; i++ ) {
secret += chars.charAt(getCryptoRandomInt(0, charsLength - 1));
}
return secret;
}
/**
* Get a random integer suitable for use in cryptography between upper
* and lower bounds.
* @param min Minimum value of integer
* @param max Maximum value of integer
* @returns Cryptographically suitable random integer
*/
export function getCryptoRandomInt(min: number, max: number):number {
// synchronous version of: https://github.com/joepie91/node-random-number-csprng
const range = max - min;
if (range >= Math.pow(2, 32)) {
console.log("Warning! Range is too large.");
}
let tmpRange = range;
let bitsNeeded = 0;
let bytesNeeded = 0;
let mask = 1;
while (tmpRange > 0) {
if (bitsNeeded % 8 === 0) {
bytesNeeded += 1;
}
bitsNeeded += 1;
mask = mask << 1 | 1;
tmpRange = tmpRange >>> 1;
}
const bytes = randomBytes(bytesNeeded);
let randomValue = 0;
for (let i = 0; i < bytesNeeded; i++) {
randomValue |= bytes[i] << 8 * i;
}
randomValue = randomValue & mask;
if (randomValue <= range) {
return min + randomValue;
} else {
return getCryptoRandomInt(min, max);
}
}
export function getComposeTerminalName(stack : string) {
return "compose-" + stack;
}
export function getCombinedTerminalName(stack : string) {
return "combined-" + stack;
}
export function getContainerTerminalName(container : string) {
return "container-" + container;
}
export function getContainerExecTerminalName(stackName : string, container : string, index : number) {
return "container-exec-" + container + "-" + index;
}
export function copyYAMLComments(doc : Document, src : Document) {
doc.comment = src.comment;
doc.commentBefore = src.commentBefore;
if (doc && doc.contents && src && src.contents) {
// @ts-ignore
copyYAMLCommentsItems(doc.contents.items, src.contents.items);
}
}
/**
* Copy yaml comments from srcItems to items
* Typescript is super annoying here, so I have to use any here
* TODO: Since comments are belong to the array index, the comments will be lost if the order of the items is changed or removed or added.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function copyYAMLCommentsItems(items : any, srcItems : any) {
if (!items || !srcItems) {
return;
}
for (let i = 0; i < items.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const item : any = items[i];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const srcItem : any = srcItems[i];
if (!srcItem) {
continue;
}
if (item.key && srcItem.key) {
item.key.comment = srcItem.key.comment;
item.key.commentBefore = srcItem.key.commentBefore;
}
if (srcItem.comment) {
item.comment = srcItem.comment;
}
if (item.value && srcItem.value) {
if (typeof item.value === "object" && typeof srcItem.value === "object") {
item.value.comment = srcItem.value.comment;
item.value.commentBefore = srcItem.value.commentBefore;
if (item.value.items && srcItem.value.items) {
copyYAMLCommentsItems(item.value.items, srcItem.value.items);
}
}
}
}
}
/**
* Possible Inputs:
* ports:
* - "3000"
* - "3000-3005"
* - "8000:8000"
* - "9090-9091:8080-8081"
* - "49100:22"
* - "8000-9000:80"
* - "127.0.0.1:8001:8001"
* - "127.0.0.1:5000-5010:5000-5010"
* - "6060:6060/udp"
* @param input
* @param defaultHostname
*/
export function parseDockerPort(input : string, defaultHostname : string = "localhost") {
let hostname = defaultHostname;
let port;
let display;
const parts = input.split("/");
const part1 = parts[0];
let protocol = parts[1] || "tcp";
// Split the last ":"
const lastColon = part1.lastIndexOf(":");
if (lastColon === -1) {
// No colon, so it's just a port or port range
// Check if it's a port range
const dash = part1.indexOf("-");
if (dash === -1) {
// No dash, so it's just a port
port = part1;
} else {
// Has dash, so it's a port range, use the first port
port = part1.substring(0, dash);
}
display = part1;
} else {
// Has colon, so it's a port mapping
let hostPart = part1.substring(0, lastColon);
display = hostPart;
// Check if it's a port range
const dash = part1.indexOf("-");
if (dash !== -1) {
// Has dash, so it's a port range, use the first port
hostPart = part1.substring(0, dash);
}
// Check if it has a ip (ip:port)
const colon = hostPart.indexOf(":");
if (colon !== -1) {
// Has colon, so it's a ip:port
hostname = hostPart.substring(0, colon);
port = hostPart.substring(colon + 1);
} else {
// No colon, so it's just a port
port = hostPart;
}
}
let portInt = parseInt(port);
if (portInt == 443) {
protocol = "https";
} else if (protocol === "tcp") {
protocol = "http";
}
return {
url: protocol + "://" + hostname + ":" + portInt,
display: display,
};
}

79
backend/util-server.ts Normal file
View File

@ -0,0 +1,79 @@
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 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 = null;
constructor(limit: number) {
super();
this.__limit = limit;
}
push(value : T) {
super.push(value);
if (this.length > this.__limit) {
const item = this.shift();
if (this.__onExceed) {
this.__onExceed(item);
}
}
}
}

18
compose.yaml Normal file
View File

@ -0,0 +1,18 @@
version: "3.8"
services:
dockge:
image: louislam/dockge:1
restart: unless-stopped
ports:
# Host Port : Container Port
- 5001:5001
volumes:
# Docker Socket
- /var/run/docker.sock:/var/run/docker.sock
# Dockge Config
- ./data:/app/data
# Your stacks directory in the host (The paths inside container must be the same as the host)
- /opt/stacks:/opt/stacks
environment:
# Tell Dockge where is your stacks directory
- DOCKGE_STACKS_DIR=/opt/stacks

39
docker/Base.Dockerfile Normal file
View File

@ -0,0 +1,39 @@
FROM node:20-bookworm-slim
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
RUN apt update && apt install --yes --no-install-recommends \
curl \
ca-certificates \
gnupg \
unzip \
dumb-init \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& chmod a+r /etc/apt/keyrings/docker.gpg \
&& echo \
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt update \
&& apt --yes --no-install-recommends install \
docker-ce-cli \
docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/* \
&& npm install pnpm -g \
&& pnpm install -g tsx
# ensures that /var/run/docker.sock exists
# changes the ownership of /var/run/docker.sock
RUN touch /var/run/docker.sock && chown node:node /var/run/docker.sock
# Full Base Image
# MariaDB, Chromium and fonts
#FROM base-slim AS base
#ENV DOCKGE_ENABLE_EMBEDDED_MARIADB=1
#RUN apt update && \
# apt --yes --no-install-recommends install mariadb-server && \
# rm -rf /var/lib/apt/lists/* && \
# apt --yes autoremove

29
docker/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
############################################
# Build
############################################
FROM louislam/dockge:base AS build
WORKDIR /app
COPY --chown=node:node ./package.json ./package.json
COPY --chown=node:node ./pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
############################################
# ⭐ Main Image
############################################
FROM louislam/dockge:base AS release
WORKDIR /app
COPY --chown=node:node . .
COPY --from=build /app/node_modules /app/node_modules
RUN mkdir ./data
VOLUME /app/data
EXPOSE 5001
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["tsx", "./backend/index.ts"]
############################################
# Mark as Nightly
############################################
FROM release AS nightly
RUN pnpm run mark-as-nightly

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

30
frontend/components.d.ts vendored Normal file
View File

@ -0,0 +1,30 @@
/* 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']
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,117 @@
<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,
}
},
data() {
return {
};
},
computed: {
array() {
// Create the array if not exists, it should be safe.
if (!this.service[this.name]) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.service[this.name] = [];
}
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() {
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,125 @@
<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" :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]) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.service[this.name] = [];
}
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() {
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,273 @@
<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" :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" />
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.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") {
return "bg-primary";
} 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>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">External Networks</h5>
<div v-if="externalNetworkList.length === 0">
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" @click="">
{{ $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,438 @@
<template>
<div class="shadow-box mb-3" :style="boxStyle">
<div class="list-header">
<div class="header-top">
<!-- TODO -->
<button v-if="false" class="btn btn-outline-normal ms-2" :class="{ 'active': selectMode }" type="button" @click="selectMode = !selectMode">
{{ $t("Select") }}
</button>
<div class="placeholder"></div>
<div class="search-wrapper">
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
</a>
<a v-if="searchText != ''" class="search-icon" style="cursor: pointer" @click="clearSearchText">
<font-awesome-icon icon="times" />
</a>
<form>
<input v-model="searchText" class="form-control search-input" autocomplete="off" />
</form>
</div>
</div>
<!-- TODO -->
<div v-if="false" class="header-filter">
<!--<StackListFilter :filterState="filterState" @update-filter="updateFilter" />-->
</div>
<!-- TODO: Selection Controls -->
<div v-if="selectMode && false" class="selection-controls px-2 pt-2">
<input
v-model="selectAll"
class="form-check-input select-input"
type="checkbox"
/>
<button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ $t("Pause") }}</button>
<button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ $t("Resume") }}</button>
<span v-if="selectedStackCount > 0">
{{ $t("selectedStackCount", [ selectedStackCount ]) }}
</span>
</div>
</div>
<div ref="stackList" class="stack-list" :class="{ scrollbar: scrollbar }" :style="stackListStyle">
<div v-if="Object.keys($root.stackList).length === 0" class="text-center mt-3">
<router-link to="/compose">{{ $t("addFirstStackMsg") }}</router-link>
</div>
<StackListItem
v-for="(item, index) in sortedStackList"
:key="index"
:stack="item"
:isSelectMode="selectMode"
:isSelected="isSelected"
:select="select"
:deselect="deselect"
/>
</div>
</div>
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
{{ $t("pauseStackMsg") }}
</Confirm>
</template>
<script>
import Confirm from "../components/Confirm.vue";
import StackListItem from "../components/StackListItem.vue";
import { CREATED_FILE, CREATED_STACK, EXITED, RUNNING, UNKNOWN } from "../../../backend/util-common";
export default {
components: {
Confirm,
StackListItem,
},
props: {
/** Should the scrollbar be shown */
scrollbar: {
type: Boolean,
},
},
data() {
return {
searchText: "",
selectMode: false,
selectAll: false,
disableSelectAllWatcher: false,
selectedStacks: {},
windowTop: 0,
filterState: {
status: null,
active: null,
tags: null,
}
};
},
computed: {
/**
* Improve the sticky appearance of the list by increasing its
* height as user scrolls down.
* Not used on mobile.
* @returns {object} Style for stack list
*/
boxStyle() {
if (window.innerWidth > 550) {
return {
height: `calc(100vh - 160px + ${this.windowTop}px)`,
};
} else {
return {
height: "calc(100vh - 160px)",
};
}
},
/**
* Returns a sorted list of stacks based on the applied filters and search text.
* @returns {Array} The sorted list of stacks.
*/
sortedStackList() {
let result = Object.values(this.$root.stackList);
result = result.filter(stack => {
// filter by search text
// finds stack name, tag name or tag value
let searchTextMatch = true;
if (this.searchText !== "") {
const loweredSearchText = this.searchText.toLowerCase();
searchTextMatch =
stack.name.toLowerCase().includes(loweredSearchText)
|| stack.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|| tag.value?.toLowerCase().includes(loweredSearchText));
}
// filter by active
let activeMatch = true;
if (this.filterState.active != null && this.filterState.active.length > 0) {
activeMatch = this.filterState.active.includes(stack.active);
}
// filter by tags
let tagsMatch = true;
if (this.filterState.tags != null && this.filterState.tags.length > 0) {
tagsMatch = stack.tags.map(tag => tag.tag_id) // convert to array of tag IDs
.filter(stackTagId => this.filterState.tags.includes(stackTagId)) // perform Array Intersaction between filter and stack's tags
.length > 0;
}
return searchTextMatch && activeMatch && tagsMatch;
});
result.sort((m1, m2) => {
if (m1.status !== m2.status) {
if (m2.status === RUNNING) {
return 1;
} else if (m1.status === RUNNING) {
return -1;
} else if (m2.status === EXITED) {
return 1;
} else if (m1.status === EXITED) {
return -1;
} else if (m2.status === CREATED_STACK) {
return 1;
} else if (m1.status === CREATED_STACK) {
return -1;
} else if (m2.status === CREATED_FILE) {
return 1;
} else if (m1.status === CREATED_FILE) {
return -1;
} else if (m2.status === UNKNOWN) {
return 1;
} else if (m1.status === UNKNOWN) {
return -1;
}
}
return m1.name.localeCompare(m2.name);
});
return result;
},
isDarkTheme() {
return document.body.classList.contains("dark");
},
stackListStyle() {
//let listHeaderHeight = 107;
let listHeaderHeight = 60;
if (this.selectMode) {
listHeaderHeight += 42;
}
return {
"height": `calc(100% - ${listHeaderHeight}px)`
};
},
selectedStackCount() {
return Object.keys(this.selectedStacks).length;
},
/**
* Determines if any filters are active.
* @returns {boolean} True if any filter is active, false otherwise.
*/
filtersActive() {
return this.filterState.status != null || this.filterState.active != null || this.filterState.tags != null || this.searchText !== "";
}
},
watch: {
searchText() {
for (let stack of this.sortedStackList) {
if (!this.selectedStacks[stack.id]) {
if (this.selectAll) {
this.disableSelectAllWatcher = true;
this.selectAll = false;
}
break;
}
}
},
selectAll() {
if (!this.disableSelectAllWatcher) {
this.selectedStacks = {};
if (this.selectAll) {
this.sortedStackList.forEach((item) => {
this.selectedStacks[item.id] = true;
});
}
} else {
this.disableSelectAllWatcher = false;
}
},
selectMode() {
if (!this.selectMode) {
this.selectAll = false;
this.selectedStacks = {};
}
},
},
mounted() {
window.addEventListener("scroll", this.onScroll);
},
beforeUnmount() {
window.removeEventListener("scroll", this.onScroll);
},
methods: {
/**
* Handle user scroll
* @returns {void}
*/
onScroll() {
if (window.top.scrollY <= 133) {
this.windowTop = window.top.scrollY;
} else {
this.windowTop = 133;
}
},
/**
* Clear the search bar
* @returns {void}
*/
clearSearchText() {
this.searchText = "";
},
/**
* Update the StackList Filter
* @param {object} newFilter Object with new filter
* @returns {void}
*/
updateFilter(newFilter) {
this.filterState = newFilter;
},
/**
* Deselect a stack
* @param {number} id ID of stack
* @returns {void}
*/
deselect(id) {
delete this.selectedStacks[id];
},
/**
* Select a stack
* @param {number} id ID of stack
* @returns {void}
*/
select(id) {
this.selectedStacks[id] = true;
},
/**
* Determine if stack is selected
* @param {number} id ID of stack
* @returns {bool} Is the stack selected?
*/
isSelected(id) {
return id in this.selectedStacks;
},
/**
* Disable select mode and reset selection
* @returns {void}
*/
cancelSelectMode() {
this.selectMode = false;
this.selectedStacks = {};
},
/**
* Show dialog to confirm pause
* @returns {void}
*/
pauseDialog() {
this.$refs.confirmPause.show();
},
/**
* Pause each selected stack
* @returns {void}
*/
pauseSelected() {
Object.keys(this.selectedStacks)
.filter(id => this.$root.stackList[id].active)
.forEach(id => this.$root.getSocket().emit("pauseStack", id, () => {}));
this.cancelSelectMode();
},
/**
* Resume each selected stack
* @returns {void}
*/
resumeSelected() {
Object.keys(this.selectedStacks)
.filter(id => !this.$root.stackList[id].active)
.forEach(id => this.$root.getSocket().emit("resumeStack", id, () => {}));
this.cancelSelectMode();
},
},
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.shadow-box {
height: calc(100vh - 150px);
position: sticky;
top: 10px;
}
.small-padding {
padding-left: 5px !important;
padding-right: 5px !important;
}
.list-header {
border-bottom: 1px solid #dee2e6;
border-radius: 10px 10px 0 0;
margin: -10px;
margin-bottom: 10px;
padding: 10px;
.dark & {
background-color: $dark-header-bg;
border-bottom: 0;
}
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-filter {
display: flex;
align-items: center;
}
@media (max-width: 770px) {
.list-header {
margin: -20px;
margin-bottom: 10px;
padding: 5px;
}
}
.search-wrapper {
display: flex;
align-items: center;
}
.search-icon {
padding: 10px;
color: #c0c0c0;
// Clear filter button (X)
svg[data-icon="times"] {
cursor: pointer;
transition: all ease-in-out 0.1s;
&:hover {
opacity: 0.5;
}
}
}
.search-input {
max-width: 15em;
}
.stack-item {
width: 100%;
}
.tags {
margin-top: 4px;
padding-left: 67px;
display: flex;
flex-wrap: wrap;
gap: 0;
}
.bottom-style {
padding-left: 67px;
margin-top: 5px;
}
.selection-controls {
margin-top: 5px;
display: flex;
align-items: center;
gap: 10px;
}
</style>

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: 16,
fontFamily: "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,54 @@
<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 "0.00%";
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;
}
</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="localhost"
/>
<button class="btn btn-outline-primary" type="button" @click="autoGetPrimaryHostname">
{{ $t("Auto Get") }}
</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 HiddenInput from "../../components/HiddenInput.vue";
import dayjs from "dayjs";
import { timezoneList } from "../../util-frontend";
export default {
components: {
HiddenInput,
},
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>

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

@ -0,0 +1,36 @@
// @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 = {
};
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 };

53
frontend/src/lang/en.json Normal file
View File

@ -0,0 +1,53 @@
{
"languageName": "English",
"authIncorrectCreds": "Incorrect username or password.",
"PasswordsDoNotMatch": "Passwords do not match.",
"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",
"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 Uptime Kuma such as Cloudflare Access, Authelia or other authentication mechanisms.",
"passwordNotMatchMsg": "The repeat password does not match.",
"autoGet": "Auto Get",
"add": "Add",
"applyToYAML": "Apply to YAML",
"createExternalNetwork": "Create",
"addInternalNetwork": "Add"
}

View File

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

View File

@ -0,0 +1,304 @@
<template>
<div :class="classes">
<div v-if="! $root.socketIO.connected && ! $root.socketIO.firstConnect" class="lost-connection">
<div class="container-fluid">
{{ $root.socketIO.connectionErrorMsg }}
</div>
</div>
<!-- Desktop header -->
<header v-if="! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom">
<router-link to="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
<object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title">Dockge</span>
</router-link>
<a v-if="hasNewVersion" target="_blank" href="https://github.com/louislam/dockge/releases" class="btn btn-info me-3">
<font-awesome-icon icon="arrow-alt-circle-up" /> {{ $t("New Update") }}
</a>
<ul class="nav nav-pills">
<li v-if="$root.loggedIn" class="nav-item me-2">
<router-link to="/" class="nav-link">
<font-awesome-icon icon="home" /> {{ $t("home") }}
</router-link>
</li>
<li v-if="$root.loggedIn" class="nav-item me-2">
<router-link to="/console" class="nav-link">
<font-awesome-icon icon="terminal" /> {{ $t("console") }}
</router-link>
</li>
<li v-if="$root.loggedIn" class="nav-item">
<div class="dropdown dropdown-profile-pic">
<div class="nav-link" data-bs-toggle="dropdown">
<div class="profile-pic">{{ $root.usernameFirstChar }}</div>
<font-awesome-icon icon="angle-down" />
</div>
<!-- Header's Dropdown Menu -->
<ul class="dropdown-menu">
<!-- Username -->
<li>
<i18n-t v-if="$root.username != null" tag="span" keypath="signedInDisp" class="dropdown-item-text">
<strong>{{ $root.username }}</strong>
</i18n-t>
<span v-if="$root.username == null" class="dropdown-item-text">{{ $t("signedInDispDisabled") }}</span>
</li>
<li><hr class="dropdown-divider"></li>
<!-- Functions -->
<!--<li>
<router-link to="/registry" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<font-awesome-icon icon="warehouse" /> {{ $t("registry") }}
</router-link>
</li>-->
<li>
<button class="dropdown-item" @click="scanFolder">
<font-awesome-icon icon="arrows-rotate" /> {{ $t("scanFolder") }}
</button>
</li>
<li>
<router-link to="/settings/general" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<font-awesome-icon icon="cog" /> {{ $t("Settings") }}
</router-link>
</li>
<li>
<button class="dropdown-item" @click="$root.logout">
<font-awesome-icon icon="sign-out-alt" />
{{ $t("Logout") }}
</button>
</li>
</ul>
</div>
</li>
</ul>
</header>
<main>
<router-view v-if="$root.loggedIn" />
<Login v-if="! $root.loggedIn && $root.allowLoginDialog" />
</main>
</div>
</template>
<script>
import Login from "../components/Login.vue";
import { compareVersions } from "compare-versions";
export default {
components: {
Login,
},
data() {
return {
};
},
computed: {
// Theme or Mobile
classes() {
const classes = {};
classes[this.$root.theme] = true;
classes["mobile"] = this.$root.isMobile;
return classes;
},
hasNewVersion() {
if (this.$root.info.latestVersion && this.$root.info.version) {
return compareVersions(this.$root.info.latestVersion, this.$root.info.version) >= 1;
} else {
return false;
}
},
},
watch: {
},
mounted() {
},
beforeUnmount() {
},
methods: {
scanFolder() {
this.$root.getSocket().emit("requestStackList", (res) => {
this.$root.toastRes(res);
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.nav-link {
&.status-page {
background-color: rgba(255, 255, 255, 0.1);
}
}
.bottom-nav {
z-index: 1000;
position: fixed;
bottom: 0;
height: calc(60px + env(safe-area-inset-bottom));
width: 100%;
left: 0;
background-color: #fff;
box-shadow: 0 15px 47px 0 rgba(0, 0, 0, 0.05), 0 5px 14px 0 rgba(0, 0, 0, 0.05);
text-align: center;
white-space: nowrap;
padding: 0 10px env(safe-area-inset-bottom);
a {
text-align: center;
width: 25%;
display: inline-block;
height: 100%;
padding: 8px 10px 0;
font-size: 13px;
color: #c1c1c1;
overflow: hidden;
text-decoration: none;
&.router-link-exact-active, &.active {
color: $primary;
font-weight: bold;
}
div {
font-size: 20px;
}
}
}
main {
min-height: calc(100vh - 160px);
}
.title {
font-weight: bold;
}
.nav {
margin-right: 25px;
}
.lost-connection {
padding: 5px;
background-color: crimson;
color: white;
position: fixed;
width: 100%;
z-index: 99999;
}
// Profile Pic Button with Dropdown
.dropdown-profile-pic {
user-select: none;
.nav-link {
cursor: pointer;
display: flex;
gap: 6px;
align-items: center;
background-color: rgba(200, 200, 200, 0.2);
padding: 0.5rem 0.8rem;
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
.dropdown-menu {
transition: all 0.2s;
padding-left: 0;
padding-bottom: 0;
margin-top: 8px !important;
border-radius: 16px;
overflow: hidden;
.dropdown-divider {
margin: 0;
border-top: 1px solid rgba(0, 0, 0, 0.4);
background-color: transparent;
}
.dropdown-item-text {
font-size: 14px;
padding-bottom: 0.7rem;
}
.dropdown-item {
padding: 0.7rem 1rem;
}
.dark & {
background-color: $dark-bg;
color: $dark-font-color;
border-color: $dark-border-color;
.dropdown-item {
color: $dark-font-color;
&.active {
color: $dark-font-color2;
background-color: $highlight !important;
}
&:hover {
background-color: $dark-bg2;
}
}
}
}
.profile-pic {
display: flex;
align-items: center;
justify-content: center;
color: white;
background-color: $primary;
width: 24px;
height: 24px;
margin-right: 5px;
border-radius: 50rem;
font-weight: bold;
font-size: 10px;
}
}
.dark {
header {
background-color: $dark-header-bg;
border-bottom-color: $dark-header-bg !important;
span {
color: #f0f6fc;
}
}
.bottom-nav {
background-color: $dark-bg;
}
}
</style>

101
frontend/src/main.ts Normal file
View File

@ -0,0 +1,101 @@
// Dayjs init inside this, so it has to be the first import
import "../../backend/util-common";
import { createApp, defineComponent, h } from "vue";
import App from "./App.vue";
import { router } from "./router";
import { FontAwesomeIcon } from "./icon.js";
import { i18n } from "./i18n";
// Dependencies
import "bootstrap";
import Toast, { POSITION, useToast } from "vue-toastification";
import "xterm/lib/xterm.js";
// CSS
import "vue-toastification/dist/index.css";
import "xterm/css/xterm.css";
import "./styles/main.scss";
// Minxins
import socket from "./mixins/socket";
import lang from "./mixins/lang";
import theme from "./mixins/theme";
const app = createApp(rootApp());
app.use(Toast, {
position: POSITION.BOTTOM_RIGHT,
showCloseButtonOnHover: true,
});
app.use(router);
app.use(i18n);
app.component("FontAwesomeIcon", FontAwesomeIcon);
app.mount("#app");
/**
* Root Vue component
*/
function rootApp() {
const toast = useToast();
return defineComponent({
mixins: [
socket,
lang,
theme,
],
data() {
return {
loggedIn: false,
allowLoginDialog: false,
username: null,
};
},
computed: {
},
methods: {
/**
* Show success or error toast dependant on response status code
* @param {object} res Response object
* @returns {void}
*/
toastRes(res) {
let msg = res.msg;
if (res.msgi18n) {
if (msg != null && typeof msg === "object") {
msg = this.$t(msg.key, msg.values);
} else {
msg = this.$t(msg);
}
}
if (res.ok) {
toast.success(msg);
} else {
toast.error(msg);
}
},
/**
* Show a success toast
* @param {string} msg Message to show
* @returns {void}
*/
toastSuccess(msg : string) {
toast.success(this.$t(msg));
},
/**
* Show an error toast
* @param {string} msg Message to show
* @returns {void}
*/
toastError(msg : string) {
toast.error(this.$t(msg));
},
},
render: () => h(App),
});
}

View File

@ -0,0 +1,39 @@
import { currentLocale } from "../i18n";
import { setPageLocale } from "../util-frontend";
import { defineComponent } from "vue";
const langModules = import.meta.glob("../lang/*.json");
export default defineComponent({
data() {
return {
language: currentLocale(),
};
},
watch: {
async language(lang) {
await this.changeLang(lang);
},
},
async created() {
if (this.language !== "en") {
await this.changeLang(this.language);
}
},
methods: {
/**
* Change the application language
* @param {string} lang Language code to switch to
* @returns {Promise<void>}
*/
async changeLang(lang : string) {
const message = (await langModules["../lang/" + lang + ".json"]()).default;
this.$i18n.setLocaleMessage(lang, message);
this.$i18n.locale = lang;
localStorage.locale = lang;
setPageLocale();
}
}
});

View File

@ -0,0 +1,317 @@
import { io } from "socket.io-client";
import { Socket } from "socket.io-client";
import { defineComponent } from "vue";
import jwtDecode from "jwt-decode";
import { Terminal } from "xterm";
let socket : Socket;
let terminalMap : Map<string, Terminal> = new Map();
export default defineComponent({
data() {
return {
socketIO: {
token: null,
firstConnect: true,
connected: false,
connectCount: 0,
initedSocketIO: false,
connectionErrorMsg: `${this.$t("Cannot connect to the socket server.")} ${this.$t("Reconnecting...")}`,
showReverseProxyGuide: true,
},
info: {
},
remember: (localStorage.remember !== "0"),
loggedIn: false,
allowLoginDialog: false,
username: null,
stackList: {},
composeTemplate: "",
};
},
computed: {
usernameFirstChar() {
if (typeof this.username == "string" && this.username.length >= 1) {
return this.username.charAt(0).toUpperCase();
} else {
return "🐻";
}
},
/**
* Frontend Version
* It should be compiled to a static value while building the frontend.
* Please see ./frontend/vite.config.ts, it is defined via vite.js
* @returns {string}
*/
frontendVersion() {
// eslint-disable-next-line no-undef
return FRONTEND_VERSION;
},
/**
* Are both frontend and backend in the same version?
* @returns {boolean}
*/
isFrontendBackendVersionMatched() {
if (!this.info.version) {
return true;
}
return this.info.version === this.frontendVersion;
},
},
watch: {
remember() {
localStorage.remember = (this.remember) ? "1" : "0";
},
// Reload the SPA if the server version is changed.
"info.version"(to, from) {
if (from && from !== to) {
window.location.reload();
}
},
},
created() {
this.initSocketIO();
},
mounted() {
return;
},
methods: {
/**
* Initialize connection to socket server
* @param bypass Should the check for if we
* are on a status page be bypassed?
*/
initSocketIO(bypass = false) {
// No need to re-init
if (this.socketIO.initedSocketIO) {
return;
}
this.socketIO.initedSocketIO = true;
let url : string;
const env = process.env.NODE_ENV || "production";
if (env === "development" || localStorage.dev === "dev") {
url = location.protocol + "//" + location.hostname + ":5001";
} else {
url = location.protocol + "//" + location.host;
}
socket = io(url, {
transports: [ "websocket", "polling" ]
});
socket.on("connect", () => {
console.log("Connected to the socket server");
this.socketIO.connectCount++;
this.socketIO.connected = true;
this.socketIO.showReverseProxyGuide = false;
const token = this.storage().token;
if (token) {
if (token !== "autoLogin") {
console.log("Logging in by token");
this.loginByToken(token);
} else {
// Timeout if it is not actually auto login
setTimeout(() => {
if (! this.loggedIn) {
this.allowLoginDialog = true;
this.storage().removeItem("token");
}
}, 5000);
}
} else {
this.allowLoginDialog = true;
}
this.socketIO.firstConnect = false;
});
socket.on("disconnect", () => {
console.log("disconnect");
this.socketIO.connectionErrorMsg = "Lost connection to the socket server. Reconnecting...";
this.socketIO.connected = false;
});
socket.on("connect_error", (err) => {
console.error(`Failed to connect to the backend. Socket.io connect_error: ${err.message}`);
this.socketIO.connectionErrorMsg = `${this.$t("Cannot connect to the socket server.")} [${err}] ${this.$t("Reconnecting...")}`;
this.socketIO.showReverseProxyGuide = true;
this.socketIO.connected = false;
this.socketIO.firstConnect = false;
});
// Custom Events
socket.on("info", (info) => {
this.info = info;
});
socket.on("autoLogin", () => {
this.loggedIn = true;
this.storage().token = "autoLogin";
this.socketIO.token = "autoLogin";
this.allowLoginDialog = false;
this.afterLogin();
});
socket.on("setup", () => {
console.log("setup");
this.$router.push("/setup");
});
socket.on("terminalWrite", (terminalName, data) => {
const terminal = terminalMap.get(terminalName);
if (!terminal) {
//console.error("Terminal not found: " + terminalName);
return;
}
terminal.write(data);
});
socket.on("stackList", (res) => {
if (res.ok) {
this.stackList = res.stackList;
}
});
socket.on("stackStatusList", (res) => {
if (res.ok) {
for (let stackName in res.stackStatusList) {
const stackObj = this.stackList[stackName];
if (stackObj) {
stackObj.status = res.stackStatusList[stackName];
}
}
}
});
},
/**
* The storage currently in use
* @returns Current storage
*/
storage() : Storage {
return (this.remember) ? localStorage : sessionStorage;
},
getSocket() : Socket {
return socket;
},
/**
* Get payload of JWT cookie
* @returns {(object | undefined)} JWT payload
*/
getJWTPayload() {
const jwtToken = this.storage().token;
if (jwtToken && jwtToken !== "autoLogin") {
return jwtDecode(jwtToken);
}
return undefined;
},
/**
* Send request to log user in
* @param {string} username Username to log in with
* @param {string} password Password to log in with
* @param {string} token User token
* @param {loginCB} callback Callback to call with result
* @returns {void}
*/
login(username : string, password : string, token : string, callback) {
this.getSocket().emit("login", {
username,
password,
token,
}, (res) => {
if (res.tokenRequired) {
callback(res);
}
if (res.ok) {
this.storage().token = res.token;
this.socketIO.token = res.token;
this.loggedIn = true;
this.username = this.getJWTPayload()?.username;
this.afterLogin();
// Trigger Chrome Save Password
history.pushState({}, "");
}
callback(res);
});
},
/**
* Log in using a token
* @param {string} token Token to log in with
* @returns {void}
*/
loginByToken(token : string) {
socket.emit("loginByToken", token, (res) => {
this.allowLoginDialog = true;
if (! res.ok) {
this.logout();
} else {
this.loggedIn = true;
this.username = this.getJWTPayload()?.username;
this.afterLogin();
}
});
},
/**
* Log out of the web application
* @returns {void}
*/
logout() {
socket.emit("logout", () => { });
this.storage().removeItem("token");
this.socketIO.token = null;
this.loggedIn = false;
this.username = null;
this.clearData();
},
/**
* @returns {void}
*/
clearData() {
},
afterLogin() {
},
bindTerminal(terminalName : string, terminal : Terminal) {
// Load terminal, get terminal screen
socket.emit("terminalJoin", terminalName, (res) => {
if (res.ok) {
terminal.write(res.buffer);
terminalMap.set(terminalName, terminal);
} else {
this.toastRes(res);
}
});
},
unbindTerminal(terminalName : string) {
terminalMap.delete(terminalName);
},
}
});

View File

@ -0,0 +1,80 @@
import { defineComponent } from "vue";
export default defineComponent({
data() {
return {
system: (window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light",
userTheme: localStorage.theme,
statusPageTheme: "light",
forceStatusPageTheme: false,
path: "",
};
},
computed: {
theme() {
if (this.userTheme === "auto") {
return this.system;
}
return this.userTheme;
},
isDark() {
return this.theme === "dark";
}
},
watch: {
"$route.fullPath"(path) {
this.path = path;
},
userTheme(to, from) {
localStorage.theme = to;
},
styleElapsedTime(to, from) {
localStorage.styleElapsedTime = to;
},
theme(to, from) {
document.body.classList.remove(from);
document.body.classList.add(this.theme);
this.updateThemeColorMeta();
},
userHeartbeatBar(to, from) {
localStorage.heartbeatBarTheme = to;
},
heartbeatBarTheme(to, from) {
document.body.classList.remove(from);
document.body.classList.add(this.heartbeatBarTheme);
}
},
mounted() {
// Default Dark
if (! this.userTheme) {
this.userTheme = "dark";
}
document.body.classList.add(this.theme);
this.updateThemeColorMeta();
},
methods: {
/**
* Update the theme color meta tag
* @returns {void}
*/
updateThemeColorMeta() {
if (this.theme === "dark") {
document.querySelector("#theme-color").setAttribute("content", "#161B22");
} else {
document.querySelector("#theme-color").setAttribute("content", "#5cdd8b");
}
}
}
});

View File

@ -0,0 +1,599 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 v-if="isAdd" class="mb-3">Compose</h1>
<h1 v-else class="mb-3"><Uptime :stack="globalStack" :pill="true" /> {{ stack.name }}</h1>
<div v-if="stack.isManagedByDockge" class="mb-3">
<div class="btn-group me-2" role="group">
<button v-if="isEditMode" class="btn btn-primary" :disabled="processing" @click="deployStack">
<font-awesome-icon icon="rocket" class="me-1" />
{{ $t("deployStack") }}
</button>
<button v-if="isEditMode" class="btn btn-normal" :disabled="processing" @click="saveStack">
<font-awesome-icon icon="save" class="me-1" />
{{ $t("saveStackDraft") }}
</button>
<button v-if="!isEditMode" class="btn btn-secondary" :disabled="processing" @click="enableEditMode">
<font-awesome-icon icon="pen" class="me-1" />
{{ $t("editStack") }}
</button>
<button v-if="!isEditMode && !active" class="btn btn-primary" :disabled="processing" @click="startStack">
<font-awesome-icon icon="play" class="me-1" />
{{ $t("startStack") }}
</button>
<button v-if="!isEditMode && active" class="btn btn-normal " :disabled="processing" @click="restartStack">
<font-awesome-icon icon="rotate" class="me-1" />
{{ $t("restartStack") }}
</button>
<button v-if="!isEditMode" class="btn btn-normal" :disabled="processing" @click="updateStack">
<font-awesome-icon icon="cloud-arrow-down" class="me-1" />
{{ $t("updateStack") }}
</button>
<button v-if="!isEditMode && active" class="btn btn-normal" :disabled="processing" @click="stopStack">
<font-awesome-icon icon="stop" class="me-1" />
{{ $t("stopStack") }}
</button>
</div>
<button v-if="isEditMode && !isAdd" class="btn btn-normal" :disabled="processing" @click="discardStack">{{ $t("discardStack") }}</button>
<button v-if="!isEditMode" class="btn btn-danger" :disabled="processing" @click="showDeleteDialog = !showDeleteDialog">
<font-awesome-icon icon="trash" class="me-1" />
{{ $t("deleteStack") }}
</button>
</div>
<!-- Progress Terminal -->
<transition name="slide-fade" appear>
<Terminal
v-show="showProgressTerminal"
ref="progressTerminal"
class="mb-3 terminal"
:name="terminalName"
:rows="progressTerminalRows"
@has-data="showProgressTerminal = true; submitted = true;"
></Terminal>
</transition>
<div v-if="stack.isManagedByDockge" class="row">
<div class="col-lg-6">
<!-- General -->
<div v-if="isAdd">
<h4 class="mb-3">{{ $t("general") }}</h4>
<div class="shadow-box big-padding mb-3">
<!-- Stack Name -->
<div class="mb-3">
<label for="name" class="form-label">{{ $t("stackName") }}</label>
<input id="name" v-model="stack.name" type="text" class="form-control" required>
</div>
</div>
</div>
<!-- Containers -->
<h4 class="mb-3">{{ $tc("container", 2) }}</h4>
<div v-if="isEditMode" class="input-group mb-3">
<input
v-model="newContainerName"
placeholder="New Container Name..."
class="form-control"
@keyup.enter="addContainer"
/>
<button class="btn btn-primary" @click="addContainer">
{{ $t("addContainer") }}
</button>
</div>
<div ref="containerList">
<Container
v-for="(service, name) in jsonConfig.services"
:key="name"
:name="name"
:is-edit-mode="isEditMode"
:first="name === Object.keys(jsonConfig.services)[0]"
:status="serviceStatusList[name]"
/>
</div>
<button v-if="false && isEditMode && jsonConfig.services && Object.keys(jsonConfig.services).length > 0" class="btn btn-normal mb-3" @click="addContainer">{{ $t("addContainer") }}</button>
<!-- Combined Terminal Output -->
<div v-show="!isEditMode">
<h4 class="mb-3">Terminal</h4>
<Terminal
ref="combinedTerminal"
class="mb-3 terminal"
:name="combinedTerminalName"
:rows="combinedTerminalRows"
:cols="combinedTerminalCols"
style="height: 350px;"
></Terminal>
</div>
</div>
<div class="col-lg-6">
<h4 class="mb-3">compose.yaml</h4>
<!-- YAML editor -->
<div class="shadow-box mb-3 editor-box" :class="{'edit-mode' : isEditMode}">
<prism-editor
ref="editor"
v-model="stack.composeYAML"
class="yaml-editor"
:highlight="highlighter"
line-numbers :readonly="!isEditMode"
@input="yamlCodeChange"
@focus="editorFocus = true"
@blur="editorFocus = false"
></prism-editor>
</div>
<div v-if="isEditMode" class="mb-3">
{{ yamlError }}
</div>
<div v-if="isEditMode">
<!-- Volumes -->
<div v-if="false">
<h4 class="mb-3">{{ $tc("volume", 2) }}</h4>
<div class="shadow-box big-padding mb-3">
</div>
</div>
<!-- Networks -->
<h4 class="mb-3">{{ $tc("network", 2) }}</h4>
<div class="shadow-box big-padding mb-3">
<NetworkInput />
</div>
</div>
<!-- <div class="shadow-box big-padding mb-3">
<div class="mb-3">
<label for="name" class="form-label"> Search Templates</label>
<input id="name" v-model="name" type="text" class="form-control" placeholder="Search..." required>
</div>
<prism-editor v-if="false" v-model="yamlConfig" class="yaml-editor" :highlight="highlighter" line-numbers @input="yamlCodeChange"></prism-editor>
</div>-->
</div>
</div>
<div v-if="!stack.isManagedByDockge && !processing">
{{ $t("stackNotManagedByDockgeMsg") }}
</div>
<!-- Delete Dialog -->
<BModal v-model="showDeleteDialog" :okTitle="$t('deleteStack')" okVariant="danger" @ok="deleteDialog">
{{ $t("deleteStackMsg") }}
</BModal>
</div>
</transition>
</template>
<script>
import { highlight, languages } from "prismjs/components/prism-core";
import { PrismEditor } from "vue-prism-editor";
import "prismjs/components/prism-yaml";
import { parseDocument, Document } from "yaml";
import "prismjs/themes/prism-tomorrow.css";
import "vue-prism-editor/dist/prismeditor.min.css";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
COMBINED_TERMINAL_COLS,
COMBINED_TERMINAL_ROWS,
copyYAMLComments,
getCombinedTerminalName,
getComposeTerminalName,
PROGRESS_TERMINAL_ROWS,
RUNNING
} from "../../../backend/util-common";
import { BModal } from "bootstrap-vue-next";
import NetworkInput from "../components/NetworkInput.vue";
const template = `version: "3.8"
services:
nginx:
image: nginx:latest
restart: unless-stopped
ports:
- "8080:80"
`;
let yamlErrorTimeout = null;
let serviceStatusTimeout = null;
export default {
components: {
NetworkInput,
FontAwesomeIcon,
PrismEditor,
BModal,
},
beforeRouteUpdate(to, from, next) {
this.exitConfirm(next);
},
beforeRouteLeave(to, from, next) {
this.exitConfirm(next);
},
yamlDoc: null, // For keeping the yaml comments
data() {
return {
editorFocus: false,
jsonConfig: {},
yamlError: "",
processing: true,
showProgressTerminal: false,
progressTerminalRows: PROGRESS_TERMINAL_ROWS,
combinedTerminalRows: COMBINED_TERMINAL_ROWS,
combinedTerminalCols: COMBINED_TERMINAL_COLS,
stack: {
},
serviceStatusList: {},
isEditMode: false,
submitted: false,
showDeleteDialog: false,
newContainerName: "",
stopServiceStatusTimeout: false,
};
},
computed: {
isAdd() {
return this.$route.path === "/compose" && !this.submitted;
},
/**
* Get the stack from the global stack list, because it may contain more real-time data like status
* @return {*}
*/
globalStack() {
return this.$root.stackList[this.stack.name];
},
status() {
return this.globalStack?.status;
},
active() {
return this.status === RUNNING;
},
terminalName() {
if (!this.stack.name) {
return "";
}
return getComposeTerminalName(this.stack.name);
},
combinedTerminalName() {
if (!this.stack.name) {
return "";
}
return getCombinedTerminalName(this.stack.name);
},
networks() {
return this.jsonConfig.networks;
}
},
watch: {
"stack.composeYAML": {
handler() {
if (this.editorFocus) {
console.debug("yaml code changed");
this.yamlCodeChange();
}
},
deep: true,
},
jsonConfig: {
handler() {
if (!this.editorFocus) {
console.debug("jsonConfig changed");
let doc = new Document(this.jsonConfig);
// Stick back the yaml comments
if (this.yamlDoc) {
copyYAMLComments(doc, this.yamlDoc);
}
this.stack.composeYAML = doc.toString();
this.yamlDoc = doc;
}
},
deep: true,
},
},
mounted() {
if (this.isAdd) {
this.processing = false;
this.isEditMode = true;
let composeYAML;
if (this.$root.composeTemplate) {
composeYAML = this.$root.composeTemplate;
this.$root.composeTemplate = "";
} else {
composeYAML = template;
}
// Default Values
this.stack = {
name: "",
composeYAML,
isManagedByDockge: true,
};
this.yamlCodeChange();
} else {
this.stack.name = this.$route.params.stackName;
this.loadStack();
}
this.requestServiceStatus();
},
unmounted() {
this.stopServiceStatusTimeout = true;
clearTimeout(serviceStatusTimeout);
},
methods: {
startServiceStatusTimeout() {
clearTimeout(serviceStatusTimeout);
serviceStatusTimeout = setTimeout(async () => {
this.requestServiceStatus();
}, 2000);
},
requestServiceStatus() {
this.$root.getSocket().emit("serviceStatusList", this.stack.name, (res) => {
if (res.ok) {
this.serviceStatusList = res.serviceStatusList;
}
if (!this.stopServiceStatusTimeout) {
this.startServiceStatusTimeout();
}
});
},
exitConfirm(next) {
if (this.isEditMode) {
if (confirm("You are currently editing a stack. Are you sure you want to leave?")) {
next();
} else {
next(false);
}
} else {
next();
}
},
bindTerminal() {
this.$refs.progressTerminal?.bind(this.terminalName);
},
loadStack() {
this.processing = true;
this.$root.getSocket().emit("getStack", this.stack.name, (res) => {
if (res.ok) {
this.stack = res.stack;
this.yamlCodeChange();
this.processing = false;
this.bindTerminal();
} else {
this.$root.toastRes(res);
}
});
},
deployStack() {
this.processing = true;
if (!this.jsonConfig.services) {
this.$root.toastError("No services found in compose.yaml");
this.processing = false;
return;
}
// Check if services is object
if (typeof this.jsonConfig.services !== "object") {
this.$root.toastError("Services must be an object");
this.processing = false;
return;
}
let serviceNameList = Object.keys(this.jsonConfig.services);
// Set the stack name if empty, use the first container name
if (!this.stack.name && serviceNameList.length > 0) {
let serviceName = serviceNameList[0];
let service = this.jsonConfig.services[serviceName];
if (service && service.container_name) {
this.stack.name = service.container_name;
} else {
this.stack.name = serviceName;
}
}
this.bindTerminal(this.terminalName);
this.$root.getSocket().emit("deployStack", this.stack.name, this.stack.composeYAML, this.isAdd, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.isEditMode = false;
this.$router.push("/compose/" + this.stack.name);
}
});
},
saveStack() {
this.processing = true;
this.$root.getSocket().emit("saveStack", this.stack.name, this.stack.composeYAML, this.isAdd, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.isEditMode = false;
this.$router.push("/compose/" + this.stack.name);
}
});
},
startStack() {
this.processing = true;
this.$root.getSocket().emit("startStack", this.stack.name, (res) => {
this.processing = false;
this.$root.toastRes(res);
});
},
stopStack() {
this.processing = true;
this.$root.getSocket().emit("stopStack", this.stack.name, (res) => {
this.processing = false;
this.$root.toastRes(res);
});
},
restartStack() {
this.processing = true;
this.$root.getSocket().emit("restartStack", this.stack.name, (res) => {
this.processing = false;
this.$root.toastRes(res);
});
},
updateStack() {
this.processing = true;
this.$root.getSocket().emit("updateStack", this.stack.name, (res) => {
this.processing = false;
this.$root.toastRes(res);
});
},
deleteDialog() {
this.$root.getSocket().emit("deleteStack", this.stack.name, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.$router.push("/");
}
});
},
discardStack() {
this.loadStack();
this.isEditMode = false;
},
highlighter(code) {
return highlight(code, languages.yaml);
},
yamlCodeChange() {
try {
let doc = parseDocument(this.stack.composeYAML);
if (doc.errors.length > 0) {
throw doc.errors[0];
}
const config = doc.toJS() ?? {};
// Check data types
// "services" must be an object
if (!config.services) {
config.services = {};
}
if (Array.isArray(config.services) || typeof config.services !== "object") {
throw new Error("Services must be an object");
}
if (!config.version) {
config.version = "3.8";
}
this.yamlDoc = doc;
this.jsonConfig = config;
clearTimeout(yamlErrorTimeout);
this.yamlError = "";
} catch (e) {
clearTimeout(yamlErrorTimeout);
if (this.yamlError) {
this.yamlError = e.message;
} else {
yamlErrorTimeout = setTimeout(() => {
this.yamlError = e.message;
}, 3000);
}
}
},
enableEditMode() {
this.isEditMode = true;
},
checkYAML() {
},
addContainer() {
this.checkYAML();
if (this.jsonConfig.services[this.newContainerName]) {
this.$root.toastError("Container name already exists");
return;
}
if (!this.newContainerName) {
this.$root.toastError("Container name cannot be empty");
return;
}
this.jsonConfig.services[this.newContainerName] = {
restart: "unless-stopped",
};
this.newContainerName = "";
let element = this.$refs.containerList.lastElementChild;
element.scrollIntoView({
block: "start",
behavior: "smooth"
});
},
}
};
</script>
<style scoped lang="scss">
.terminal {
height: 200px;
}
.editor-box {
&.edit-mode {
background-color: #2c2f38 !important;
}
}
</style>

View File

@ -0,0 +1,48 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 class="mb-3">Console</h1>
<div>
<p>
Allowed commands:
<template v-for="(command, index) in allowedCommandList" :key="command">
<code>{{ command }}</code>
<!-- No comma at the end -->
<span v-if="index !== allowedCommandList.length - 1">, </span>
</template>
</p>
</div>
<Terminal class="terminal" :rows="20" mode="mainTerminal" name="console"></Terminal>
</div>
</transition>
</template>
<script>
import { allowedCommandList } from "../../../backend/util-common";
export default {
components: {
},
data() {
return {
allowedCommandList,
};
},
mounted() {
},
methods: {
}
};
</script>
<style scoped lang="scss">
.terminal {
height: 410px;
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 class="mb-3">Terminal - {{ serviceName }} ({{ stackName }})</h1>
<div class="mb-3">
<router-link :to="sh" class="btn btn-normal me-2">Switch to sh</router-link>
</div>
<Terminal class="terminal" :rows="20" mode="interactive" :name="terminalName" :stack-name="stackName" :service-name="serviceName" :shell="shell"></Terminal>
</div>
</transition>
</template>
<script>
import { getContainerExecTerminalName } from "../../../backend/util-common";
export default {
components: {
},
data() {
return {
};
},
computed: {
stackName() {
return this.$route.params.stackName;
},
shell() {
return this.$route.params.type;
},
serviceName() {
return this.$route.params.serviceName;
},
terminalName() {
return getContainerExecTerminalName(this.stackName, this.serviceName, 0);
},
sh() {
return {
name: "containerTerminal",
params: {
stackName: this.stackName,
serviceName: this.serviceName,
type: "sh",
},
};
},
},
mounted() {
},
methods: {
}
};
</script>
<style scoped lang="scss">
.terminal {
height: 410px;
}
</style>

View File

@ -0,0 +1,42 @@
<template>
<div class="container-fluid">
<div class="row">
<div v-if="!$root.isMobile" class="col-12 col-md-4 col-xl-3">
<div>
<router-link to="/compose" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("compose") }}</router-link>
</div>
<StackList :scrollbar="true" />
</div>
<div ref="container" class="col-12 col-md-8 col-xl-9 mb-3">
<!-- Add :key to disable vue router re-use the same component -->
<router-view :key="$route.fullPath" :calculatedHeight="height" />
</div>
</div>
</div>
</template>
<script>
import StackList from "../components/StackList.vue";
export default {
components: {
StackList,
},
data() {
return {
height: 0
};
},
mounted() {
this.height = this.$refs.container.offsetHeight;
},
};
</script>
<style lang="scss" scoped>
.container-fluid {
width: 98%;
}
</style>

View File

@ -0,0 +1,231 @@
<template>
<transition ref="tableContainer" name="slide-fade" appear>
<div v-if="$route.name === 'DashboardHome'">
<h1 class="mb-3">
{{ $t("home") }}
</h1>
<div class="shadow-box big-padding text-center mb-4">
<div class="row">
<div class="col">
<h3>{{ $t("active") }}</h3>
<span class="num active">{{ activeNum }}</span>
</div>
<div class="col">
<h3>{{ $t("exited") }}</h3>
<span class="num exited">{{ exitedNum }}</span>
</div>
<div class="col">
<h3>{{ $t("inactive") }}</h3>
<span class="num inactive">{{ inactiveNum }}</span>
</div>
</div>
</div>
<h2 class="mb-3">Docker Run</h2>
<div class="mb-3">
<textarea id="name" v-model="dockerRunCommand" type="text" class="form-control docker-run" required placeholder="docker run ..."></textarea>
</div>
<button class="btn-normal btn" @click="convertDockerRun">Convert to Compose</button>
</div>
</transition>
<router-view ref="child" />
</template>
<script>
import { statusNameShort } from "../../../backend/util-common";
export default {
components: {
},
props: {
calculatedHeight: {
type: Number,
default: 0
}
},
data() {
return {
page: 1,
perPage: 25,
initialPerPage: 25,
paginationConfig: {
hideCount: true,
chunksNavigation: "scroll",
},
importantHeartBeatListLength: 0,
displayedRecords: [],
dockerRunCommand: "",
};
},
computed: {
activeNum() {
return this.getStatusNum("active");
},
inactiveNum() {
return this.getStatusNum("inactive");
},
exitedNum() {
return this.getStatusNum("exited");
},
},
watch: {
perPage() {
this.$nextTick(() => {
this.getImportantHeartbeatListPaged();
});
},
page() {
this.getImportantHeartbeatListPaged();
},
},
mounted() {
this.initialPerPage = this.perPage;
window.addEventListener("resize", this.updatePerPage);
this.updatePerPage();
},
beforeUnmount() {
window.removeEventListener("resize", this.updatePerPage);
},
methods: {
getStatusNum(statusName) {
let num = 0;
for (let stackName in this.$root.stackList) {
const stack = this.$root.stackList[stackName];
if (statusNameShort(stack.status) === statusName) {
num += 1;
}
}
return num;
},
convertDockerRun() {
if (this.dockerRunCommand.trim() === "docker run") {
throw new Error("Please enter a docker run command");
}
// composerize is working in dev, but after "vite build", it is not working
// So pass to backend to do the conversion
this.$root.getSocket().emit("composerize", this.dockerRunCommand, (res) => {
if (res.ok) {
this.$root.composeTemplate = res.composeTemplate;
this.$router.push("/compose");
} else {
this.$root.toastRes(res);
}
});
},
/**
* Updates the displayed records when a new important heartbeat arrives.
* @param {object} heartbeat - The heartbeat object received.
* @returns {void}
*/
onNewImportantHeartbeat(heartbeat) {
if (this.page === 1) {
this.displayedRecords.unshift(heartbeat);
if (this.displayedRecords.length > this.perPage) {
this.displayedRecords.pop();
}
this.importantHeartBeatListLength += 1;
}
},
/**
* Retrieves the length of the important heartbeat list for all monitors.
* @returns {void}
*/
getImportantHeartbeatListLength() {
this.$root.getSocket().emit("monitorImportantHeartbeatListCount", null, (res) => {
if (res.ok) {
this.importantHeartBeatListLength = res.count;
this.getImportantHeartbeatListPaged();
}
});
},
/**
* Retrieves the important heartbeat list for the current page.
* @returns {void}
*/
getImportantHeartbeatListPaged() {
const offset = (this.page - 1) * this.perPage;
this.$root.getSocket().emit("monitorImportantHeartbeatListPaged", null, offset, this.perPage, (res) => {
if (res.ok) {
this.displayedRecords = res.data;
}
});
},
/**
* Updates the number of items shown per page based on the available height.
* @returns {void}
*/
updatePerPage() {
const tableContainer = this.$refs.tableContainer;
const tableContainerHeight = tableContainer.offsetHeight;
const availableHeight = window.innerHeight - tableContainerHeight;
const additionalPerPage = Math.floor(availableHeight / 58);
if (additionalPerPage > 0) {
this.perPage = Math.max(this.initialPerPage, this.perPage + additionalPerPage);
} else {
this.perPage = this.initialPerPage;
}
},
},
};
</script>
<style lang="scss" scoped>
@import "../styles/vars";
.num {
font-size: 30px;
font-weight: bold;
display: block;
&.active {
color: $primary;
}
&.exited {
color: $danger;
}
}
.shadow-box {
padding: 20px;
}
table {
font-size: 14px;
tr {
transition: all ease-in-out 0.2ms;
}
@media (max-width: 550px) {
table-layout: fixed;
overflow-wrap: break-word;
}
}
.docker-run {
background-color: $dark-bg !important;
border: none;
}
</style>

View File

@ -0,0 +1,252 @@
<template>
<div>
<h1 v-show="show" class="mb-3">
{{ $t("Settings") }}
</h1>
<div class="shadow-box shadow-box-settings">
<div class="row">
<div v-if="showSubMenu" class="settings-menu col-lg-3 col-md-5">
<router-link
v-for="(item, key) in subMenus"
:key="key"
:to="`/settings/${key}`"
>
<div class="menu-item">
{{ item.title }}
</div>
</router-link>
<!-- Logout Button -->
<a v-if="$root.isMobile && $root.loggedIn && $root.socket.token !== 'autoLogin'" class="logout" @click.prevent="$root.logout">
<div class="menu-item">
<font-awesome-icon icon="sign-out-alt" />
{{ $t("Logout") }}
</div>
</a>
</div>
<div class="settings-content col-lg-9 col-md-7">
<div v-if="currentPage" class="settings-content-header">
{{ subMenus[currentPage].title }}
</div>
<div class="mx-3">
<router-view v-slot="{ Component }">
<transition name="slide-fade" appear>
<component :is="Component" />
</transition>
</router-view>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { useRoute } from "vue-router";
export default {
data() {
return {
show: true,
settings: {},
settingsLoaded: false,
};
},
computed: {
currentPage() {
let pathSplit = useRoute().path.split("/");
let pathEnd = pathSplit[pathSplit.length - 1];
if (!pathEnd || pathEnd === "settings") {
return null;
}
return pathEnd;
},
showSubMenu() {
if (this.$root.isMobile) {
return !this.currentPage;
} else {
return true;
}
},
subMenus() {
return {
general: {
title: this.$t("General"),
},
appearance: {
title: this.$t("Appearance"),
},
security: {
title: this.$t("Security"),
},
about: {
title: this.$t("About"),
},
};
},
},
watch: {
"$root.isMobile"() {
this.loadGeneralPage();
}
},
mounted() {
this.loadSettings();
this.loadGeneralPage();
},
methods: {
/**
* Load the general settings page
* For desktop only, on mobile do nothing
*/
loadGeneralPage() {
if (!this.currentPage && !this.$root.isMobile) {
this.$router.push("/settings/appearance");
}
},
/** Load settings from server */
loadSettings() {
this.$root.getSocket().emit("getSettings", (res) => {
this.settings = res.data;
if (this.settings.checkUpdate === undefined) {
this.settings.checkUpdate = true;
}
this.settingsLoaded = true;
});
},
/**
* Callback for saving settings
* @callback saveSettingsCB
* @param {Object} res Result of operation
*/
/**
* Save Settings
* @param {saveSettingsCB} [callback]
* @param {string} [currentPassword] Only need for disableAuth to true
*/
saveSettings(callback, currentPassword) {
let valid = this.validateSettings();
if (valid.success) {
this.$root.getSocket().emit("setSettings", this.settings, currentPassword, (res) => {
this.$root.toastRes(res);
this.loadSettings();
if (callback) {
callback();
}
});
} else {
this.$root.toastError(valid.msg);
}
},
/**
* Ensure settings are valid
* @returns {Object} Contains success state and error msg
*/
validateSettings() {
if (this.settings.keepDataPeriodDays < 0) {
return {
success: false,
msg: this.$t("dataRetentionTimeError"),
};
}
return {
success: true,
msg: "",
};
},
}
};
</script>
<style lang="scss" scoped>
@import "../styles/vars.scss";
.shadow-box-settings {
padding: 20px;
min-height: calc(100vh - 155px);
}
footer {
color: #aaa;
font-size: 13px;
margin-top: 20px;
padding-bottom: 30px;
text-align: center;
}
.settings-menu {
a {
text-decoration: none !important;
}
.menu-item {
border-radius: 10px;
margin: 0.5em;
padding: 0.7em 1em;
cursor: pointer;
border-left-width: 0;
transition: all ease-in-out 0.1s;
}
.menu-item:hover {
background: $highlight-white;
.dark & {
background: $dark-header-bg;
}
}
.active .menu-item {
background: $highlight-white;
border-left: 4px solid $primary;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
.dark & {
background: $dark-header-bg;
}
}
}
.settings-content {
.settings-content-header {
width: calc(100% + 20px);
border-bottom: 1px solid #dee2e6;
border-radius: 0 10px 0 0;
margin-top: -20px;
margin-right: -20px;
padding: 12.5px 1em;
font-size: 26px;
.dark & {
background: $dark-header-bg;
border-bottom: 0;
}
.mobile & {
padding: 15px 0 0 0;
.dark & {
background-color: transparent;
}
}
}
}
.logout {
color: $danger !important;
}
</style>

View File

@ -0,0 +1,138 @@
<template>
<div class="form-container" data-cy="setup-form">
<div class="form">
<form @submit.prevent="submit">
<div>
<object width="64" height="64" data="/icon.svg" />
<div style="font-size: 28px; font-weight: bold; margin-top: 5px;">
Dockge
</div>
</div>
<p class="mt-3">
{{ $t("Create your admin account") }}
</p>
<div class="form-floating">
<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>
<label for="language" class="form-label">{{ $t("Language") }}</label>
</div>
<div class="form-floating mt-3">
<input id="floatingInput" v-model="username" type="text" class="form-control" :placeholder="$t('Username')" required data-cy="username-input">
<label for="floatingInput">{{ $t("Username") }}</label>
</div>
<div class="form-floating mt-3">
<input id="floatingPassword" v-model="password" type="password" class="form-control" :placeholder="$t('Password')" required data-cy="password-input">
<label for="floatingPassword">{{ $t("Password") }}</label>
</div>
<div class="form-floating mt-3">
<input id="repeat" v-model="repeatPassword" type="password" class="form-control" :placeholder="$t('Repeat Password')" required data-cy="password-repeat-input">
<label for="repeat">{{ $t("Repeat Password") }}</label>
</div>
<button class="w-100 btn btn-primary mt-3" type="submit" :disabled="processing" data-cy="submit-setup-form">
{{ $t("Create") }}
</button>
</form>
</div>
</div>
</template>
<script>
export default {
data() {
return {
processing: false,
username: "",
password: "",
repeatPassword: "",
};
},
watch: {
},
mounted() {
// TODO: Check if it is a database setup
this.$root.getSocket().emit("needSetup", (needSetup) => {
if (! needSetup) {
this.$router.push("/");
}
});
},
methods: {
/**
* Submit form data for processing
* @returns {void}
*/
submit() {
this.processing = true;
if (this.password !== this.repeatPassword) {
this.$root.toastError("PasswordsDoNotMatch");
this.processing = false;
return;
}
this.$root.getSocket().emit("setup", this.username, this.password, (res) => {
this.processing = false;
this.$root.toastRes(res);
if (res.ok) {
this.processing = true;
this.$root.login(this.username, this.password, "", () => {
this.processing = false;
this.$router.push("/");
});
}
});
},
},
};
</script>
<style lang="scss" scoped>
.form-container {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
}
.form-floating {
> .form-select {
padding-left: 1.3rem;
padding-top: 1.525rem;
line-height: 1.35;
~ label {
padding-left: 1.3rem;
}
}
> 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>

90
frontend/src/router.ts Normal file
View File

@ -0,0 +1,90 @@
import { createRouter, createWebHistory } from "vue-router";
import Layout from "./layouts/Layout.vue";
import Setup from "./pages/Setup.vue";
import Dashboard from "./pages/Dashboard.vue";
import DashboardHome from "./pages/DashboardHome.vue";
import Console from "./pages/Console.vue";
import Compose from "./pages/Compose.vue";
import ContainerTerminal from "./pages/ContainerTerminal.vue";
const Settings = () => import("./pages/Settings.vue");
// Settings - Sub Pages
import Appearance from "./components/settings/Appearance.vue";
import General from "./components/settings/General.vue";
const Security = () => import("./components/settings/Security.vue");
import About from "./components/settings/About.vue";
const routes = [
{
path: "/empty",
component: Layout,
children: [
{
path: "",
component: Dashboard,
children: [
{
name: "DashboardHome",
path: "/",
component: DashboardHome,
children: [
{
path: "/compose",
component: Compose,
},
{
path: "/compose/:stackName",
name: "compose",
component: Compose,
props: true,
},
{
path: "/terminal/:stackName/:serviceName/:type",
component: ContainerTerminal,
name: "containerTerminal",
},
]
},
{
path: "/console",
component: Console,
},
{
path: "/settings",
component: Settings,
children: [
{
path: "general",
component: General,
},
{
path: "appearance",
component: Appearance,
},
{
path: "security",
component: Security,
},
{
path: "about",
component: About,
},
]
},
]
},
]
},
{
path: "/setup",
component: Setup,
},
];
export const router = createRouter({
linkActiveClass: "active",
history: createWebHistory(),
routes,
});

View File

@ -0,0 +1,9 @@
html[lang='fa'] {
#app {
font-family: 'IRANSans', 'Iranian Sans','B Nazanin', 'Tahoma', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji;
}
}
ul.multiselect__content {
padding-left: 0 !important;
}

View File

@ -0,0 +1,697 @@
@import "vars.scss";
@import "bootstrap/scss/bootstrap";
@import "bootstrap-vue-next/dist/bootstrap-vue-next.css";
#app {
font-family: BlinkMacSystemFont, segoe ui, Roboto, helvetica neue, Arial, noto sans, sans-serif, apple color emoji, segoe ui emoji, segoe ui symbol, noto color emoji;
}
h1 {
font-size: 32px;
}
h2 {
font-size: 26px;
}
textarea.form-control {
border-radius: 19px;
}
::-webkit-scrollbar {
width: 10px;
}
.bg-maintenance {
color: white !important;
background-color: $maintenance !important;
}
.bg-dark {
color: white;
}
.text-maintenance {
color: $maintenance !important;
}
::placeholder {
color: $dark-font-color3 !important;
}
.incident a,
.bg-maintenance a {
color: inherit;
}
.list-group {
border-radius: 0.75rem;
.dark & {
.list-group-item {
background-color: $dark-bg2;
color: $dark-font-color;
border-color: $dark-border-color;
}
}
}
// optgroup
optgroup {
color: #b1b1b1;
option {
color: #212529;
}
}
.dark {
optgroup {
color: #535864;
option {
color: $dark-font-color;
}
}
}
// Scrollbar
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 20px;
}
.modal {
backdrop-filter: blur(3px);
}
.modal-content {
border-radius: 1rem;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
.dark & {
box-shadow: 0 15px 70px rgb(0 0 0);
background-color: $dark-bg;
}
}
.VuePagination__count {
font-size: 13px;
text-align: center;
}
.shadow-box {
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
padding: 10px;
border-radius: 10px;
&.big-padding {
padding: 20px;
}
}
.btn {
padding-left: 20px;
padding-right: 20px;
}
.btn-sm {
border-radius: 25px;
}
.btn-primary {
color: white;
background: $primary-gradient;
&:hover, &:active, &:focus, &.active {
color: white;
background: $primary-gradient-active;
border-color: $highlight;
}
.dark & {
color: $dark-font-color2;
}
}
.btn-normal {
$bg-color: #F5F5F5;
background-color: $bg-color;
border-color: $bg-color;
&:hover {
$hover-color: darken($bg-color, 3%);
background-color: $hover-color;
border-color: $hover-color;
}
}
.btn-warning {
color: white;
&:hover, &:active, &:focus, &.active {
color: white;
}
}
.btn-info {
color: white;
&:hover, &:active, &:focus, &.active {
color: white;
}
}
.btn-dark {
background-color: #161B22;
}
.btn-outline-normal {
padding: 4px 10px;
border: 1px solid #ced4da;
border-radius: 25px;
background-color: transparent;
.dark & {
color: $dark-font-color;
border: 1px solid $dark-font-color2;
}
&.active {
background-color: $highlight-white;
.dark & {
background-color: $dark-font-color2;
}
}
}
@media (max-width: 550px) {
.table-shadow-box {
padding: 10px !important;
thead {
display: none;
}
tbody {
.shadow-box {
background-color: white;
}
}
tr {
margin-top: 0 !important;
padding: 4px 10px !important;
display: block;
margin-bottom: 6px;
td:first-child {
font-weight: bold;
}
td:nth-child(-n+3) {
text-align: center;
}
td:last-child {
text-align: left;
}
td {
border-bottom: 1px solid $dark-font-color;
display: block;
padding: 4px;
.badge {
margin: auto;
display: block;
width: 30%;
}
}
}
}
}
// Dark Theme override here
.dark {
background-color: #090c10;
color: $dark-font-color;
mark, .mark {
background-color: #b6ad86;
}
&::-webkit-scrollbar-thumb, ::-webkit-scrollbar-thumb {
background: $dark-border-color;
}
.shadow-box {
&:not(.alert) {
background-color: $dark-bg;
}
}
.form-check-input {
background-color: $dark-bg2;
border-color: $dark-border-color;
}
.input-group-text {
background-color: #282f39;
border-color: $dark-border-color;
color: $dark-font-color;
}
.form-check-input:checked {
border-color: $primary; // Re-apply bootstrap border
}
.form-switch .form-check-input {
background-color: #232f3b;
}
a:not(.btn),
.table,
.nav-link {
color: $dark-font-color;
&.btn-info {
color: white;
}
}
.incident a,
.bg-maintenance a {
color: inherit;
}
.form-control,
.form-control:focus,
.form-select,
.form-select:focus {
color: $dark-font-color;
background-color: $dark-bg2;
}
.form-select:disabled {
color: rgba($dark-font-color, 0.7);
background-color: $dark-bg;
}
.form-control, .form-select {
border-color: $dark-border-color;
}
.form-control:disabled, .form-control[readonly] {
background-color: #232f3b;
opacity: 1;
}
.table-hover > tbody > tr:hover > * {
--bs-table-accent-bg: #070a10;
color: $dark-font-color;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: $dark-font-color2;
background: $primary-gradient;
&:hover {
background: $primary-gradient-active;
}
}
.bg-primary {
color: $dark-font-color2;
background: $primary-gradient;
}
.btn-secondary {
color: white;
}
.btn-normal {
$bg-color: $dark-header-bg;
color: $dark-font-color;
background-color: $bg-color;
border-color: $bg-color;
&:hover {
$hover-color: darken($bg-color, 3%);
background-color: $hover-color;
border-color: $hover-color;
}
}
.btn-warning {
color: $dark-font-color2;
&:hover, &:active, &:focus, &.active {
color: $dark-font-color2;
}
}
.btn-close {
box-shadow: none;
filter: invert(1);
&:hover {
opacity: 0.6;
}
}
.modal-header {
border-color: $dark-bg;
}
.modal-footer {
border-color: $dark-bg;
}
// Pagination
.page-item.disabled .page-link {
background-color: $dark-bg;
border-color: $dark-border-color;
}
.page-link {
background-color: $dark-bg;
border-color: $dark-border-color;
color: $dark-font-color;
}
.stack-list {
.item {
&:hover {
background-color: $dark-bg2;
}
&.active {
background-color: $dark-bg2;
}
}
}
@media (max-width: 550px) {
.table-shadow-box {
tbody {
.shadow-box {
background-color: $dark-bg2;
td {
border-bottom: 1px solid $dark-border-color;
}
}
}
}
}
.alert {
&.bg-info,
&.bg-warning,
&.bg-danger,
&.bg-maintenance,
&.bg-light {
color: $dark-font-color2;
}
}
}
// Floating Label
.form-floating > .form-control:focus ~ label::after, .form-floating > .form-control:not(:placeholder-shown) ~ label::after, .form-floating > .form-control-plaintext ~ label::after, .form-floating > .form-select ~ label::after {
background-color: transparent;
}
.form-floating > label {
.dark & {
color: $dark-font-color3 !important;
}
}
/*
* Transitions
*/
// page-change
.slide-fade-enter-active {
transition: all 0.2s $easing-in;
}
.slide-fade-leave-active {
transition: all 0.2s $easing-in;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(50px);
opacity: 0;
}
.slide-fade-right-enter-active {
transition: all 0.2s $easing-in;
}
.slide-fade-right-leave-active {
transition: all 0.2s $easing-in;
}
.slide-fade-right-enter-from,
.slide-fade-right-leave-to {
transform: translateX(50px);
opacity: 0;
}
.slide-fade-up-enter-active {
transition: all 0.2s $easing-in;
}
.slide-fade-up-leave-active {
transition: all 0.2s $easing-in;
}
.slide-fade-up-enter-from,
.slide-fade-up-leave-to {
transform: translateY(-50px);
opacity: 0;
}
.stack-list {
&.scrollbar {
overflow-y: auto;
}
@media (max-width: 770px) {
&.scrollbar {
height: calc(100% - 97px);
}
}
.item {
display: flex;
align-items: center;
height: 52px;
text-decoration: none;
border-radius: 10px;
transition: all ease-in-out 0.15s;
width: 100%;
padding: 0 8px;
&.disabled {
opacity: 0.3;
}
&:hover {
background-color: $highlight-white;
}
&.active {
background-color: #cdf8f4;
}
.title {
display: inline-block;
margin-top: -4px;
}
}
}
.alert-success {
color: #122f21;
background-color: $primary;
border-color: $primary;
}
.alert-info {
color: #055160;
background-color: #cff4fc;
border-color: #cff4fc;
}
.alert-danger {
color: #842029;
background-color: #f8d7da;
border-color: #f8d7da;
}
.btn-success {
color: #fff;
background-color: #4caf50;
border-color: #4caf50;
}
[contenteditable=true] {
transition: all $easing-in 0.2s;
background-color: rgba(239, 239, 239, 0.7);
border-radius: 8px;
&.no-bg {
background-color: transparent !important;
}
&:focus {
outline: 0 solid #eee;
background-color: rgba(245, 245, 245, 0.9);
}
&:hover {
background-color: rgba(239, 239, 239, 0.8);
}
.dark & {
background-color: rgba(239, 239, 239, 0.2);
}
/*
&::after {
margin-left: 5px;
content: "🖊️";
font-size: 13px;
color: #eee;
}
*/
}
.action {
transition: all $easing-in 0.2s;
&:hover {
cursor: pointer;
transform: scale(1.2);
}
}
.vue-image-crop-upload .vicp-wrap {
border-radius: 10px !important;
}
.spinner {
color: $primary;
}
.prism-editor__textarea {
outline: none !important;
}
h5.settings-subheading::after {
content: "";
display: block;
width: 50%;
padding-top: 8px;
border-bottom: 1px solid $dark-border-color;
}
/* required class */
.code-editor, .css-editor {
/* we dont use `language-` classes anymore so thats why we need to add background and text color manually */
border-radius: 1rem;
padding: 10px 5px;
border: 1px solid #ced4da;
.dark & {
background: $dark-bg2;
border: 1px solid $dark-border-color;
}
}
$shadow-box-padding: 20px;
.shadow-box-with-fixed-bottom-bar {
padding-top: $shadow-box-padding;
padding-bottom: 0;
padding-right: $shadow-box-padding;
padding-left: $shadow-box-padding;
}
.fixed-bottom-bar {
position: sticky;
bottom: 0;
margin-left: -$shadow-box-padding;
margin-right: -$shadow-box-padding;
z-index: 100;
background-color: rgba(white, 0.2);
backdrop-filter: blur(2px);
border-radius: 0 0 10px 10px;
.dark & {
background-color: rgba($dark-header-bg, 0.9);
}
}
@media (max-width: 770px) {
.toast-container {
margin-bottom: 100px !important;
}
}
@media (max-width: 550px) {
.toast-container {
margin-bottom: 126px !important;
}
}
.main-terminal {
.xterm-viewport {
border-radius: 10px;
background-color: $dark-bg !important;
}
}
code {
padding: .2em .4em;
margin: 0;
font-size: 85%;
white-space: break-spaces;
background-color: rgba(239, 239, 239, 0.15);
border-radius: 6px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: black;
.dark & {
color: $dark-font-color;
}
}
// Vue Prism Editor bug - workaround
// https://github.com/koca/vue-prism-editor/issues/87
/*
.prism-editor__textarea {
width: 999999px !important;
}
.prism-editor__editor {
white-space: pre !important;
}
.prism-editor__container {
overflow-x: scroll !important;
}*/
// Localization
@import "localization.scss";

View File

@ -0,0 +1,26 @@
$primary: #74c2ff;
$danger: #dc3545;
$warning: #f8a306;
$maintenance: #1747f5;
$link-color: #111;
$border-radius: 50rem;
$highlight: #9dd1ff;
$highlight-white: #e7faec;
$dark-font-color: #b1b8c0;
$dark-font-color2: #020b05;
$dark-font-color3: #575c62;
$dark-bg: #0d1117;
$dark-bg2: #070a10;
$dark-border-color: #1d2634;
$dark-header-bg: #161b22;
$easing-in: cubic-bezier(0.54, 0.78, 0.55, 0.97);
$easing-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
$easing-in-out: cubic-bezier(0.79, 0.14, 0.15, 0.86);
$dropdown-border-radius: 0.5rem;
$primary-gradient: linear-gradient(135deg, #74c2ff 0%, #74c2ff 75%, #86e6a9);
$primary-gradient-active: linear-gradient(135deg, #74c2ff 0%, #74c2ff 50%, #86e6a9);

View File

@ -0,0 +1,215 @@
import dayjs from "dayjs";
import timezones from "timezones-list";
import { localeDirection, currentLocale } from "./i18n";
import { POSITION } from "vue-toastification";
/**
* Returns the offset from UTC in hours for the current locale.
* @param {string} timeZone Timezone to get offset for
* @returns {number} The offset from UTC in hours.
*
* Generated by Trelent
*/
function getTimezoneOffset(timeZone) {
const now = new Date();
const tzString = now.toLocaleString("en-US", {
timeZone,
});
const localString = now.toLocaleString("en-US");
const diff = (Date.parse(localString) - Date.parse(tzString)) / 3600000;
const offset = diff + now.getTimezoneOffset() / 60;
return -offset;
}
/**
* Returns a list of timezones sorted by their offset from UTC.
* @returns {object[]} A list of the given timezones sorted by their offset from UTC.
*
* Generated by Trelent
*/
export function timezoneList() {
let result = [];
for (let timezone of timezones) {
try {
let display = dayjs().tz(timezone.tzCode).format("Z");
result.push({
name: `(UTC${display}) ${timezone.tzCode}`,
value: timezone.tzCode,
time: getTimezoneOffset(timezone.tzCode),
});
} catch (e) {
// Skipping not supported timezone.tzCode by dayjs
}
}
result.sort((a, b) => {
if (a.time > b.time) {
return 1;
}
if (b.time > a.time) {
return -1;
}
return 0;
});
return result;
}
/**
* Set the locale of the HTML page
* @returns {void}
*/
export function setPageLocale() {
const html = document.documentElement;
html.setAttribute("lang", currentLocale() );
html.setAttribute("dir", localeDirection() );
}
/**
* Get the base URL
* Mainly used for dev, because the backend and the frontend are in different ports.
* @returns {string} Base URL
*/
export function getResBaseURL() {
const env = process.env.NODE_ENV;
if (env === "development" && isDevContainer()) {
return location.protocol + "//" + getDevContainerServerHostname();
} else if (env === "development" || localStorage.dev === "dev") {
return location.protocol + "//" + location.hostname + ":3001";
} else {
return "";
}
}
/**
* Are we currently running in a dev container?
* @returns {boolean} Running in dev container?
*/
export function isDevContainer() {
// eslint-disable-next-line no-undef
return (typeof DEVCONTAINER === "string" && DEVCONTAINER === "1");
}
/**
* Supports GitHub Codespaces only currently
* @returns {string} Dev container server hostname
*/
export function getDevContainerServerHostname() {
if (!isDevContainer()) {
return "";
}
// eslint-disable-next-line no-undef
return CODESPACE_NAME + "-3001." + GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN;
}
/**
* Regex pattern fr identifying hostnames and IP addresses
* @param {boolean} mqtt whether or not the regex should take into
* account the fact that it is an mqtt uri
* @returns {RegExp} The requested regex
*/
export function hostNameRegexPattern(mqtt = false) {
// mqtt, mqtts, ws and wss schemes accepted by mqtt.js (https://github.com/mqttjs/MQTT.js/#connect)
const mqttSchemeRegexPattern = "((mqtt|ws)s?:\\/\\/)?";
// Source: https://digitalfortress.tech/tips/top-15-commonly-used-regex/
const ipRegexPattern = `((^${mqtt ? mqttSchemeRegexPattern : ""}((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))$)|(^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))(%.+)?$))`;
// Source: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
const hostNameRegexPattern = `^${mqtt ? mqttSchemeRegexPattern : ""}([a-zA-Z0-9])?(([a-zA-Z0-9_]|[a-zA-Z0-9_][a-zA-Z0-9\\-_]*[a-zA-Z0-9_])\\.)*([A-Za-z0-9_]|[A-Za-z0-9_][A-Za-z0-9\\-_]*[A-Za-z0-9_])(\\.)?$`;
return `${ipRegexPattern}|${hostNameRegexPattern}`;
}
/**
* Get the tag color options
* Shared between components
* @param {any} self Component
* @returns {object[]} Colour options
*/
export function colorOptions(self) {
return [
{ name: self.$t("Gray"),
color: "#4B5563" },
{ name: self.$t("Red"),
color: "#DC2626" },
{ name: self.$t("Orange"),
color: "#D97706" },
{ name: self.$t("Green"),
color: "#059669" },
{ name: self.$t("Blue"),
color: "#2563EB" },
{ name: self.$t("Indigo"),
color: "#4F46E5" },
{ name: self.$t("Purple"),
color: "#7C3AED" },
{ name: self.$t("Pink"),
color: "#DB2777" },
];
}
/**
* Loads the toast timeout settings from storage.
* @returns {object} The toast plugin options object.
*/
export function loadToastSettings() {
return {
position: POSITION.BOTTOM_RIGHT,
containerClassName: "toast-container",
showCloseButtonOnHover: true,
filterBeforeCreate: (toast, toasts) => {
if (toast.timeout === 0) {
return false;
} else {
return toast;
}
},
};
}
/**
* Get timeout for success toasts
* @returns {(number|boolean)} Timeout in ms. If false timeout disabled.
*/
export function getToastSuccessTimeout() {
let successTimeout = 20000;
if (localStorage.toastSuccessTimeout !== undefined) {
const parsedTimeout = parseInt(localStorage.toastSuccessTimeout);
if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {
successTimeout = parsedTimeout;
}
}
if (successTimeout === -1) {
successTimeout = false;
}
return successTimeout;
}
/**
* Get timeout for error toasts
* @returns {(number|boolean)} Timeout in ms. If false timeout disabled.
*/
export function getToastErrorTimeout() {
let errorTimeout = -1;
if (localStorage.toastErrorTimeout !== undefined) {
const parsedTimeout = parseInt(localStorage.toastErrorTimeout);
if (parsedTimeout != null && !Number.isNaN(parsedTimeout)) {
errorTimeout = parsedTimeout;
}
}
if (errorTimeout === -1) {
errorTimeout = false;
}
return errorTimeout;
}

7
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

36
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Components from "unplugin-vue-components/vite";
import { BootstrapVueNextResolver } from "unplugin-vue-components/resolvers";
import viteCompression from "vite-plugin-compression";
import "vue";
const viteCompressionFilter = /\.(js|mjs|json|css|html|svg)$/i;
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 5000,
},
define: {
"FRONTEND_VERSION": JSON.stringify(process.env.npm_package_version),
},
root: "./frontend",
build: {
outDir: "../frontend-dist",
},
plugins: [
vue(),
Components({
resolvers: [ BootstrapVueNextResolver() ],
}),
viteCompression({
algorithm: "gzip",
filter: viteCompressionFilter,
}),
viteCompression({
algorithm: "brotliCompress",
filter: viteCompressionFilter,
}),
],
});

79
package.json Normal file
View File

@ -0,0 +1,79 @@
{
"name": "dockge",
"version": "1.0.0",
"type": "module",
"scripts": {
"fmt": "eslint \"**/*.{ts,vue}\" --fix",
"lint": "eslint \"**/*.{ts,vue}\"",
"start": "tsx ./backend/index.ts",
"dev:backend": "cross-env NODE_ENV=development tsx watch ./backend/index.ts",
"dev:frontend": "cross-env NODE_ENV=development vite --host --config ./frontend/vite.config.ts",
"build:frontend": "vite build --config ./frontend/vite.config.ts",
"build:docker-base": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:base -f ./docker/Base.Dockerfile . --push",
"build:docker": "pnpm run build:frontend && docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:latest -t louislam/dockge:1 -t louislam/dockge:1.0.0 -f ./docker/Dockerfile . --push",
"build:docker-nightly": "pnpm run build:frontend && docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/dockge:nightly --target nightly -f ./docker/Dockerfile . --push",
"start-docker": "docker run --rm -p 5001:5001 --name dockge louislam/dockge:latest",
"mark-as-nightly": "tsx ./extra/mark-as-nightly.ts"
},
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "~0.11.10",
"@louislam/sqlite3": "~15.1.6",
"bcryptjs": "~2.4.3",
"check-password-strength": "~2.0.7",
"command-exists": "~1.2.9",
"compare-versions": "~6.1.0",
"composerize": "~1.4.1",
"croner": "~7.0.4",
"dayjs": "~1.11.10",
"express": "~4.18.2",
"express-static-gzip": "~2.1.7",
"http-graceful-shutdown": "~3.1.13",
"jsonwebtoken": "~9.0.2",
"jwt-decode": "~3.1.2",
"knex": "~2.5.1",
"limiter-es6-compat": "~2.1.2",
"mysql2": "^3.6.3",
"redbean-node": "0.3.2",
"socket.io": "~4.7.2",
"socket.io-client": "~4.7.2",
"timezones-list": "~3.0.2",
"ts-command-line-args": "~2.5.1",
"tsx": "~3.14.0",
"type-fest": "~4.3.3",
"yaml": "~2.3.4"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "6.4.2",
"@fortawesome/free-regular-svg-icons": "6.4.2",
"@fortawesome/free-solid-svg-icons": "6.4.2",
"@fortawesome/vue-fontawesome": "3.0.3",
"@types/bootstrap": "~5.2.8",
"@types/command-exists": "~1.2.3",
"@types/express": "~4.17.21",
"@types/jsonwebtoken": "~9.0.4",
"@typescript-eslint/eslint-plugin": "~6.8.0",
"@typescript-eslint/parser": "~6.8.0",
"@vitejs/plugin-vue": "~4.3.4",
"bootstrap": "5.3.2",
"bootstrap-vue-next": "~0.14.10",
"cross-env": "~7.0.3",
"eslint": "~8.50.0",
"eslint-plugin-jsdoc": "~46.8.2",
"eslint-plugin-vue": "~9.17.0",
"prismjs": "~1.29.0",
"sass": "~1.68.0",
"typescript": "~5.2.2",
"unplugin-vue-components": "~0.25.2",
"vite": "~4.5.0",
"vite-plugin-compression": "~0.5.1",
"vue": "~3.3.8",
"vue-eslint-parser": "~9.3.2",
"vue-i18n": "~9.5.0",
"vue-prism-editor": "2.0.0-alpha.2",
"vue-qrcode": "~2.2.0",
"vue-router": "~4.2.5",
"vue-toastification": "2.0.0-rc.5",
"xterm": "~5.4.0-beta.37",
"xterm-addon-web-links": "~0.9.0"
}
}

4477
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

8
tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"strict": true,
"moduleResolution": "bundler"
}
}