mirror of
https://github.com/louislam/dockge.git
synced 2024-11-21 15:53:34 +01:00
Init (#1)
This commit is contained in:
parent
205bff2359
commit
6749e343ba
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@ -0,0 +1,17 @@
|
||||
# Should be identical to .gitignore
|
||||
.env
|
||||
node_modules
|
||||
.idea
|
||||
data
|
||||
stacks
|
||||
tmp
|
||||
/private
|
||||
|
||||
# Docker extra
|
||||
docker
|
||||
frontend
|
||||
.editorconfig
|
||||
.eslintrc.cjs
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
24
.editorconfig
Normal file
24
.editorconfig
Normal file
@ -0,0 +1,24 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.yaml]
|
||||
indent_size = 2
|
||||
|
||||
[*.yml]
|
||||
indent_size = 2
|
||||
|
||||
[*.vue]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
97
.eslintrc.cjs
Normal file
97
.eslintrc.cjs
Normal 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
12
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: louislam # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
#patreon: # Replace with a single Patreon username
|
||||
open_collective: uptime-kuma # Replace with a single Open Collective username
|
||||
#ko_fi: # Replace with a single Ko-fi username
|
||||
#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
#liberapay: # Replace with a single Liberapay username
|
||||
#issuehunt: # Replace with a single IssueHunt username
|
||||
#otechie: # Replace with a single Otechie username
|
||||
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
# Should update .dockerignore as well
|
||||
.env
|
||||
node_modules
|
||||
.idea
|
||||
data
|
||||
stacks
|
||||
tmp
|
||||
/private
|
||||
|
||||
# Git only
|
||||
frontend-dist
|
||||
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Louis Lam
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
130
README.md
130
README.md
@ -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
71
backend/check-version.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { log } from "./log";
|
||||
import compareVersions from "compare-versions";
|
||||
import packageJSON from "../package.json";
|
||||
import { Settings } from "./settings";
|
||||
|
||||
export const obj = {
|
||||
version: packageJSON.version,
|
||||
latestVersion: null,
|
||||
};
|
||||
export default obj;
|
||||
|
||||
// How much time in ms to wait between update checks
|
||||
const UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 60 * 48;
|
||||
const CHECK_URL = "https://dockge.kuma.pet/version";
|
||||
|
||||
let interval : NodeJS.Timeout;
|
||||
|
||||
export function startInterval() {
|
||||
const check = async () => {
|
||||
if (await Settings.get("checkUpdate") === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("update-checker", "Retrieving latest versions");
|
||||
|
||||
try {
|
||||
const res = await fetch(CHECK_URL);
|
||||
const data = await res.json();
|
||||
|
||||
// For debug
|
||||
if (process.env.TEST_CHECK_VERSION === "1") {
|
||||
data.slow = "1000.0.0";
|
||||
}
|
||||
|
||||
const checkBeta = await Settings.get("checkBeta");
|
||||
|
||||
if (checkBeta && data.beta) {
|
||||
if (compareVersions.compare(data.beta, data.slow, ">")) {
|
||||
obj.latestVersion = data.beta;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.slow) {
|
||||
obj.latestVersion = data.slow;
|
||||
}
|
||||
|
||||
} catch (_) {
|
||||
log.info("update-checker", "Failed to check for new versions");
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
check();
|
||||
interval = setInterval(check, UPDATE_CHECKER_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the check update feature
|
||||
* @param value Should the check update feature be enabled?
|
||||
* @returns
|
||||
*/
|
||||
export async function enableCheckUpdate(value : boolean) {
|
||||
await Settings.set("checkUpdate", value);
|
||||
|
||||
clearInterval(interval);
|
||||
|
||||
if (value) {
|
||||
startInterval();
|
||||
}
|
||||
}
|
247
backend/database.ts
Normal file
247
backend/database.ts
Normal 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
3
backend/docker.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export class Docker {
|
||||
|
||||
}
|
559
backend/dockge-server.ts
Normal file
559
backend/dockge-server.ts
Normal 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
6
backend/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { DockgeServer } from "./dockge-server";
|
||||
import { log } from "./log";
|
||||
|
||||
log.info("server", "Welcome to dockge!");
|
||||
const server = new DockgeServer();
|
||||
await server.serve();
|
208
backend/log.ts
Normal file
208
backend/log.ts
Normal 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();
|
14
backend/migrations/2023-10-20-0829-setting-table.ts
Normal file
14
backend/migrations/2023-10-20-0829-setting-table.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
return knex.schema.createTable("setting", (table) => {
|
||||
table.increments("id");
|
||||
table.string("key", 200).notNullable().unique().collate("utf8_general_ci");
|
||||
table.text("value");
|
||||
table.string("type", 20);
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
return knex.schema.dropTable("setting");
|
||||
}
|
19
backend/migrations/2023-10-20-0829-user-table.ts
Normal file
19
backend/migrations/2023-10-20-0829-user-table.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Create the user table
|
||||
return knex.schema.createTable("user", (table) => {
|
||||
table.increments("id");
|
||||
table.string("username", 255).notNullable().unique().collate("utf8_general_ci");
|
||||
table.string("password", 255);
|
||||
table.boolean("active").notNullable().defaultTo(true);
|
||||
table.string("timezone", 150);
|
||||
table.string("twofa_secret", 64);
|
||||
table.boolean("twofa_status").notNullable().defaultTo(false);
|
||||
table.string("twofa_last_token", 6);
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
return knex.schema.dropTable("user");
|
||||
}
|
46
backend/models/user.ts
Normal file
46
backend/models/user.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { R } from "redbean-node";
|
||||
import { BeanModel } from "redbean-node/dist/bean-model";
|
||||
import { generatePasswordHash, shake256, SHAKE256_LENGTH } from "../password-hash";
|
||||
|
||||
export class User extends BeanModel {
|
||||
/**
|
||||
* Reset user password
|
||||
* Fix #1510, as in the context reset-password.js, there is no auto model mapping. Call this static function instead.
|
||||
* @param {number} userID ID of user to update
|
||||
* @param {string} newPassword Users new password
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async resetPassword(userID : number, newPassword : string) {
|
||||
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
|
||||
generatePasswordHash(newPassword),
|
||||
userID
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset this users password
|
||||
* @param {string} newPassword
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async resetPassword(newPassword : string) {
|
||||
await User.resetPassword(this.id, newPassword);
|
||||
this.password = newPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new JWT for a user
|
||||
* @param {User} user The User to create a JsonWebToken for
|
||||
* @param {string} jwtSecret The key used to sign the JsonWebToken
|
||||
* @returns {string} the JsonWebToken as a string
|
||||
*/
|
||||
static createJWT(user : User, jwtSecret : string) {
|
||||
return jwt.sign({
|
||||
username: user.username,
|
||||
h: shake256(user.password, SHAKE256_LENGTH),
|
||||
}, jwtSecret);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default User;
|
47
backend/password-hash.ts
Normal file
47
backend/password-hash.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
const saltRounds = 10;
|
||||
|
||||
/**
|
||||
* Hash a password
|
||||
* @param {string} password Password to hash
|
||||
* @returns {string} Hash
|
||||
*/
|
||||
export function generatePasswordHash(password : string) {
|
||||
return bcrypt.hashSync(password, saltRounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
* @param {string} password Password to verify
|
||||
* @param {string} hash Hash to verify against
|
||||
* @returns {boolean} Does the password match the hash?
|
||||
*/
|
||||
export function verifyPassword(password, 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
75
backend/rate-limiter.ts
Normal 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
6
backend/router.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { DockgeServer } from "./dockge-server";
|
||||
import { Express, Router as ExpressRouter } from "express";
|
||||
|
||||
export abstract class Router {
|
||||
abstract create(app : Express, server : DockgeServer): ExpressRouter;
|
||||
}
|
23
backend/routers/main-router.ts
Normal file
23
backend/routers/main-router.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { DockgeServer } from "../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
174
backend/settings.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
6
backend/socket-handler.ts
Normal file
6
backend/socket-handler.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { DockgeServer } from "./dockge-server";
|
||||
import { DockgeSocket } from "./util-server";
|
||||
|
||||
export abstract class SocketHandler {
|
||||
abstract create(socket : DockgeSocket, server : DockgeServer): void;
|
||||
}
|
262
backend/socket-handlers/docker-socket-handler.ts
Normal file
262
backend/socket-handlers/docker-socket-handler.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
295
backend/socket-handlers/main-socket-handler.ts
Normal file
295
backend/socket-handlers/main-socket-handler.ts
Normal 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;
|
||||
}
|
||||
}
|
151
backend/socket-handlers/terminal-socket-handler.ts
Normal file
151
backend/socket-handlers/terminal-socket-handler.ts
Normal 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
356
backend/stack.ts
Normal 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
230
backend/terminal.ts
Normal 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
337
backend/util-common.ts
Normal 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
79
backend/util-server.ts
Normal 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;
|
||||
}
|
24
backend/utils/limit-queue.ts
Normal file
24
backend/utils/limit-queue.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Limit Queue
|
||||
* The first element will be removed when the length exceeds the limit
|
||||
*/
|
||||
export class LimitQueue<T> extends Array<T> {
|
||||
__limit;
|
||||
__onExceed = 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
18
compose.yaml
Normal 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
39
docker/Base.Dockerfile
Normal 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
29
docker/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
||||
############################################
|
||||
# Build
|
||||
############################################
|
||||
FROM louislam/dockge:base AS build
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node ./package.json ./package.json
|
||||
COPY --chown=node:node ./pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
|
||||
############################################
|
||||
# ⭐ Main Image
|
||||
############################################
|
||||
FROM louislam/dockge:base AS release
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node . .
|
||||
COPY --from=build /app/node_modules /app/node_modules
|
||||
RUN mkdir ./data
|
||||
|
||||
VOLUME /app/data
|
||||
EXPOSE 5001
|
||||
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||
CMD ["tsx", "./backend/index.ts"]
|
||||
|
||||
|
||||
############################################
|
||||
# Mark as Nightly
|
||||
############################################
|
||||
FROM release AS nightly
|
||||
RUN pnpm run mark-as-nightly
|
22
extra/mark-as-nightly.ts
Normal file
22
extra/mark-as-nightly.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import pkg from "../package.json";
|
||||
import fs from "fs";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const oldVersion = pkg.version;
|
||||
const newVersion = oldVersion + "-nightly-" + dayjs().format("YYYYMMDDHHmmss");
|
||||
|
||||
console.log("Old Version: " + oldVersion);
|
||||
console.log("New Version: " + newVersion);
|
||||
|
||||
if (newVersion) {
|
||||
// Process package.json
|
||||
pkg.version = newVersion;
|
||||
//pkg.scripts.setup = pkg.scripts.setup.replaceAll(oldVersion, newVersion);
|
||||
//pkg.scripts["build-docker"] = pkg.scripts["build-docker"].replaceAll(oldVersion, newVersion);
|
||||
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n");
|
||||
|
||||
// Process README.md
|
||||
if (fs.existsSync("README.md")) {
|
||||
fs.writeFileSync("README.md", fs.readFileSync("README.md", "utf8").replaceAll(oldVersion, newVersion));
|
||||
}
|
||||
}
|
9
extra/templates/mariadb/compose.yaml
Normal file
9
extra/templates/mariadb/compose.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3306:3306
|
||||
environment:
|
||||
- MARIADB_ROOT_PASSWORD=123456
|
12
extra/templates/nginx-proxy-manager/compose.yaml
Normal file
12
extra/templates/nginx-proxy-manager/compose.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
nginx-proxy-manager:
|
||||
image: 'jc21/nginx-proxy-manager:latest'
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '80:80'
|
||||
- '81:81'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./letsencrypt:/etc/letsencrypt
|
9
extra/templates/uptime-kuma/compose.yaml
Normal file
9
extra/templates/uptime-kuma/compose.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
uptime-kuma:
|
||||
image: louislam/uptime-kuma:1
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "3001:3001"
|
||||
restart: always
|
30
frontend/components.d.ts
vendored
Normal file
30
frontend/components.d.ts
vendored
Normal 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
33
frontend/index.html
Normal file
@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" id="theme-color" content="" />
|
||||
<meta name="description" content="" />
|
||||
<title>Dockge</title>
|
||||
<style>
|
||||
.noscript-message {
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<div class="noscript-message">
|
||||
Sorry, you don't seem to have JavaScript enabled or your browser
|
||||
doesn't support it.<br />This website requires JavaScript to function.
|
||||
Please enable JavaScript in your browser settings to continue.
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
BIN
frontend/public/icon-192x192.png
Normal file
BIN
frontend/public/icon-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/icon-512x512.png
Normal file
BIN
frontend/public/icon-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
14
frontend/public/icon.svg
Normal file
14
frontend/public/icon.svg
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="640" height="640" viewBox="0 0 640 640" xml:space="preserve">
|
||||
<desc>Created with Fabric.js 5.3.0</desc>
|
||||
<defs>
|
||||
</defs>
|
||||
<g transform="matrix(0.9544918218 0 0 0.9544918218 320 325.5657767239)" id="0UAuLmXgnot4bJxVEVJCQ" >
|
||||
<linearGradient id="SVGID_136_0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1 0 0 1 -236.6470440833 -213.9441386034)" x1="259.78" y1="261.15" x2="463.85" y2="456.49">
|
||||
<stop offset="0%" style="stop-color:#74C2FF;stop-opacity: 1"/>
|
||||
<stop offset="100%" style="stop-color:rgb(134,230,169);stop-opacity: 1"/>
|
||||
</linearGradient>
|
||||
<path style="stroke: rgb(242,242,242); stroke-opacity: 0.51; stroke-width: 190; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: url(#SVGID_136_0); fill-rule: nonzero; opacity: 1;" transform=" translate(0, 0)" d="M 131.8665 -139.04883 C 159.01022 -111.20969000000001 170.12421 -99.45396000000001 203.11849999999998 -51.72057000000001 C 236.1128 -3.9871800000000093 264.44147999999996 83.98416999999998 187.33995 144.05073 C 177.72728999999998 151.53955 166.73827 158.81189999999998 154.65932999999998 165.65812999999997 C 69.85514999999998 213.72433999999998 -68.67309000000003 240.78578 -161.79279 174.28328999999997 C -268.17583 98.30862999999997 -260.10282 -68.66557000000003 -144.35093 -170.50579000000005 C -28.599040000000002 -272.34602000000007 104.72278 -166.88797000000005 131.86649999999997 -139.04883000000004 z" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
19
frontend/public/manifest.json
Normal file
19
frontend/public/manifest.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Dockge",
|
||||
"short_name": "Dockge",
|
||||
"start_url": "/",
|
||||
"background_color": "#fff",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
9
frontend/src/App.vue
Normal file
9
frontend/src/App.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
};
|
||||
</script>
|
117
frontend/src/components/ArrayInput.vue
Normal file
117
frontend/src/components/ArrayInput.vue
Normal 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>
|
125
frontend/src/components/ArraySelect.vue
Normal file
125
frontend/src/components/ArraySelect.vue
Normal 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>
|
84
frontend/src/components/Confirm.vue
Normal file
84
frontend/src/components/Confirm.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div ref="modal" class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 id="exampleModalLabel" class="modal-title">
|
||||
{{ title || $t("Confirm") }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" :class="btnStyle" data-bs-dismiss="modal" @click="yes">
|
||||
{{ yesText }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="no">
|
||||
{{ noText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Modal } from "bootstrap";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
/** Style of button */
|
||||
btnStyle: {
|
||||
type: String,
|
||||
default: "btn-primary",
|
||||
},
|
||||
/** Text to use as yes */
|
||||
yesText: {
|
||||
type: String,
|
||||
default: "Yes", // TODO: No idea what to translate this
|
||||
},
|
||||
/** Text to use as no */
|
||||
noText: {
|
||||
type: String,
|
||||
default: "No",
|
||||
},
|
||||
/** Title to show on modal. Defaults to translated version of "Config" */
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
}
|
||||
},
|
||||
emits: [ "yes", "no" ],
|
||||
data: () => ({
|
||||
modal: null,
|
||||
}),
|
||||
mounted() {
|
||||
this.modal = new Modal(this.$refs.modal);
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Show the confirm dialog
|
||||
* @returns {void}
|
||||
*/
|
||||
show() {
|
||||
this.modal.show();
|
||||
},
|
||||
/**
|
||||
* @fires string "yes" Notify the parent when Yes is pressed
|
||||
* @returns {void}
|
||||
*/
|
||||
yes() {
|
||||
this.$emit("yes");
|
||||
},
|
||||
/**
|
||||
* @fires string "no" Notify the parent when No is pressed
|
||||
* @returns {void}
|
||||
*/
|
||||
no() {
|
||||
this.$emit("no");
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
273
frontend/src/components/Container.vue
Normal file
273
frontend/src/components/Container.vue
Normal 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>
|
87
frontend/src/components/HiddenInput.vue
Normal file
87
frontend/src/components/HiddenInput.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="input-group mb-3">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="model"
|
||||
:type="visibility"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxlength"
|
||||
:autocomplete="autocomplete"
|
||||
:required="required"
|
||||
:readonly="readonly"
|
||||
>
|
||||
|
||||
<a v-if="visibility == 'password'" class="btn btn-outline-primary" @click="showInput()">
|
||||
<font-awesome-icon icon="eye" />
|
||||
</a>
|
||||
<a v-if="visibility == 'text'" class="btn btn-outline-primary" @click="hideInput()">
|
||||
<font-awesome-icon icon="eye-slash" />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
/** The value of the input */
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
/** A placeholder to use */
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
/** Maximum length of the input */
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: 255
|
||||
},
|
||||
/** Should the field auto complete */
|
||||
autocomplete: {
|
||||
type: String,
|
||||
default: "new-password",
|
||||
},
|
||||
/** Is the input required? */
|
||||
required: {
|
||||
type: Boolean
|
||||
},
|
||||
/** Should the input be read only? */
|
||||
readonly: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: [ "update:modelValue" ],
|
||||
data() {
|
||||
return {
|
||||
visibility: "password",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
model: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
|
||||
},
|
||||
methods: {
|
||||
/** Show users input in plain text */
|
||||
showInput() {
|
||||
this.visibility = "text";
|
||||
},
|
||||
/** Censor users input */
|
||||
hideInput() {
|
||||
this.visibility = "password";
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
114
frontend/src/components/Login.vue
Normal file
114
frontend/src/components/Login.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="form-container">
|
||||
<div class="form">
|
||||
<form @submit.prevent="submit">
|
||||
<h1 class="h3 mb-3 fw-normal" />
|
||||
|
||||
<div v-if="!tokenRequired" class="form-floating">
|
||||
<input id="floatingInput" v-model="username" type="text" class="form-control" placeholder="Username" autocomplete="username" required>
|
||||
<label for="floatingInput">{{ $t("Username") }}</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!tokenRequired" class="form-floating mt-3">
|
||||
<input id="floatingPassword" v-model="password" type="password" class="form-control" placeholder="Password" autocomplete="current-password" required>
|
||||
<label for="floatingPassword">{{ $t("Password") }}</label>
|
||||
</div>
|
||||
|
||||
<div v-if="tokenRequired">
|
||||
<div class="form-floating mt-3">
|
||||
<input id="otp" v-model="token" type="text" maxlength="6" class="form-control" placeholder="123456" autocomplete="one-time-code" required>
|
||||
<label for="otp">{{ $t("Token") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3 mt-3 d-flex justify-content-center pe-4">
|
||||
<div class="form-check">
|
||||
<input id="remember" v-model="$root.remember" type="checkbox" value="remember-me" class="form-check-input">
|
||||
|
||||
<label class="form-check-label" for="remember">
|
||||
{{ $t("Remember me") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-100 btn btn-primary" type="submit" :disabled="processing">
|
||||
{{ $t("Login") }}
|
||||
</button>
|
||||
|
||||
<div v-if="res && !res.ok" class="alert alert-danger mt-3" role="alert">
|
||||
{{ $t(res.msg) }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
username: "",
|
||||
password: "",
|
||||
token: "",
|
||||
res: null,
|
||||
tokenRequired: false,
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
document.title += " - Login";
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
document.title = document.title.replace(" - Login", "");
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Submit the user details and attempt to log in
|
||||
* @returns {void}
|
||||
*/
|
||||
submit() {
|
||||
this.processing = true;
|
||||
|
||||
this.$root.login(this.username, this.password, this.token, (res) => {
|
||||
this.processing = false;
|
||||
|
||||
if (res.tokenRequired) {
|
||||
this.tokenRequired = true;
|
||||
} else {
|
||||
this.res = res;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-floating {
|
||||
> label {
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
|
||||
> .form-control {
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 330px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
223
frontend/src/components/NetworkInput.vue
Normal file
223
frontend/src/components/NetworkInput.vue
Normal file
@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div>
|
||||
<h5>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>
|
438
frontend/src/components/StackList.vue
Normal file
438
frontend/src/components/StackList.vue
Normal file
@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div class="shadow-box mb-3" :style="boxStyle">
|
||||
<div class="list-header">
|
||||
<div class="header-top">
|
||||
<!-- TODO -->
|
||||
<button v-if="false" class="btn btn-outline-normal ms-2" :class="{ 'active': selectMode }" type="button" @click="selectMode = !selectMode">
|
||||
{{ $t("Select") }}
|
||||
</button>
|
||||
|
||||
<div class="placeholder"></div>
|
||||
<div class="search-wrapper">
|
||||
<a v-if="searchText == ''" class="search-icon">
|
||||
<font-awesome-icon icon="search" />
|
||||
</a>
|
||||
<a v-if="searchText != ''" class="search-icon" style="cursor: pointer" @click="clearSearchText">
|
||||
<font-awesome-icon icon="times" />
|
||||
</a>
|
||||
<form>
|
||||
<input v-model="searchText" class="form-control search-input" autocomplete="off" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TODO -->
|
||||
<div v-if="false" class="header-filter">
|
||||
<!--<StackListFilter :filterState="filterState" @update-filter="updateFilter" />-->
|
||||
</div>
|
||||
|
||||
<!-- TODO: Selection Controls -->
|
||||
<div v-if="selectMode && false" class="selection-controls px-2 pt-2">
|
||||
<input
|
||||
v-model="selectAll"
|
||||
class="form-check-input select-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
|
||||
<button class="btn-outline-normal" @click="pauseDialog"><font-awesome-icon icon="pause" size="sm" /> {{ $t("Pause") }}</button>
|
||||
<button class="btn-outline-normal" @click="resumeSelected"><font-awesome-icon icon="play" size="sm" /> {{ $t("Resume") }}</button>
|
||||
|
||||
<span v-if="selectedStackCount > 0">
|
||||
{{ $t("selectedStackCount", [ selectedStackCount ]) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="stackList" class="stack-list" :class="{ scrollbar: scrollbar }" :style="stackListStyle">
|
||||
<div v-if="Object.keys($root.stackList).length === 0" class="text-center mt-3">
|
||||
<router-link to="/compose">{{ $t("addFirstStackMsg") }}</router-link>
|
||||
</div>
|
||||
|
||||
<StackListItem
|
||||
v-for="(item, index) in sortedStackList"
|
||||
:key="index"
|
||||
:stack="item"
|
||||
:isSelectMode="selectMode"
|
||||
:isSelected="isSelected"
|
||||
:select="select"
|
||||
:deselect="deselect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
|
||||
{{ $t("pauseStackMsg") }}
|
||||
</Confirm>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Confirm from "../components/Confirm.vue";
|
||||
import StackListItem from "../components/StackListItem.vue";
|
||||
import { CREATED_FILE, CREATED_STACK, EXITED, RUNNING, UNKNOWN } from "../../../backend/util-common";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Confirm,
|
||||
StackListItem,
|
||||
},
|
||||
props: {
|
||||
/** Should the scrollbar be shown */
|
||||
scrollbar: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchText: "",
|
||||
selectMode: false,
|
||||
selectAll: false,
|
||||
disableSelectAllWatcher: false,
|
||||
selectedStacks: {},
|
||||
windowTop: 0,
|
||||
filterState: {
|
||||
status: null,
|
||||
active: null,
|
||||
tags: null,
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* Improve the sticky appearance of the list by increasing its
|
||||
* height as user scrolls down.
|
||||
* Not used on mobile.
|
||||
* @returns {object} Style for stack list
|
||||
*/
|
||||
boxStyle() {
|
||||
if (window.innerWidth > 550) {
|
||||
return {
|
||||
height: `calc(100vh - 160px + ${this.windowTop}px)`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
height: "calc(100vh - 160px)",
|
||||
};
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a sorted list of stacks based on the applied filters and search text.
|
||||
* @returns {Array} The sorted list of stacks.
|
||||
*/
|
||||
sortedStackList() {
|
||||
let result = Object.values(this.$root.stackList);
|
||||
|
||||
result = result.filter(stack => {
|
||||
// filter by search text
|
||||
// finds stack name, tag name or tag value
|
||||
let searchTextMatch = true;
|
||||
if (this.searchText !== "") {
|
||||
const loweredSearchText = this.searchText.toLowerCase();
|
||||
searchTextMatch =
|
||||
stack.name.toLowerCase().includes(loweredSearchText)
|
||||
|| stack.tags.find(tag => tag.name.toLowerCase().includes(loweredSearchText)
|
||||
|| tag.value?.toLowerCase().includes(loweredSearchText));
|
||||
}
|
||||
|
||||
// filter by active
|
||||
let activeMatch = true;
|
||||
if (this.filterState.active != null && this.filterState.active.length > 0) {
|
||||
activeMatch = this.filterState.active.includes(stack.active);
|
||||
}
|
||||
|
||||
// filter by tags
|
||||
let tagsMatch = true;
|
||||
if (this.filterState.tags != null && this.filterState.tags.length > 0) {
|
||||
tagsMatch = stack.tags.map(tag => tag.tag_id) // convert to array of tag IDs
|
||||
.filter(stackTagId => this.filterState.tags.includes(stackTagId)) // perform Array Intersaction between filter and stack's tags
|
||||
.length > 0;
|
||||
}
|
||||
|
||||
return searchTextMatch && activeMatch && tagsMatch;
|
||||
});
|
||||
|
||||
result.sort((m1, m2) => {
|
||||
if (m1.status !== m2.status) {
|
||||
if (m2.status === RUNNING) {
|
||||
return 1;
|
||||
} else if (m1.status === RUNNING) {
|
||||
return -1;
|
||||
} else if (m2.status === EXITED) {
|
||||
return 1;
|
||||
} else if (m1.status === EXITED) {
|
||||
return -1;
|
||||
} else if (m2.status === CREATED_STACK) {
|
||||
return 1;
|
||||
} else if (m1.status === CREATED_STACK) {
|
||||
return -1;
|
||||
} else if (m2.status === CREATED_FILE) {
|
||||
return 1;
|
||||
} else if (m1.status === CREATED_FILE) {
|
||||
return -1;
|
||||
} else if (m2.status === UNKNOWN) {
|
||||
return 1;
|
||||
} else if (m1.status === UNKNOWN) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return m1.name.localeCompare(m2.name);
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
isDarkTheme() {
|
||||
return document.body.classList.contains("dark");
|
||||
},
|
||||
|
||||
stackListStyle() {
|
||||
//let listHeaderHeight = 107;
|
||||
let listHeaderHeight = 60;
|
||||
|
||||
if (this.selectMode) {
|
||||
listHeaderHeight += 42;
|
||||
}
|
||||
|
||||
return {
|
||||
"height": `calc(100% - ${listHeaderHeight}px)`
|
||||
};
|
||||
},
|
||||
|
||||
selectedStackCount() {
|
||||
return Object.keys(this.selectedStacks).length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines if any filters are active.
|
||||
* @returns {boolean} True if any filter is active, false otherwise.
|
||||
*/
|
||||
filtersActive() {
|
||||
return this.filterState.status != null || this.filterState.active != null || this.filterState.tags != null || this.searchText !== "";
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchText() {
|
||||
for (let stack of this.sortedStackList) {
|
||||
if (!this.selectedStacks[stack.id]) {
|
||||
if (this.selectAll) {
|
||||
this.disableSelectAllWatcher = true;
|
||||
this.selectAll = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
selectAll() {
|
||||
if (!this.disableSelectAllWatcher) {
|
||||
this.selectedStacks = {};
|
||||
|
||||
if (this.selectAll) {
|
||||
this.sortedStackList.forEach((item) => {
|
||||
this.selectedStacks[item.id] = true;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.disableSelectAllWatcher = false;
|
||||
}
|
||||
},
|
||||
selectMode() {
|
||||
if (!this.selectMode) {
|
||||
this.selectAll = false;
|
||||
this.selectedStacks = {};
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener("scroll", this.onScroll);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("scroll", this.onScroll);
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Handle user scroll
|
||||
* @returns {void}
|
||||
*/
|
||||
onScroll() {
|
||||
if (window.top.scrollY <= 133) {
|
||||
this.windowTop = window.top.scrollY;
|
||||
} else {
|
||||
this.windowTop = 133;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the search bar
|
||||
* @returns {void}
|
||||
*/
|
||||
clearSearchText() {
|
||||
this.searchText = "";
|
||||
},
|
||||
/**
|
||||
* Update the StackList Filter
|
||||
* @param {object} newFilter Object with new filter
|
||||
* @returns {void}
|
||||
*/
|
||||
updateFilter(newFilter) {
|
||||
this.filterState = newFilter;
|
||||
},
|
||||
/**
|
||||
* Deselect a stack
|
||||
* @param {number} id ID of stack
|
||||
* @returns {void}
|
||||
*/
|
||||
deselect(id) {
|
||||
delete this.selectedStacks[id];
|
||||
},
|
||||
/**
|
||||
* Select a stack
|
||||
* @param {number} id ID of stack
|
||||
* @returns {void}
|
||||
*/
|
||||
select(id) {
|
||||
this.selectedStacks[id] = true;
|
||||
},
|
||||
/**
|
||||
* Determine if stack is selected
|
||||
* @param {number} id ID of stack
|
||||
* @returns {bool} Is the stack selected?
|
||||
*/
|
||||
isSelected(id) {
|
||||
return id in this.selectedStacks;
|
||||
},
|
||||
/**
|
||||
* Disable select mode and reset selection
|
||||
* @returns {void}
|
||||
*/
|
||||
cancelSelectMode() {
|
||||
this.selectMode = false;
|
||||
this.selectedStacks = {};
|
||||
},
|
||||
/**
|
||||
* Show dialog to confirm pause
|
||||
* @returns {void}
|
||||
*/
|
||||
pauseDialog() {
|
||||
this.$refs.confirmPause.show();
|
||||
},
|
||||
/**
|
||||
* Pause each selected stack
|
||||
* @returns {void}
|
||||
*/
|
||||
pauseSelected() {
|
||||
Object.keys(this.selectedStacks)
|
||||
.filter(id => this.$root.stackList[id].active)
|
||||
.forEach(id => this.$root.getSocket().emit("pauseStack", id, () => {}));
|
||||
|
||||
this.cancelSelectMode();
|
||||
},
|
||||
/**
|
||||
* Resume each selected stack
|
||||
* @returns {void}
|
||||
*/
|
||||
resumeSelected() {
|
||||
Object.keys(this.selectedStacks)
|
||||
.filter(id => !this.$root.stackList[id].active)
|
||||
.forEach(id => this.$root.getSocket().emit("resumeStack", id, () => {}));
|
||||
|
||||
this.cancelSelectMode();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../styles/vars.scss";
|
||||
|
||||
.shadow-box {
|
||||
height: calc(100vh - 150px);
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
.small-padding {
|
||||
padding-left: 5px !important;
|
||||
padding-right: 5px !important;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
border-radius: 10px 10px 0 0;
|
||||
margin: -10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
|
||||
.dark & {
|
||||
background-color: $dark-header-bg;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 770px) {
|
||||
.list-header {
|
||||
margin: -20px;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
padding: 10px;
|
||||
color: #c0c0c0;
|
||||
|
||||
// Clear filter button (X)
|
||||
svg[data-icon="times"] {
|
||||
cursor: pointer;
|
||||
transition: all ease-in-out 0.1s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
max-width: 15em;
|
||||
}
|
||||
|
||||
.stack-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin-top: 4px;
|
||||
padding-left: 67px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bottom-style {
|
||||
padding-left: 67px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.selection-controls {
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
</style>
|
154
frontend/src/components/StackListItem.vue
Normal file
154
frontend/src/components/StackListItem.vue
Normal file
@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<router-link :to="`/compose/${stack.name}`" :class="{ 'dim' : !stack.isManagedByDockge }" class="item">
|
||||
<Uptime :stack="stack" :fixed-width="true" class="me-2" />
|
||||
<span class="title">{{ stackName }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Uptime from "./Uptime.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Uptime
|
||||
},
|
||||
props: {
|
||||
/** Stack this represents */
|
||||
stack: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
/** If the user is in select mode */
|
||||
isSelectMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** How many ancestors are above this stack */
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
/** Callback to determine if stack is selected */
|
||||
isSelected: {
|
||||
type: Function,
|
||||
default: () => {}
|
||||
},
|
||||
/** Callback fired when stack is selected */
|
||||
select: {
|
||||
type: Function,
|
||||
default: () => {}
|
||||
},
|
||||
/** Callback fired when stack is deselected */
|
||||
deselect: {
|
||||
type: Function,
|
||||
default: () => {}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isCollapsed: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
depthMargin() {
|
||||
return {
|
||||
marginLeft: `${31 * this.depth}px`,
|
||||
};
|
||||
},
|
||||
stackName() {
|
||||
return this.stack.name;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isSelectMode() {
|
||||
// TODO: Resize the heartbeat bar, but too slow
|
||||
// this.$refs.heartbeatBar.resize();
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Changes the collapsed value of the current stack and saves
|
||||
* it to local storage
|
||||
* @returns {void}
|
||||
*/
|
||||
changeCollapsed() {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
|
||||
// Save collapsed value into local storage
|
||||
let storage = window.localStorage.getItem("stackCollapsed");
|
||||
let storageObject = {};
|
||||
if (storage !== null) {
|
||||
storageObject = JSON.parse(storage);
|
||||
}
|
||||
storageObject[`stack_${this.stack.id}`] = this.isCollapsed;
|
||||
|
||||
window.localStorage.setItem("stackCollapsed", JSON.stringify(storageObject));
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle selection of stack
|
||||
* @returns {void}
|
||||
*/
|
||||
toggleSelection() {
|
||||
if (this.isSelected(this.stack.id)) {
|
||||
this.deselect(this.stack.id);
|
||||
} else {
|
||||
this.select(this.stack.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../styles/vars.scss";
|
||||
|
||||
.small-padding {
|
||||
padding-left: 5px !important;
|
||||
padding-right: 5px !important;
|
||||
}
|
||||
|
||||
.collapse-padding {
|
||||
padding-left: 8px !important;
|
||||
padding-right: 2px !important;
|
||||
}
|
||||
|
||||
// .stack-item {
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
.tags {
|
||||
margin-top: 4px;
|
||||
padding-left: 67px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.animated {
|
||||
transition: all 0.2s $easing-in;
|
||||
}
|
||||
|
||||
.select-input-wrapper {
|
||||
float: left;
|
||||
margin-top: 15px;
|
||||
margin-left: 3px;
|
||||
margin-right: 10px;
|
||||
padding-left: 4px;
|
||||
position: relative;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.dim {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
</style>
|
228
frontend/src/components/Terminal.vue
Normal file
228
frontend/src/components/Terminal.vue
Normal file
@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="shadow-box">
|
||||
<div v-pre ref="terminal" class="main-terminal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Terminal } from "xterm";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import { TERMINAL_COLS, TERMINAL_ROWS } from "../../../backend/util-common";
|
||||
|
||||
export default {
|
||||
/**
|
||||
* @type {Terminal}
|
||||
*/
|
||||
terminal: null,
|
||||
components: {
|
||||
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
require: true,
|
||||
},
|
||||
|
||||
// Require if mode is interactive
|
||||
stackName: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
// Require if mode is interactive
|
||||
serviceName: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
// Require if mode is interactive
|
||||
shell: {
|
||||
type: String,
|
||||
default: "bash",
|
||||
},
|
||||
|
||||
rows: {
|
||||
type: Number,
|
||||
default: TERMINAL_ROWS,
|
||||
},
|
||||
|
||||
cols: {
|
||||
type: Number,
|
||||
default: TERMINAL_COLS,
|
||||
},
|
||||
|
||||
// Mode
|
||||
// displayOnly: Only display terminal output
|
||||
// mainTerminal: Allow input limited commands and output
|
||||
// interactive: Free input and output
|
||||
mode: {
|
||||
type: String,
|
||||
default: "displayOnly",
|
||||
}
|
||||
},
|
||||
emits: [ "has-data" ],
|
||||
data() {
|
||||
return {
|
||||
first: true,
|
||||
terminalInputBuffer: "",
|
||||
cursorPosition: 0,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
|
||||
},
|
||||
mounted() {
|
||||
let cursorBlink = true;
|
||||
|
||||
if (this.mode === "displayOnly") {
|
||||
cursorBlink = false;
|
||||
}
|
||||
|
||||
this.terminal = new Terminal({
|
||||
fontSize: 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>
|
203
frontend/src/components/TwoFADialog.vue
Normal file
203
frontend/src/components/TwoFADialog.vue
Normal file
@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
{{ $t("Setup 2FA") }}
|
||||
<span v-if="twoFAStatus == true" class="badge bg-primary">{{ $t("Active") }}</span>
|
||||
<span v-if="twoFAStatus == false" class="badge bg-primary">{{ $t("Inactive") }}</span>
|
||||
</h5>
|
||||
<button :disabled="processing" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<div v-if="uri && twoFAStatus == false" class="mx-auto text-center" style="width: 210px;">
|
||||
<vue-qrcode :key="uri" :value="uri" type="image/png" :quality="1" :color="{ light: '#ffffffff' }" />
|
||||
<button v-show="!showURI" type="button" class="btn btn-outline-primary btn-sm mt-2" @click="showURI = true">{{ $t("Show URI") }}</button>
|
||||
</div>
|
||||
<p v-if="showURI && twoFAStatus == false" class="text-break mt-2">{{ uri }}</p>
|
||||
|
||||
<div v-if="!(uri && twoFAStatus == false)" class="mb-3">
|
||||
<label for="current-password" class="form-label">
|
||||
{{ $t("Current Password") }}
|
||||
</label>
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
class="form-control"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button v-if="uri == null && twoFAStatus == false" class="btn btn-primary" type="button" @click="prepare2FA()">
|
||||
{{ $t("Enable 2FA") }}
|
||||
</button>
|
||||
|
||||
<button v-if="twoFAStatus == true" class="btn btn-danger" type="button" :disabled="processing" @click="confirmDisableTwoFA()">
|
||||
{{ $t("Disable 2FA") }}
|
||||
</button>
|
||||
|
||||
<div v-if="uri && twoFAStatus == false" class="mt-3">
|
||||
<label for="basic-url" class="form-label">{{ $t("twoFAVerifyLabel") }}</label>
|
||||
<div class="input-group">
|
||||
<input v-model="token" type="text" maxlength="6" class="form-control" autocomplete="one-time-code" required>
|
||||
<button class="btn btn-outline-primary" type="button" @click="verifyToken()">{{ $t("Verify Token") }}</button>
|
||||
</div>
|
||||
<p v-show="tokenValid" class="mt-2" style="color: green;">{{ $t("tokenValidSettingsMsg") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uri && twoFAStatus == false" class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary" :disabled="processing || tokenValid == false" @click="confirmEnableTwoFA()">
|
||||
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
|
||||
{{ $t("Save") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Confirm ref="confirmEnableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="save2FA">
|
||||
{{ $t("confirmEnableTwoFAMsg") }}
|
||||
</Confirm>
|
||||
|
||||
<Confirm ref="confirmDisableTwoFA" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="disable2FA">
|
||||
{{ $t("confirmDisableTwoFAMsg") }}
|
||||
</Confirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Modal } from "bootstrap";
|
||||
import Confirm from "./Confirm.vue";
|
||||
import VueQrcode from "vue-qrcode";
|
||||
import { useToast } from "vue-toastification";
|
||||
const toast = useToast();
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Confirm,
|
||||
VueQrcode,
|
||||
},
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
currentPassword: "",
|
||||
processing: false,
|
||||
uri: null,
|
||||
tokenValid: false,
|
||||
twoFAStatus: null,
|
||||
token: null,
|
||||
showURI: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.modal = new Modal(this.$refs.modal);
|
||||
this.getStatus();
|
||||
},
|
||||
methods: {
|
||||
/** Show the dialog */
|
||||
show() {
|
||||
this.modal.show();
|
||||
},
|
||||
|
||||
/** Show dialog to confirm enabling 2FA */
|
||||
confirmEnableTwoFA() {
|
||||
this.$refs.confirmEnableTwoFA.show();
|
||||
},
|
||||
|
||||
/** Show dialog to confirm disabling 2FA */
|
||||
confirmDisableTwoFA() {
|
||||
this.$refs.confirmDisableTwoFA.show();
|
||||
},
|
||||
|
||||
/** Prepare 2FA configuration */
|
||||
prepare2FA() {
|
||||
this.processing = true;
|
||||
|
||||
this.$root.getSocket().emit("prepare2FA", this.currentPassword, (res) => {
|
||||
this.processing = false;
|
||||
|
||||
if (res.ok) {
|
||||
this.uri = res.uri;
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Save the current 2FA configuration */
|
||||
save2FA() {
|
||||
this.processing = true;
|
||||
|
||||
this.$root.getSocket().emit("save2FA", this.currentPassword, (res) => {
|
||||
this.processing = false;
|
||||
|
||||
if (res.ok) {
|
||||
this.$root.toastRes(res);
|
||||
this.getStatus();
|
||||
this.currentPassword = "";
|
||||
this.modal.hide();
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Disable 2FA for this user */
|
||||
disable2FA() {
|
||||
this.processing = true;
|
||||
|
||||
this.$root.getSocket().emit("disable2FA", this.currentPassword, (res) => {
|
||||
this.processing = false;
|
||||
|
||||
if (res.ok) {
|
||||
this.$root.toastRes(res);
|
||||
this.getStatus();
|
||||
this.currentPassword = "";
|
||||
this.modal.hide();
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Verify the token generated by the user */
|
||||
verifyToken() {
|
||||
this.$root.getSocket().emit("verifyToken", this.token, this.currentPassword, (res) => {
|
||||
if (res.ok) {
|
||||
this.tokenValid = res.valid;
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Get current status of 2FA */
|
||||
getStatus() {
|
||||
this.$root.getSocket().emit("twoFAStatus", (res) => {
|
||||
if (res.ok) {
|
||||
this.twoFAStatus = res.status;
|
||||
} else {
|
||||
toast.error(res.msg);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../styles/vars.scss";
|
||||
|
||||
.dark {
|
||||
.modal-dialog .form-text, .modal-dialog p {
|
||||
color: $dark-font-color;
|
||||
}
|
||||
}
|
||||
</style>
|
54
frontend/src/components/Uptime.vue
Normal file
54
frontend/src/components/Uptime.vue
Normal 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>
|
66
frontend/src/components/settings/About.vue
Normal file
66
frontend/src/components/settings/About.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<div class="logo d-flex flex-column justify-content-center align-items-center">
|
||||
<object class="my-4" width="200" height="200" data="/icon.svg" />
|
||||
<div class="fs-4 fw-bold">Dockge</div>
|
||||
<div>{{ $t("Version") }}: {{ $root.info.version }}</div>
|
||||
<div class="frontend-version">{{ $t("Frontend Version") }}: {{ $root.frontendVersion }}</div>
|
||||
|
||||
<div v-if="!$root.isFrontendBackendVersionMatched" class="alert alert-warning mt-4" role="alert">
|
||||
⚠️ {{ $t("Frontend Version do not match backend version!") }}
|
||||
</div>
|
||||
|
||||
<div class="my-3 update-link"><a href="https://github.com/louislam/dockge/releases" target="_blank" rel="noopener">{{ $t("Check Update On GitHub") }}</a></div>
|
||||
|
||||
<div class="mt-1">
|
||||
<div class="form-check">
|
||||
<label><input v-model="settings.checkUpdate" type="checkbox" @change="saveSettings()" /> {{ $t("Show update if available") }}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<label><input v-model="settings.checkBeta" type="checkbox" :disabled="!settings.checkUpdate" @change="saveSettings()" /> {{ $t("Also check beta release") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
computed: {
|
||||
settings() {
|
||||
return this.$parent.$parent.$parent.settings;
|
||||
},
|
||||
saveSettings() {
|
||||
return this.$parent.$parent.$parent.saveSettings;
|
||||
},
|
||||
settingsLoaded() {
|
||||
return this.$parent.$parent.$parent.settingsLoaded;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
margin: 4em 1em;
|
||||
}
|
||||
|
||||
.update-link {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.frontend-version {
|
||||
font-size: 0.9em;
|
||||
color: #cccccc;
|
||||
|
||||
.dark & {
|
||||
color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
94
frontend/src/components/settings/Appearance.vue
Normal file
94
frontend/src/components/settings/Appearance.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="my-4">
|
||||
<label for="language" class="form-label">
|
||||
{{ $t("Language") }}
|
||||
</label>
|
||||
<select id="language" v-model="$root.language" class="form-select">
|
||||
<option
|
||||
v-for="(lang, i) in $i18n.availableLocales"
|
||||
:key="`Lang${i}`"
|
||||
:value="lang"
|
||||
>
|
||||
{{ $i18n.messages[lang].languageName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-show="false" class="my-4">
|
||||
<label for="timezone" class="form-label">{{ $t("Theme") }}</label>
|
||||
<div>
|
||||
<div
|
||||
class="btn-group"
|
||||
role="group"
|
||||
aria-label="Basic checkbox toggle button group"
|
||||
>
|
||||
<input
|
||||
id="btncheck1"
|
||||
v-model="$root.userTheme"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="theme"
|
||||
autocomplete="off"
|
||||
value="light"
|
||||
/>
|
||||
<label class="btn btn-outline-primary" for="btncheck1">
|
||||
{{ $t("Light") }}
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="btncheck2"
|
||||
v-model="$root.userTheme"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="theme"
|
||||
autocomplete="off"
|
||||
value="dark"
|
||||
/>
|
||||
<label class="btn btn-outline-primary" for="btncheck2">
|
||||
{{ $t("Dark") }}
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="btncheck3"
|
||||
v-model="$root.userTheme"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="theme"
|
||||
autocomplete="off"
|
||||
value="auto"
|
||||
/>
|
||||
<label class="btn btn-outline-primary" for="btncheck3">
|
||||
{{ $t("Auto") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../styles/vars.scss";
|
||||
|
||||
.btn-check:active + .btn-outline-primary,
|
||||
.btn-check:checked + .btn-outline-primary,
|
||||
.btn-check:hover + .btn-outline-primary {
|
||||
color: #fff;
|
||||
|
||||
.dark & {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.list-group-item {
|
||||
background-color: $dark-bg2;
|
||||
color: $dark-font-color;
|
||||
}
|
||||
}
|
||||
</style>
|
114
frontend/src/components/settings/General.vue
Normal file
114
frontend/src/components/settings/General.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div>
|
||||
<form class="my-4" autocomplete="off" @submit.prevent="saveGeneral">
|
||||
<!-- Client side Timezone -->
|
||||
<div v-if="false" class="mb-4">
|
||||
<label for="timezone" class="form-label">
|
||||
{{ $t("Display Timezone") }}
|
||||
</label>
|
||||
<select id="timezone" v-model="$root.userTimezone" class="form-select">
|
||||
<option value="auto">
|
||||
{{ $t("Auto") }}: {{ guessTimezone }}
|
||||
</option>
|
||||
<option
|
||||
v-for="(timezone, index) in timezoneList"
|
||||
:key="index"
|
||||
:value="timezone.value"
|
||||
>
|
||||
{{ timezone.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Server Timezone -->
|
||||
<div v-if="false" class="mb-4">
|
||||
<label for="timezone" class="form-label">
|
||||
{{ $t("Server Timezone") }}
|
||||
</label>
|
||||
<select id="timezone" v-model="settings.serverTimezone" class="form-select">
|
||||
<option value="UTC">UTC</option>
|
||||
<option
|
||||
v-for="(timezone, index) in timezoneList"
|
||||
:key="index"
|
||||
:value="timezone.value"
|
||||
>
|
||||
{{ timezone.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Primary Hostname -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="primaryBaseURL">
|
||||
{{ $t("primaryHostname") }}
|
||||
</label>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<input
|
||||
v-model="settings.primaryHostname"
|
||||
class="form-control"
|
||||
placeholder="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>
|
||||
|
205
frontend/src/components/settings/Security.vue
Normal file
205
frontend/src/components/settings/Security.vue
Normal file
@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="settingsLoaded" class="my-4">
|
||||
<!-- Change Password -->
|
||||
<template v-if="!settings.disableAuth">
|
||||
<p>
|
||||
{{ $t("Current User") }}: <strong>{{ $root.username }}</strong>
|
||||
<button v-if="! settings.disableAuth" id="logout-btn" class="btn btn-danger ms-4 me-2 mb-2" @click="$root.logout">{{ $t("Logout") }}</button>
|
||||
</p>
|
||||
|
||||
<h5 class="my-4 settings-subheading">{{ $t("Change Password") }}</h5>
|
||||
<form class="mb-3" @submit.prevent="savePassword">
|
||||
<div class="mb-3">
|
||||
<label for="current-password" class="form-label">
|
||||
{{ $t("Current Password") }}
|
||||
</label>
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="password.currentPassword"
|
||||
type="password"
|
||||
class="form-control"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new-password" class="form-label">
|
||||
{{ $t("New Password") }}
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="password.newPassword"
|
||||
type="password"
|
||||
class="form-control"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="repeat-new-password" class="form-label">
|
||||
{{ $t("Repeat New Password") }}
|
||||
</label>
|
||||
<input
|
||||
id="repeat-new-password"
|
||||
v-model="password.repeatNewPassword"
|
||||
type="password"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': invalidPassword }"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<div class="invalid-feedback">
|
||||
{{ $t("passwordNotMatchMsg") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
{{ $t("Update Password") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- TODO: Hidden for now -->
|
||||
<div v-if="! settings.disableAuth && false" class="mt-5 mb-3">
|
||||
<h5 class="my-4 settings-subheading">
|
||||
{{ $t("Two Factor Authentication") }}
|
||||
</h5>
|
||||
<div class="mb-4">
|
||||
<button
|
||||
class="btn btn-primary me-2"
|
||||
type="button"
|
||||
@click="$refs.TwoFADialog.show()"
|
||||
>
|
||||
{{ $t("2FA Settings") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4">
|
||||
<!-- Advanced -->
|
||||
<h5 class="my-4 settings-subheading">{{ $t("Advanced") }}</h5>
|
||||
|
||||
<div class="mb-4">
|
||||
<button v-if="settings.disableAuth" id="enableAuth-btn" class="btn btn-outline-primary me-2 mb-2" @click="enableAuth">{{ $t("Enable Auth") }}</button>
|
||||
<button v-if="! settings.disableAuth" id="disableAuth-btn" class="btn btn-primary me-2 mb-2" @click="confirmDisableAuth">{{ $t("Disable Auth") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TwoFADialog ref="TwoFADialog" />
|
||||
|
||||
<Confirm ref="confirmDisableAuth" btn-style="btn-danger" :yes-text="$t('I understand, please disable')" :no-text="$t('Leave')" @yes="disableAuth">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p v-html="$t('disableauth.message1')"></p>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p v-html="$t('disableauth.message2')"></p>
|
||||
<p>{{ $t("Please use this option carefully!") }}</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="current-password2" class="form-label">
|
||||
{{ $t("Current Password") }}
|
||||
</label>
|
||||
<input
|
||||
id="current-password2"
|
||||
v-model="password.currentPassword"
|
||||
type="password"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Confirm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Confirm from "../../components/Confirm.vue";
|
||||
import TwoFADialog from "../../components/TwoFADialog.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Confirm,
|
||||
TwoFADialog
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
invalidPassword: false,
|
||||
password: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
repeatNewPassword: "",
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
settings() {
|
||||
return this.$parent.$parent.$parent.settings;
|
||||
},
|
||||
saveSettings() {
|
||||
return this.$parent.$parent.$parent.saveSettings;
|
||||
},
|
||||
settingsLoaded() {
|
||||
return this.$parent.$parent.$parent.settingsLoaded;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
"password.repeatNewPassword"() {
|
||||
this.invalidPassword = false;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/** Check new passwords match before saving them */
|
||||
savePassword() {
|
||||
if (this.password.newPassword !== this.password.repeatNewPassword) {
|
||||
this.invalidPassword = true;
|
||||
} else {
|
||||
this.$root
|
||||
.getSocket()
|
||||
.emit("changePassword", this.password, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
if (res.ok) {
|
||||
this.password.currentPassword = "";
|
||||
this.password.newPassword = "";
|
||||
this.password.repeatNewPassword = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/** Disable authentication for web app access */
|
||||
disableAuth() {
|
||||
this.settings.disableAuth = true;
|
||||
|
||||
// Need current password to disable auth
|
||||
// Set it to empty if done
|
||||
this.saveSettings(() => {
|
||||
this.password.currentPassword = "";
|
||||
this.$root.username = null;
|
||||
this.$root.socketIO.token = "autoLogin";
|
||||
}, this.password.currentPassword);
|
||||
},
|
||||
|
||||
/** Enable authentication for web app access */
|
||||
enableAuth() {
|
||||
this.settings.disableAuth = false;
|
||||
this.saveSettings();
|
||||
this.$root.storage().removeItem("token");
|
||||
location.reload();
|
||||
},
|
||||
|
||||
/** Show confirmation dialog for disable auth */
|
||||
confirmDisableAuth() {
|
||||
this.$refs.confirmDisableAuth.show();
|
||||
},
|
||||
|
||||
},
|
||||
};
|
||||
</script>
|
36
frontend/src/i18n.ts
Normal file
36
frontend/src/i18n.ts
Normal 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
115
frontend/src/icon.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
// Add Free Font Awesome Icons
|
||||
// https://fontawesome.com/v6/icons?d=gallery&p=2&s=solid&m=free
|
||||
// In order to add an icon, you have to:
|
||||
// 1) add the icon name in the import statement below;
|
||||
// 2) add the icon name to the library.add() statement below.
|
||||
import {
|
||||
faArrowAltCircleUp,
|
||||
faCog,
|
||||
faEdit,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faList,
|
||||
faPause,
|
||||
faStop,
|
||||
faPlay,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faTachometerAlt,
|
||||
faTimes,
|
||||
faTimesCircle,
|
||||
faTrash,
|
||||
faCheckCircle,
|
||||
faStream,
|
||||
faSave,
|
||||
faExclamationCircle,
|
||||
faBullhorn,
|
||||
faArrowsAltV,
|
||||
faUnlink,
|
||||
faQuestionCircle,
|
||||
faImages,
|
||||
faUpload,
|
||||
faCopy,
|
||||
faCheck,
|
||||
faFile,
|
||||
faAward,
|
||||
faLink,
|
||||
faChevronDown,
|
||||
faSignOutAlt,
|
||||
faPen,
|
||||
faExternalLinkSquareAlt,
|
||||
faSpinner,
|
||||
faUndo,
|
||||
faPlusCircle,
|
||||
faAngleDown,
|
||||
faWrench,
|
||||
faHeartbeat,
|
||||
faFilter,
|
||||
faInfoCircle,
|
||||
faClone,
|
||||
faCertificate,
|
||||
faTerminal, faWarehouse, faHome, faRocket,
|
||||
faRotate,
|
||||
faCloudArrowDown, faArrowsRotate,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
faArrowAltCircleUp,
|
||||
faCog,
|
||||
faEdit,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faList,
|
||||
faPause,
|
||||
faStop,
|
||||
faPlay,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faTachometerAlt,
|
||||
faTimes,
|
||||
faTimesCircle,
|
||||
faTrash,
|
||||
faCheckCircle,
|
||||
faStream,
|
||||
faSave,
|
||||
faExclamationCircle,
|
||||
faBullhorn,
|
||||
faArrowsAltV,
|
||||
faUnlink,
|
||||
faQuestionCircle,
|
||||
faImages,
|
||||
faUpload,
|
||||
faCopy,
|
||||
faCheck,
|
||||
faFile,
|
||||
faAward,
|
||||
faLink,
|
||||
faChevronDown,
|
||||
faSignOutAlt,
|
||||
faPen,
|
||||
faExternalLinkSquareAlt,
|
||||
faSpinner,
|
||||
faUndo,
|
||||
faPlusCircle,
|
||||
faAngleDown,
|
||||
faLink,
|
||||
faWrench,
|
||||
faHeartbeat,
|
||||
faFilter,
|
||||
faInfoCircle,
|
||||
faClone,
|
||||
faCertificate,
|
||||
faTerminal,
|
||||
faWarehouse,
|
||||
faHome,
|
||||
faRocket,
|
||||
faRotate,
|
||||
faCloudArrowDown,
|
||||
faArrowsRotate,
|
||||
);
|
||||
|
||||
export { FontAwesomeIcon };
|
||||
|
53
frontend/src/lang/en.json
Normal file
53
frontend/src/lang/en.json
Normal 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"
|
||||
}
|
8
frontend/src/layouts/EmptyLayout.vue
Normal file
8
frontend/src/layouts/EmptyLayout.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {};
|
||||
</script>
|
||||
|
304
frontend/src/layouts/Layout.vue
Normal file
304
frontend/src/layouts/Layout.vue
Normal 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
101
frontend/src/main.ts
Normal 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),
|
||||
});
|
||||
}
|
39
frontend/src/mixins/lang.ts
Normal file
39
frontend/src/mixins/lang.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
317
frontend/src/mixins/socket.ts
Normal file
317
frontend/src/mixins/socket.ts
Normal 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);
|
||||
},
|
||||
|
||||
}
|
||||
});
|
80
frontend/src/mixins/theme.ts
Normal file
80
frontend/src/mixins/theme.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
599
frontend/src/pages/Compose.vue
Normal file
599
frontend/src/pages/Compose.vue
Normal 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>
|
48
frontend/src/pages/Console.vue
Normal file
48
frontend/src/pages/Console.vue
Normal 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>
|
63
frontend/src/pages/ContainerTerminal.vue
Normal file
63
frontend/src/pages/ContainerTerminal.vue
Normal 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>
|
42
frontend/src/pages/Dashboard.vue
Normal file
42
frontend/src/pages/Dashboard.vue
Normal 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>
|
231
frontend/src/pages/DashboardHome.vue
Normal file
231
frontend/src/pages/DashboardHome.vue
Normal 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>
|
252
frontend/src/pages/Settings.vue
Normal file
252
frontend/src/pages/Settings.vue
Normal 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>
|
138
frontend/src/pages/Setup.vue
Normal file
138
frontend/src/pages/Setup.vue
Normal 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
90
frontend/src/router.ts
Normal 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,
|
||||
});
|
9
frontend/src/styles/localization.scss
Normal file
9
frontend/src/styles/localization.scss
Normal 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;
|
||||
}
|
697
frontend/src/styles/main.scss
Normal file
697
frontend/src/styles/main.scss
Normal 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";
|
26
frontend/src/styles/vars.scss
Normal file
26
frontend/src/styles/vars.scss
Normal 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);
|
215
frontend/src/util-frontend.ts
Normal file
215
frontend/src/util-frontend.ts
Normal 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
7
frontend/src/vite-env.d.ts
vendored
Normal 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
36
frontend/vite.config.ts
Normal 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
79
package.json
Normal 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
4477
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user